Node.js library and CLI
The dkimkit npm package runs the whole setup from your own code or terminal: it gets the records from your email provider, writes them to your DNS host, waits until they're live, has the provider verify the domain, and optionally sets up inbound email.
It runs entirely on your machine with your own provider keys. It doesn't call DKIMkit's servers, so there's no account, API key, plan or domain limit. Only your email and DNS providers' own rate limits apply. Want us to run it for you, with a dashboard, webhooks and an embeddable widget? Use the hosted API instead.
| Library and CLI | Hosted API | |
|---|---|---|
| Runs | In your process | On DKIMkit |
| Domains | No limit | Set by your plan |
| Waits for DNS and verification | While your code runs | In the background, then sends a webhook |
| Provider keys | Stay with you | Stored encrypted |
| UI for your customers | Build your own | Embeddable widget |
Install
npm install dkimkit
Requires Node 20 or later. The package has no runtime dependencies and ships its own TypeScript types.
Quick start
import { setupDomain, createEmailProvider, createDnsProvider } from 'dkimkit';
const email = createEmailProvider('sendgrid', { apiKey: process.env.SENDGRID_API_KEY });
const dns = createDnsProvider('cloudflare', { cloudflareApiToken: process.env.CLOUDFLARE_API_TOKEN });
const result = await setupDomain(email, dns, {
domain: 'example.com',
onEvent: (e) => console.log(e.type),
});
if (result.status === 'complete') console.log('example.com is authenticated');
else console.log('Not finished yet:', result.status);
Every step is idempotent: existing provider domains and DNS records are reused. If a run ends with dns-pending or validation-failed, run it again later and it picks up where it stopped.
setupDomain(email, dns, options)
Runs the full flow and resolves with a result once the domain is verified, or when it can't go further yet.
- Domain. Reuses the provider's existing authentication for the domain, or creates one.
- Plan. Compares the records the provider needs with what's already in DNS. Matching records are left alone. A record pointing somewhere else is a conflict, and nothing is written.
- Apply. Writes the new and changed records.
- Propagation. Polls the domain's authoritative nameservers until every record is visible.
- Validation. Asks the provider to verify, retrying because its resolvers can lag behind.
- Inbound. If you asked for it, registers the inbound webhook once the domain is verified.
Options
| Option | Type | |
|---|---|---|
domain | string | The domain to authenticate. Required |
subdomain | string | Return-path or MAIL FROM label. SendGrid picks one by default, Postmark uses pm-bounces, SES uses mail. Not used by Mailgun |
inbound | { hostname, url, spamCheck?, sendRaw? } | Receive email at hostname and POST it to url. Not supported for Amazon SES |
overwrite | boolean | Replace CNAME and MX records that point elsewhere instead of stopping with a conflict |
dryRun | boolean | Plan only and write nothing to DNS. The provider may still create its domain entry, since that's the only way to get the records |
confirm | (changes) => Promise<boolean> | Called before writing DNS when there are changes. Return false to stop with status aborted |
propagation | { timeoutMs?, intervalMs?, lookup? } | How long to wait for DNS. Defaults: 300000 ms, polling every 5000 ms |
validation | { attempts?, intervalMs? } | Verification retries. Defaults: 6 attempts, 10000 ms apart |
onEvent | (event) => void | Progress, for logs or a live UI. See Events |
signal | AbortSignal | Stops waiting and rejects with an AbortError |
Result
const result = await setupDomain(email, dns, { domain: 'example.com' });
result.status; // 'complete' | 'dns-pending' | 'validation-failed' | 'aborted' | 'dry-run'
result.domain; // { id, domain, valid, records, created }
result.changes; // the DNS plan: [{ record, action: 'create' | 'update' | 'noop' | 'conflict' }]
result.pending; // dns-pending: records that weren't visible before the timeout
result.validation; // the provider's last answer: { valid, results: [{ record, valid, reason? }] }
result.inbound; // 'created' | 'updated' | 'unchanged', when inbound was set up
| Status | Meaning |
|---|---|
complete | Verified, and inbound set up if requested |
dns-pending | Records were written but aren't visible yet. Run again later |
validation-failed | Records are visible but the provider hasn't verified them yet. Run again later |
aborted | confirm returned false. Nothing was written |
dry-run | dryRun was set. changes shows what would be written |
Events
onEvent is called as each step happens:
type | Fields |
|---|---|
domain | auth: the provider's domain and the records it needs |
plan | changes: what will be created, updated, left alone, or conflicts |
applied | changes: what was written |
propagation | pending, total, elapsedMs, after each DNS check |
validation | attempt, result, after each verification attempt |
inbound | hostname, url, action |
For example, to stream progress to your own UI per customer:
await setupDomain(email, dns, {
domain: tenant.domain,
inbound: { hostname: `parse.${tenant.domain}`, url: `https://app.example.com/inbound/${tenant.id}` },
onEvent: (e) => {
if (e.type === 'propagation') progress(tenant.id, `${e.total - e.pending.length} of ${e.total} records live`);
if (e.type === 'validation' && e.result.valid) progress(tenant.id, 'Verified');
},
});
checkStatus(email, domain, subdomain?)
A read-only check that changes nothing. It finds the domain at the provider and asks it to re-validate:
import { checkStatus } from 'dkimkit';
const { domain, validation } = await checkStatus(email, 'example.com');
if (!domain) console.log('Not set up with this provider yet');
else if (validation?.valid) console.log('Verified');
else console.log('Not verified yet:', validation?.results.filter((r) => !r.valid));
Email providers
Create one with createEmailProvider(name, credentials, options?):
| Provider | Name | Credentials | Records |
|---|---|---|---|
| SendGrid | sendgrid | apiKey, with Sender Authentication and Inbound Parse access | 3 CNAMEs |
| Postmark | postmark | accountToken (an account token, not a server token), and inboundServerId to receive email | DKIM TXT and Return-Path CNAME |
| Mailgun | mailgun | apiKey, and region: us (default) or eu | SPF and DKIM TXT, tracking CNAME |
| Amazon SES | ses | accessKeyId, secretAccessKey, optional sessionToken, and region, e.g. us-east-1 | 3 DKIM CNAMEs, MAIL FROM MX and SPF |
const sendgrid = createEmailProvider('sendgrid', { apiKey }, { subuser: tenant.sendgridSubuser });
const mailgun = createEmailProvider('mailgun', { apiKey, region: 'eu' });
const ses = createEmailProvider('ses', { accessKeyId, secretAccessKey, region: 'us-east-1' });
The subuser option runs SendGrid calls on behalf of a subuser, which keeps each customer's domains in their own SendGrid subuser. Missing or invalid credentials throw a ConfigError before any request is made.
Each provider also has its own class (SendGridProvider, PostmarkProvider, MailgunProvider, SesProvider) if you'd rather construct it directly.
When a provider needs an SPF record and the name already has one, the provider is added to the existing record instead of creating a second one, which would make SPF fail.
DNS providers
Create one with createDnsProvider(name, config?):
| DNS host | Name | Config |
|---|---|---|
| Cloudflare | cloudflare | cloudflareApiToken (or CLOUDFLARE_API_TOKEN), with Zone:Read and DNS:Edit on the zone |
| Amazon Route 53 | route53 | aws: { accessKeyId, secretAccessKey, sessionToken? }, or awsProfile, or the usual AWS environment variables. Optional route53HostedZoneId |
| Any other host | manual | onManualRecords(records): show the records to whoever adds them |
Route 53 credentials need route53:ListHostedZonesByName, route53:ListResourceRecordSets and route53:ChangeResourceRecordSets. Records are merged into existing record sets in one atomic change, so an existing TXT record keeps its other values.
Records added by hand
With manual, setupDomain hands the records to your callback and then waits for them to appear in DNS, so give whoever adds them enough time with propagation.timeoutMs:
import { createDnsProvider, describeRecord, detectDnsHost } from 'dkimkit';
const host = await detectDnsHost('example.com'); // { name: 'GoDaddy', provider: null, nameservers: [...] }
const dns = createDnsProvider('manual', {
onManualRecords: async (records) => {
console.log(`Add these records at ${host.name ?? 'your DNS host'}:`);
for (const r of records) console.log(' ', describeRecord(r));
},
});
const result = await setupDomain(email, dns, { domain: 'example.com', propagation: { timeoutMs: 30 * 60_000 } });
detectDnsHost(domain) recognizes common DNS hosts from the nameservers. Its provider is cloudflare or route53 when records can be written automatically there, and null otherwise. It never throws.
Errors
| Error | When | Useful fields |
|---|---|---|
ConfigError | Missing or invalid credentials or options | message |
DnsConflictError | A record the provider needs already points somewhere else. Nothing was written | conflicts: the conflicting planned changes |
ApiError | An email or DNS provider rejected a request | provider, status, body |
import { ApiError, DnsConflictError, apiErrorHint } from 'dkimkit';
try {
await setupDomain(email, dns, { domain: 'example.com' });
} catch (err) {
if (err instanceof DnsConflictError) console.log(err.message); // lists each conflict; pass overwrite: true to replace them
else if (err instanceof ApiError) console.log(err.message, apiErrorHint(err) ?? '');
else throw err;
}
apiErrorHint(err) returns what to check when a provider rejects the credentials (401 or 403), such as the permissions a key needs.
Verifying webhooks
If you also use the hosted API, the package verifies its webhook signatures:
import { verifyWebhookSignature } from 'dkimkit';
const ok = verifyWebhookSignature(process.env.DKIMKIT_WEBHOOK_SECRET!, rawBody, req.headers['x-dkimkit-signature']);
Pass the raw request body, before any JSON parsing. Requests older than 5 minutes are rejected as replays.
CLI
The same flow from a terminal, with no code:
export SENDGRID_API_KEY=SG.xxxx
export CLOUDFLARE_API_TOKEN=xxxx
npx dkimkit --domain example.com --inbound-url https://api.example.com/webhooks/inbound-email
It prints the DNS plan, asks before writing, then shows each step until the domain is verified.
| Command | |
|---|---|
dkimkit --domain example.com | Set up a domain |
dkimkit status --domain example.com | Show the provider's verification status without changing anything |
dkimkit ui | Open a local web interface for the same flow |
| Option | |
|---|---|
-d, --domain | Domain to authenticate |
--provider | sendgrid (default), postmark, mailgun or ses |
--region | Mailgun us or eu, or the Amazon SES region |
--subdomain | Return-path or MAIL FROM label |
--dns | cloudflare, route53 or manual. Defaults to cloudflare when CLOUDFLARE_API_TOKEN is set |
--inbound-url, --inbound-host | Receive email and POST it to this URL. The host defaults to parse.<domain> (Mailgun: the domain itself) |
--spam-check, --send-raw | Inbound options |
--overwrite | Replace CNAME and MX records that point elsewhere |
--dry-run | Show the DNS plan without writing anything |
--wait | Seconds to wait for DNS to propagate (default 300) |
--on-behalf-of | Run as a SendGrid subuser |
--aws-profile, --zone-id | Route 53 profile and hosted zone |
-y, --yes | Write DNS without asking |
--json | Print the result as JSON, for scripts |
Credentials come from the environment: SENDGRID_API_KEY, POSTMARK_ACCOUNT_TOKEN and POSTMARK_INBOUND_SERVER_ID, MAILGUN_API_KEY and MAILGUN_REGION, AWS_REGION for SES, CLOUDFLARE_API_TOKEN, and the usual AWS variables or ~/.aws/credentials for Route 53 and SES.
Exit codes: 0 done, 1 error, 2 not finished yet (DNS pending, not verified yet, or you declined the changes). Run the same command again to continue.