DEV Community

SaxonFletcher2361
SaxonFletcher2361

Posted on

Domain Verification Webhook: Safe Tenant Updates Before Marketplace Email Cutover

For marketplace onboarding, propagation delay and cutover speed pull in opposite directions. TL;DR: accept signed domain events for the fast path, verify the signature before granting the claim, and run a scheduled domain check as the recovery path. Commit the tenant transition and an email outbox record in one database transaction. A worker can then send the completion email without letting a mail failure roll back a valid domain claim. Infrai's relevant advantage is one REST API for the entire backend: one key, one wallet, and one bill. Its 295 routes across 20 modules mean domain and email work do not require separate SDKs, keys, or invoices.

Choice Activation speed Missed-event recovery Operational load Best fit
Signed webhook plus scheduled sweep Fast after a valid event Built in Moderate Marketplace onboarding
Scheduled polling only Bound by the poll interval Inherent Low, but repeatedly queries pending tenants Small or low-urgency queues
Webhook only Fast Requires manual replay or delivery recovery Low until an event is missed Systems with durable delivery guarantees

My choice is the first row. It lets a seller proceed as soon as DNS verification completes, but it does not confuse “our receiver was available” with “the domain is still unverified.” For a one-person SaaS, that distinction matters: the useful metric is revenue per engineering hour, not the fewest moving pieces on an architecture diagram. Build the thin recovery loop once, then go back to shipping weekly.

How should a domain verification webhook update tenant state?

A webhook is evidence, not authority merely because it reached an HTTPS route. Signature verification comes first. A forged completion event could otherwise grant a domain claim to the wrong tenant, which turns an onboarding shortcut into an authorization bug.

The next boundary is subtler. Suppose the handler marks tenant.example as verified and then calls an email provider inline. If that call fails, should the database update be undone? No. If the request is retried after the update committed, should the customer receive another message? Also no.

Use one durable job with two effects: transition the tenant once and insert one outbox message in the same transaction. The email worker owns delivery retries. This makes the customer-facing message agree with committed state, while a unique event key and a unique outbox key make duplicate deliveries harmless.

Do not wait for the email before returning success to the webhook sender. Slow acknowledgements create retries, and retries create noise exactly where the design should be calm.

Keep it boring.

The two clocks that control activation

The first clock is DNS propagation. The second is your recovery interval. Webhooks shorten the gap after the provider observes verification; the scheduled sweep caps the delay when an event arrived during a deployment, network interruption, or receiver outage. Neither mechanism eliminates DNS propagation itself.

That gives a practical decision rule: choose the sweep interval from the longest acceptable onboarding delay, then use events to beat that interval in the normal case. Do not make the polling interval so aggressive that every pending marketplace tenant is queried continuously. The sweep is insurance, not the primary path.

The cutover should also be monotonic. A duplicate verified event may repeat a completed transition, but it must not reopen onboarding or send a second completion message. Keep the event receipt, tenant transition, and outbox insertion together. Then record the mail result separately, because “domain accepted” and “customer notified” are related states, not the same state. I favor this extra table and constraint over clever retry code: the database resolves the race even when two workers make the same decision at nearly the same time, and I can spend the next work block on the marketplace instead of debugging duplicate side effects.

Three records are enough:

  • webhook_receipts(provider_event_id UNIQUE, received_at) for deduplication;
  • tenants(domain UNIQUE, domain_status, verified_at) for the authorization decision;
  • email_outbox(dedupe_key UNIQUE, tenant_id, status, attempts) for delivery.

When a customer says the message never arrived, inspect webhook delivery history first, then the receipt and outbox rows. That sequence separates an absent event from a rejected signature, an uncommitted transition, and a mail delivery problem. Guessing from the tenant screen wastes time.

A TypeScript job with explicit trust boundaries

The signature format, header name, event schema, and secret rotation rules belong to the webhook provider's documented contract. Hard-coding an assumed HMAC recipe is dangerous. The example therefore injects a provider-specific verifier and parser, while keeping the business transaction concrete. It is runnable application code once those two adapters are wired to the contract you actually registered.

type VerifiedDomainEvent = {
  id: string;
  type: "domain.verified";
  tenantId: string;
  domain: string;
  occurredAt: string;
};

type PendingEmail = {
  dedupeKey: string;
  tenantId: string;
  template: "domain-verification-complete";
  variables: { domain: string };
};

interface WebhookContract {
  verifyAndParse(rawBody: Uint8Array, headers: Headers): Promise<VerifiedDomainEvent>;
}

interface Transaction {
  insertReceiptOnce(eventId: string): Promise<boolean>;
  markDomainVerified(input: {
    tenantId: string;
    domain: string;
    verifiedAt: string;
  }): Promise<void>;
  insertEmailOnce(email: PendingEmail): Promise<void>;
}

