DEV Community

FrostY45
FrostY45

Posted on

Custom Domain Verification, DKIM Rotation, and Suppression for Transactional Email APIs

Short answer: Choose a transactional email API only after its custom domain verification, DKIM rotation, suppression export, event history, and rollback controls let a small team explain every accepted, deferred, bounced, or blocked message.

A transactional email API is only simple while delivery state stays simple. The operational constraint is recovery, not the length of the send request.

That is the choice. A low send price is useful, but it can't compensate for a sender identity nobody can rotate safely or a suppression list nobody can inspect.

I've been paged for missed jobs and duplicate deliveries. Email creates the same class of incident: an application retries because it can't tell what happened, then either drops a message or sends it twice. Treat the provider as one part of a delivery system, not as a Send() function with a receipt.

What should a startup verify in a simple transactional email deliverability API?

Start with a short proof, using a subdomain that is separate from employee mail. Verify that the service can establish the custom domain through DNS records you control, show each record's status independently, and preserve the previous signing configuration while a new DKIM selector is being rolled out. A single green "verified" badge isn't enough evidence for a runbook.

Then trace one synthetic message from the application's request ID to the provider's message ID and onward to the final event. The API should distinguish request acceptance from actual delivery. Those are different states, and collapsing them makes retry policy dangerous. Check how long event data remains queryable, whether webhook events can be replayed or recovered, and whether a human can export the same data during an incident.

Suppression management deserves its own test. You need to know what creates a suppression, its scope, how it is queried, and what review is required before removal. An unsubscribe, a permanent delivery failure, and an operator block may all prevent a send, but they represent different intent. Store the provider's reason and timestamp beside your own recipient policy; don't reduce all three to one boolean.

For authentication, DMARC evaluates alignment between the visible author domain and authenticated identifiers. RFC 7489 also defines aggregate and failure reporting mechanisms, plus policy modes that include monitoring and requested handling for failed messages. That makes DMARC reporting an operational signal, not a one-time DNS ceremony. The startup's runbook should name who reads those reports and what change would trigger investigation.

The buying checklist is compact:

Area Proof to request Failure you are containing
Domain verification Per-record status and a repeatable DNS check A partial DNS change looks complete
DKIM rotation Overlapping selectors and a documented rollback Rotation invalidates mail still in flight
Suppressions Query, reason, scope, timestamps, and export A retry overrides recipient or operator intent
Events Stable message IDs, ordered timestamps, and retention terms Acceptance is mistaken for delivery
Webhooks Signed events, retry behavior, and replay guidance Lost or duplicated events corrupt local state
Operations Status visibility, limits, and escalation path An incident has no bounded next action

Ask for these behaviors in writing, then test them. Marketing labels such as "simple" don't define an incident boundary.

Build the delivery ledger before the send path

The application should own a small delivery ledger. Give every business notification an immutable idempotency key, record the content version and recipient policy decision, and create an attempt row before calling the external API. On acceptance, attach the provider message ID. Webhooks then advance that attempt through explicit states; they don't overwrite the business record or silently trigger a fresh send.

Keep the state machine conservative. queued, accepted, delivered, deferred, failed, and suppressed are useful conceptual states, but the exact mapping must follow the provider's documented events. I'm not sure a universal state vocabulary exists at the useful level of detail; a migration test against both providers' event taxonomies is what resolves that uncertainty.

This Go sketch keeps the external interface narrow and makes the idempotency boundary visible:

package delivery

import (
    "context"
    "errors"
)

type Message struct {
    IdempotencyKey string
    FromDomain     string
    To             string
    Template       string
}

type Receipt struct {
    ProviderMessageID string
}

type Sender interface {
    Send(ctx context.Context, message Message) (Receipt, error)
}

type Ledger interface {
    Reserve(ctx context.Context, message Message) (bool, error)
    MarkAccepted(ctx context.Context, key, providerMessageID string) error
}

func Deliver(ctx context.Context, ledger Ledger, sender Sender, message Message) error {
    reserved, err := ledger.Reserve(ctx, message)
    if err != nil {
        return err
    }
    if !reserved {
        return nil
    }

    receipt, err := sender.Send(ctx, message)
    if err != nil {
        return err
    }
    if receipt.ProviderMessageID == "" {
        return errors.New("accepted message has no provider message ID")
    }
    return ledger.MarkAccepted(ctx, message.IdempotencyKey, receipt.ProviderMessageID)
}
Enter fullscreen mode Exit fullscreen mode

