dkimkit 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +161 -2
  2. package/dist/src/cli.d.ts +2 -0
  3. package/dist/src/cli.js +398 -0
  4. package/dist/src/cli.js.map +1 -0
  5. package/dist/src/dns-host.d.ts +10 -0
  6. package/dist/src/dns-host.js +31 -0
  7. package/dist/src/dns-host.js.map +1 -0
  8. package/dist/src/errors.d.ts +17 -0
  9. package/dist/src/errors.js +51 -0
  10. package/dist/src/errors.js.map +1 -0
  11. package/dist/src/hosted/api.d.ts +107 -0
  12. package/dist/src/hosted/api.js +919 -0
  13. package/dist/src/hosted/api.js.map +1 -0
  14. package/dist/src/hosted/auth.d.ts +80 -0
  15. package/dist/src/hosted/auth.js +147 -0
  16. package/dist/src/hosted/auth.js.map +1 -0
  17. package/dist/src/hosted/billing-dodo.d.ts +58 -0
  18. package/dist/src/hosted/billing-dodo.js +139 -0
  19. package/dist/src/hosted/billing-dodo.js.map +1 -0
  20. package/dist/src/hosted/billing-stripe.d.ts +82 -0
  21. package/dist/src/hosted/billing-stripe.js +205 -0
  22. package/dist/src/hosted/billing-stripe.js.map +1 -0
  23. package/dist/src/hosted/billing.d.ts +114 -0
  24. package/dist/src/hosted/billing.js +173 -0
  25. package/dist/src/hosted/billing.js.map +1 -0
  26. package/dist/src/hosted/bulk.d.ts +47 -0
  27. package/dist/src/hosted/bulk.js +66 -0
  28. package/dist/src/hosted/bulk.js.map +1 -0
  29. package/dist/src/hosted/cli.d.ts +2 -0
  30. package/dist/src/hosted/cli.js +318 -0
  31. package/dist/src/hosted/cli.js.map +1 -0
  32. package/dist/src/hosted/crypto.d.ts +22 -0
  33. package/dist/src/hosted/crypto.js +55 -0
  34. package/dist/src/hosted/crypto.js.map +1 -0
  35. package/dist/src/hosted/index.d.ts +7 -0
  36. package/dist/src/hosted/index.js +11 -0
  37. package/dist/src/hosted/index.js.map +1 -0
  38. package/dist/src/hosted/markdown.d.ts +16 -0
  39. package/dist/src/hosted/markdown.js +168 -0
  40. package/dist/src/hosted/markdown.js.map +1 -0
  41. package/dist/src/hosted/net.d.ts +9 -0
  42. package/dist/src/hosted/net.js +60 -0
  43. package/dist/src/hosted/net.js.map +1 -0
  44. package/dist/src/hosted/postgres-store.d.ts +62 -0
  45. package/dist/src/hosted/postgres-store.js +387 -0
  46. package/dist/src/hosted/postgres-store.js.map +1 -0
  47. package/dist/src/hosted/serialize.d.ts +144 -0
  48. package/dist/src/hosted/serialize.js +126 -0
  49. package/dist/src/hosted/serialize.js.map +1 -0
  50. package/dist/src/hosted/service.d.ts +170 -0
  51. package/dist/src/hosted/service.js +603 -0
  52. package/dist/src/hosted/service.js.map +1 -0
  53. package/dist/src/hosted/site.d.ts +84 -0
  54. package/dist/src/hosted/site.js +653 -0
  55. package/dist/src/hosted/site.js.map +1 -0
  56. package/dist/src/hosted/sqlite-store.d.ts +45 -0
  57. package/dist/src/hosted/sqlite-store.js +351 -0
  58. package/dist/src/hosted/sqlite-store.js.map +1 -0
  59. package/dist/src/hosted/store.d.ts +257 -0
  60. package/dist/src/hosted/store.js +181 -0
  61. package/dist/src/hosted/store.js.map +1 -0
  62. package/dist/src/hosted/widget.d.ts +46 -0
  63. package/dist/src/hosted/widget.js +130 -0
  64. package/dist/src/hosted/widget.js.map +1 -0
  65. package/dist/src/hosted/worker.d.ts +45 -0
  66. package/dist/src/hosted/worker.js +142 -0
  67. package/dist/src/hosted/worker.js.map +1 -0
  68. package/dist/src/http.d.ts +14 -0
  69. package/dist/src/http.js +54 -0
  70. package/dist/src/http.js.map +1 -0
  71. package/dist/src/index.d.ts +19 -0
  72. package/dist/src/index.js +19 -0
  73. package/dist/src/index.js.map +1 -0
  74. package/dist/src/orchestrator.d.ts +70 -0
  75. package/dist/src/orchestrator.js +69 -0
  76. package/dist/src/orchestrator.js.map +1 -0
  77. package/dist/src/propagation.d.ts +30 -0
  78. package/dist/src/propagation.js +76 -0
  79. package/dist/src/propagation.js.map +1 -0
  80. package/dist/src/providers/dns/aws.d.ts +28 -0
  81. package/dist/src/providers/dns/aws.js +103 -0
  82. package/dist/src/providers/dns/aws.js.map +1 -0
  83. package/dist/src/providers/dns/cloudflare.d.ts +26 -0
  84. package/dist/src/providers/dns/cloudflare.js +97 -0
  85. package/dist/src/providers/dns/cloudflare.js.map +1 -0
  86. package/dist/src/providers/dns/manual.d.ts +12 -0
  87. package/dist/src/providers/dns/manual.js +18 -0
  88. package/dist/src/providers/dns/manual.js.map +1 -0
  89. package/dist/src/providers/dns/route53.d.ts +34 -0
  90. package/dist/src/providers/dns/route53.js +176 -0
  91. package/dist/src/providers/dns/route53.js.map +1 -0
  92. package/dist/src/providers/email/factory.d.ts +29 -0
  93. package/dist/src/providers/email/factory.js +67 -0
  94. package/dist/src/providers/email/factory.js.map +1 -0
  95. package/dist/src/providers/email/mailgun.d.ts +36 -0
  96. package/dist/src/providers/email/mailgun.js +141 -0
  97. package/dist/src/providers/email/mailgun.js.map +1 -0
  98. package/dist/src/providers/email/postmark.d.ts +37 -0
  99. package/dist/src/providers/email/postmark.js +121 -0
  100. package/dist/src/providers/email/postmark.js.map +1 -0
  101. package/dist/src/providers/email/sendgrid.d.ts +31 -0
  102. package/dist/src/providers/email/sendgrid.js +109 -0
  103. package/dist/src/providers/email/sendgrid.js.map +1 -0
  104. package/dist/src/providers/email/ses.d.ts +39 -0
  105. package/dist/src/providers/email/ses.js +123 -0
  106. package/dist/src/providers/email/ses.js.map +1 -0
  107. package/dist/src/providers/factory.d.ts +14 -0
  108. package/dist/src/providers/factory.js +32 -0
  109. package/dist/src/providers/factory.js.map +1 -0
  110. package/dist/src/records.d.ts +35 -0
  111. package/dist/src/records.js +109 -0
  112. package/dist/src/records.js.map +1 -0
  113. package/dist/src/server.d.ts +67 -0
  114. package/dist/src/server.js +301 -0
  115. package/dist/src/server.js.map +1 -0
  116. package/dist/src/types.d.ts +75 -0
  117. package/dist/src/types.js +2 -0
  118. package/dist/src/types.js.map +1 -0
  119. package/dist/src/xml.d.ts +6 -0
  120. package/dist/src/xml.js +23 -0
  121. package/dist/src/xml.js.map +1 -0
  122. package/docs/billing.md +43 -0
  123. package/docs/getting-started.md +66 -0
  124. package/docs/hosted-api.md +192 -0
  125. package/docs/library.md +253 -0
  126. package/docs/self-hosting.md +153 -0
  127. package/docs/widget.md +121 -0
  128. package/package.json +63 -4
  129. package/web/dashboard.html +669 -0
  130. package/web/index.html +697 -0
  131. package/web/widget-frame.html +480 -0
  132. package/web/widget.js +91 -0