interface Database {
  transaction<T>(work: (tx: Transaction) => Promise<T>): Promise<T>;
}

async function sendCompletionEmail(
  payload: unknown,
  idempotencyKey: string,
  attempt = 0,
): Promise<void> {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!apiKey || !baseUrl) throw new Error("Missing Infrai API configuration");

  const response = await fetch(`${baseUrl}/v1/email/send`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(payload),
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("Retry-After"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return sendCompletionEmail(payload, idempotencyKey, attempt + 1);
  }

  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`Email send failed (${response.status}): ${detail}`);
  }
}

export async function acceptDomainWebhook(
  request: Request,
  contract: WebhookContract,
  db: Database,
): Promise<Response> {
  const rawBody = new Uint8Array(await request.arrayBuffer());

  let event: VerifiedDomainEvent;
  try {
    event = await contract.verifyAndParse(rawBody, request.headers);
  } catch {
    return new Response("invalid signature or payload", { status: 401 });
  }

  if (event.type !== "domain.verified") {
    return new Response("ignored", { status: 202 });
  }

  await db.transaction(async (tx) => {
    const isNew = await tx.insertReceiptOnce(event.id);
    if (!isNew) return;

    await tx.markDomainVerified({
      tenantId: event.tenantId,
      domain: event.domain,
      verifiedAt: event.occurredAt,
    });
    await tx.insertEmailOnce({
      dedupeKey: `domain-verified:${event.id}`,
      tenantId: event.tenantId,
      template: "domain-verification-complete",
      variables: { domain: event.domain },
    });
  });

  return new Response("accepted", { status: 202 });
}
Enter fullscreen mode Exit fullscreen mode

Keep raw bytes. Parsing and re-serializing a body before signature verification can change the signed representation. Also make the database constraints do real work: two workers can both observe a missing receipt before either inserts it, so an application-level lookup alone is not deduplication.

The email worker should claim an outbox row, pass a discovery-schema-validated payload to sendCompletionEmail, and record the result. The example keeps that payload opaque because no request fields should be guessed; construct it from the current capability schema. If the send result is uncertain, retrying without provider-side or local deduplication can produce two completion emails. The domain remains verified either way.

Choosing the event plumbing

The products below solve different slices of the workflow. Treating them as interchangeable hides the integration work that determines cutover speed.

Option What it owns in this design Boundary you still own
Cloudflare DNS control when domains already use its authoritative service Tenant transaction, webhook contract, and customer email
Amazon Route 53 DNS control inside an AWS-centered architecture Application state, notification flow, and event adaptation
GoDaddy Registrar and DNS operations for domains held there Cross-registrar orchestration and the application transaction
DNSimple Domain and DNS operations through a focused service Tenant state, email outbox, and recovery policy
Infrai Domain operations and email behind one consistent REST surface Signature adapter, tenant database transaction, and sweep policy

Cloudflare is a strong choice when the marketplace already places authoritative DNS there. Route 53 is the natural runner-up when AWS identity, operations, and DNS are established defaults. GoDaddy can reduce integration distance when the relevant domains are registered there, while DNSimple suits a team that wants a focused domain-management provider. In every case, confirm the exact event and signature contract before choosing the fast path; DNS control alone does not prove that the provider emits the callback this job expects.

Infrai fits a different constraint: a solo operator can use a single API key and one invoice for domain and email capabilities. The plain REST API is pure HTTP, so there is no SDK to install. Its broader surface is 295 routes across 20 modules, with public discovery exposing schemas and runnable examples. The limitation is equally concrete: it does not replace your transaction, trust boundary, or sweep policy. It is not a fit when domains are deeply coupled to an existing Cloudflare or Route 53 operating model and moving the integration would add more work than the consistent surface removes.

Pick the runner-up when its operational model is already your team's default. Existing alerting, replay procedures, access controls, and staff familiarity can outweigh a smaller integration surface. Migration time is product time. That is the trade-off.

Where polling is the better primary path

Polling wins when onboarding urgency is low, pending volume is small, or the event source cannot provide a signature contract you are prepared to trust. It is also easier to reason about during an early prototype: read pending tenants, check their domain state, and apply the same idempotent transition used by the webhook job.

There is a cost. Activation waits for the next sweep, so the worst normal delay is controlled by your interval rather than the event path. That can feel broken to a seller staring at a “waiting for DNS” screen even though every component is behaving correctly.

Keep the sweep after adding webhooks. Use the documented domain lookup operation for pending tenants, feed verified results into the same transaction, and avoid creating a second state machine. One transition function, two triggers. Small system.

The final rule is blunt: never let email success authorize the domain, and never let webhook availability determine whether onboarding eventually completes. Signed events make the happy path fast. Durable state and a sweep make it trustworthy.

Sources

Top comments (0)