Verify Stripe’s signature over the raw request body, persist the event, and create one confirmation operation per paid Checkout Session. Deduplicating only event IDs is not enough: different events can describe the same purchase. Keep an uncertain email attempt on its original operation rather than creating another send.
Before you start #
- For
- Developers building and operating application email.
- Bring
- Node.js 22+, npm, PostgreSQL 17+, and a dedicated loopback _test database. The ZIP pins pg and stripe.
- Scope
- The download runs locally with synthetic data. All email delivery is simulated; no provider credentials or live sends are needed.
Confirm payment before writing payment-confirmed copy #
A browser returning to a success page is not your payment authority. Base the confirmation on verified server-side evidence. This lab handles one-time Checkout sessions in payment mode and only queues a confirmation when payment_status is paid.
A checkout.session.completed event can arrive while a delayed payment is still unpaid. Store the event without sending the confirmation. A later checkout.session.async_payment_succeeded event can create the operation. If an older unpaid event arrives afterward, it must not undo the paid operation.
Subscriptions, free orders, refunds, and fulfillment of goods need their own business rules. This tutorial intentionally addresses a paid one-time order confirmation. It is not a general billing engine or a jurisdiction-specific invoice generator.
Run the persisted duplicate-event experiment #
Extract the ZIP and run npm ci. Create a disposable database named stampwing_guides_test, then set GUIDE_DATABASE_URL to its loopback PostgreSQL connection URL. The README includes the exact command shape. The lab ignores DATABASE_URL and refuses remote hosts, query options, and database names without the _test suffix.
Run npm test and npm run demo. Every run creates a randomly named schema and cleans up only that schema. The Stripe SDK signs fixture requests locally; the fake API key is never used to call Stripe. No account, real payment, or email provider is required.
npm ci
createdb -h 127.0.0.1 stampwing_guides_test
export GUIDE_DATABASE_URL="postgresql://YOUR_LOCAL_USER@127.0.0.1:5432/stampwing_guides_test"
npm test
npm run demoRead the key part of the example #
This excerpt comes from lab.mjs, lines 98–131, in the download. It shows the central decision; run the complete package with the commands above.
const inserted = await client.query(
"INSERT INTO inbox VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING id",
[e.id, e],
);
if (!inserted.rowCount) {
const original = await client.query(
"SELECT body = $2::jsonb AS same FROM inbox WHERE id=$1",
[e.id, e],
);
if (!original.rows[0]?.same)
throw Object.assign(
Error("Event ID was reused with different content"),
{ status: 409 },
);
}
if (paid) {
await client.query(
"INSERT INTO outbox(operation,recipient,amount,currency) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING",
[s.id, s.customer_details.email, s.amount_total, s.currency],
);
const original = (
await client.query("SELECT * FROM outbox WHERE operation=$1", [s.id])
).rows[0];
if (
original.recipient !== s.customer_details.email ||
BigInt(original.amount) !== BigInt(s.amount_total) ||
original.currency !== s.currency
)
throw Object.assign(
Error("Paid session conflicts with the stored purchase"),
{ status: 409 },
);
}Find the files you’ll change #
Pinned dependencies: pg 8.23.1, stripe 23.0.0. Install with npm ci so the lockfile controls the resolved versions.
| File | Purpose |
|---|---|
| lab.mjs | The example behavior shown in this article. |
| test.mjs | Acceptance checks and synthetic failure cases. |
| demo.mjs / expected-output.json | A repeatable local experiment and its recorded result. |
| README.md | Setup commands, expected behavior and production boundaries. |
| BUILD-BRIEF.md | The coding-agent brief below. |
| package.json / package-lock.json | Pinned dependencies and runnable commands. |
| LICENSE | MIT license for adapting this example. |
Compare the recorded local result #
Captured from this package’s demo command on October 5, 2026. These results use synthetic fixtures and simulated delivery; they do not measure a live provider or inbox placement.
{
"simulated": true,
"webhookAttempts": 3,
"storedEvents": 2,
"logicalConfirmations": 1,
"secondDispatch": "nothing pending"
}Deduplicate events and business operations separately #
The inbox primary key is the Stripe event ID. The outbox primary key is the Checkout Session ID representing the confirmation. Persist both inside one database transaction. A duplicate event is harmless, and a second event about the same paid session cannot create another confirmation.
The original paid session supplies the recipient, amount, and currency. The handler validates their shape and keeps the first operation immutable. A conflicting business correction should be an explicit later operation, not an unnoticed overwrite caused by webhook arrival order.
The local experiment submits three webhook requests: two deliveries of one event and one different event for the same purchase. Its recorded output shows two inbox rows and one logical confirmation. That is a reproducible local result, not a claim of exactly-once delivery across a real provider.
Make the crash window visible #
The dispatcher first claims a pending operation by recording an uncertain state. It then runs the simulated transport and records an explicit accepted or rejected outcome. A crash or timeout after the claim leaves the operation uncertain, so another worker does not automatically submit it again.
This conservative lab also leaves a claim uncertain if a crash happened before any submission. Production recovery needs evidence: provider identifiers, supported idempotency semantics, and an operator or reconciliation job. A lease expiring does not prove that the original email was not accepted.
Connect Stripe and the email worker #
Register the HTTPS webhook endpoint for the needed Checkout events, pin the Stripe API version you interpret, and store the endpoint signing secret on the server. Preserve the raw body for signature verification. Acknowledge only after the database transaction commits; on storage failure return a retryable response.
Connect the outbox worker to your chosen email service with a verified sender and stable operation identity. Restrict access to the inbox because the stored event can contain customer data; reduce or encrypt retained fields under your retention policy. Keep the email template tied to the verified purchase record.
Decide whether Stripe should also send its own payment receipt. A product order confirmation and a provider receipt can serve different purposes, but they should not surprise the customer with indistinguishable copies. Check both configurations before launch.
Investigate HTTP 409 conflicts instead of treating them as successful duplicates: the lab detects an event ID reused with different content, or a paid Session whose recipient, amount or currency disagrees with the stored confirmation. It rolls back the conflicting transaction.
Should I deduplicate on the Stripe event ID or the Checkout Session? #
Use both identities for different purposes. Store each event once to make webhook retries safe. Use the Checkout Session as the confirmation operation so separate events for the same purchase cannot enqueue a second confirmation. This lab covers one-time Checkout purchases, not invoice or subscription lifecycles.
Use with your coding agent #
Download the example, then copy this brief into your coding agent. The same brief is included as BUILD-BRIEF.md.
# Send Stripe order confirmations without duplicates — coding-agent brief
## Objective
Persist signed one-time Checkout events and queue exactly one logical confirmation per paid Checkout Session.
## Read first
Read README.md, package.json, lab.mjs, test.mjs and demo.mjs. Keep dependency versions pinned to package-lock.json. Use Node.js 22+; PostgreSQL 17+ with a disposable loopback \_test database.
## Dependencies
pg 8.23.1, stripe 23.0.0. Install with npm ci and preserve the lockfile.
## Work
Start at `receive` in lab.mjs. Run the existing synthetic fixtures before changing behavior. Preserve the guide's original scenario and add a regression check for each changed failure case.
## Constraints
All email is simulated. Do not add provider credentials, send email, deploy services, or use the application's database. Preserve unknown outcomes instead of claiming delivery. Do not turn the local demonstration into production authentication or a public mail relay. Explain the production setup separately.
## Verification
Run `npm ci`, `npm test`, and `npm run demo`; set GUIDE_DATABASE_URL as described in README.md first.
## Acceptance
Exercise duplicate event IDs, different event IDs for the same Session, conflicting paid facts, unpaid completion, delayed success, reordered events, database loss, signatures and uncertain sends. No second confirmation may be claimed after uncertainty.
No real tokens, signing secrets, or recipient data appear in logs. All fixtures use synthetic values.Check the download #
The ZIP contains 11,702 bytes. Compare its SHA-256 digest with this value before extracting. Downloads are free and require no signup.
fd119259240d6166d2dd4272914ae2339c9a8ff30110965423e5205f3cbc7f1fSources and further reading
- Stripe: webhook signatures and delivery behavior
- Stripe: Checkout fulfillment
- Stripe: receipts
- PostgreSQL: transactions
Written for Stampwing with AI assistance and checked against the linked documentation. Examples are educational; simulated results are labelled. How these resources are maintained.