DKIM rotation, suppression and event APIs: what to compare
Deliverability tooling splits into monitoring platforms and sending APIs. Which operations should be API calls, with runnable Infrai examples for rotation and audit export.
Shopping for a “deliverability platform” usually returns two unrelated product categories in one list. DMARC monitoring services like PowerDMARC and Red Sift read your aggregate reports and tell you which senders are failing alignment; sending APIs like SendGrid, Postmark or Infrai actually put messages on the wire and own the reputation. You often need one of each, and the comparison that matters is narrower than the listicles suggest: which day-two operations are real API calls, and which are a dashboard click or a support ticket.
Four operations decide that. Domain verification, DKIM key rotation, suppression list read and write, and event history you can page through. On Infrai all four are free REST calls on the same key that carries the send — worth knowing before you build an audit process around screenshots.
The two categories, kept apart
| Question you have | Answered by | Not answered by |
|---|---|---|
| Who is sending mail as my domain? | DMARC monitoring (PowerDMARC, Red Sift) | Your sending API — it only sees its own traffic |
| Are my SPF/DKIM records published correctly? | Either; the sending API knows its own records | — |
| Why did this message bounce? | Sending API event history | Monitoring platforms, which work in aggregate |
| Can I rotate a DKIM key on a schedule? | Sending API, if it exposes rotation | Monitoring platforms |
| Can I export the suppression list for a data request? | Sending API, if suppression is readable | Monitoring platforms |
Nothing here replaces reading your own DMARC aggregates. If regulators or a security questionnaire are the reason you’re shopping, buy the monitoring product too.
Rotating a DKIM key
Key rotation is the operation that separates an API from a control panel, because it’s the one you want on a schedule. RFC 6376 doesn’t set a rotation interval, but annual is a common security-policy answer and it’s painful when it means emailing support.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/email/domain/rotate_dkim/mail.example.com" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"domain": "mail.example.com",
"domain_id": "dom_Arov0eH2udgqOcYTGu0MtvvB",
"status": "pending_dns",
"dns_records": [
{ "type": "TXT", "name": "cf._domainkey.mail.example.com", "value": "v=DKIM1;k=rsa;p=MIIBIj...AB;rot=2", "purpose": "dkim", "ttl_recommended": 3600 },
{ "type": "TXT", "name": "_dmarc.mail.example.com", "value": "v=DMARC1;p=none;rua=mailto:dmarc@infrai.cc", "purpose": "dmarc", "ttl_recommended": 3600 }
],
"warm_up_state": "not_started",
"rotated": true
}
}
Read the response carefully, because there’s a real trade-off buried in it. The domain drops back to pending_dns, and the new key reuses the same selector — cf._domainkey — rather than publishing a second one alongside the first. Classic dual-selector rotation lets you publish the new key, wait a TTL, switch signing, then retire the old record, so nothing is ever unsigned. Here you replace the record in place, which means there’s a propagation window (3600 seconds at the recommended TTL) where mail signed with the new key can fail DKIM at receivers still caching the old value.
So rotate at a quiet hour, publish the TXT record immediately, and re-check with GET /v1/email/domain/get/{domain} before you resume volume. Don’t schedule it for a Monday morning.
Suppression you can actually export
A suppression list that lives only in a UI is a compliance problem the first time someone asks what you hold about an address.
curl -sS "https://api.infrai.cc/v1/email/suppression/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "email": "user@example.com", "reason": "manual", "added_at": "2026-07-04T17:02:22.803322Z", "scope": "account", "attempt_count_blocked": 0 },
{ "email": "unsub-probe@example.com", "reason": "unsubscribed", "added_at": "2026-06-29T09:54:38.768897Z", "scope": "account", "attempt_count_blocked": 0 }
],
"count": 2,
"next_cursor": null
}
}
reason and added_at are the two fields an auditor asks for, and attempt_count_blocked tells you how often the list has done its job. Removal is DELETE /v1/email/suppression/delete/{email} and additions are POST /v1/email/suppression/add, whose one required field is email.
One caveat we hit in testing: the delete route reports found: false for an address that a list read still shows, so treat removal as best-effort and re-read the list to confirm rather than trusting the delete response.
Paging event history into your own store
Events are where “polling” in the query actually bites. The list route takes a message_id and returns a cursor, so an incremental export is a loop, not a firehose subscription.
import { appendFile } from "node:fs/promises";
import process from "node:process";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
async function get(path) {
const res = await fetch(API + path, {
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
});
const payload = await res.json();
if (!res.ok || payload.ok === false) {
const e = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
throw new Error(`${path} -> ${e.code}: ${e.message}`);
}
return payload.data;
}
async function exportEvents(messageId, outFile) {
let cursor = null;
let written = 0;
do {
const qs = new URLSearchParams({ message_id: messageId });
if (cursor) qs.set("cursor", cursor);
const page = await get(`/v1/email/event/list?${qs}`);
const rows = page.items ?? page.records ?? [];
for (const row of rows) {
await appendFile(outFile, JSON.stringify({ message_id: messageId, ...row }) + "\n");
written++;
}
cursor = page.next_cursor ?? null;
} while (cursor);
return written;
}
const recent = await get("/v1/email/list?limit=50");
let total = 0;
for (const msg of recent.items) {
total += await exportEvents(msg.message_id, "email-events.ndjson");
}
console.log(`exported ${total} events for ${recent.items.length} messages`);
Each row carries type, recipient and at. The types you care about for a compliance trail are bounced and complained; opened and clicked are marketing telemetry and in the EU you probably shouldn’t be recording them by default anyway.
Here’s the shape of a single page, straight off the wire:
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_DgOWYJSuArAxcSI9MCzYLSJp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
What the free/billable split looks like
Every operation above — domain verification, rotation, suppression reads and writes, event and message listing — is free and rate-limited rather than metered. Only POST /v1/email/send and POST /v1/email/batch/send cost anything: $0.000115 per recipient, verified 2026-07-25, with a $2 credit on a new account covering roughly 17,391 messages. That split is the point. An audit script that runs hourly doesn’t show up on the invoice.
Check the live figure yourself; rates trend downward as upstream discounts land:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'],c['billing'].get('price_usd'),c['billing']['unit']) for c in d['capabilities'] if c['id'].startswith('email.')])"
Limitations, honestly
Custom sender domains sit behind a paid plan — a standard account gets HTTP 402 PRO_REQUIRED from POST /v1/email/domain/verify, which is a surprising place to meet a paywall when you’re evaluating. Region selection is coarse: the western email path runs on Resend, with Amazon SES and Tencent listed as pending rather than ready, so if your compliance posture requires a named subprocessor in a named jurisdiction you’ll want that in writing before you build.
And the aggregate view is missing. Infrai reports on mail it sent, and that’s all it can do — a full DMARC RUA pipeline sees every sender using your domain, including the ones you didn’t authorise. Pair it with a monitoring product if that’s the requirement.
What you get instead is consolidation. The same credential covers SMS, object storage, cron, queues and error tracking, so the retention job that trims your exported events and the alert that fires when bounce_rate_30d crosses 2% are calls on the same account — one bill, one usage view, and per-tenant attribution as a query rather than a spreadsheet.