DKIMkit

Embeddable widget

A drop-in UI that lets your users connect their own domain for sending email from inside your app. It runs on the hosted API. The widget:

  • asks for the domain, or uses one you already registered;
  • detects where the domain's DNS is hosted;
  • either connects Cloudflare or Route 53 and writes the records automatically, or shows the exact records to paste at any other provider;
  • tracks progress until the domain is verified.
Your backend ── POST /v1/widget-sessions (secret key) ──▶ API ──▶ client_secret (wss_…, 1 hour)
Your frontend ── <script src=".../widget.js"> + DKIMkit.mount(el, { session }) ──▶ iframe on the API's origin

1. Allow your app's origin (once)

curl -X PATCH https://dkimkit.com/v1/account \
  -H "Authorization: Bearer $DKIMKIT_API_KEY" -H "Content-Type: application/json" \
  -d '{"widget_origins": ["https://app.yourproduct.com", "http://localhost:3000"]}'

Only these origins can embed the widget. http:// is accepted only for localhost.

2. Create a session on your backend

Never send your API key to the browser. Create a short-lived session for the signed-in user instead:

// POST /api/email-domain/session (your route; requires the user to be signed in)
const res = await fetch('https://dkimkit.com/v1/widget-sessions', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.DKIMKIT_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    tenant_id: currentUser.workspaceId,       // the user enters their domain in the widget
    inbound_url: 'https://api.yourproduct.com/inbound-email',  // optional
    metadata: { plan: currentUser.plan },     // optional, never shown to the user
  }),
});
const { client_secret } = await res.json();
return { session: client_secret };

There are two ways to create a session:

  • { tenant_id, … }: the user types their domain in the widget. The domain is created with tenant_id, metadata, subdomain, sendgrid_subuser and inbound_url (its hostname becomes parse.<domain>). If the same tenant comes back with the same domain, the widget resumes it. If another tenant already registered that domain, the widget refuses without revealing anything about it.
  • { domain_id }: for a domain you already created with POST /v1/domains.

Sessions last 1 hour by default (expires_in: 60 to 86400 seconds). Each session is tied to one domain and can't read or change anything else.

3. Mount it

<div id="email-domain"></div>
<script src="https://dkimkit.com/widget.js"></script>
<script>
  const { session } = await fetch('/api/email-domain/session', { method: 'POST' }).then((r) => r.json());
  DKIMkit.mount('#email-domain', {
    session,
    onReady: (domain) => {},                 // domain is null until the user enters one
    onStatusChange: (domain) => {},          // pending → awaiting_dns → verifying → verified, or conflict / failed
    onVerified: (domain) => showToast(`${domain.domain} is ready`),
    onError: (error) => {},                  // { type: 'session_expired', message }
    appearance: { accent: '#4f46e5', mode: 'light', radius: 8 },
  });
</script>

React:

function EmailDomainSetup({ session }: { session: string }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const widget = window.DKIMkit.mount(ref.current!, { session, onVerified: () => refetch() });
    return () => widget.unmount();
  }, [session]);
  return <div ref={ref} />;
}

mount returns { iframe, unmount }. The iframe resizes itself to fit its content.

Appearance:

  • accent: #rrggbb. Button text switches between black and white for contrast.
  • mode: light (default), dark, or auto. auto follows the operating system; light is the default because matching your page matters more than the OS setting.
  • radius: 0–24.

If you load widget.js some other way (a bundler or a module import), pass apiOrigin: 'https://dkimkit.com'.

Caching: widget.js is served with a 5-minute cache, so updates to the loader take up to 5 minutes to reach browsers.

What your users see

  1. Domain: only for tenant sessions.
  2. Setup method: chosen based on where the domain's DNS is hosted, which the widget detects from its nameservers.
    • Cloudflare or Route 53: "Your DNS is hosted at Cloudflare. Connect it." The user creates a scoped token, or an IAM key with a minimal policy. The API checks the credentials against the zone before saving them, so a wrong token gets a clear error immediately.
    • GoDaddy, Namecheap and other recognized hosts: "Add the records in your GoDaddy account." The records are shown with host names in the form registrars expect (em1234, s1._domainkey, @), each with a copy button.
  3. Progress: each record shows Waiting or Found, with a "Check now" button. The user can close the page while checks continue in the background, and your webhooks still fire.
  4. Conflicts: if existing records with the same names point elsewhere, the user sees which ones and can choose "Replace them". Records with other names are never touched.
  5. Done: the domain is verified.

Security model

  • Credential isolation. The widget is an iframe served from the API's origin. The Cloudflare tokens and AWS keys your users type go straight to the API, encrypted at rest. They never pass through your page's JavaScript or your servers.
  • Session tokens, not API keys, in the browser. wss_… tokens are stored as hashes, expire, cover one domain, and are rejected by the account API. The reverse holds too: your API key doesn't work on widget endpoints.
  • Clickjacking. The frame sends Content-Security-Policy: frame-ancestors <your widget_origins>, so other sites can't embed it to trick users into typing credentials. An expired or invalid session sends frame-ancestors 'none'.
  • Locked-down frame. default-src 'none', a per-response nonce on the script, connect-src 'self', no referrer, and theme parameters validated before they reach the page.
  • Messaging. The frame only exchanges postMessage with a parent whose origin is in widget_origins, and the loader only accepts messages from its own iframe on the API origin. Messages carry the end-user view of the domain: no ids, metadata, webhook URLs or credentials.
  • End-user errors are sanitized. Email-provider and configuration failures, which are yours to fix, appear to users as a generic message. The full error is in last_error on the API and in your domain.failed webhook.

Widget endpoints

These are called by the iframe with Authorization: Bearer wss_…. You normally don't call them yourself.

GET /v1/widget/sessionSession state: needs_domain, domain (end-user view), dns_host
POST /v1/widget/domain{ domain }, for tenant sessions
POST /v1/widget/connect{ provider: 'cloudflare' | 'route53' | 'manual', … }. Credentials are verified before they're saved
POST /v1/widget/verifyCheck now
POST /v1/widget/resolve-conflictsThe user approves replacing conflicting records
GET /widget.js, GET /widget/frameThe loader and the iframe page