Paddle webhook keeps retrying
Short answer: Paddle wants your server to reply with HTTP 200 within five seconds. If the reply is slower, or the status is anything else, Paddle counts it as a fail and sends the same event again. Your code may have done the work just fine, so you get the same email, unlock or row twice. Check the signature, save the event_id, reply 200 right away, and do the slow work after the reply.
Signs you have this problem
- In Paddle, under Events > Notifications, the event shows more than one attempt or the status
needs_retry. - Your logs show the same
event_idtwo or more times. - A customer got the same "thanks for paying" email more than once.
- It only happens sometimes, often on the first event after a quiet time.
- Your handler sends an email or calls another API before it replies.
Why Paddle sends it again
Paddle's docs say your webhook server should:
- Return
200within five seconds of getting the request. - Reply before doing other work, like calling another service.
If it gets another status code, or no reply in five seconds, Paddle tries again later:
- Sandbox: 3 attempts in total, all within about two minutes.
- Live: up to 60 attempts over about two days. About 10 of them happen in the first hour.
While it waits, the status is needs_retry. When all attempts are used up, the status is failed, and Paddle stops on its own. Every retry carries the same event_id, so you can tell it is a repeat.
What makes a reply too slow or wrong
- Slow work before the reply. Sending an email, calling an AI or CRM API, making a PDF, or a slow database call. Five seconds goes fast.
- A cold start. On serverless hosts (Vercel Functions, AWS Lambda and the like), the first call after a quiet time has to start up. Paddle's docs name this as a cause of timeouts that come and go.
- A crash after the work. Your code saves the order, then throws on a later line. The host sends a 5xx, so Paddle retries, and the work runs again.
- A different status. Paddle's docs ask for a
200. To be safe, send exactly200, not201or204. - A redirect or a firewall. Paddle does not follow a redirect like
301or302. It counts as a fail. So does a firewall or bot check that blocks Paddle. See /fix/webhook-redirect-307-308.
A real case: one developer on the Netlify forum saw Paddle log 503 errors on what looked like cold starts. Their code still ran each time, so the customer got an email for each try. Their fix was to record each event in the database and check it before sending the email. That is the fix below, plus a faster reply.
A route that replies in time (Next.js App Router)
This checks the signature on the raw body, skips repeats by event_id, saves the event, and replies 200. The slow work runs in Next.js after(), which runs once the reply is sent. after() is stable since Next.js 15.1.
// app/api/paddle/webhook/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import { after } from 'next/server'
export const runtime = 'nodejs'
const MAX_AGE_SECONDS = 5 // the same window Paddle's SDKs use
type PaddleEvent = { event_id: string; event_type: string; occurred_at: string; data: unknown }
// Swap this Map for a database table with a UNIQUE event_id column.
// The Map is only for the example: it is wiped on every deploy.
const events = new Map<string, { done: boolean }>()
function isValidPaddleSignature(rawBody: string, header: string, secret: string): boolean {
let ts = ''
const h1s: string[] = []
for (const part of header.split(';')) {
const [key, value] = part.split('=')
if (key === 'ts') ts = value ?? ''
if (key === 'h1' && value) h1s.push(value)
}
if (!ts || h1s.length === 0) return false
const age = Math.floor(Date.now() / 1000) - Number(ts)
if (!Number.isFinite(age) || age > MAX_AGE_SECONDS) return false
const expected = createHmac('sha256', secret).update(`${ts}:${rawBody}`).digest('hex')
return h1s.some(
(h1) => h1.length === expected.length && timingSafeEqual(Buffer.from(h1), Buffer.from(expected)),
)
}
// The slow part: unlock access, send the email, call other APIs.
async function handleEvent(event: PaddleEvent) {
console.log('handling', event.event_type, event.event_id)
}
export async function POST(req: Request) {
const secret = process.env.PADDLE_WEBHOOK_SECRET
if (!secret) {
console.error('PADDLE_WEBHOOK_SECRET is not set on this server')
return new Response('Server not set up', { status: 500 })
}
// 1. Check the signature first, on the raw text.
const rawBody = await req.text()
const header = req.headers.get('paddle-signature') ?? ''
if (!isValidPaddleSignature(rawBody, header, secret)) {
return new Response('Invalid signature', { status: 401 })
}
let event: PaddleEvent
try {
event = JSON.parse(rawBody)
} catch {
return new Response('Bad JSON', { status: 400 })
}
if (!event.event_id) return new Response('Missing event_id', { status: 400 })
// 2. A retry of an event we already have: say 200 and stop.
if (events.has(event.event_id)) {
return new Response('ok', { status: 200 })
}
// 3. Save it. This is one quick write, so it fits in the 5 seconds.
events.set(event.event_id, { done: false })
// 4. Do the slow work after the reply is sent.
after(async () => {
try {
await handleEvent(event)
events.set(event.event_id, { done: true })
} catch (err) {
// Paddle already got its 200 and will not retry.
// The row stays done: false, so your own retry job must pick it up.
console.error('Paddle event failed after reply', event.event_id, err)
}
})
// 5. Reply now. Exactly 200.
return new Response('ok', { status: 200 })
}
Three things to know before you copy it:
- Use a real table. Make
event_ida UNIQUE column, and use "insert, and skip if it is already there". AMapin memory is wiped on every deploy, and two copies that arrive at the same moment can both get past it. - After the 200, Paddle will not retry. If the work in
after()fails, Paddle still thinks it is done. So keep adoneflag on each row, and run a small job (a cron, every few minutes) that picks up rows that are still not done. after()has a time limit too. It runs within your route's max time on your host. Long jobs belong in a real queue.
Paddle can also send events out of order. Save occurred_at and don't let an older event undo a newer one. Signature errors are a different problem: see /fix/paddle-webhook-signature-verification-failed.
Step by step
- Open the delivery log. In Paddle, go to Events > Notifications and open the event. Note the status code and the time of each try.
- No reply, or a 5xx with no error in your logs? That points to a slow reply or a cold start. Move slow work into
after()or a queue. - A 5xx with an error in your logs? Your code threw. Fix the bug, and don't throw after the event is saved.
- A 3xx? Use the exact final address in Paddle, with no redirect.
- Skip repeats. Save
event_idand ignore one you already have. This also makes replays safe. - Deploy, then send a test with Paddle's webhook simulator to your live address.
- Catch up. Find events that ran out of tries with the script below. After the fix is live, replay them. Paddle keeps notifications for 90 days.
Find webhooks that gave up
This script only reads. It lists notifications with the status failed or needs_retry since a date you pick, and follows every page. It needs a Paddle API key with read access to notifications.
// scripts/list-failed-paddle-notifications.ts
// Read-only. Lists Paddle webhooks that gave up (failed) or are still waiting (needs_retry).
// Run: PADDLE_API_KEY=... npx tsx scripts/list-failed-paddle-notifications.ts 2026-11-01T00:00:00Z
type Notification = {
id: string
type: string
status: string
times_attempted: number
last_attempt_at: string | null
payload: { event_id: string }
}
type Page = { data: Notification[]; meta: { pagination: { next: string; has_more: boolean } } }
export function baseUrl(apiKey: string): string {
// Sandbox keys start with pdl_sdbx_ and only work on the sandbox API.
return apiKey.startsWith('pdl_sdbx_') ? 'https://sandbox-api.paddle.com' : 'https://api.paddle.com'
}
export function firstUrl(apiKey: string, from: string): string {
const params = new URLSearchParams({ status: 'failed,needs_retry', from, per_page: '200' })
return `${baseUrl(apiKey)}/notifications?${params}`
}
export async function listStuck(apiKey: string, from: string): Promise<Notification[]> {
const found: Notification[] = []
let url: string | null = firstUrl(apiKey, from)
while (url) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } })
if (!res.ok) throw new Error(`Paddle API said ${res.status}: ${await res.text()}`)
const page = (await res.json()) as Page
found.push(...page.data)
url = page.meta.pagination.has_more ? page.meta.pagination.next : null
}
return found
}
export async function main(argv: string[], env: Record<string, string | undefined>): Promise<number> {
const apiKey = env.PADDLE_API_KEY
const from = argv[0]
if (!apiKey || !from) {
console.error('Usage: PADDLE_API_KEY=... npx tsx list-failed-paddle-notifications.ts <from, like 2026-11-01T00:00:00Z>')
return 1
}
const list = await listStuck(apiKey, from)
for (const n of list) {
console.log(`${n.id} ${n.status} ${n.type} ${n.payload.event_id} tries=${n.times_attempted} last=${n.last_attempt_at}`)
}
console.log(`${list.length} notification(s) not delivered since ${from}`)
return 0
}
if (process.argv[1]?.endsWith('list-failed-paddle-notifications.ts')) {
main(process.argv.slice(2), process.env).then((code) => process.exit(code))
}
To send one again, use Paddle's replay a notification API (POST /notifications/{notification_id}/replay). It works on failed or delivered notifications, not on ones still waiting for a retry. Paddle makes a new notification with the same event_id, so your skip-repeats check matters here too.
Check it with a free scan
Paste your webhook address into the free Tillping scan: https://tillping.online/#scan, and pick Paddle. No account is needed. Only scan an address you own or are allowed to test.
The scan sends a few safe test requests, with no real payment. One has a fake Paddle-Signature, and one has no signature. It times the reply. It warns you at 3 seconds and flags 8 seconds or no reply as a serious problem. It also flags a 5xx, a 404, a redirect, a Vercel login wall, a Cloudflare challenge, a bad certificate, and an endpoint that says OK to a fake signature or to no signature.
What it can't do: a good route turns down our fake signature before any slow work runs. So the scan times your start-up and your signature check, not your real work after a real event. One scan may also hit a warm server and miss a cold start. It can't see your Paddle dashboard, your delivery log or your retries. Use Events > Notifications for that.
Sources
- Paddle, Handle webhook delivery (return
200within five seconds; respond before other work; cold starts on serverless; retries: sandbox 3 within about two minutes, live up to 60 over about two days, about 10 in the first hour;event_id;occurred_at;failed; replay; firewall and bot checks; simulator): https://developer.paddle.com/webhooks/about/respond-to-webhooks - Paddle, How webhooks work (at-least-once delivery; statuses
not_attempted,needs_retry,delivered,failed; delivery logs under Events > Notifications): https://developer.paddle.com/webhooks/about/how-webhooks-work - Paddle API, List notifications (
status,from,per_pageup to 200,meta.pagination.nextandhas_more, 90 days): https://developer.paddle.com/api-reference/notifications/list-notifications - Paddle API, Replay a notification (only
deliveredorfailed, new notification for the sameevent_id, 90 days): https://developer.paddle.com/api-reference/notifications/replay-notification - Paddle webhooks skill on GitHub (a
301or302is a failed delivery and is not followed): https://github.com/PaddleHQ/paddle-agent-skills/blob/main/skills/webhooks/SKILL.md - Paddle, Verify webhook signatures (
ts:body, HMAC SHA-256, 5-second SDK window): https://developer.paddle.com/webhooks/about/signature-verification - Next.js,
after(runs after the response; uses the route's max duration; stable since 15.1): https://nextjs.org/docs/app/api-reference/functions/after - Netlify forum, Paddle webhook
503on cold starts and a repeat email per try: https://answers.netlify.com/t/503-response-without-logs-in-function-serving-webhook/121894
Check your webhook now
Paste your webhook address to see what it answers. Free, no account, no keys.
Check my webhookGet an email when Deep Scans open
One email to confirm, then one when we launch. Leave any time.
