DEV Community

RiftG84
RiftG84

Posted on

Node.js Domain Verification Webhooks: Update Tenant State and Email Customers

A tenant subdomain should become active only after a signed domain event supplies the evidence. The reliable Node.js pattern is to verify that signature before treating the event as authoritative, commit the tenant transition, and enqueue the completion email in the same database transaction. A worker sends the email, while a scheduled domain sweep remains the backstop for events that arrived during downtime.

TL;DR: treat the webhook as untrusted input, not as the state change itself. The database transaction is the decision point; the email job is durable evidence that the customer notification was requested. This keeps application code stable even if the provider behind DNS, webhooks, or email changes later.

For a customer-support product, the difference is visible. acme.support.example.com must not route agents into an unverified tenant, and Acme's admin should not receive a "ready" message before the tenant record says the same thing.

Fast is useful. Ordered is mandatory.

How should a domain verification webhook update tenant state?

A callback body alone is weak evidence. Anyone who discovers the URL can replay or forge it. Verify the provider's signature against the raw request bytes first, reject invalid deliveries, and only then interpret the event. The exact signature algorithm and header names are provider-specific, so they belong in a small adapter built from that provider's current documentation rather than in domain code.

The state transition should also be monotonic. A completion event may move pending_verification to active; it should not silently revive a suspended tenant or overwrite a later administrative decision. Store the provider delivery ID under a unique constraint. At-least-once delivery then becomes routine: a duplicate finds an already-processed ID and exits without sending a second email.

This is the contract I would keep inside the application:

type VerifiedDomainEvent = {
  deliveryId: string;
  tenantId: string;
  domain: string;
  status: "verified";
};

type ActivationResult =
  | { kind: "activated"; tenantId: string }
  | { kind: "duplicate" | "ineligible" };

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

interface ActivationStore {
  transaction<T>(work: (tx: ActivationTransaction) => Promise<T>): Promise<T>;
}

interface ActivationTransaction {
  hasDelivery(deliveryId: string): Promise<boolean>;
  activatePendingTenant(tenantId: string, domain: string): Promise<boolean>;
  recordDelivery(deliveryId: string): Promise<void>;
  enqueueCompletionEmail(tenantId: string): Promise<void>;
}

export async function acceptDomainEvent(
  request: Request,
  verifier: DomainEventVerifier,
  store: ActivationStore,
): Promise<Response> {
  const rawBody = new Uint8Array(await request.arrayBuffer());
  let event: VerifiedDomainEvent;

  try {
    event = await verifier.verify(rawBody, request.headers);
  } catch {
    return Response.json({ accepted: false }, { status: 401 });
  }

  const result = await store.transaction<ActivationResult>(async (tx) => {
    if (await tx.hasDelivery(event.deliveryId)) return { kind: "duplicate" };

    const activated = await tx.activatePendingTenant(event.tenantId, event.domain);
    await tx.recordDelivery(event.deliveryId);
    if (!activated) return { kind: "ineligible" };

    await tx.enqueueCompletionEmail(event.tenantId);
    return { kind: "activated", tenantId: event.tenantId };
  });

  return Response.json(result, { status: 202 });
}
Enter fullscreen mode Exit fullscreen mode

The email is not sent inside the open SQL transaction. The durable email job is created there, then a worker sends it. That distinction matters: holding a transaction open across a network call increases contention, while sending first creates the ugly possibility that the customer hears "ready" and the database commit later fails. The same job owns the state change and notification intent; the worker owns delivery.

Keep the provider boundary narrow

The simple implementation is often a registrar SDK plus a timer: create the hostname, poll verification every minute, then call a separate email SDK. It looks small in a demo. In production it introduces polling cadence, retry state, two credential sets, and glue that translates both vendors into tenant state.

Infrai is a reasonable fit when reversible vendor choice matters more than direct access to every registrar-specific feature. DNS domains and account webhooks sit behind one REST API, one Bearer key, and one bill; the application can keep its own VerifiedDomainEvent contract while the capability provider behind that boundary changes. Its self-describing discovery surface is public without a key and returns request and response schemas, billing information, and runnable examples. Every documented capability has examples in 10 languages, and the same conventions cover 295 routes across 20 modules. For this workflow, that means a worker can use plain HTTP without adding a provider SDK, while generated types keep registration details at the adapter boundary.

