Get your API key
Getting started

Automated onboarding

Everything below exists so a tool you ship — a boilerplate, a CMS plugin, a platform that sends on behalf of its users — can take someone from "no MailKite account" to "sending email" without them leaving your UI, opening a dashboard, or touching DNS.

There are two entry points. Register creates a new account from an email address, no password. Link connects an account the user already has, over OAuth. Both hand you an mk_live_… API key at the end, and from there every other page in these docs applies unchanged.

1. Register an account from an email

POST /api/v1/provision is public — it takes no credential, because it is the call you make when you don't have one yet. Give it an email address and it returns a working API key immediately.

register
const mk = new MailKite();                     // no key yet — this route is public

const account = await mk.register({
email: "owner@myapp.ai",
channel: "my-boilerplate", // your slug, for per-channel stats
ref: "xmbf3bd0", // your affiliate code
});
// → { api_key: "mk_live_…", user_id: "usr_…", email_verified: false, is_new: true }
Install Docs →
201 Created
{
"api_key": "mk_live_9hK2mQx7Tw4bVnR8",
"user_id": "usr_7Fj3MnQw",
"email": "owner@myapp.ai",
"email_verified": false,
"is_new": true
}

There is no password anywhere in this flow. What makes that safe is the verification gate in the next section, not a secret the user has to invent.

When the email already has an account

You get 409 with code: "account_exists" and no credentials — registering someone else's address can never hand you their key. If that account is unverified, a fresh verification email goes out as a side effect.

409 Conflict
{
"is_new": false,
"email": "owner@myapp.ai",
"email_verified": true,
"error": "this email already has an account — connect it with an API key from the dashboard, or sign in",
"code": "account_exists"
}

Treat a 409 as a signal to offer the link flow instead: the user has an account, they just need to connect it.

Attribution

Three optional fields decide how the signup is attributed. They are JSON fields in the request body — not query parameters on the URL, and not headers:

attribution on the register call
{
"email": "owner@myapp.ai",
"ref": "xmbf3bd0",
"channel": "my-boilerplate",
"referrer": "https://myapp.ai/pricing"
}

What each one does:

  • ref — the affiliate code of the account that referred this signup. Find yours at your affiliate page. An unknown code is ignored rather than failing the signup.
  • channel — a slug of your own (^[a-z0-9][a-z0-9_-]{0,31}$) that breaks your referrals down by source, so a plugin, a CLI, and a hosted app report separately.
  • referrer — the first-touch landing URL, when you know it.

Country, city, and device are derived server-side from the request and can't be set by the caller.

Don't confuse this with the ?ref= link. https://mailkite.dev/?ref=<code> is a different mechanism: it drops a 60-day last-click cookie in the visitor's browser, for people you send to the site who then sign up through the dashboard themselves. It has no effect on register() — an API call carries no cookies. If your integration creates the account, attribution has to be in the body.

2. The verification gate

The new account can do almost everything right away — add domains, create routes and templates, set webhooks, receive inbound mail. The one thing it cannot do until the address is verified is send:

403 Forbidden — POST /v1/send
{
"error": "verify your account email before sending — check your inbox for the verification link, or re-request it from the dashboard",
"code": "email_unverified"
}

A verification link is emailed at registration and is good for 24 hours; clicking it flips the account and lands the user on a confirmation page. Poll GET /v1/me to drive your own UI:

check verification
const mk = new MailKite(account.api_key);

const { email, emailVerified, plan } = await mk.me();
if (!emailVerified) {
// Show "check your inbox" — every other call works, but send() will 403.
}
Install Docs →

This is the gate that makes password-less registration safe: an address you don't control yields an account that can never send. Design your onboarding around it — show "check your inbox", and don't present sending as ready until emailVerified is true.

3. A sending identity, with no DNS

Sending always requires a verified domain — there is no shared sandbox from-address. The fastest way to get one is to claim a free MailKite subdomain — a <label>.<base> host on a zone we run. We host the DNS, so it comes back already verified with an empty dns array. Nothing for your user to publish, nobody to email about DNS records.

