A customer pays once and gets two receipts. Before changing your email code, open the payment in Stripe and check whether there are actually two payments. Duplicate emails and duplicate charges need different fixes. This guide handles the first case: one paid invoice, more than one message about it.
Start by finding every sender. Stripe might send its own receipt while your app sends another. Your app might react to two different events for the same invoice. Or it might send successfully, return an error, and send again when Stripe retries. The fix depends on which history you find. We'll check the dashboards first, then build a small Postgres outbox for a Next.js app using Resend. An outbox is a database table of emails your app has committed to sending.
3 days
Stripe live retries
Automatic webhook delivery retries can outlast a provider's duplicate protection.
24 hours
Resend key retention
The same key and payload can be retried within this window.
1 owner
For each notification
Choose whether Stripe or your app sends the paid-invoice message.
Provider behavior checked October 7, 2026: see Stripe's retry documentation and Resend's idempotency documentation. The database design below is our implementation guidance.
Find the second sender before touching the code
Ask for both messages, including their send times, subjects, and invoice numbers. Then open Stripe's payment details and receipt history. Search your email provider for the same recipient and time range. Two provider email IDs mean two accepted sends. One provider ID and two visible copies need a closer look at forwarding rules or the receiving mailbox. Keep that distinction in your incident notes.
| What you find | Likely cause | Next check |
|---|---|---|
| One Stripe receipt and one branded app email | Two systems own the same notification | Stripe Customer emails settings and your app's receipt handler |
| Two provider IDs tied to the same Stripe event ID | A webhook retry or a worker retry sent again | Event delivery responses and the send idempotency key |
| Different event IDs, same invoice number | Two event types or endpoints trigger the same message | Every receipt-producing handler and webhook destination |
| A second message appears after a timeout | The send succeeded before your app recorded it | Provider acceptance time versus database update time |
| Copies appear after a deploy or manual replay | Duplicate history was temporary or was deleted | Persistent outbox rows and payload changes |
Use the evidence in both dashboards. Timing alone does not prove the cause.
Stripe's receipt guide puts automatic receipts under Settings → Business → Customer emails → Successful payments. If Stripe owns payment receipts, remove the app's equivalent send. If your app owns them, review that setting and any explicit receipt sends in your integration before disabling automatic receipts. Keep refunds and other notifications accounted for separately. Turning off everything creates a different support problem.
For app duplicates, open Workbench → Webhooks → your destination → Event deliveries. Compare attempts, HTTP responses, and endpoint URLs. Two destinations can both reach the same sending code. Give every sender a name in your audit, including automation tools, background workers, and old endpoints left active after a migration.
Keep a small incident timeline: the invoice ID, Stripe event ID, destination URL, provider email ID, and the HTTP response for each attempt. If the provider accepted a message at 10:00 and Stripe saw a timeout at 10:01, you have a specific path to investigate. A graph of total emails per hour will not tell you which customer message was repeated. Avoid logging full invoice links or message bodies into general application logs; the restricted outbox already stores the data the worker needs.
Using Lovable? Its current own-Stripe-account integration checks payment status directly by default and adds webhooks when requested. An AI-built app does not necessarily have a webhook. Inspect polling, checkout callbacks, and server functions too.
Audit duplicate payment emails in this project before editing.
Find every sender: checkout callbacks, polling, Stripe webhook handlers,
background jobs, and automations. List the event type and invoice/payment
identifier each sender uses. Tell me which dashboard settings I must check.
For invoice-backed payments, choose one owner for the paid-invoice email.
Do not remove payment processing or subscription updates.
Make email intent persistent with a unique business key that includes
Stripe account, test/live mode, invoice ID, and message purpose.
Freeze the send payload and reuse its Resend idempotency key on retries.
Return webhook success only after persisting the email job.
Test duplicate events, different events for the same invoice, worker crashes,
and an uncertain send older than the provider's idempotency window.
Do not claim exactly-once delivery or use a new random key for each retry.Choose one message for each business outcome
A Stripe event ID answers "have I handled this delivery?" Your email key answers "have I already arranged this particular message?" Those questions overlap, but they are not interchangeable. An invoice can generate several events with different event IDs. If several handlers all send a receipt, remembering each event ID still allows several receipts.
For the invoice-backed flow here, let invoice.paid create the paid-invoice notification. Keep checkout onboarding as a separate message with a separate purpose. Stripe's event catalog distinguishes invoice.paid, which also covers invoices marked paid out of band, from invoice.payment_succeeded. Saying "your invoice is paid" avoids claiming your app just charged a card.
This is a once-per-invoice confirmation policy. It is not a receipt for each individual payment against an invoice. For one-time Checkout purchases without invoices, design the key around the payment object and handle delayed payment methods in that flow. Do not bolt this invoice handler onto those purchases and expect it to run.
Decide how to handle multiple recipients before choosing the key. This example sends one message to the invoice's customer email. If a customer asks for a second copy for their accountant, make that a deliberate new send with an audited purpose. Do not change the recipient on a pending retry. For separate notifications to each billing contact, include a stable contact identifier in each job's identity. Otherwise the first contact's job can suppress the second contact's notification.
Use a stable message key
Build the key from account, mode, invoice ID, and purpose. Account and mode keep tenants and test sends apart. An invoice ID allows a customer to receive next month's confirmation. A customer ID by itself would suppress later invoices. A timestamp or a fresh UUID would make every retry look like a new message.
import type Stripe from "stripe";
export type EmailPayload = {
from: string;
to: string[];
subject: string;
text: string;
html?: string;
};
export function receiptJob(event: Stripe.Event, from: string) {
if (event.type !== "invoice.paid") return null;
const invoice = event.data.object;
// Policy: no email for zero-value invoices.
if (invoice.status !== "paid" || invoice.amount_paid <= 0) return null;
if (!invoice.customer_email || !invoice.hosted_invoice_url) {
throw new Error("Paid invoice needs recipient and hosted URL");
}
const url = new URL(invoice.hosted_invoice_url);
if (url.protocol !== "https:" || url.hostname !== "invoice.stripe.com") {
throw new Error("Unexpected hosted invoice URL");
}
const key = [
event.account ?? "platform",
event.livemode ? "live" : "test",
invoice.id,
"invoice-paid",
].join("/");
if (key.length > 256) throw new Error("Message key is too long");
const payload: EmailPayload = {
from,
to: [invoice.customer_email],
subject: "Your invoice is paid",
text: "Your invoice is paid. View its details: " + url.href,
};
return { key, payload };
}The helper expects verified Stripe snapshot events with an API version compatible with your installed Stripe SDK. Configure the destination accordingly. It intentionally skips zero-value invoices; change that product policy if you want those confirmations. Missing recipient data raises an error so it becomes visible, rather than quietly dropping a paid customer's email. Alert on repeated failures and repair the data before replaying.
Keep identity separate from design
A template change does not create a new reason to email a customer. Keep the message key stable across deploys. Render any React Email HTML and plain text before inserting the job, then store both. Retries must use that stored payload, even if the template changed.
Persist the email before acknowledging the webhook
A process-local Set disappears on a restart and cannot coordinate multiple server instances. A "check, then insert" query can race: two requests both see no record and both send. Give the database a unique key and let a single insert decide which request creates the job. Duplicate inserts should leave the original payload untouched.
CREATE TABLE email_outbox (
message_key text PRIMARY KEY,
source_event_id text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'sent', 'review')),
attempts integer NOT NULL DEFAULT 0,
available_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
first_attempt_at timestamptz,
locked_until timestamptz,
lease_token text,
provider_id text,
last_error text
);
CREATE INDEX email_outbox_ready ON email_outbox (available_at, created_at)
WHERE status IN ('pending', 'processing');Keep this table accessible only to your server and worker database roles. Its payload contains recipient data and invoice links. It should never be readable through an unauthenticated browser API. Use your database host's pooled connection string and TLS settings. Install stripe and pg, plus @types/pg as a development dependency.
import Stripe from "stripe";
import { Pool } from "pg";
import { receiptJob } from "@/lib/receipt-job";
export const runtime = "nodejs";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error("Missing environment variable: " + name);
return value;
}
const stripe = new Stripe(required("STRIPE_SECRET_KEY"));
const secret = required("STRIPE_WEBHOOK_SECRET");
const from = required("EMAIL_FROM"); // Your verified sending domain.
const db = new Pool({ connectionString: required("DATABASE_URL"), max: 2 });
export async function POST(request: Request): Promise<Response> {
const signature = request.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
await request.text(), signature, secret
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
try {
const job = receiptJob(event, from);
if (job) {
await db.query(
`INSERT INTO email_outbox (message_key, source_event_id, payload)
VALUES ($1, $2, $3::jsonb)
ON CONFLICT (message_key) DO NOTHING`,
[job.key, event.id, JSON.stringify(job.payload)]
);
}
return Response.json({ received: true });
} catch {
console.error("Could not persist invoice email", { eventId: event.id });
return new Response("Persistence failed", { status: 500 });
}
}The route verifies the raw body and writes one job before returning success. It does not send email or start an unawaited background task. A database outage returns an error so the delivery can be retried. A duplicate job returns success because the original durable record already owns the work. This route handles email intent only; retain your existing payment and entitlement processing.
If an email depends on a local order or entitlement update, commit that update and the outbox insert in the same database transaction. A "processed event" flag written before the outbox insert can lose email after a crash. Sending first and writing the flag afterward can duplicate it. A durable intent avoids both shortcuts.
Retry with the same key and the same payload
A worker claims a job for a short lease, sends it, and records the provider's acceptance ID. A lease is a temporary reservation; another worker can recover the job if the first process disappears. Postgres SKIP LOCKED lets workers claim different rows without waiting for each other. The claim below is one atomic statement, so no database transaction stays open during the network call.
Resend accepts an Idempotency-Key header on the send API. Reuse the outbox key. If the provider accepted a send but your worker lost the response, retrying within its retention window can recover the original response. Store the first attempt time before making the request, so a crash cannot reset the retry clock.
import { randomUUID } from "node:crypto";
import type { Pool } from "pg";
import type { EmailPayload } from "../lib/receipt-job";
type Job = {
message_key: string;
payload: EmailPayload;
attempts: number;
first_attempt_at: Date;
};
export async function deliverOne(
db: Pool, apiKey: string, send: typeof fetch = fetch
): Promise<boolean> {
const token = randomUUID();
const { rows } = await db.query<Job>(
`WITH candidate AS (
SELECT message_key FROM email_outbox
WHERE status IN ('pending', 'processing')
AND available_at <= now()
AND (locked_until IS NULL OR locked_until < now())
ORDER BY available_at, created_at
LIMIT 1 FOR UPDATE SKIP LOCKED
)
UPDATE email_outbox AS jobs
SET status = 'processing', lease_token = $1,
locked_until = now() + interval '60 seconds',
first_attempt_at = COALESCE(first_attempt_at, now()),
attempts = attempts + 1
FROM candidate WHERE jobs.message_key = candidate.message_key
RETURNING jobs.*`, [token]
);
const job = rows[0];
if (!job) return false;
let status = "review";
let providerId: string | null = null;
let reason: string | null = "retry_budget_exhausted";
const age = Date.now() - job.first_attempt_at.getTime();
// A conservative margin inside Resend's 24-hour retention.
if (age < 23 * 60 * 60 * 1000 && job.attempts <= 10) {
try {
const response = await send("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: "Bearer " + apiKey,
"Content-Type": "application/json",
"Idempotency-Key": job.message_key,
},
body: JSON.stringify(job.payload),
signal: AbortSignal.timeout(10_000),
});
const body: unknown = await response.json();
const fields = body && typeof body === "object"
? body as Record<string, unknown> : {};
reason = typeof fields.name === "string"
? fields.name : "http_" + response.status;
if (response.ok && typeof fields.id === "string") {
status = "sent";
providerId = fields.id;
reason = null;
} else if (
response.status === 429 || response.status >= 500 ||
reason === "concurrent_idempotent_requests"
) {
status = "pending";
} else if (response.ok) {
status = "pending"; // Accepted response could not be identified.
reason = "unrecognized_success_response";
}
} catch {
status = "pending";
reason = "network_or_response_error";
}
}
if (status === "pending" && job.attempts >= 10) status = "review";
const delay = Math.min(3600, 15 * 2 ** job.attempts);
const saved = await db.query(
`UPDATE email_outbox SET status = $3, provider_id = $4,
last_error = $5, locked_until = NULL, lease_token = NULL,
available_at = now() + ($6::int * interval '1 second')
WHERE message_key = $1 AND lease_token = $2`,
[job.message_key, token, status, providerId, reason, delay]
);
if (saved.rowCount !== 1) throw new Error("Worker lease was replaced");
return true;
}Run this exported function from your existing authenticated worker or scheduled job. Give it a pooled pg connection and a server-side Resend API key. Schedule repeat invocations to drain ready jobs, respect provider sending limits, and alert on rows inreview. Never expose the worker as a public "send email" endpoint. Backoff here is bounded; add jitter and a shared rate limiter when several workers send concurrently.
Each claim gets a fresh lease token. Database updates require that token, so an old worker cannot overwrite a newer worker's state. If the process crashes, the lease expires. If the provider accepted the send before a database failure, the next worker repeats the stored request with the same key. A row marked sent means provider acceptance; delivery and bounces still need their own monitoring.
Track the oldest pending job, the number of review jobs, and lease recoveries. Alert well before the retry cutoff so a worker outage does not turn a routine retry into a manual investigation. Use a separate delivery-status field for provider webhooks rather than changing a sent job back to pending when a bounce arrives. A rejected address needs correction; repeatedly sending the same receipt will not repair it. Keep access to manual resends limited to staff who can see the payment history and explain the resend to the customer.
Treat provider errors differently
Resend's error reference distinguishes two 409 responses. An in-progress request with the same key can be retried later. A changed payload under an existing key needs investigation. Do not generate a new key to silence that error: you may send the very duplicate you were trying to prevent. Invalid recipients and sender configuration errors also go to review in this example, rather than an endless retry loop.
What happens after the retry window?
There is a failure you cannot solve with another database flag: the provider accepted the email, your process died before saving its ID, and nobody retried until the provider had forgotten the key. Sending again might duplicate the message. Suppressing it might lose a message that never actually sent. The two systems do not share a transaction.
Our worker stops automatic sends after 23 hours from the first attempt, or after its attempt budget. That is our conservative operating policy, not a provider guarantee. Investigate the provider logs using the message key and attempt time. If you confirm acceptance, reconcile the provider ID and mark the job sent. If you cannot establish the outcome, decide whether a deliberate resend is appropriate for that customer and record the decision. Do not silently reset the clock.
Keep the deduplication key after a job succeeds. Deleting the entire row lets an old event create a new pending job. Choose a retention policy that covers your replay and backfill practices; you can remove sensitive payload data while retaining a minimal sent-message record.
Before enabling this for an existing app, reconcile recent accepted sends into the outbox and switch all receipt producers to the chosen owner. Otherwise the first replay after migration can resend a receipt your old code already delivered. Keep the ownership and persistence rules in your application when you change templates or providers.
Prove the fix before replaying customer events
Test with one invoice fixture, then replay that fixture. Generating a new invoice each time only proves that distinct invoices receive distinct emails. It does not test deduplication. Use Stripe sandbox events and a mocked email transport first so a broken retry does not reach a customer.
| Failure to simulate | Expected result |
|---|---|
| The same signed event arrives twice | One outbox row; both requests can succeed |
| Another invoice.paid event ID refers to the same invoice | The original message key and payload remain |
| The same fixture uses another account or test/live mode | A different message key |
| The worker loses a response after provider acceptance | A retry uses identical body and key |
| A processing worker crashes | The job becomes claimable after its lease expires |
| An uncertain job is older than 23 hours | Review status; no automatic provider request |
| Resend returns a payload-conflict error | Review status; no new key invented |
| An invalid Stripe signature arrives | HTTP 400; no job created |
Use these acceptance criteria when reviewing an AI builder's implementation.
For this article, we extracted the displayed TypeScript and SQL and checked them locally with Stripe's SDK, PGlite's embedded Postgres engine, and a mocked Resend HTTP transport. We exercised duplicate signed events, payload preservation, expired leases, provider errors, and the retry cutoff. Those checks validate the examples' local behavior. They do not measure live inbox delivery or establish concurrency behavior on your hosted database. Run the same failure cases in staging, including two worker processes.
If you need the broader event-to-email setup, our Stripe webhook integration guide covers receipt and payment-failure flows. For delivery visibility, follow the webhook monitoring guide. Once the send behavior is correct, adapt the Fintech invoice template for the HTML stored in your outbox. The layout saves design work; the durable job still controls when it sends.
- Confirm there is one payment and identify every receipt sender.
- Assign one owner and a stable account/mode/invoice/purpose key.
- Persist a unique outbox job before returning webhook success.
- Retry the stored payload with the same provider key and recover expired leases.
- Review uncertain old sends and keep deduplication history for replays.