Nov 13, 202611 min read

Subscription Dunning Backend: Retry Failed Payments and Suspend Access

Turn failed subscription payments into a retry schedule, customer emails and an access check: idempotent webhook handling, a retry job that cannot charge twice, and suspension after the last retry, built by hand on AWS and in TypeScript with Encore.

Illustration for Subscription Dunning Backend: Retry Failed Payments and Suspend Access

When a subscription renewal fails, the card is often not gone for good: it may have expired, hit a limit or been declined by the issuer for the day, and a retry a few days later can go through. A billing system without dunning treats that first decline as the end and loses a paying customer over a temporary problem, or does the opposite and keeps serving a customer who will never pay.

This guide builds the backend that handles the gap: it records failed payments from the provider's webhooks exactly once, retries each charge on day 1, 3, 5 and 7, emails the customer at each step, and suspends access after the last retry fails. The build is shown first as AWS pieces by hand, then in TypeScript with Encore.

The dunning backend at a glance

JobPatternHand-built on AWSWith Encore in your AWS account
Receive payment eventsVerify the signature, record each event ID onceALB or API Gateway to a container or Lambdaapi.raw endpoint
Dunning stateOne case per invoice: open, recovered or suspendedRDS for PostgreSQLEncore database on RDS
RetriesHourly job claims due cases with SKIP LOCKEDEventBridge Scheduler or an ECS scheduled taskCron job managed by the Encore platform
Customer emailsOne notice per invoice, kind and attemptSQS queue and a workerPub/Sub topic and subscription
Access checkSuspended cases block paid featuresAn internal endpointA private endpoint other services call
Provider keysWebhook secret and API key per environmentSecrets ManagerEncore secrets

When to build your own dunning

Stripe Billing already retries failed invoices with Smart Retries, enabled by default for new accounts with up to 8 retries within 2 weeks, and if that is your whole billing setup, configure it there. A separate dunning backend earns its keep when you charge through a payment provider that only offers one-off charges, when invoices are split across several providers, or when the rules depend on your own data, such as a longer grace period for annual plans or keeping read-only access during dunning.

How do you build it by hand on AWS?

  1. Receive events. A webhook endpoint behind an ALB or API Gateway verifies the provider's signature over the raw body. Providers deliver events more than once, and Stripe's webhook docs say to log processed event IDs and skip repeats, and that live-mode deliveries are retried for up to three days.
  2. Store dunning state. An RDS for PostgreSQL database holds one case per invoice with its status, attempt count and next attempt time, plus tables for processed events and sent notices.
  3. Schedule retries. An EventBridge Scheduler rule or an ECS scheduled task starts the retry job every hour. The job claims due cases one at a time with FOR UPDATE SKIP LOCKED, so two overlapping runs never pick the same invoice.
  4. Charge safely. Each retry sends an idempotency key built from the invoice ID and attempt number. Stripe documents that idempotent requests retried with the same key return the saved response of the first one, and keys can be pruned after 24 hours, which is far longer than a crashed job takes to rerun.
  5. Notify. The webhook handler and the retry job put notices on an SQS queue, and a worker sends the email, recording each notice so a redelivered message does not email twice.
  6. Access and secrets. The webhook secret and the provider API key live in Secrets Manager, the task roles need the secrets, the queue and a network path to RDS, and the product services call an internal endpoint before serving paid features.

The schema is the same in both builds:

