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.

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.
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.
FOR UPDATE SKIP LOCKED, so two overlapping runs never pick the same invoice.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.
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.

Run in your terminal to get started locally.
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.
// 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.
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.
opened_at in UTC, so a retry can land at night for the customer, which matters for emails more than for charges.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.
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.
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.
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.