DKIMkit

API reference

A multi-tenant REST API for SaaS products whose customers send email from their own domains. Your backend registers a customer's domain. The API returns the DNS records to show that customer, or writes them through the customer's DNS provider. It then keeps checking until SendGrid verifies the domain, and calls your webhook when the status changes.

Your app ──POST /v1/domains──▶ API ──▶ SendGrid / Postmark / Mailgun / Amazon SES
   ▲                            │
   └──── signed webhooks ◀── worker ──▶ Cloudflare / Route 53 (tenant's DNS) + DNS polling

Authentication and errors

Send Authorization: Bearer dk_live_…. Each account is limited to 10 requests/second, with bursts up to 30. Over the limit you get 429 and a Retry-After header.

Errors always have this shape:

{ "error": { "type": "invalid_request", "message": "domain must be a valid domain name, like example.com.", "request_id": "…" } }
StatustypeWhen
400invalid_requestValidation failed. Unknown fields are rejected, so typos surface
401unauthorizedMissing or wrong API key
404not_foundNot found, or it belongs to another account
402plan_limit_reachedThe account is at its domain_limit; the error includes domain_limit
409conflictThe domain is already registered; the error includes existing_id
429rate_limitedSlow down
502provider_errorThe email provider, Cloudflare or AWS rejected the call; includes provider, provider_status and a hint

Domains

POST /v1/domains

{
  "domain": "customer.com",
  "tenant_id": "your-customer-id",
  "metadata": { "plan": "pro" },
  "subdomain": "em",
  "sendgrid_subuser": "customer-sub",
  "inbound": { "url": "https://api.yourapp.com/inbound", "hostname": "parse.customer.com", "spam_check": false, "send_raw": false },
  "dns": { "provider": "cloudflare", "cloudflare_api_token": "…" },
  "overwrite": false
}

Only domain is required.

  • dns.provider: one of
    • manual (default): you show the records to your customer.
    • cloudflare: needs cloudflare_api_token.
    • route53: needs aws_access_key_id and aws_secret_access_key. aws_session_token and route53_hosted_zone_id are optional.
  • Credentials: they are encrypted at rest and never returned. The API never falls back to the server's own credentials.
  • sendgrid_subuser: SendGrid accounts only: runs every SendGrid call for this domain as that subuser.
  • subdomain: the return-path / MAIL FROM label (SendGrid picks one by default, Postmark uses pm-bounces, Amazon SES mail; Mailgun doesn't use it).
  • inbound.hostname: defaults to parse.<domain>, except with Mailgun, which only receives on domains added to it, so the default is the domain itself (send from a subdomain such as mg.customer.com). Postmark receives one inbound domain per Postmark server. Amazon SES doesn't support inbound here yet.
  • overwrite: allows replacing existing CNAME/MX records that point elsewhere.

The domain is created at the email provider during the request, so the 201 response already contains the records. Records may carry a note, for example advice to merge an SPF record into an existing one:

{
  "id": "dom_…", "object": "domain", "domain": "customer.com", "status": "pending",
  "records": [
    { "type": "CNAME", "name": "em1234.customer.com", "value": "u1234.wl.sendgrid.net", "purpose": "mail_cname", "status": "pending" },
    { "type": "CNAME", "name": "s1._domainkey.customer.com", "value": "s1.domainkey.u1234.wl.sendgrid.net", "purpose": "dkim1", "status": "pending" },
    { "type": "MX", "name": "parse.customer.com", "value": "mx.sendgrid.net", "priority": 10, "purpose": "inbound_mx", "status": "pending" }
  ],
  "dns": { "provider": "cloudflare", "credentials_set": true },
  "validation": null, "conflicts": null, "last_error": null,
  "next_check_at": "…", "verified_at": null, "expires_at": "…", "created_at": "…", "updated_at": "…"
}

Other endpoints

GET /v1/domains?tenant_id=&status=&limit=&starting_after=Newest first. Responses carry has_more and next_cursor
GET /v1/domains/:id
PATCH /v1/domains/:idtenant_id, metadata, inbound (null removes it), dns, overwrite. Changes to DNS, inbound or overwrite restart checks
POST /v1/domains/:id/verifyCheck now. Also restarts a failed or conflict domain. Returns 202
DELETE /v1/domains/:id?remote=trueRemove the domain. With remote=true, also delete the domain and its inbound route at the email provider. Never deletes DNS records
GET /v1/domains/:id/eventsHistory of what happened to this domain
GET /v1/account, PATCH /v1/accountname, webhook_url, widget_origins, and email_provider (credentials are write-only; see below)
GET /v1/usage?from=&to=&group_by=tenantUsage for billing; see Usage and billing
POST /v1/account/rotate-api-keyReturns a new key and invalidates the old one immediately
POST /v1/account/rotate-webhook-secret
PATCH /v1/account widget_originsOrigins allowed to embed the widget
POST /v1/widget-sessionsMint a browser session for the embeddable widget: { domain_id } or { tenant_id, … }

Email provider

Each account uses one email provider. Set it, and its credentials, with PATCH /v1/account:

{ "email_provider": { "provider": "postmark", "account_token": "…", "inbound_server_id": "123" } }
providerFields
sendgridapi_key (sendgrid_api_key at the top level also works)
postmarkaccount_token; inbound_server_id to receive email
mailgunapi_key, region (us or eu, default us)
sesaccess_key_id, secret_access_key, optional session_token, region

Fields you leave out keep their saved values, so you can rotate one key at a time. Credentials are encrypted and never returned; GET /v1/account shows email_provider and email_credentials_set. To switch provider, delete the account's domains first, since each domain lives at the provider that created it.

Lifecycle

pending ─▶ awaiting_dns ─▶ verifying ─▶ verified ──(daily re-check fails)──▶ awaiting_dns
   │            │              │
   └────────────┴──────────────┴──▶ conflict   (existing records point elsewhere; nothing written)
                                └──▶ failed     (bad credentials, missing zone, or not verified within 72h)
  • Checking: the worker checks at 30s, 1m, 2m, 5m, 10m, 15m, then every 30 minutes. Each check writes DNS if needed, looks for the records on the domain's authoritative nameservers, then asks SendGrid to validate.
  • Verified domains: these are re-checked every 24 hours.
  • Credential and config problems: these fail immediately with a hint in last_error. Network errors and 5xx responses are retried.
  • Restarting: fix the cause, then call verify, or PATCH the domain.

Usage and billing

Usage is metered automatically. Each billable fact is stored once, under an id derived from what it is (for example active_domain_day:dom_1:2026-10-25). Recording it again, or several workers recording it at once, can't double-count. Usage rows keep a snapshot of the domain name and tenant_id, so deleting a domain doesn't erase its billing history.

MetricMeaning
active_domainsDistinct domains that were verified on at least one day in the period. The usual number to bill on, like seats
active_domain_daysThe sum over days of verified domains, for prorated billing (e.g. bill active_domain_days / days in month)
domains_createdDomains registered in the period
verificationsTimes a domain became verified, including after a lost verification

A domain counts as active on a day when it was verified at some point that day. That's recorded the moment it verifies, at each daily re-check, and by an hourly pass over all verified domains. Days are UTC.

GET /v1/usage lets an account see its own usage. It defaults to the current month so far; from/to are inclusive YYYY-MM-DD dates, up to 366 days. Add group_by=tenant to break it down by your customers, which is useful if you pass the cost on to them:

{
  "object": "usage", "from": "2026-10-01", "to": "2026-10-25",
  "active_domains": 3, "active_domain_days": 41, "domains_created": 4, "verifications": 3,
  "current_domains": 4, "domain_limit": 25,
  "by_tenant": [
    { "tenant_id": "cus_a", "active_domains": 2, "active_domain_days": 30, "domains_created": 2, "verifications": 2 },
    { "tenant_id": "cus_b", "active_domains": 1, "active_domain_days": 11, "domains_created": 2, "verifications": 1 }
  ]
}

Plans can cap how many domains an account has at once (the free plan allows 3). Over the cap, POST /v1/domains returns 402 plan_limit_reached with an upgrade_url. In the widget, end users see a neutral "contact support" message instead.

Webhooks

Set webhook_url on the account (it must be public https). Each event is POSTed as JSON with a snapshot of the domain:

{ "id": "evt_…", "object": "event", "type": "domain.verified", "created_at": "…", "data": { "domain": { "id": "dom_…", "status": "verified", … } } }

Event types:

  • domain.created
  • domain.dns_written
  • domain.verified
  • domain.verification_lost
  • domain.conflict
  • domain.failed (with data.reason)
  • domain.deleted

Delivery: a non-2xx response or a timeout (10s) is retried after 10s, 1m, 5m, 30m, 2h, 6h, 12h and 24h. Delivery is at least once and can arrive out of order. Deduplicate on id (also sent as X-DKIMkit-Event-Id), and trust data.domain.updated_at rather than arrival order.

Verify each request with the raw body and the account's webhook_secret:

import { verifyWebhookSignature } from 'dkimkit';

app.post('/webhooks/dkimkit', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyWebhookSignature(process.env.DKIMKIT_WEBHOOK_SECRET!, req.body.toString(), req.header('x-dkimkit-signature'))) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body.toString());
  // …
  res.sendStatus(200);
});

The signature header is t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">. Requests older than 5 minutes are rejected as replays.

Security notes

  • Secrets at rest: API keys are stored as SHA-256 hashes. Email provider credentials and DNS credentials are encrypted with AES-256-GCM using random IVs and authentication tags. None of them are ever returned by the API.
  • Tenant isolation: every query is scoped to the authenticated account, and other accounts' ids return 404.
  • Webhook targets: URLs are checked when saved and again before every delivery. They must be https and must not resolve to loopback, private, link-local, CGNAT or multicast addresses. Redirects are not followed.