@@ -0,0 +1,192 @@
1
+ # API reference
2
+
3
+ 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.
4
+
5
+ ```
6
+ Your app ──POST /v1/domains──▶ API ──▶ SendGrid / Postmark / Mailgun / Amazon SES
7
+ ▲ │
8
+ └──── signed webhooks ◀── worker ──▶ Cloudflare / Route 53 (tenant's DNS) + DNS polling
9
+ ```
10
+
11
+ ## Authentication and errors
12
+
13
+ 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.
14
+
15
+ Errors always have this shape:
16
+
17
+ ```json
18
+ { "error": { "type": "invalid_request", "message": "domain must be a valid domain name, like example.com.", "request_id": "…" } }
19
+ ```
20
+
21
+ | Status | `type` | When |
22
+ |---|---|---|
23
+ | 400 | `invalid_request` | Validation failed. Unknown fields are rejected, so typos surface |
24
+ | 401 | `unauthorized` | Missing or wrong API key |
25
+ | 404 | `not_found` | Not found, or it belongs to another account |
26
+ | 402 | `plan_limit_reached` | The account is at its `domain_limit`; the error includes `domain_limit` |
27
+ | 409 | `conflict` | The domain is already registered; the error includes `existing_id` |
28
+ | 429 | `rate_limited` | Slow down |
29
+ | 502 | `provider_error` | The email provider, Cloudflare or AWS rejected the call; includes `provider`, `provider_status` and a `hint` |
30
+
31
+ ## Domains
32
+
33
+ ### `POST /v1/domains`
34
+
35
+ ```json
36
+ {
37
+ "domain": "customer.com",
38
+ "tenant_id": "your-customer-id",
39
+ "metadata": { "plan": "pro" },
40
+ "subdomain": "em",
41
+ "sendgrid_subuser": "customer-sub",
42
+ "inbound": { "url": "https://api.yourapp.com/inbound", "hostname": "parse.customer.com", "spam_check": false, "send_raw": false },
43
+ "dns": { "provider": "cloudflare", "cloudflare_api_token": "…" },
44
+ "overwrite": false
45
+ }
46
+ ```
47
+
48
+ Only `domain` is required.
49
+ - **`dns.provider`:** one of
50
+ - `manual` (default): you show the records to your customer.
51
+ - `cloudflare`: needs `cloudflare_api_token`.
52
+ - `route53`: needs `aws_access_key_id` and `aws_secret_access_key`. `aws_session_token` and `route53_hosted_zone_id` are optional.
53
+ - **Credentials:** they are encrypted at rest and never returned. The API never falls back to the server's own credentials.
54
+ - **`sendgrid_subuser`:** SendGrid accounts only: runs every SendGrid call for this domain as that subuser.
55
+ - **`subdomain`:** the return-path / MAIL FROM label (SendGrid picks one by default, Postmark uses `pm-bounces`, Amazon SES `mail`; Mailgun doesn't use it).
56
+ - **`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.
57
+ - **`overwrite`:** allows replacing existing CNAME/MX records that point elsewhere.
58
+
59
+ 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:
60
+
61
+ ```json
62
+ {
63
+ "id": "dom_…", "object": "domain", "domain": "customer.com", "status": "pending",
64
+ "records": [
65
+ { "type": "CNAME", "name": "em1234.customer.com", "value": "u1234.wl.sendgrid.net", "purpose": "mail_cname", "status": "pending" },
66
+ { "type": "CNAME", "name": "s1._domainkey.customer.com", "value": "s1.domainkey.u1234.wl.sendgrid.net", "purpose": "dkim1", "status": "pending" },
67
+ { "type": "MX", "name": "parse.customer.com", "value": "mx.sendgrid.net", "priority": 10, "purpose": "inbound_mx", "status": "pending" }
68
+ ],
69
+ "dns": { "provider": "cloudflare", "credentials_set": true },
70
+ "validation": null, "conflicts": null, "last_error": null,
71
+ "next_check_at": "…", "verified_at": null, "expires_at": "…", "created_at": "…", "updated_at": "…"
72
+ }
73
+ ```
74
+
75
+ ### Other endpoints
76
+
77
+ | | |
78
+ |---|---|
79
+ | `GET /v1/domains?tenant_id=&status=&limit=&starting_after=` | Newest first. Responses carry `has_more` and `next_cursor` |
80
+ | `GET /v1/domains/:id` | |
81
+ | `PATCH /v1/domains/:id` | `tenant_id`, `metadata`, `inbound` (`null` removes it), `dns`, `overwrite`. Changes to DNS, inbound or overwrite restart checks |
82
+ | `POST /v1/domains/:id/verify` | Check now. Also restarts a `failed` or `conflict` domain. Returns `202` |
83
+ | `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 |
84
+ | `GET /v1/domains/:id/events` | History of what happened to this domain |
85
+ | `GET /v1/account`, `PATCH /v1/account` | `name`, `webhook_url`, `widget_origins`, and `email_provider` (credentials are write-only; see below) |
86
+ | `GET /v1/usage?from=&to=&group_by=tenant` | Usage for billing; see [Usage and billing](#usage-and-billing) |
87
+ | `POST /v1/account/rotate-api-key` | Returns a new key and invalidates the old one immediately |
88
+ | `POST /v1/account/rotate-webhook-secret` | |
89
+ | `PATCH /v1/account` `widget_origins` | Origins allowed to embed the widget |
90
+ | `POST /v1/widget-sessions` | Mint a browser session for the [embeddable widget](widget.md): `{ domain_id }` or `{ tenant_id, … }` |
91
+
92
+ ### Email provider
93
+
94
+ Each account uses one email provider. Set it, and its credentials, with `PATCH /v1/account`:
95
+
96
+ ```json
97
+ { "email_provider": { "provider": "postmark", "account_token": "…", "inbound_server_id": "123" } }
98
+ ```
99
+
100
+ | `provider` | Fields |
101
+ |---|---|
102
+ | `sendgrid` | `api_key` (`sendgrid_api_key` at the top level also works) |
103
+ | `postmark` | `account_token`; `inbound_server_id` to receive email |
104
+ | `mailgun` | `api_key`, `region` (`us` or `eu`, default `us`) |
105
+ | `ses` | `access_key_id`, `secret_access_key`, optional `session_token`, `region` |
106
+
107
+ 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.
108
+
109
+ ### Lifecycle
110
+
111
+ ```
112
+ pending ─▶ awaiting_dns ─▶ verifying ─▶ verified ──(daily re-check fails)──▶ awaiting_dns
113
+ │ │ │
114
+ └────────────┴──────────────┴──▶ conflict (existing records point elsewhere; nothing written)
115
+ └──▶ failed (bad credentials, missing zone, or not verified within 72h)
116
+ ```
117
+
118
+ - **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.
119
+ - **Verified domains:** these are re-checked every 24 hours.
120
+ - **Credential and config problems:** these fail immediately with a hint in `last_error`. Network errors and 5xx responses are retried.
121
+ - **Restarting:** fix the cause, then call `verify`, or `PATCH` the domain.
122
+
123
+ ## Usage and billing
124
+
125
+ 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.
126
+
127
+ | Metric | Meaning |
128
+ |---|---|
129
+ | `active_domains` | Distinct domains that were verified on at least one day in the period. The usual number to bill on, like seats |
130
+ | `active_domain_days` | The sum over days of verified domains, for prorated billing (e.g. bill `active_domain_days / days in month`) |
131
+ | `domains_created` | Domains registered in the period |
132
+ | `verifications` | Times a domain became verified, including after a lost verification |
133
+
134
+ 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.
135
+
136
+ `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:
137
+
138
+ ```json
139
+ {
140
+ "object": "usage", "from": "2026-10-01", "to": "2026-10-25",
141
+ "active_domains": 3, "active_domain_days": 41, "domains_created": 4, "verifications": 3,
142
+ "current_domains": 4, "domain_limit": 25,
143
+ "by_tenant": [
144
+ { "tenant_id": "cus_a", "active_domains": 2, "active_domain_days": 30, "domains_created": 2, "verifications": 2 },
145
+ { "tenant_id": "cus_b", "active_domains": 1, "active_domain_days": 11, "domains_created": 2, "verifications": 1 }
146
+ ]
147
+ }
148
+ ```
149
+
150
+ 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.
151
+
152
+ ## Webhooks
153
+
154
+ Set `webhook_url` on the account (it must be public `https`). Each event is POSTed as JSON with a snapshot of the domain:
155
+
156
+ ```json
157
+ { "id": "evt_…", "object": "event", "type": "domain.verified", "created_at": "…", "data": { "domain": { "id": "dom_…", "status": "verified", … } } }
158
+ ```
159
+
160
+ **Event types:**
161
+ - `domain.created`
162
+ - `domain.dns_written`
163
+ - `domain.verified`
164
+ - `domain.verification_lost`
165
+ - `domain.conflict`
166
+ - `domain.failed` (with `data.reason`)
167
+ - `domain.deleted`
168
+
169
+ **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.
170
+
171
+ Verify each request with the raw body and the account's `webhook_secret`:
172
+
173
+ ```ts
174
+ import { verifyWebhookSignature } from 'dkimkit';
175
+
176
+ app.post('/webhooks/dkimkit', express.raw({ type: 'application/json' }), (req, res) => {
177
+ if (!verifyWebhookSignature(process.env.DKIMKIT_WEBHOOK_SECRET!, req.body.toString(), req.header('x-dkimkit-signature'))) {
178
+ return res.sendStatus(400);
179
+ }
180
+ const event = JSON.parse(req.body.toString());
181
+ // …
182
+ res.sendStatus(200);
183
+ });
184
+ ```
185
+
186
+ The signature header is `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`. Requests older than 5 minutes are rejected as replays.
187
+
188
+ ## Security notes
189
+
190
+ - **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.
191
+ - **Tenant isolation:** every query is scoped to the authenticated account, and other accounts' ids return `404`.
192
+ - **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.
@@ -0,0 +1,253 @@
1
+ # Node.js library and CLI
2
+
3
+ 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.
4
+
5
+ It runs entirely on your machine with your own provider keys. It doesn't call {{APP_NAME}}'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](/docs/hosted-api) instead.
6
+
7
+ | | Library and CLI | Hosted API |
8
+ |---|---|---|
9
+ | Runs | In your process | On {{APP_NAME}} |
10
+ | Domains | No limit | Set by your plan |
11
+ | Waits for DNS and verification | While your code runs | In the background, then sends a webhook |
12
+ | Provider keys | Stay with you | Stored encrypted |
13
+ | UI for your customers | Build your own | Embeddable widget |
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install dkimkit
19
+ ```
20
+
21
+ Requires Node 20 or later. The package has no runtime dependencies and ships its own TypeScript types.
22
+
23
+ ## Quick start
24
+
25
+ ```ts
26
+ import { setupDomain, createEmailProvider, createDnsProvider } from 'dkimkit';
27
+
28
+ const email = createEmailProvider('sendgrid', { apiKey: process.env.SENDGRID_API_KEY });
29
+ const dns = createDnsProvider('cloudflare', { cloudflareApiToken: process.env.CLOUDFLARE_API_TOKEN });
30
+
31
+ const result = await setupDomain(email, dns, {
32
+ domain: 'example.com',
33
+ onEvent: (e) => console.log(e.type),
34
+ });
35
+
36
+ if (result.status === 'complete') console.log('example.com is authenticated');
37
+ else console.log('Not finished yet:', result.status);
38
+ ```
39
+
40
+ 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.
41
+
42
+ ## setupDomain(email, dns, options)
43
+
44
+ Runs the full flow and resolves with a result once the domain is verified, or when it can't go further yet.
45
+
46
+ 1. **Domain.** Reuses the provider's existing authentication for the domain, or creates one.
47
+ 2. **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.
48
+ 3. **Apply.** Writes the new and changed records.
49
+ 4. **Propagation.** Polls the domain's authoritative nameservers until every record is visible.
50
+ 5. **Validation.** Asks the provider to verify, retrying because its resolvers can lag behind.
51
+ 6. **Inbound.** If you asked for it, registers the inbound webhook once the domain is verified.
52
+
53
+ ### Options
54
+
55
+ | Option | Type | |
56
+ |---|---|---|
57
+ | `domain` | `string` | The domain to authenticate. Required |
58
+ | `subdomain` | `string` | Return-path or MAIL FROM label. SendGrid picks one by default, Postmark uses `pm-bounces`, SES uses `mail`. Not used by Mailgun |
59
+ | `inbound` | `{ hostname, url, spamCheck?, sendRaw? }` | Receive email at `hostname` and POST it to `url`. Not supported for Amazon SES |
60
+ | `overwrite` | `boolean` | Replace CNAME and MX records that point elsewhere instead of stopping with a conflict |
61
+ | `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 |
62
+ | `confirm` | `(changes) => Promise<boolean>` | Called before writing DNS when there are changes. Return `false` to stop with status `aborted` |
63
+ | `propagation` | `{ timeoutMs?, intervalMs?, lookup? }` | How long to wait for DNS. Defaults: 300000 ms, polling every 5000 ms |
64
+ | `validation` | `{ attempts?, intervalMs? }` | Verification retries. Defaults: 6 attempts, 10000 ms apart |
65
+ | `onEvent` | `(event) => void` | Progress, for logs or a live UI. See [Events](#events) |
66
+ | `signal` | `AbortSignal` | Stops waiting and rejects with an `AbortError` |
67
+
68
+ ### Result
69
+
70
+ ```ts
71
+ const result = await setupDomain(email, dns, { domain: 'example.com' });
72
+ result.status; // 'complete' | 'dns-pending' | 'validation-failed' | 'aborted' | 'dry-run'
73
+ result.domain; // { id, domain, valid, records, created }
74
+ result.changes; // the DNS plan: [{ record, action: 'create' | 'update' | 'noop' | 'conflict' }]
75
+ result.pending; // dns-pending: records that weren't visible before the timeout
76
+ result.validation; // the provider's last answer: { valid, results: [{ record, valid, reason? }] }
77
+ result.inbound; // 'created' | 'updated' | 'unchanged', when inbound was set up
78
+ ```
79
+
80
+ | Status | Meaning |
81
+ |---|---|
82
+ | `complete` | Verified, and inbound set up if requested |
83
+ | `dns-pending` | Records were written but aren't visible yet. Run again later |
84
+ | `validation-failed` | Records are visible but the provider hasn't verified them yet. Run again later |
85
+ | `aborted` | `confirm` returned `false`. Nothing was written |
86
+ | `dry-run` | `dryRun` was set. `changes` shows what would be written |
87
+
88
+ ### Events
89
+
90
+ `onEvent` is called as each step happens:
91
+
92
+ | `type` | Fields |
93
+ |---|---|
94
+ | `domain` | `auth`: the provider's domain and the records it needs |
95
+ | `plan` | `changes`: what will be created, updated, left alone, or conflicts |
96
+ | `applied` | `changes`: what was written |
97
+ | `propagation` | `pending`, `total`, `elapsedMs`, after each DNS check |
98
+ | `validation` | `attempt`, `result`, after each verification attempt |
99
+ | `inbound` | `hostname`, `url`, `action` |
100
+
101
+ For example, to stream progress to your own UI per customer:
102
+
103
+ ```ts
104
+ await setupDomain(email, dns, {
105
+ domain: tenant.domain,
106
+ inbound: { hostname: `parse.${tenant.domain}`, url: `https://app.example.com/inbound/${tenant.id}` },
107
+ onEvent: (e) => {
108
+ if (e.type === 'propagation') progress(tenant.id, `${e.total - e.pending.length} of ${e.total} records live`);
109
+ if (e.type === 'validation' && e.result.valid) progress(tenant.id, 'Verified');
110
+ },
111
+ });
112
+ ```
113
+
114
+ ## checkStatus(email, domain, subdomain?)
115
+
116
+ A read-only check that changes nothing. It finds the domain at the provider and asks it to re-validate:
117
+
118
+ ```ts
119
+ import { checkStatus } from 'dkimkit';
120
+
121
+ const { domain, validation } = await checkStatus(email, 'example.com');
122
+ if (!domain) console.log('Not set up with this provider yet');
123
+ else if (validation?.valid) console.log('Verified');
124
+ else console.log('Not verified yet:', validation?.results.filter((r) => !r.valid));
125
+ ```
126
+
127
+ ## Email providers
128
+
129
+ Create one with `createEmailProvider(name, credentials, options?)`:
130
+
131
+ | Provider | Name | Credentials | Records |
132
+ |---|---|---|---|
133
+ | SendGrid | `sendgrid` | `apiKey`, with Sender Authentication and Inbound Parse access | 3 CNAMEs |
134
+ | Postmark | `postmark` | `accountToken` (an account token, not a server token), and `inboundServerId` to receive email | DKIM TXT and Return-Path CNAME |
135
+ | Mailgun | `mailgun` | `apiKey`, and `region`: `us` (default) or `eu` | SPF and DKIM TXT, tracking CNAME |
136
+ | Amazon SES | `ses` | `accessKeyId`, `secretAccessKey`, optional `sessionToken`, and `region`, e.g. `us-east-1` | 3 DKIM CNAMEs, MAIL FROM MX and SPF |
137
+
138
+ ```ts
139
+ const sendgrid = createEmailProvider('sendgrid', { apiKey }, { subuser: tenant.sendgridSubuser });
140
+ const mailgun = createEmailProvider('mailgun', { apiKey, region: 'eu' });
141
+ const ses = createEmailProvider('ses', { accessKeyId, secretAccessKey, region: 'us-east-1' });
142
+ ```
143
+
144
+ 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.
145
+
146
+ Each provider also has its own class (`SendGridProvider`, `PostmarkProvider`, `MailgunProvider`, `SesProvider`) if you'd rather construct it directly.
147
+
148
+ 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.
149
+
150
+ ## DNS providers
151
+
152
+ Create one with `createDnsProvider(name, config?)`:
153
+
154
+ | DNS host | Name | Config |
155
+ |---|---|---|
156
+ | Cloudflare | `cloudflare` | `cloudflareApiToken` (or `CLOUDFLARE_API_TOKEN`), with Zone:Read and DNS:Edit on the zone |
157
+ | Amazon Route 53 | `route53` | `aws: { accessKeyId, secretAccessKey, sessionToken? }`, or `awsProfile`, or the usual AWS environment variables. Optional `route53HostedZoneId` |
158
+ | Any other host | `manual` | `onManualRecords(records)`: show the records to whoever adds them |
159
+
160
+ 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.
161
+
162
+ ### Records added by hand
163
+
164
+ 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`:
165
+
166
+ ```ts
167
+ import { createDnsProvider, describeRecord, detectDnsHost } from 'dkimkit';
168
+
169
+ const host = await detectDnsHost('example.com'); // { name: 'GoDaddy', provider: null, nameservers: [...] }
170
+ const dns = createDnsProvider('manual', {
171
+ onManualRecords: async (records) => {
172
+ console.log(`Add these records at ${host.name ?? 'your DNS host'}:`);
173
+ for (const r of records) console.log(' ', describeRecord(r));
174
+ },
175
+ });
176
+ const result = await setupDomain(email, dns, { domain: 'example.com', propagation: { timeoutMs: 30 * 60_000 } });
177
+ ```
178
+
179
+ `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.
180
+
181
+ ## Errors
182
+
183
+ | Error | When | Useful fields |
184
+ |---|---|---|
185
+ | `ConfigError` | Missing or invalid credentials or options | `message` |
186
+ | `DnsConflictError` | A record the provider needs already points somewhere else. Nothing was written | `conflicts`: the conflicting planned changes |
187
+ | `ApiError` | An email or DNS provider rejected a request | `provider`, `status`, `body` |
188
+
189
+ ```ts
190
+ import { ApiError, DnsConflictError, apiErrorHint } from 'dkimkit';
191
+
192
+ try {
193
+ await setupDomain(email, dns, { domain: 'example.com' });
194
+ } catch (err) {
195
+ if (err instanceof DnsConflictError) console.log(err.message); // lists each conflict; pass overwrite: true to replace them
196
+ else if (err instanceof ApiError) console.log(err.message, apiErrorHint(err) ?? '');
197
+ else throw err;
198
+ }
199
+ ```
200
+
201
+ `apiErrorHint(err)` returns what to check when a provider rejects the credentials (401 or 403), such as the permissions a key needs.
202
+
203
+ ## Verifying webhooks
204
+
205
+ If you also use the [hosted API](/docs/hosted-api#webhooks), the package verifies its webhook signatures:
206
+
207
+ ```ts
208
+ import { verifyWebhookSignature } from 'dkimkit';
209
+
210
+ const ok = verifyWebhookSignature(process.env.DKIMKIT_WEBHOOK_SECRET!, rawBody, req.headers['x-dkimkit-signature']);
211
+ ```
212
+
213
+ Pass the raw request body, before any JSON parsing. Requests older than 5 minutes are rejected as replays.
214
+
215
+ ## CLI
216
+
217
+ The same flow from a terminal, with no code:
218
+
219
+ ```bash
220
+ export SENDGRID_API_KEY=SG.xxxx
221
+ export CLOUDFLARE_API_TOKEN=xxxx
222
+
223
+ npx dkimkit --domain example.com --inbound-url https://api.example.com/webhooks/inbound-email
224
+ ```
225
+
226
+ It prints the DNS plan, asks before writing, then shows each step until the domain is verified.
227
+
228
+ | Command | |
229
+ |---|---|
230
+ | `dkimkit --domain example.com` | Set up a domain |
231
+ | `dkimkit status --domain example.com` | Show the provider's verification status without changing anything |
232
+ | `dkimkit ui` | Open a local web interface for the same flow |
233
+
234
+ | Option | |
235
+ |---|---|
236
+ | `-d, --domain` | Domain to authenticate |
237
+ | `--provider` | `sendgrid` (default), `postmark`, `mailgun` or `ses` |
238
+ | `--region` | Mailgun `us` or `eu`, or the Amazon SES region |
239
+ | `--subdomain` | Return-path or MAIL FROM label |
240
+ | `--dns` | `cloudflare`, `route53` or `manual`. Defaults to `cloudflare` when `CLOUDFLARE_API_TOKEN` is set |
241
+ | `--inbound-url`, `--inbound-host` | Receive email and POST it to this URL. The host defaults to `parse.<domain>` (Mailgun: the domain itself) |
242
+ | `--spam-check`, `--send-raw` | Inbound options |
243
+ | `--overwrite` | Replace CNAME and MX records that point elsewhere |
244
+ | `--dry-run` | Show the DNS plan without writing anything |
245
+ | `--wait` | Seconds to wait for DNS to propagate (default 300) |
246
+ | `--on-behalf-of` | Run as a SendGrid subuser |
247
+ | `--aws-profile`, `--zone-id` | Route 53 profile and hosted zone |
248
+ | `-y, --yes` | Write DNS without asking |
249
+ | `--json` | Print the result as JSON, for scripts |
250
+
251
+ 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.
252
+
253
+ 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.
@@ -0,0 +1,153 @@
1
+ # Self-hosting and operations
2
+
3
+ For whoever runs the service. This file is not published on the website.
4
+
5
+ ## Running the server
6
+
7
+ ```bash
8
+ export ENCRYPTION_KEY=$(npx dkimkit-api generate-encryption-key) # keep this safe; it decrypts stored credentials
9
+ export DATABASE_URL=postgres://user:pass@db.internal/dkimkit # or DATABASE_PATH=./data/dkimkit.db for SQLite
10
+
11
+ SENDGRID_API_KEY=SG.xxx npx dkimkit-api create-account --name "Acme" --with-sendgrid-key
12
+ # Account: acct_…
13
+ # API key: dk_live_… (shown once; only a hash is stored)
14
+
15
+ npx dkimkit-api serve # :8080, API and background worker in one process
16
+ ```
17
+
18
+ Docker:
19
+
20
+ ```bash
21
+ docker build -t dkimkit-api .
22
+ docker run -p 8080:8080 -v dkimkit-data:/data -e ENCRYPTION_KEY=… dkimkit-api
23
+ ```
24
+
25
+ | Variable | |
26
+ |---|---|
27
+ | `ENCRYPTION_KEY` | Required. 32 bytes, base64 or hex. AES-256-GCM key for stored SendGrid keys and DNS credentials |
28
+ | `DATABASE_URL` | `postgres://…` connection string. Use this in production |
29
+ | `DATABASE_PATH` | Used when `DATABASE_URL` is unset: SQLite file, default `./data/dkimkit.db` |
30
+ | `PORT`, `HOST` | Default `8080`, `0.0.0.0` |
31
+ | `WORKER_CONCURRENCY` | Domains checked in parallel, default 4 |
32
+ | `RUN_WORKERS=false` | Serve the API only (run workers in another process on the same database) |
33
+ | `ALLOW_PRIVATE_WEBHOOKS=true` | Development only: allow `http://` and private-network webhook URLs |
34
+ | `PUBLIC_URL` | Public base URL (e.g. `https://dns.example.com`) used in widget links. Defaults to the request's host |
35
+
36
+ ## Storage
37
+
38
+ | | SQLite (`DATABASE_PATH`) | Postgres (`DATABASE_URL`) |
39
+ |---|---|---|
40
+ | Good for | One server, development | Production, several API or worker instances |
41
+ | Needs | Node 22.13+ (`node:sqlite`) | The `pg` package (an optional dependency, included in the Docker image) |
42
+
43
+ Both create and upgrade their schema on startup, and that's safe when several instances start at once (Postgres uses an advisory lock). With Postgres you can run any number of API instances and workers against one database. Workers claim due jobs with `FOR UPDATE SKIP LOCKED`, so no domain is checked twice. A common setup is API instances with `RUN_WORKERS=false` behind a load balancer, plus one or two dedicated worker instances.
44
+
45
+ The schema keeps the same shape on both backends: JSON columns are `TEXT` in SQLite and `JSONB` in Postgres, and timestamps are millisecond integers (`BIGINT` in Postgres). The column mapping lives in one place, `src/hosted/store.ts`. To add another backend, implement the `Store` interface there and run `test/store.test.ts` against it.
46
+
47
+ ## Accounts, limits and usage reports
48
+
49
+ Invoice from the report for all accounts, and cap plans with a domain limit:
50
+
51
+ ```bash
52
+ npx dkimkit-api usage-report --from 2026-10-01 --to 2026-10-31 # CSV, one row per account
53
+ npx dkimkit-api usage-report --format json
54
+ npx dkimkit-api set-domain-limit --account acct_… --limit 25 # or --unlimited
55
+ SENDGRID_API_KEY=SG.xxx npx dkimkit-api create-account --name "Acme" --with-sendgrid-key # operator-created account
56
+ ```
57
+
58
+ The limit counts an account's current domains in any status. It's checked before anything is created at SendGrid, so a rejected request leaves nothing behind. Over the limit, `POST /v1/domains` returns `402 plan_limit_reached`. In the widget, end users see a neutral "contact support" message instead. Creates for a limited account run one at a time within a server process, so a bulk add can't overshoot. With several API instances the limit is soft: creates in different processes at the same instant can both pass.
59
+
60
+
61
+ ## Website and legal pages
62
+
63
+ The service serves a public site when `PUBLIC_URL` is set: a landing page at `/`, `/pricing`, `/docs`, `/about`, `/contact`, `/terms`, `/privacy` and `/refunds`. Set these so the pages show the right details:
64
+
65
+ | Variable | Used for |
66
+ |---|---|
67
+ | `APP_NAME` | Product name everywhere (default "DKIMkit") |
68
+ | `LEGAL_NAME` | Who operates the service: your name or business name, on About and the legal pages |
69
+ | `CONTACT_EMAIL` | Support and legal contact. Messages from the website's feedback form are emailed here (with the sign-in mailer) |
70
+ | `LEGAL_COUNTRY` | Country whose law governs the terms (e.g. "India") |
71
+ | `LEGAL_EFFECTIVE_DATE` | "Last updated" date on the legal pages (default: the build's date) |
72
+
73
+ Until they're set, the pages show visible placeholders. The legal pages are drafts written for this product. Have them reviewed by someone qualified before relying on them.
74
+
75
+ ## Free plan before payments launch
76
+
77
+ Without `BILLING_PROVIDER`, setting `FREE_DOMAINS` (e.g. `FREE_DOMAINS=10`) caps each account at that many domains, and the pricing page shows the free plan with pay as you go marked "coming soon". Raise one account's cap with `set-domain-limit`, which replaces the free plan's limit for that account. When billing is turned on, `FREE_DOMAINS` becomes the free threshold and must match the one in your payment provider.
78
+
79
+ ## Billing: payment provider
80
+
81
+ Payments go through **[Dodo Payments](https://dodopayments.com)** by default. Dodo is a *merchant of record*: it is the legal seller to your customers, so it charges them, handles VAT, GST and sales tax worldwide, and pays you out. It accepts individual developers, including in India, without a registered company. Stripe is also supported (`BILLING_PROVIDER=stripe`) for when you have a company that Stripe accepts. The two are interchangeable behind a small provider interface (`src/hosted/billing.ts`).
82
+
83
+ ## Billing setup (Dodo Payments)
84
+
85
+ Do this in **test mode** first (Dodo dashboard toggle), then repeat in live mode.
86
+
87
+ 1. **Meter.** In the Dodo dashboard, open Products → Meters and create:
88
+ - Event name: `active_domains` (must match `DODO_METER_EVENT`, default `active_domains`)
89
+ - Aggregation: **Max**, over property `active_domains`
90
+ - Measurement unit: `domains`
91
+ 2. **Product.** Create a subscription product with pricing type **Usage Based Billing**, billed monthly. Attach the meter with:
92
+ - Price per unit: **$2** (match `PRICE_PER_DOMAIN_CENTS`, default 200)
93
+ - Free threshold: **3** (match `FREE_DOMAINS`, default 3)
94
+
95
+ Copy the product id (`pdt_…`).
96
+ 3. **Webhook.** Developer → Webhooks → add an endpoint at `https://<your PUBLIC_URL>/billing/webhook` subscribed to the `subscription.*` events. Copy its signing secret (`whsec_…`).
97
+ 4. **API key.** Developer → API keys → create one.
98
+ 5. **Email for sign-in links**, sent through your own Resend or SendGrid account:
99
+ ```bash
100
+ PUBLIC_URL=https://dns.yourapp.com # required for sign-up and billing
101
+ EMAIL_FROM=login@yourapp.com # on a domain verified in Resend (or a verified SendGrid sender)
102
+ MAIL_RESEND_API_KEY=re_… # a Resend key with sending access, or:
103
+ MAIL_SENDGRID_API_KEY=SG.… # a SendGrid key with Mail Send permission
104
+ APP_NAME="Your Product"
105
+ ```
106
+ If both keys are set, Resend is used. Without `EMAIL_FROM` and a key, sign-in links are written to the log instead, which is handy in development.
107
+ 6. **Run:**
108
+ ```bash
109
+ BILLING_PROVIDER=dodo \
110
+ DODO_MODE=test \
111
+ DODO_API_KEY=… \
112
+ DODO_WEBHOOK_SECRET=whsec_… \
113
+ DODO_PRODUCT_ID=pdt_… \
114
+ npx dkimkit-api serve
115
+ ```
116
+ The startup log line shows `"billing": "dodo (test mode)"`. Switch to `DODO_MODE=live`, with live-mode keys, product and webhook secret, when you're ready to charge.
117
+
118
+ `FREE_DOMAINS` and `PRICE_PER_DOMAIN_CENTS` only drive the dashboard's limits and estimates. What's charged is the product's price and free threshold in Dodo, so keep them in sync.
119
+
120
+ ## How usage reaches Dodo
121
+
122
+ - **Hourly reporting:** every hour, the worker records an active day for each verified domain, then counts each paying account's distinct active domains in its current billing period. The period comes from the subscription's `previous_billing_date` and `next_billing_date` in Dodo's webhooks.
123
+ - **Running totals:** when that count has grown, it sends one usage event to `POST /events/ingest` with the **running total** (`metadata.active_domains`). The meter's **Max** aggregation bills the period's highest total, and Dodo's free threshold makes the first 3 free. The count never decreases within a period, so its maximum is exactly the number of distinct active domains.
124
+ - **No double billing:**
125
+ - Each event's `event_id` is `account:periodStart:total`, which Dodo treats as an idempotency key. Re-sending the same total is harmless.
126
+ - The total already reported is stored per account and period, so unchanged totals aren't re-sent.
127
+ - A renewal starts a new period, which is reported from zero.
128
+ - **Timing:** Dodo accepts usage events up to 1 hour old. Events are timestamped when sent, so the hourly report always qualifies.
129
+
130
+ ## Webhook handling
131
+
132
+ Dodo signs webhooks with the Standard Webhooks scheme. Each request is checked as follows:
133
+ - **Signature:** an HMAC-SHA256 over `webhook-id.webhook-timestamp.body`, keyed with the base64 secret after its `whsec_` prefix, compared in constant time. Several `v1,` signatures are accepted during secret rotation.
134
+ - **Freshness:** the timestamp must be within 5 minutes.
135
+ - **Processed once:** each `webhook-id` is processed only once, since Dodo retries with the same id.
136
+ - **Out-of-order events:** events older than the last one applied to an account are ignored, because delivery order isn't guaranteed. A cancellation for a subscription that has already been replaced doesn't downgrade the new one.
137
+
138
+ | Dodo subscription status | Account |
139
+ |---|---|
140
+ | `active` | Pay as you go |
141
+ | `past_due`, `on_hold` | Pay as you go, payment issue |
142
+ | `cancelled`, `expired`, `failed`, `paused` | Free |
143
+ | `pending` | Unchanged until it settles |
144
+
145
+ ## Stripe (alternative)
146
+
147
+ With `BILLING_PROVIDER=stripe`, the same flow uses Stripe Checkout and Stripe's Customer Portal, and reports usage to a Stripe billing meter. Stripe's meter sums events, so this provider reports increments rather than running totals. `npx dkimkit-api stripe-setup` creates the meter, a graduated metered price (first 3 free) and the webhook endpoint. Set `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` and `STRIPE_PRICE_ID`.
148
+
149
+ ## Limits and notes
150
+
151
+ - **Caps across instances:** a cap is exact within one process, but two instances creating domains for the same account at the same instant can both pass it.
152
+ - **Free accounts:** usage is metered for them too (visible in the dashboard), but only accounts with a payment method on file are reported to the provider.
153
+ - **Not yet tested against a live Dodo account:** the integration follows Dodo's API reference and is tested against a simulated Dodo API. Before charging real customers, run one full checkout → usage → invoice cycle in test mode.