claim a subdomain
const mk = new MailKite(account.api_key);

const { subdomain, base } = await mk.suggestSubdomain(); // "swift-otter", "mailk.us"
const { available } = await mk.checkSubdomain("myapp");

const claimed = await mk.claimSubdomain({ subdomain: "myapp" });
// → { domain: { domain: "myapp.mailk.us", status: "verified", … }, dns: [] }
Install Docs →
201 Created
{
"domain": {
"id": "dom_2VbXqTpN8rKw",
"domain": "myapp.mailk.us",
"status": "verified",
"mx_verified": 1, "spf_verified": 1, "dkim_verified": 1, "dmarc_verified": 1
},
"dns": []
}

Labels are 3–32 characters, lowercase letters, digits and hyphens. suggestSubdomain prefills the input with a free one and checkSubdomain is cheap enough to call as the user types — its reason is written to be shown verbatim.

Always take the zone from the base field rather than hard-coding a hostname. Which zone new subdomains are handed out under changes over time — it has already moved once — and more than one may be on offer. Subdomains claimed on a retired zone keep working.

When your user does want mail to come from their own name, that's the ordinary add-a-domain path: createDomain returns the records to publish, and verifyDomain re-checks them. The domain flips to verified as soon as its MX resolves; SPF, DKIM and DMARC keep getting checked in the background.

End to end, on the CLI

The same three steps, if you'd rather see them without an SDK:

onboard from scratch
mailkite register --email owner@myapp.ai --channel my-boilerplate
mailkite domains claim myapp
mailkite send --to you@example.com --from hello@myapp.mailk.us --subject "Hi" --text "It works"

When the user already has MailKite, don't ask them to paste a key. Link over OAuth: they sign in with whatever method they already use — Google, GitHub, password, or an email link — approve your app, and you receive a token. No credentials are ever typed into your UI.

Registration is dynamic (RFC 7591), so there is no app-review queue and no client secret to ship: PKCE takes its place. Read the endpoints from /.well-known/oauth-authorization-server rather than hard-coding them.

Step 1 — register a client

Once per installation. Keep the client_id; there is no secret.

register an OAuth client
const client = await mk.registerOAuthClient({
client_name: "MailKite for myapp.ai",
redirect_uris: ["https://myapp.ai/settings/mailkite/callback"],
});
// → { client_id: "mkcli_…", token_endpoint_auth_method: "none", … }

Step 2 — send the browser to authorize

This leg is a redirect, not an API call. PKCE is mandatory and S256-only.

redirect to authorize
// PKCE: keep the verifier server-side, send only the S256 challenge.
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());

const url = new URL("https://api.mailkite.dev/oauth/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", client.client_id);
url.searchParams.set("redirect_uri", "https://myapp.ai/settings/mailkite/callback");
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("scope", "mcp");
url.searchParams.set("state", state); // your CSRF token — verify it on the way back

redirect(url.toString());

Step 3 — exchange the code, then store an API key

The access token is account-wide and short-lived. The pattern we recommend — and the one the WordPress plugin uses — is to spend it immediately on GET /api/keys, store the mk_live_… key, and discard the OAuth tokens.

exchange + store
// At your redirect_uri, after checking `state`:
const tokens = await mk.exchangeOAuthToken({
grant_type: "authorization_code",
code,
redirect_uri: "https://myapp.ai/settings/mailkite/callback",
client_id: client.client_id,
code_verifier: verifier,
});

// Exchange the short-lived access token for the account's API key, store that,
// and drop the OAuth tokens.
const linked = new MailKite(tokens.access_token);
const { key } = await linked.getApiKey();

Which path to offer

Offer both, and let the 409 route between them. A good settings screen has three buttons: create an account (register), connect an existing account (link), and paste an API key for anyone who prefers it. That is exactly what the WordPress plugin ships, and it covers every user without a support thread.

Next: Send API for the first send, or Domains & DNS when your user brings their own domain.