CREATE TABLE processed_events ( event_id TEXT PRIMARY KEY, received_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE dunning_cases ( invoice_id TEXT PRIMARY KEY, customer_id TEXT NOT NULL, amount_cents BIGINT NOT NULL, currency TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', -- open, recovered, suspended attempt INT NOT NULL DEFAULT 0, -- retries made so far opened_at TIMESTAMPTZ NOT NULL DEFAULT now(), next_attempt_at TIMESTAMPTZ, closed_at TIMESTAMPTZ ); CREATE INDEX dunning_cases_due ON dunning_cases (next_attempt_at) WHERE status = 'open'; CREATE INDEX dunning_cases_customer ON dunning_cases (customer_id) WHERE status = 'suspended'; CREATE TABLE sent_notices ( invoice_id TEXT NOT NULL, kind TEXT NOT NULL, attempt INT NOT NULL, sent_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (invoice_id, kind, attempt) );

The partial index on next_attempt_at keeps the hourly query to open cases only, and the primary key on sent_notices is what makes each email go out once per invoice, kind and attempt.

How do you build it with Encore?

Encore

Encore is a backend framework with built-in infrastructure for TypeScript and Go, where the database, the notices topic, the cron job and the two secrets are declared in the service code. When the app deploys to your own AWS account, Encore provisions RDS, SNS and SQS and Secrets Manager with IAM for the service, as the AWS infrastructure docs list. The same code deploys to GCP with Cloud SQL, Pub/Sub and Secret Manager.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create billing --example=ts/hello-world$ cd billing && mkdir -p dunning/migrations$ encore secret set --type prod,dev,local PspWebhookSecret$ encore secret set --type prod,dev,local PspApiKey

Put the schema above in dunning/migrations/1_dunning.up.sql. Secret values are not stored in the repository, and each environment gets its own webhook secret and provider key.

The dunning service

// dunning/dunning.ts import { api } from "encore.dev/api"; import { secret } from "encore.dev/config"; import { CronJob } from "encore.dev/cron"; import { Subscription, Topic } from "encore.dev/pubsub"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import log from "encore.dev/log"; import { createHmac, timingSafeEqual } from "node:crypto"; // RDS or Cloud SQL in your cloud account, a local Postgres with `encore run`. const db = new SQLDatabase("dunning", { migrations: "./migrations" }); // Set per environment with `encore secret set`, never stored in the repo. const webhookSecret = secret("PspWebhookSecret"); const pspApiKey = secret("PspApiKey"); // Days after the first failure on which the charge is retried. // Access is suspended when the last retry fails. const RETRY_DAYS = [1, 3, 5, 7]; interface PspEvent { id: string; type: string; // "payment.failed", "payment.succeeded", ... invoiceId: string; customerId: string; amountCents: number; currency: string; } export interface DunningNotice { kind: "payment_failed" | "retry_failed" | "recovered" | "suspended"; invoiceId: string; customerId: string; attempt: number; } export const notices = new Topic<DunningNotice>("dunning-notices", { deliveryGuarantee: "at-least-once", }); // The payment provider calls this for every payment event. export const pspWebhook = api.raw( { expose: true, method: "POST", path: "/webhooks/psp" }, async (req, resp) => { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks); const got = Buffer.from(String(req.headers["x-psp-signature"] ?? ""), "hex"); const want = createHmac("sha256", webhookSecret()).update(body).digest(); if (got.length !== want.length || !timingSafeEqual(got, want)) { resp.writeHead(401).end("invalid signature"); return; } let event: PspEvent; try { event = JSON.parse(body.toString("utf8")); } catch { resp.writeHead(400).end("invalid json"); return; } await handleEvent(event); resp.writeHead(204).end(); }, ); async function handleEvent(ev: PspEvent) { await using tx = await db.begin(); // Providers deliver webhooks at least once: record each event ID once. const fresh = await tx.queryRow` INSERT INTO processed_events (event_id) VALUES (${ev.id}) ON CONFLICT DO NOTHING RETURNING event_id`; if (!fresh) return; let notice: DunningNotice | null = null; if (ev.type === "payment.failed") { const opened = await tx.queryRow` INSERT INTO dunning_cases (invoice_id, customer_id, amount_cents, currency, next_attempt_at) VALUES (${ev.invoiceId}, ${ev.customerId}, ${ev.amountCents}, ${ev.currency}, now() + make_interval(days => ${RETRY_DAYS[0]}::int)) ON CONFLICT (invoice_id) DO NOTHING RETURNING invoice_id`; if (opened) notice = { kind: "payment_failed", invoiceId: ev.invoiceId, customerId: ev.customerId, attempt: 0 }; } else if (ev.type === "payment.succeeded") { // The customer updated their card and paid, or a retry went through. const closed = await tx.queryRow<{ attempt: number }>` UPDATE dunning_cases SET status = 'recovered', closed_at = now(), next_attempt_at = NULL WHERE invoice_id = ${ev.invoiceId} AND status IN ('open', 'suspended') RETURNING attempt`; if (closed) notice = { kind: "recovered", invoiceId: ev.invoiceId, customerId: ev.customerId, attempt: closed.attempt }; } await tx.commit(); if (notice) await notices.publish(notice); } interface DueCase { invoice_id: string; customer_id: string; attempt: number; } // Called every hour by the Encore platform, and by hand when testing locally. export const retryDue = api({}, async (): Promise<{ attempted: number }> => { let attempted = 0; while (attempted < 500) { await using tx = await db.begin(); // SKIP LOCKED lets an overlapping run move on to other cases // instead of charging the same invoice twice. const c = await tx.queryRow<DueCase>` SELECT invoice_id, customer_id, attempt FROM dunning_cases WHERE status = 'open' AND next_attempt_at <= now() ORDER BY next_attempt_at LIMIT 1 FOR UPDATE SKIP LOCKED`; if (!c) break; const attempt = c.attempt + 1; const paid = await chargeInvoice(c.invoice_id, attempt); let kind: DunningNotice["kind"]; if (paid) { kind = "recovered"; await tx.exec` UPDATE dunning_cases SET status = 'recovered', attempt = ${attempt}, closed_at = now(), next_attempt_at = NULL WHERE invoice_id = ${c.invoice_id}`; } else if (attempt >= RETRY_DAYS.length) { kind = "suspended"; await tx.exec` UPDATE dunning_cases SET status = 'suspended', attempt = ${attempt}, closed_at = now(), next_attempt_at = NULL WHERE invoice_id = ${c.invoice_id}`; } else { kind = "retry_failed"; await tx.exec` UPDATE dunning_cases SET attempt = ${attempt}, next_attempt_at = opened_at + make_interval(days => ${RETRY_DAYS[attempt]}::int) WHERE invoice_id = ${c.invoice_id}`; } await tx.commit(); await notices.publish({ kind, invoiceId: c.invoice_id, customerId: c.customer_id, attempt }); attempted++; } return { attempted }; }); const _ = new CronJob("retry-failed-payments", { title: "Retry failed subscription payments", every: "1h", endpoint: retryDue, }); // Returns true when the provider charged the card, false on a decline, // and throws on provider errors so the case is retried on the next run. async function chargeInvoice(invoiceId: string, attempt: number): Promise<boolean> { const resp = await fetch(`https://psp.example.com/v1/invoices/${invoiceId}/pay`, { method: "POST", headers: { Authorization: `Bearer ${pspApiKey()}`, // The same attempt never charges twice, even if this run crashes and repeats it. "Idempotency-Key": `${invoiceId}-retry-${attempt}`, }, }); if (resp.ok) return true; if (resp.status === 402) return false; // declined throw new Error(`payment provider returned ${resp.status}`); } // Other services ask this before serving paid features. export const access = api( { expose: false, method: "GET", path: "/customers/:customerId/access" }, async ({ customerId }: { customerId: string }): Promise<{ active: boolean }> => { const row = await db.queryRow` SELECT 1 FROM dunning_cases WHERE customer_id = ${customerId} AND status = 'suspended' LIMIT 1`; return { active: !row }; }, ); // Sends each notice once, even when Pub/Sub delivers it twice. const __ = new Subscription(notices, "email-customer", { handler: async (n) => { const first = await db.queryRow` INSERT INTO sent_notices (invoice_id, kind, attempt) VALUES (${n.invoiceId}, ${n.kind}, ${n.attempt}) ON CONFLICT DO NOTHING RETURNING invoice_id`; if (!first) return; log.info("dunning email", { kind: n.kind, customer: n.customerId, invoice: n.invoiceId }); // Call your email provider here; "payment_failed" links to the card update page. }, });

The webhook records the event ID and opens or closes the case in one transaction, and await using rolls the transaction back when the handler returns early for a duplicate. The retry job holds the row lock while it calls the provider, so a second run started during a slow charge moves on to the next case instead of waiting or charging again.

The database docs cover migrations and transactions, the Pub/Sub docs cover at-least-once delivery, and the cron job docs cover schedules. Cron jobs do not run locally or in preview environments, so under encore run you call retryDue from the local development dashboard after moving a case's next_attempt_at into the past. Once deployed, the Encore platform calls it every hour.

Deploy to your AWS account

Connect your AWS account as described in Connect your cloud account, create an environment and run git push encore. When a coding agent later adds a rule, such as skipping retries on hard declines, the change is a few lines in retryDue, and it reaches production through the same review and deploy as any other pull request, with the database, topic and secrets unchanged.

Things to decide early

  • Which declines to retry. Stripe's docs list cases where it does not retry at all, such as no payment method on file, and a dunning job should also stop early on declines that a retry cannot fix.
  • What suspension means. Read-only access keeps customers able to export their data and update their card, while a full lockout still has to leave the card update page reachable.
  • Publishing after commit. The code publishes notices after the transaction commits, so a crash between the two loses one email but never sends one for a rolled-back change. A transactional outbox table removes that gap if every email must go out.
  • Time zones. Retry days count from opened_at in UTC, so a retry can land at night for the customer, which matters for emails more than for charges.

Frequently asked questions

What is dunning in subscription billing?

Dunning is the process that runs after a recurring payment fails: retrying the charge on a schedule, telling the customer so they can update their card, and finally pausing or cancelling the subscription if no payment succeeds. A dunning backend keeps that state per invoice and decides when each step happens.

How often should failed subscription payments be retried?

Spread a handful of retries over one to two weeks, for example on day 1, 3, 5 and 7 after the failure, and stop early when the customer pays or the decline is one that retrying cannot fix. Stripe's Smart Retries default for new accounts is up to 8 retries within 2 weeks.

How do you stop a dunning job from charging a customer twice?

Claim each due invoice inside a transaction with SELECT FOR UPDATE SKIP LOCKED so overlapping runs skip it, and send the charge with an idempotency key built from the invoice ID and attempt number. A crash and rerun then repeats the same request, which the provider answers without charging again.

Do I need my own dunning if I use Stripe Billing?

Usually not, since Stripe Billing retries failed invoices with Smart Retries and can email customers about failed payments. Build your own when you charge through a provider or a mix of providers without subscription billing, or when access rules, grace periods and messaging depend on your own product data.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders