Skip to content
← All posts

·

How to handle Dodo Payments webhooks in Next.js 16

Verify the signature, store payments in your own Postgres, survive retries and out-of-order events, and check access without calling Dodo.

Short answer: read the raw request body, verify it with the Dodo SDK's webhooks.unwrap() while passing the request headers, and write the event into your own database with idempotent queries. Return 401 for a bad signature and let real errors return 500, so Dodo retries them. Then decide who has access by reading your own tables, never by calling Dodo on a page request.

This is how the webhook handler in Launchfast works. The code below is trimmed from the kit.

1. The route: raw body in, verified event out

Webhook signatures are computed over the exact bytes Dodo sent. Read the body as text and parse it only after verifying it. If you call request.json() first, you verify a re-serialised body that may not match.

// src/app/api/webhooks/dodo/route.ts
export async function POST(request: Request) {
  const body = await request.text();

  let event;
  try {
    event = verifyWebhook(body, request.headers);
  } catch (error) {
    if (error instanceof InvalidWebhookError) {
      return Response.json({ error: "Invalid signature" }, { status: 401 });
    }
    throw error;
  }

  // Errors here return 500, so Dodo retries the event later.
  await handleWebhookEvent(event);
  return Response.json({ received: true });
}

Only a bad signature gets a 4xx. A database outage should throw and produce a 500, because a 200 tells Dodo the event was handled and it won't send it again.

2. Always pass the headers

export function verifyWebhook(body: string, headers: Headers) {
  const secret = requireEnv("DODO_PAYMENTS_WEBHOOK_SECRET");
  try {
    return dodo().webhooks.unwrap(body, { headers: Object.fromEntries(headers), key: secret });
  } catch {
    throw new InvalidWebhookError("Invalid webhook signature.");
  }
}

The signature lives in the webhook-id, webhook-timestamp and webhook-signature headers. In the SDK version Launchfast uses, unwrap() skips verification when the headers are left out, so a handler that forgets them accepts forged events without any error. Keep a test that sends a request with a wrong signature and expects 401.

3. Make every write safe to repeat

Dodo retries events that didn't get a 2xx, so the same event can arrive twice. Each write has to give the same result the second time.

Payments are a ledger keyed by Dodo's payment ID. A duplicate insert does nothing:

await db
  .insert(payment)
  .values({ userId, provider: "dodo", providerPaymentId: charge.payment_id, amount: charge.total_amount /* … */ })
  .onConflictDoNothing();

Two details matter here:

  • Skip card updates. A payment.succeeded event with is_update_payment_method set is a zero-amount charge that only saves a new card. It isn't revenue.
  • Store money as integers in the currency's smallest unit (cents, paise). Format it only when you display it.

4. Ignore subscription events that arrive late

Events aren't guaranteed to arrive in order. If subscription.cancelled is processed before an older subscription.active that was retried, a naive upsert re-activates a cancelled subscription.

Store the event's timestamp and only apply an update that is newer:

const eventTime = new Date(event.timestamp);
await db
  .insert(subscription)
  .values({ userId, providerSubscriptionId: sub.subscription_id, status: sub.status, providerUpdatedAt: eventTime /* … */ })
  .onConflictDoUpdate({
    target: subscription.providerSubscriptionId,
    set: { status: sub.status, providerUpdatedAt: eventTime /* … */ },
    setWhere: lt(subscription.providerUpdatedAt, eventTime),
  });

The setWhere makes Postgres do the comparison inside the same statement, so two deliveries running at once can't race each other.

5. Link payments to users on the server

The webhook tells you a Dodo customer ID, not your user. Create the Dodo customer yourself when the user starts checkout, and save the pair in a billing_customer table:

Your tableHolds
billing_customerYour user ID and their Dodo customer ID, written server side at checkout
paymentEvery successful charge, keyed by Dodo's payment ID
subscriptionThe latest known state of each subscription, plus providerUpdatedAt

When an event arrives, look the user up by customer ID. Don't trust a user ID sent from the browser or copied into checkout metadata: it is only as trustworthy as the request that set it.

6. Decide access from your own tables

Once webhooks keep these tables in sync, the access rule is two small queries:

  • A paid one_time row in payment grants access.
  • Otherwise, the latest subscription row must have status = 'active'.

Pages call one function, getBillingStatus(userId), which reads only Postgres. Page loads stay fast, they don't fail when the payment provider has an outage, and the rule doesn't change if you add a second provider later.

Checklist

  • Read the body with request.text() and verify before parsing.
  • Pass the headers to unwrap(), and test that a forged event gets 401.
  • Return 500 on real errors so Dodo retries.
  • Make inserts idempotent on the provider's IDs.
  • Compare event timestamps before updating a subscription.
  • Map Dodo customers to users through a table you write at checkout.
  • Read access from your database, not from the provider.

Launchfast ships this handler, its tests and the admin revenue dashboard that reads the same tables. See what's inside.