Back
NimBuild AI

NimBuild AI

Stripe Webhook Idempotency Keeps Credits Honest

Stripe Webhook Idempotency Keeps Credits Honest

A webhook is where a payment processor and your product database meet. It is also where duplicate deliveries, retries, and out-of-order events test your data model.

NimBuild’s Stripe webhook flow starts with two requirements: prove the event came from Stripe and prove this exact payment has not already been processed.

Verify the Signature Before Trusting the Body

NimBuild exposes:

POST /api/payments/stripe/webhook

Every request is verified with Stripe’s official webhook helper. The stripe-signature header and raw request body are checked against STRIPE_WEBHOOK_SECRET.

The raw body matters. Parsing JSON first can invalidate the signature check. Signature verification must happen before event data touches billing state.

Duplicate Events Are Normal

Stripe may deliver the same event more than once. NimBuild checks providerPaymentId in the payment table. If the provider payment ID already exists, the webhook is acknowledged without processing it again.

This simple rule prevents a common production failure:

  1. Customer pays once.
  2. Provider sends the same payment event twice.
  3. App inserts two payment records.
  4. Credits are granted twice.
  5. User balance and revenue reporting disagree.

Acknowledging a duplicate is not lost information. The original payment remains the source of truth.

The Events That Change Product State

NimBuild handles the documented Stripe events:

EventAction
checkout.session.completedCreate payment, grant credits, send email
invoice.paidProcess subscription renewal payment
customer.subscription.createdMark subscription active when applicable
customer.subscription.updatedMark subscription active when applicable
customer.subscription.deletedMark subscription canceled

The important discipline is that each event changes a narrow part of the state machine. Subscription state updates do not blindly grant credits; paid checkout or invoice events drive credit accounting.

Keep the Records Connected

After a successful paid event, NimBuild aligns:

  • user plan
  • payment
  • subscription
  • credit balance
  • credit ledger
  • confirmation email result

That list is why updating one plan label is not enough. A renewal may update the period but grant credits only from an invoice. A canceled subscription may remove future entitlement without rewriting historical payments.

Annual Plans Use Scheduled Grants

NimBuild annual plans do not grant all credits at once. The first month’s credits are granted immediately; the remaining 11 installments are scheduled in subscription_credit_schedule.

An hourly cron route processes due grants. This creates a repeatable audit pattern:

  1. Annual checkout completes.
  2. First installment is granted.
  3. Future installments are scheduled.
  4. Cron processes only due records.
  5. Each grant writes subscription_schedule ledger history.

If a scheduled grant fails, it can be retried without guessing how many credits were already issued.

Test the Failure Modes, Not Only Success

Before launch, run these webhook cases:

  1. Valid checkout completion.
  2. The same payment ID delivered twice.
  3. Invalid signature.
  4. Renewal invoice paid.
  5. Subscription canceled.
  6. Annual plan first installment.
  7. Annual plan scheduled grant.
  8. Cron retry after a partial downstream failure.

Then inspect the database rather than only the UI. The page can look correct while a ledger entry is missing.

Debugging Checklist

If Stripe webhooks do not work:

  1. Check Stripe Dashboard delivery logs.
  2. Confirm the endpoint is publicly accessible.
  3. Verify STRIPE_WEBHOOK_SECRET.
  4. Inspect server logs for signature errors.
  5. Confirm raw request body handling.
  6. Correlate the Stripe event ID with local payment and ledger records.

Idempotent webhook accounting is not glamorous, but it is what lets subscription credits remain trustworthy after retries and renewals.

Model the Event, Not Just the Payment

The payment table is enough to prevent duplicate credit grants, but support and incident reviews become easier when you also persist the provider event. A small table can record every event ID and processing outcome:

create table provider_webhook_event (
  id uuid primary key default gen_random_uuid(),
  provider text not null,
  event_id text not null,
  event_type text not null,
  provider_payment_id text,
  status text not null,
  error_message text,
  processed_at timestamptz,
  created_at timestamptz not null default now(),
  unique (provider, event_id)
);

The unique constraint on (provider, event_id) is the hard boundary. Even if two workers receive the same delivery concurrently, only one insert can win.

Processing can then follow a narrow sequence:

  1. Verify the signature.
  2. Parse the event.
  3. Insert the event row with status = 'processing'.
  4. If insert conflicts, load the existing row and acknowledge without accounting.
  5. Process payment, subscription, and credits transactionally.
  6. Mark the event processed.

