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": "…" } }
| Status | type | When |
|---|---|---|
| 400 | invalid_request | Validation failed. Unknown fields are rejected, so typos surface |
| 401 | unauthorized | Missing or wrong API key |
| 404 | not_found | Not found, or it belongs to another account |
| 402 | plan_limit_reached | The account is at its domain_limit; the error includes domain_limit |
| 409 | conflict | The domain is already registered; the error includes existing_id |
| 429 | rate_limited | Slow down |
| 502 | provider_error | The 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 ofmanual(default): you show the records to your customer.cloudflare: needscloudflare_api_token.route53: needsaws_access_key_idandaws_secret_access_key.aws_session_tokenandroute53_hosted_zone_idare 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 usespm-bounces, Amazon SESmail; Mailgun doesn't use it).inbound.hostname: defaults toparse.<domain>, except with Mailgun, which only receives on domains added to it, so the default is the domain itself (send from a subdomain such asmg.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/:id | tenant_id, metadata, inbound (null removes it), dns, overwrite. Changes to DNS, inbound or overwrite restart checks |
POST /v1/domains/:id/verify | Check now. Also restarts a failed or conflict domain. Returns 202 |
DELETE /v1/domains/:id?remote=true | Remove 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/events | History of what happened to this domain |
GET /v1/account, PATCH /v1/account | name, webhook_url, widget_origins, and email_provider (credentials are write-only; see below) |
GET /v1/usage?from=&to=&group_by=tenant | Usage for billing; see Usage and billing |
POST /v1/account/rotate-api-key | Returns a new key and invalidates the old one immediately |
POST /v1/account/rotate-webhook-secret | |
PATCH /v1/account widget_origins | Origins allowed to embed the widget |
POST /v1/widget-sessions | Mint 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" } }
provider | Fields |
|---|---|
sendgrid | api_key (sendgrid_api_key at the top level also works) |
postmark | account_token; inbound_server_id to receive email |
mailgun | api_key, region (us or eu, default us) |
ses | access_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, orPATCHthe 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.
| Metric | Meaning |
|---|---|
active_domains | Distinct domains that were verified on at least one day in the period. The usual number to bill on, like seats |
active_domain_days | The sum over days of verified domains, for prorated billing (e.g. bill active_domain_days / days in month) |
domains_created | Domains registered in the period |
verifications | Times 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.createddomain.dns_writtendomain.verifieddomain.verification_lostdomain.conflictdomain.failed(withdata.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.