Plenty of indie SaaS runs on a merchant of record now. Paddle and Lemon Squeezy handle sales tax, VAT, and chargebacks, which is a genuinely good trade for a solo founder who does not want to think about EU VAT thresholds.
The webhooks are not the same shape as Stripe's, and the differences are the kind that only show up under load. If you ported a Stripe handler across and changed the signature check, there are two numbers worth knowing.
TL;DR
- Paddle wants a
200within five seconds, not the twenty or so Stripe informally allows - Paddle live mode retries 60 times over 3 days, with 20 attempts in the first hour and 47 in the first day
- Paddle sandbox behaves completely differently: 3 retries in 15 minutes
- Lemon Squeezy signs with
X-Signature, an HMAC-SHA256 hex digest over the raw body, and has dashboard replay - A slow handler is punished much harder here than on Stripe, because the retry curve is front-loaded rather than exponential
Five seconds, not twenty
Stripe never publishes a number and the community works to about twenty seconds. Paddle publishes one: your endpoint "Returns 200 within five seconds of receiving a request."
Five seconds puts Paddle in the same bracket as Shopify rather than Stripe, and it is roughly a quarter of the budget your Stripe handler was written against. A fulfilment handler that provisions a licence key, writes to your database, and sends a welcome email through a third-party API is comfortably inside twenty seconds and comfortably outside five.
The shape that survives is the same one that survives everywhere: verify, persist, acknowledge, process later.
export async function POST(req: Request) {
const raw = await req.text(); // raw bytes, before parsing
if (!verifyPaddleSignature(raw, req.headers.get("paddle-signature"))) {
return new Response("unauthorized", { status: 401 });
}
await queue.publish(raw); // durable, ~10ms
return new Response(null, { status: 200 }); // inside 5s
}
The retry curve is the real difference
Paddle's live-mode schedule: "we retry 60 times within 3 days. The first 20 attempts happen in the first hour, with 47 in the first day."
Stripe covers the same three days with exponential backoff and a handful of attempts. Paddle covers it with sixty, most of them early.
Consider what that means for a handler that is not broken, just slow. It takes six seconds because your database is having a moment. Paddle times out at five and retries. Within the first hour that happens twenty times, each one starting another six-second run of your handler, against a database that is already the reason for the problem.
Side by side with the other senders in our webhook provider reference, Paddle is the outlier. Exponential backoff exists to give a struggling system room to recover. A front-loaded curve does the opposite: it applies the most pressure exactly when your endpoint is least able to take it. Paddle is not being unreasonable, because for billing events you want delivery to be persistent, but it does mean the cost of a slow handler compounds far faster than the same handler on Stripe.
It also means a non-idempotent handler can do the same work twenty times in an hour rather than three times in a day. If that work is "provision a licence" or "send a receipt," your customer notices.
Sandbox will not show you this
"Sandbox: we retry 3 times within 15 minutes."
Three retries over fifteen minutes is a different system from sixty over three days. Every timing assumption you validate in sandbox is wrong in production, and in the safer direction, which is the worst kind of wrong. Your handler will look fine right up until it is handling real money.
If you want to test the retry behaviour that matters, deliberately return a 500 in live mode against a test destination and watch the first hour.
Signature verification
Paddle signs with a Paddle-Signature header carrying a timestamp and an H1 signature, in the same general family as Stripe's t=/v1= scheme. Lemon Squeezy signs with X-Signature, an HMAC-SHA256 hex digest of the raw request body using your webhook's signing secret.
Both share the trap every provider shares: the HMAC is over the exact bytes that arrived. Parse the JSON first and re-serialize it and the digest changes, while the payload in your logs looks perfectly correct. This is the single most common webhook bug across every provider, and the per-provider mechanics are in Verifying Stripe, GitHub, and Shopify Webhook Signatures.
import crypto from "node:crypto";
function verifyLemonSqueezy(raw: string, header: string | null, secret: string) {
if (!header) return false;
const expected = crypto.createHmac("sha256", secret).update(raw).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
The length check before timingSafeEqual matters, because it throws on a length mismatch rather than returning false, and a malformed header from an attacker should not produce a 500.
On the Next.js App Router, await req.text() before anything else. On serverless generally, Receiving Webhooks on Vercel Without Losing Them covers the middleware matcher that silently consumes the body stream.
Idempotency, with a merchant of record wrinkle
Both providers send an event identifier you can key on, and the pattern is the usual unique constraint:
INSERT INTO processed_events (event_id, provider)
VALUES ($1, 'paddle') ON CONFLICT (event_id) DO NOTHING;
Zero rows affected means you have seen it. Return 200 rather than an error, or you have built a loop with sixty attempts in it.
The wrinkle specific to a merchant of record: subscription lifecycle events for one customer can arrive close together, and a retry can reorder them. A subscription.updated retried an hour later can land after a subscription.canceled and put the subscription back to active. Deduplication does not help, because both events are distinct and both are legitimate. You need a version guard keyed on the event's own timestamp, which is the same pattern described in You Received the Same Webhook Twice.
Given Paddle's retry density, the window in which reordering is possible is much wider than on Stripe. This is not a theoretical concern here.
Coming from Stripe: what to change
Cut your handler's time budget by four. Anything not inside verify-persist-acknowledge moves to a worker.
Assume duplicates are common rather than rare. Twenty attempts in an hour changes the arithmetic on how careful your idempotency needs to be.
Add version guards to anything with a lifecycle. Subscriptions especially.
Do not tune against sandbox. Its retry behaviour is nothing like production's.
Keep a log of the raw payloads. Lemon Squeezy has dashboard replay and Paddle keeps delivery history, but both are the provider's copy, on the provider's retention, and neither answers "what did our handler actually do with it."
Where AnyHook fits
Five seconds is a transport constraint and sixty retries is a transport behaviour. Both stop applying to your code once something else is holding the connection.
Point the notification destination at in.anyhook.net/you/paddle. AnyHook verifies and persists at the edge and returns 200 in under 50ms, so the five-second budget is satisfied by a worker that only ever does two things, and the sixty-attempt curve never engages because there is nothing to retry.
- Your handler gets 60 to 300 seconds depending on plan, and a slow database no longer invites twenty more requests
- Every event is stored before delivery, encrypted at rest, so replaying a bad deploy window is a selection and a click
- Delivery retries against your endpoint on exponential backoff, which is the right curve for a system that is recovering
- Alerts fire at 1, 5, and 20 consecutive failures, and delivery auto-pauses at 20 so a broken handler cannot be hammered
Your handler still needs idempotency and version guards, because retries and replay both exist.
Takeaway
Merchant of record billing is a good trade for indie SaaS, and the webhook contract is stricter than the one you are probably migrating from. Five seconds, sixty attempts, most of them early. Verify against raw bytes, acknowledge before you work, dedupe on the provider's event ID, and guard subscription writes on the event's own timestamp.
The handler that has been fine on Stripe for two years is not automatically fine here, and sandbox will not be the thing that tells you.