If step 5 fails, leave the event row in a failed or processing state and let monitoring retry the work through a controlled job. Do not blindly ask Stripe to resend the event while your handler still lacks a concurrency guard.

Keep Accounting Inside a Transaction

Signature verification and event storage should happen before accounting. But the accounting itself should still be atomic. A simplified transaction looks like this:

begin;

insert into payment (
  id, user_id, provider, provider_payment_id,
  amount_cents, currency, status, credits_granted
) values (...);

update subscription
set status = $status,
    current_period_end = $periodEnd
where provider_subscription_id = $providerSubscriptionId;

update "user"
set credits = credits + $creditsGranted
where id = $userId;

insert into credit_ledger (
  user_id, amount, reason, reference_id
) values (
  $userId, $creditsGranted, 'subscription_cycle', $providerPaymentId
);

commit;

If confirmation email delivery fails after commit, do not roll back the payment. Queue a retry. Billing facts are more important than a transient email failure, and the email job can safely read the committed record.

Reconcile From Three Sources

Every billing incident should be reconstructable from:

  1. Stripe's event and payment objects.
  2. Your local webhook event row.
  3. Your local payment, subscription, balance, and ledger rows.

For a missing grant, compare:

  • Stripe event ID and creation time
  • Stripe payment intent or invoice ID
  • local providerPaymentId
  • local ledger reason and reference
  • user balance before and after the event

If Stripe says the invoice is paid but no local event row exists, ingestion failed. If the event row is processed but no ledger row exists, accounting failed. If all three agree but the UI is wrong, the bug is presentation rather than billing.

Annual Installments Need Their Own Guard

Annual subscriptions introduce a second replay risk. The invoice may arrive once, but a scheduled-grant cron can run several times around the due time.

The schedule row should carry, at minimum:

alter table subscription_credit_schedule
  add column grant_status text not null default 'scheduled',
  add column processed_at timestamptz,
  add column attempts integer not null default 0;

The cron worker should claim a due row with a status update before granting credits. A practical pattern is:

begin;

update subscription_credit_schedule
set grant_status = 'processing',
    attempts = attempts + 1
where id = $scheduleId
  and grant_status = 'scheduled'
returning *;

-- If no row returns, another worker owns it.

commit;

Then grant credits and mark the row processed. If the worker dies after claiming but before granting, monitoring can identify stuck processing rows and retry them under the same unique schedule ID.

Test Realistic Delivery Order

Automated tests should not only send one happy event. Cover these sequences:

  1. checkout.session.completed before the browser returns.
  2. The same event delivered twice within seconds.
  3. Two different events for the same payment.
  4. invoice.paid for a renewal.
  5. customer.subscription.deleted after renewal.
  6. Annual first grant followed by a scheduled monthly grant.
  7. A duplicate cron attempt on the same schedule row.
  8. A provider timeout between local database writes.

For each test, assert the complete state: payment, subscription, user plan, fast balance, bucket, and ledger. A UI assertion alone can hide a missing ledger row.

Observability

Log four stable identifiers for every webhook:

  • Stripe event ID
  • event type
  • local user or customer reference
  • provider payment or subscription ID

Also record processing duration and terminal status. Then an incident becomes a query rather than an archaeology project.

The engineering goal is simple: Stripe may retry, clocks may disagree, workers may overlap, and your database should still say exactly one thing about each paid event.

Raw Bodies and Framework Pitfalls

Most signature failures are not Stripe misconfigurations; they are framework transformations. If your server framework parses JSON before the webhook handler sees it, whitespace and property order may no longer match the signed payload. Read the raw request body first, verify it, and only then parse it.

Do not log the raw body by default. It can contain customer email addresses and payment references. Log the event ID, type, and derived local identifiers instead.

Also avoid deriving idempotency from an order number generated by your checkout page. Use Stripe's stable event ID for event replay, and the provider payment or invoice ID for business-level deduplication. These solve different races.

Deployment Notes

The webhook endpoint must be excluded from authentication middleware and rate limiting that could reject provider requests. Keep a separate, tighter rate limit for malformed signatures rather than blocking all retries during an incident.

When you rotate STRIPE_WEBHOOK_SECRET, support both secrets for a short overlap if your provider supports it. Deploy the handler that accepts old and new secrets before changing the dashboard secret. Then remove the old value after delivery success rate stabilizes.

Finally, rehearse recovery. A staging test that replays a real event shape is more valuable than a code review that says "we check the ID." The output of the rehearsal should be a documented query that shows why the duplicate did not grant credits twice.