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.
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 }mk = MailKite() # no key yet — this route is public
account = mk.register({
"email": "owner@myapp.ai",
"channel": "my-boilerplate",
"ref": "xmbf3bd0",
})$mk = new \MailKite\Client();
$account = $mk->register([
'email' => 'owner@myapp.ai',
'channel' => 'my-boilerplate',
'ref' => 'xmbf3bd0',
]);MailKite mk = new MailKite();
Object account = mk.register(Map.of(
"email", "owner@myapp.ai",
"channel", "my-boilerplate",
"ref", "xmbf3bd0"));mk := mailkite.New("")
account, err := mk.Register(map[string]any{
"email": "owner@myapp.ai",
"channel": "my-boilerplate",
"ref": "xmbf3bd0",
})mk = MailKite::Client.new
account = mk.register({
email: "owner@myapp.ai",
channel: "my-boilerplate",
ref: "xmbf3bd0",
})curl https://api.mailkite.dev/api/v1/provision \
-H "Content-Type: application/json" \
-d '{
"email": "owner@myapp.ai",
"channel": "my-boilerplate",
"ref": "xmbf3bd0"
}' {
"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.
{
"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:
{
"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:
{
"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:
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.
}mk = MailKite(account["api_key"])
me = mk.me()
if not me["emailVerified"]:
... # show "check your inbox"$mk = new \MailKite\Client($account['api_key']);
$me = $mk->me();
if (!$me['emailVerified']) {
// show "check your inbox"
}MailKite mk = new MailKite(apiKey);
Object me = mk.me();mk := mailkite.New(apiKey)
me, err := mk.Me()mk = MailKite::Client.new(api_key)
me = mk.me
curl https://api.mailkite.dev/v1/me \
-H "Authorization: Bearer mk_live_…"
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.
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: [] }mk = MailKite(account["api_key"])
suggestion = mk.suggestSubdomain()
check = mk.checkSubdomain("myapp")
claimed = mk.claimSubdomain({"subdomain": "myapp"})$mk = new \MailKite\Client($account['api_key']);
$suggestion = $mk->suggestSubdomain();
$check = $mk->checkSubdomain('myapp');
$claimed = $mk->claimSubdomain(['subdomain' => 'myapp']);MailKite mk = new MailKite(apiKey);
Object suggestion = mk.suggestSubdomain();
Object check = mk.checkSubdomain("myapp");
Object claimed = mk.claimSubdomain(Map.of("subdomain", "myapp"));mk := mailkite.New(apiKey)
suggestion, _ := mk.SuggestSubdomain()
check, _ := mk.CheckSubdomain("myapp")
claimed, err := mk.ClaimSubdomain(map[string]any{"subdomain": "myapp"})mk = MailKite::Client.new(api_key)
suggestion = mk.suggestSubdomain
check = mk.checkSubdomain("myapp")
claimed = mk.claimSubdomain({ subdomain: "myapp" })curl https://api.mailkite.dev/api/domains/subdomain \
-H "Authorization: Bearer mk_live_…" \
-H "Content-Type: application/json" \
-d '{ "subdomain": "myapp" }' {
"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:
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" Linking an existing account
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.
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.
// 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.
// 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.