01The environment is in the key
A key is a prefix plus twenty-four random bytes in base64url — forty characters in total, of which the first eight tell you, and anyone who ever sees it, which world it acts on.
Eight characters of environment, thirty-two characters of secret. The environment travels inside the credential, so a test key in a production log is something a person can notice rather than something you find out about when the mail arrives.
ms_live_V2h5IGFyZSB5b3UgcmVhZGluZyB0aA ← production. Real recipients. ms_test_aXMgYmFzZTY0IGluIGEgZG9jcz8g ← the test environment.
| Part | What it is | Why it is that |
|---|---|---|
| ms_live_ · ms_test_ | Eight characters of prefix | The environment, visible in the string itself — in a Slack thread, a screenshot, a logger’s output, or read aloud on a call. The alternative design, where the environment lives in a separate MAILYSEND_ENV variable, has no such moment. |
| 24 random bytes | base64url, thirty-two characters | 192 bits of entropy — not a number chosen to sound impressive, just comfortably past the point where guessing is the attack. Nobody brute-forces one of these; they find it in a repository, a CI log or a screenshot. |
| 40 characters in total | Prefix plus secret | The whole token, returned in exactly one API response and never again. |
| ms_live_V2h5…dGhp | The stored preview: first twelve, ellipsis, last four | Twelve is not arbitrary — the prefix is eight, so the preview shows the environment plus four characters of the secret. Enough to match against your secrets manager, nowhere near enough to be a credential. |
| ^ms_(live|test)_[A-Za-z0-9_-]{20,}$ | The shape check, before anything else | A token that does not match is rejected on the spot — no hash, no cache read, no database round trip. A scanner spraying random bearer tokens at /v1 should cost you a regular expression, not a query. |
This mirrors Stripe's scheme deliberately, and not out of flattery. Every property in that table is about handling rather than about key length, because handling is where keys are actually lost.
02Full access and sending access
There are exactly two permission levels. Not a scope matrix, not a role builder, not a policy language — two values, and you can hold both of them in your head while looking at a form.
full_access carries the wildcard scope; sending_access carries exactly one, emails:send. Everything below is what that one scope does and does not reach.
- Everything the API can do: send, read messages and events, manage contacts, domains, templates, webhooks and other keys.
- It is the default, and it is the one to think twice about.
- Exactly one scope, emails:send. No writes to contacts, domains, webhooks or other keys.
- The right credential for an application server whose only job is to put messages in the queue.
| Scope | Held by | What it guards |
|---|---|---|
| emails:send | Both levels | POST /v1/emails and POST /v1/emails/batch |
| contacts:read | full_access only | GET /v1/contacts/search |
| contacts:write | full_access only | Creating, importing, updating and deleting contacts |
| audiences:write | full_access only | Creating and editing audiences |
| segments:write | full_access only | Creating and editing segments |
| automations:write | full_access only | Creating, editing and running automations |
| inbound:write | full_access only | Inbound routing configuration |
| webhooks:write | full_access only | Creating, updating, deleting and test-sending endpoints |
| api_keys:write | full_access only | Minting and revoking keys |
The refusal names the scope it wanted: “This API key is limited to sending; `webhooks:write` requires a full-access key.”
Two rather than twenty is the actual argument here. A permission model people do not understand is a permission model everybody sets to admin. A twelve-checkbox scope matrix looks more rigorous and produces worse outcomes, because the person creating the key at four in the afternoon does not know which six of the twelve their integration needs, and the safe-feeling move — tick them all — is the unsafe one.
And sending_access genuinely is the common case. Most integrations are an application server that renders a receipt and posts it to /v1/emails. If it is ever compromised, the difference between the two levels is the difference between “somebody sent mail from our domain” and “somebody has our mailing list”.
POST /v1/api-keys { "name": "checkout-service", "permission": "sending_access", "domain_id": "dom_4Rk", "expires_at": "2027-01-01T00:00:00Z" }
Creating a key is itself a privileged action: it needs the api_keys:write scope and at least a developer role. A sending_access credential cannot mint itself a better one, which is the property that makes the narrow scope worth anything at all.
03Why a key is shown exactly once
The token is generated, returned in exactly one API response, and then it is gone. What stays behind is a SHA-256 hash and a twelve-character preview. There is no endpoint that returns a token, and no query in the codebase that reads the hash into a response body.
Authentication hashes what the caller presented and looks for that digest. The same direction, never the reverse — which is why a database dump yields nothing usable.
This is the property worth being unfriendly about. If a key could be read back, the database would contain a set of working credentials, and every backup, every replica, every debugging export and every support engineer with read access would be holding them too. You cannot present a SHA-256 digest to /v1/emails and have it send anything.
So what is the preview for? Telling two keys apart. It is what lets a colleague say “the one ending dGhp” in an incident channel without saying anything dangerous.
$ curl -s $BASE/v1/api-keys -H "Authorization: Bearer $KEY"{ "data": [{ "id": "key_9Fb", "name": "checkout-service", "token_preview": "ms_live_V2h5…dGhp","permission": "sending_access", "environment": "live", "revoked_at": null }] }
One operational detail that matters later: resolving a key hits the database once and is then cached for five minutes, because the send path cannot afford a lookup per request. Revocation does not wait out that cache — it deletes the cached entry in the same request, and does so before responding, so a 200 from the revoke call means the key is dead now rather than dead soon.
04Rotating without an outage
Rotation is three steps in one specific order. The order is the whole content of this section, because two of the six possible orderings work and the other four contain a window where production is holding a credential that no longer authenticates.
Overlap, cut over, revoke. In that order, nothing is ever offline.
Mint the replacement with the same permission, the same domain pin, and a name that says when and why. Nothing is using it yet. Both keys are now valid, which is the entire point: there is no instant at which zero keys work.
Update the secret in your secrets manager and roll your services. Then wait for every long-lived process to actually pick it up — a worker that read its environment at boot three weeks ago is still using the old key no matter what your configuration says, and a cron job that runs monthly has not run yet.
Only once nothing is using it. Revoking marks the row rather than deleting it, so the key that a future investigation cares about is still there to be named.
- Two valid keys for the duration, so there is no instant at which zero work.
- Rotation becomes a non-event, and a non-event is something you will actually do quarterly instead of never.
- Revoking first is a deliberate outage as long as your deploy takes, plus however long it takes somebody to notice.
- Skipping the overlap deploys a key that does not exist yet — the same outage, with a more confusing error message.
05What to do about a leaked key
A key is in a public repository, a CI log, a screenshot in a ticket, or a client-side bundle. The order here is not the same as rotation, and getting it right matters more, because every minute you spend understanding the leak is a minute the key still works.
Revoke first. Investigate second. Revocation is cheap and you can always mint a replacement; the window will not still be there in ten minutes, and the investigation will.
$ curl -X DELETE $BASE/v1/api-keys/key_9Fb -H "Authorization: Bearer $ADMIN_KEY"{ "object": "api_key", "id": "key_9Fb", "revoked_at": "…", "deleted": true }
That response is the confirmation, not an acknowledgement: the cached entry is dropped before it returns. Purging means rewriting the git history if that is what it takes — a revoked key in a commit is still a signal about your naming and your habits.
- What was sent. Every message is a row, with its sender, its recipients, its subject, its environment and its timestamp, and every delivery event is queryable.
- If the leaked key was used to send, that mail is in your message list and you can read it.
- A sudden run of sends you cannot account for, or sends from a domain that service never uses, is the strongest evidence available — and it usually answers the question that actually matters.
- Which key sent a given message. The row records the workspace, not the credential.
- Whether a key was used at all, from the key resource itself.
- The practical answer to both: give each integration its own sending domain, so the message carries the attribution the row does not.
Revocation marks the row rather than deleting it. The key stays listed, with its name, its preview, its permission and the moment it was retired. An audit trail that erases the credential an incident was traced to is not an audit trail, and “there is no record of that key” is not a sentence you want to write in a post-mortem.
Finally, prevention that actually works: keep ms_live_ out of anything a browser downloads, add a secret scanner to CI that fails the build on the prefix — it is a distinctive, greppable eight characters, which is another quiet argument for the scheme — and prefer sending_access everywhere it will do. If an agent is going to be holding one of these, the agent inbox guide covers the confirmation gate that sits in front of sending regardless of what the key allows.
What just happened
You know what a key looks like, why the environment is baked into the string rather than kept in a config file somewhere else, and why nobody can ever read one back to you. The thing to internalise now, while nothing is on fire, is the rotation order: overlap, cut over, revoke. Doing it in any other order gives you a window where production has no working credential, and that window always turns out to be during a deploy.