Pick a transactional email service you can drive over plain HTTP, verify the sending domain with SPF and DKIM before the first player hits the signup form, and write down whatever the API hands back on every send. For a game backend the deciding constraint isn't raw speed and it isn't the monthly bill — it is evidence. When a player disputes an account takeover months later, or a publisher's reviewer asks how you established that an address belonged to a human, somebody has to produce a record.
Deliverability is the easy half of this problem. The receipts are the half people skip.
I run a one-person shop, so I measure infra work in hours I did not spend on the game itself. Running my own outbound mail server isn't a defensible use of those hours. Designing what gets stored when a verification link goes out is, because nobody else can do it for me and it's the artifact an auditor actually reads.
Consent, age gates, and the retention story behind one link
A signup flow in games usually carries more than one obligation at once. There is the address check itself — did this person control the mailbox. There is often an age gate whose result you must retain. And on the same screen, almost always, a checkbox for patch notes and promotions, which under GDPR Article 7 means you must be able to demonstrate that the player consented, not merely assert it.
Those are three different records with three different retention stories, and bundling them into one row is how teams end up unable to answer any of the three.
Here is the set I keep for the verification link alone: the token issuance (hashed token, player id, expiry, the exact template version used), the accept receipt returned by the sending API, the delivery or bounce event that arrives afterward, and the request log line for the click that completed verification. Four rows, one join key. The join key is the provider's message id, which is why the choice of service matters at all: a service that accepts your request and returns nothing you can store has just made your compliance evidence unprovable.
That is the real selection axis for this job. Not the feature grid.
How should I verify the sending domain with SPF and DKIM for a transactional email service?
Do the DNS setup on a dedicated subdomain, and do it before launch week rather than during it.
SPF is a TXT record on the sending domain listing who may send on its behalf (RFC 7208). One record per domain — publishing two is a permanent error, and the specification caps you at 10 DNS lookups during evaluation, which is easy to blow past once you have a mail service, a helpdesk tool, and an invoicing tool all asking for their own include:. DKIM is a signature over the message headers and body, verified against a public key you publish under a selector (RFC 6376). Your provider will either generate the keypair and give you a TXT record, or accept a public key you generated yourself; the second option costs ten minutes and keeps rotation under your control.
DMARC is the one that turns all of this into an audit artifact. Publishing _dmarc with a rua= address means the receiving side mails you aggregate XML reports describing how your traffic authenticated. Those reports are evidence — dated, third-party, and not written by you. Start at p=none, read the reports until every legitimate stream aligns, then move to quarantine and reject.
mail.example-game.com. TXT "v=spf1 include:_spf.mailprovider.example ~all"
s2026a._domainkey.mail.example-game.com. TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQ..."
_dmarc.example-game.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@example-game.com; adkim=s; aspf=s"
Separately from the standards, the service itself will ask you to prove you own the domain — a TXT or CNAME it tells you to publish — before it will sign mail as you. That step is domain verification in the vendor sense, and it is not the same thing as SPF or DKIM passing. I have seen the two conflated in enough onboarding checklists that it is worth separating: one proves ownership to the vendor, the other proves authenticity to the receiver. Google's sender guidelines want the second kind, with DMARC required once you cross their bulk threshold.
The smallest API call that keeps its own receipts
The send is one HTTP call and one insert. If the insert is missing, treat the send as if it never happened.
type Accepted = { messageId: string; acceptedAt: string };
export async function sendSignupVerification(
db: Db,
player: { id: string; email: string; consentedAt: string | null },
tokenHash: string,
link: string,
): Promise<Accepted> {
const idempotencyKey = `signup-verify:${player.id}:${tokenHash.slice(0, 16)}`;
const res = await fetch(`${process.env.MAIL_API_BASE}/messages`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.MAIL_API_KEY}`,
"content-type": "application/json",
"idempotency-key": idempotencyKey,
},
body: JSON.stringify({
from: "no-reply@mail.example-game.com",
to: player.email,
subject: "Verify your account",
text: `Open this link within 30 minutes:\n${link}`,
// No open pixel. It cannot be used as proof of anything, so it only adds a privacy question.
tracking: { opens: false, clicks: false },
}),
});
if (!res.ok) throw new MailRejected(res.status, await res.text());
const accepted = (await res.json()) as { id: string; created_at: string };
await db.mailReceipts.insert({
playerId: player.id,
stream: "signup-verification",
messageId: accepted.id, // join key for every delivery event that arrives later
idempotencyKey,
templateVersion: "verify-v4",
consentedAt: player.consentedAt, // marketing opt-in, recorded separately from the address check
acceptedAt: accepted.created_at,
});
return { messageId: accepted.id, acceptedAt: accepted.created_at };
}
The webhook side is the other half, and it is smaller than people expect: verify the signature, look up the receipt by message id, append the event, and stop. Do not let the handler mutate player state directly. A bounce is a fact about a message, and turning it into a decision about an account is a separate, testable step.
An API-first path buys you one thing that matters here: the accept response is synchronous and carries an identifier, so the evidence chain starts inside the request that created the account.
Splitting streams and rotating DKIM keys without a big rollout
Split the streams before the streams split you. Signup verification, password resets, and the welcome email that arrives after verification all deserve their own subdomain and their own DKIM selector, so a marketing send that generates complaints cannot drag the verification link's reputation down with it. Rotation gets easier too: publish a second selector, sign with it, retire the first after your longest expected retry window.
I would also make bounce classification explicit rather than implicit. Hard bounce on a signup address means the account never becomes usable, so the client should ask for a corrected address instead of silently queuing another attempt — players typo their address at signup, and a queue that keeps retrying a dead mailbox is how you earn complaints from the receiving side.
For testing, point staging at a local SMTP sink and assert on the parsed message: link expiry, template version, the absence of tracking pixels. That catches template regressions without mailing anyone. Emails are the one part of a backend where a bad deploy reaches humans instantly and cannot be rolled back.
The failure modes this design doesn't cover
The catch is that none of this proves inbox placement. A delivery event proves the receiving server accepted the message; where it landed after that is not observable from your side, and any vendor claiming otherwise is selling you an inference. Open tracking does not rescue it either — Apple's Mail Privacy Protection fetches remote images through a proxy whether or not a human read the message, so opens are noise for compliance purposes. The click on the verification link, logged by your own server, is the only human signal in the chain that you fully control.
Stick with an SMTP relay when portability matters more than ergonomics. SMTP is the older interface, but it's the interchangeable one: your framework's mailer already speaks it, and swapping providers is a credentials change rather than a rewrite of your request bodies. The relay's 250 response carries a queue identifier that works as an accept receipt too, as long as your client library surfaces it instead of throwing it away. An HTTP API ties your code to one vendor's JSON shape, and that's a real trade-off, not a detail.
This whole design is also not suitable for bulk marketing. Suppression lists, preference centers, and campaign-level reporting are a different product category, and a transactional API that does one message well usually lacks them by design. If data residency is contractual for your region, check where message bodies are stored before you commit — that answer is rarely on the pricing page, and I am not sure any two providers define retention the same way.
Get the domain verified, keep the receipts, and let someone else run the mail servers.
References
- SPF, RFC 7208: https://www.rfc-editor.org/rfc/rfc7208
- DKIM Signatures, RFC 6376: https://www.rfc-editor.org/rfc/rfc6376
- DMARC, RFC 7489: https://www.rfc-editor.org/rfc/rfc7489
- SMTP, RFC 5321: https://www.rfc-editor.org/rfc/rfc5321
- Delivery Status Notifications, RFC 3464: https://www.rfc-editor.org/rfc/rfc3464
- Google Email sender guidelines: https://support.google.com/mail/answer/81126
- Apple, Use Mail Privacy Protection: https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios
- GDPR Article 7, Conditions for consent: https://gdpr-info.eu/art-7-gdpr/
Top comments (0)