There is a deliberate unresolved state if the process stops after the provider accepts the request but before MarkAccepted commits. Don't paper over it with an automatic resend. Reconcile it by idempotency key or provider event history first. This is the email equivalent of checking whether a timed-out job actually ran before queuing another copy.

Webhook consumers need the same reflex. Persist the raw event, deduplicate on the provider's event identifier where one is documented, and make state transitions monotonic. If a delayed accepted event arrives after delivered, it should not move the message backward. Keep authentication failure separate from payload rejection so an attacker cannot fill the event table with plausible JSON.

Rotate DKIM as a change, not a checkbox

DKIM rotation crosses application, provider, and DNS ownership, so it needs a change window even when no application deploy occurs. Inventory the current selector and public key, publish the new record, confirm it from more than one resolver, enable signing with the new selector, and retain the old record until messages signed with it have cleared the relevant delivery and retry windows. The exact overlap depends on documented service behavior and DNS settings; your mileage may vary.

Rollback is short: restore signing with the previous selector while its DNS record remains valid. That option disappears if cleanup happens in the same step as activation — a tiny procedural shortcut with a large blast radius.

DMARC should begin with observation appropriate to the domain's existing mail flows, because the reports can reveal legitimate senders that were missed during inventory. RFC 7489 describes the effect of requested receiver policies but also leaves final message disposition to the receiver. Therefore, don't write a runbook that assumes your published policy guarantees identical handling everywhere.

No heroics.

Record the change ID, old and new selectors, DNS evidence, a signed-message sample, DMARC alignment result, owner, and rollback deadline. That packet is what the next operator needs at 03:00, not a screenshot of a dashboard badge.

Verify failure behavior and suppression policy

The pre-production test should exercise more than the happy path. Use controlled recipients and documented test mechanisms to verify accepted, delivered, deferred, permanent-failure, complaint, unsubscribe, and suppression flows where the candidate supports them. Never manufacture complaints against uninvolved mailbox users, and don't infer production behavior from an undocumented address.

For each case, compare four records: the application attempt, the API response, the webhook event, and the query or export result. The identifiers must join cleanly. Timestamps should be retained in their original form and normalized for analysis, since ordering by local receipt time alone can produce a false sequence when webhooks are delayed.

Now test the ugly part — loss of your webhook consumer. Pause consumption in a controlled environment, restore it, and follow the service's documented recovery path. If recovery depends on event queries, verify their retention and pagination. If there is no sufficient replay or query mechanism, the local ledger must represent an "unknown, reconcile manually" state rather than pretend absence of an event means failure.

Suppression removal must be a reviewed operation. Require the original reason, proof that recipient intent permits another message, an operator identity, and an audit record. A growth request is not evidence of consent. Also decide whether suppressions are scoped to an account, sending domain, stream, or recipient; a scope mismatch can turn a narrow correction into a broad resend risk.

Run one restore drill from exported suppression data before committing. A feature that exports rows but cannot reconstruct policy is archival theater.

Decide with exits, limits, and rollback in view

Score candidates on demonstrated operations, not a feature count. Weight domain and DKIM control, event traceability, suppression semantics, idempotency support, observability, documented limits, access control, data export, and escalation. Evaluate cost against your expected volume and event-retention needs only after the mandatory controls pass. Cheap means the total operating constraint fits the startup, not merely that the first invoice is small.

The catch is that a minimal API is not suitable when the team needs complex marketing journeys, visual campaign authoring, or deep audience segmentation; use a system designed for those workflows instead. Conversely, a broad engagement suite may be poor fit for a small transactional path if it adds operational surface the on-call team cannot own. Stick with an existing provider when it passes the rotation, reconciliation, suppression, and recovery drills and migration would only exchange familiar limits for new ones.

Before production, define rollback in two layers. The fast rollback stops new sends or returns traffic to the previous configuration without deleting DNS records or suppression data. The slow exit exports templates, event mappings, suppressions, domain records, and provider identifiers, then proves the generic Sender interface against another implementation. Don't wait for an incident to discover that an export omits the reason field your policy depends on.

Ship with alerts on queue age, unknown delivery states, webhook verification failures, event lag, permanent-failure rate, and suppression changes. Thresholds must come from your own baseline and risk tolerance; invented universal percentages create noisy pages. Review one synthetic transaction end to end after every authentication change.

Then keep the runbook boring: owner, signal, query, decision, rollback, escalation. Boring survives handoff.

Sources

Top comments (0)