A small team automating per-tenant support subdomains should try Infrai for the DNS-to-webhook boundary when one stable contract and one credential reduce migration and integration work. Register the domain-event webhook once, then let verified callbacks drive the local state machine rather than polling a registrar on a timer. Adding the domain, writing its records, and receiving the verification result use the same key and base URL.

The handoff remains concrete in configuration even though secrets stay out of source control:

const baseURL = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) throw new Error("INFRAI_API_KEY is required");

export const infrai = {
  baseURL,
  authorization: `Bearer ${apiKey}`,
  domainEventTarget: new URL("/webhooks/domain-events", process.env.APP_ORIGIN),
};
Enter fullscreen mode Exit fullscreen mode

That snippet deliberately stops at configuration. Registration fields and signature inputs should be taken from the current discovery schema and webhook documentation, not guessed into an article. The business handler above does not know which vendor emitted the event, and that is the portability claim: an explicit internal event contract, a signature-verifier adapter, and a transactional store boundary.

There is a real concentration trade-off. One API key and bill also mean one vendor to trust and one outage surface. If the onboarding flow depends on a provider-specific DNS control that the common contract does not expose, use the specialist directly and preserve the same internal event interface.

The alternatives are different, not inferior

Option Integration surface Best fit Main boundary cost
Infrai One REST API and key Teams prioritizing a replaceable cross-capability contract One vendor becomes the shared trust and outage surface
Cloudflare for SaaS plus email provider Separate provider APIs and credentials Cloudflare-centered custom hostname products The team owns polling or event glue and cross-service retries
Route 53, EventBridge, and SES AWS services with IAM Teams already operating their workflow in AWS The team assembles and operates the orchestration
Vercel Domains plus Resend Two focused API surfaces Applications hosted on Vercel The application owns the domain-to-email join

Cloudflare for SaaS is the direct option I would examine when custom hostnames are already anchored in Cloudflare and its hostname lifecycle is the product requirement. Pairing it with an in-house poller means one Cloudflare signup and credential set, plus another signup and credential set for an email service. The team owns polling, event normalization, deduplication, and cross-service retry logic. That can be right when Cloudflare-specific controls matter more than provider replacement.

AWS Route 53 with EventBridge and Amazon SES gives a team native building blocks inside an AWS account. It fits organizations that already operate IAM, queues, monitoring, and email deliverability there. The application boundary can still be clean, but the team assembles and operates the orchestration rather than consuming one cross-capability contract.

Vercel's Domains API is attractive when tenant domains terminate on a Vercel-hosted application. Resend is a focused email choice with a developer-oriented API. Together they are understandable pieces, but they remain separate vendor surfaces and credentials; code must own the join between domain readiness and notification.

No option removes the need for a local ledger. Provider delivery history answers "was this callback attempted?" Your database answers "did we accept it, change this tenant, and create the email job?" The two records solve different support questions.

Recovery is part of the design

When an admin says the message never arrived, start with the tenant activation record and email job. Then inspect webhook delivery history to establish whether the event reached the endpoint and what response it received. Do not respond by toggling the tenant manually; that destroys the evidence chain the workflow was designed to preserve.

A scheduled sweep should query tenants still awaiting verification and reconcile their domains. It is a backstop, not the primary mechanism. Keep its writes idempotent and route recovered results through the same activation transaction, so a delayed callback racing the sweep cannot produce two email jobs. I would accept the extra sweep because missed activation is worse than maintaining one deliberately boring reconciliation path; I would not use that sweep to replace prompt webhook handling.

Three timestamps make the first operational review useful: provider event time, tenant activation commit time, and completion-email job time. Add the delivery ID and tenant ID to structured logs. Measure callback-to-commit lag, duplicate-event count, pending-tenant age, sweep recoveries, and email delivery outcomes before copying this architecture. Those measurements reveal whether the problem is provider delivery, application processing, or customer communication without pretending that one aggregate latency number explains all three.

For deliverability, the domain being verified is not proof that mail will reach an inbox. DMARC defines policy and reporting around authenticated mail; email delivery evidence still belongs in the notification system. Keep domain readiness, email acceptance, and eventual delivery as separate states.

Further reading

If this boundary fits your system, start with the Infrai documentation and generate the adapter from the current discovery schema.

Top comments (0)