The webhook passed signature verification. Paddle said the transaction was complete. The customer paid for 5 seats.
The database granted 500.
Nothing forged the webhook. The dangerous value had entered earlier, through customData in browser checkout code. Paddle stored it, included it in transaction.completed, and correctly signed the entire payload.
The handler confused authentic delivery with authoritative data.
The two quantities inside one valid event
Paddle Checkout can receive custom metadata from frontend code:
Paddle.Checkout.open({
items: [
{
priceId: "pri_seat",
quantity: 5,
},
],
customData: {
workspaceId: "ws_123",
seats: 500,
},
});
When the transaction completes, the webhook may carry both values:
{
"event_type": "transaction.completed",
"data": {
"custom_data": {
"workspaceId": "ws_123",
"seats": 500
},
"items": [
{
"price": { "id": "pri_seat" },
"quantity": 5
}
]
}
}
Both are inside the body protected by Paddle-Signature.
But the fields answer different questions:
-
custom_data.seatssays what checkout code attached -
items[0].quantitysays what the completed transaction billed
The signature proves Paddle sent those bytes. It does not turn browser-originated metadata into a billing fact.
The handler that looked secure
The vulnerable code was not missing signature verification:
const event = paddle.webhooks.unmarshal(
rawBody,
process.env.PADDLE_WEBHOOK_SECRET!,
signature,
);
if (event.eventType === "transaction.completed") {
const seats = Number(event.data.customData?.seats ?? 1);
await grantSeats(
event.data.customData?.workspaceId,
seats,
);
}
It checked the signature, waited for the completed event, and wrote the entitlement on the server.
Then it trusted the wrong field.
The same problem exists with workspaceId. If browser code supplies the tenant identifier and the webhook handler treats it as authorization, payment for one checkout can be routed to an account the server never approved.
What the fixed trust boundary looks like
Use custom data for correlation, not authority.
The safer flow is:
- Create a purchase intent on your server.
- Store the intended workspace against an opaque checkout reference.
- Put only that reference in Paddle
customData. - On
transaction.completed, resolve the reference on your server. - Find the expected, server-known
price.idinsidedata.items[]. - Grant the quantity on that paid line item.
- Deduplicate on Paddle's
event_id.
The quantity extraction should be explicit:
const seatItem = event.data.items.find(
(item) =>
item.price?.id === process.env.PADDLE_SEAT_PRICE_ID,
);
const seats = seatItem?.quantity;
if (
!Number.isSafeInteger(seats) ||
seats < 1 ||
seats > 10_000
) {
throw new Error("Invalid billed seat quantity");
}
That upper bound is an application decision. The important part is that the entitlement comes from the completed, paid line item rather than an arbitrary metadata key.
Then resolve ownership separately:
const checkoutRef = event.data.customData?.checkoutRef;
const purchase = await db.purchaseIntent.findUnique({
where: { checkoutRef },
});
if (!purchase) {
throw new Error("Unknown checkout reference");
}
await applyEntitlement({
workspaceId: purchase.workspaceId,
eventId: event.eventId,
transactionId: event.data.id,
seats,
});
Payment facts, correlation metadata, and tenant authorization are now three separate concerns.
The test that exposes the bug
A happy-path webhook fixture usually gives both fields the same value:
custom_data.seats = 5
items[0].quantity = 5
The vulnerable and fixed handlers both pass.
The useful test makes the trust boundary disagree:
Paddle-Signature = valid
custom_data.seats = 500
items[0].quantity = 5
expected grant = 5
Do not stop at response.status === 200. Read the entitlement your handler wrote.
The complete invariant is:
5 units billed
500 claimed in client metadata
5 seats granted
same event delivered again
still 5 seats granted
That last line matters because Paddle uses at-least-once webhook delivery. Fixing the quantity source while leaving the grant non-idempotent still doubles access on a retry.
Run it from the coding agent
FetchSandbox has a deterministic paddle_seat_quantity probe for this exact mismatch. It sends a freshly signed transaction.completed event to the application's real handler and observes how many seats the application grants.
With FetchSandbox MCP connected to Cursor or Claude Code:
./fetchsandbox audit my Paddle webhook for client-controlled seat
quantity. Propose the fix, then call prove_fix before applying it.
Show whether the buggy tree grants 500 while the fixed tree grants
the 5 units actually billed. Return the proof receipt.
The same probe runs against both trees:
buggy tree → granted=500, billed=5 → VIOLATED
fixed tree → granted=5, billed=5 → HELD
If the probe cannot load the handler or measure the grant, the result is inconclusive. It does not turn “could not run” into green.
Here is a measured Paddle seat-quantity receipt.
Carry the proof into review and CI
Attach the receipt to the pull request with the exact invariant:
## Paddle entitlement proof
- signature: valid
- paid quantity: 5
- client metadata claim: 500
- before patch: granted 500
- after patch: granted 5
- receipt: https://fetchsandbox.com/runs/fix-...?flow=...
That receipt proves the proposed diff against the mismatched-data failure. Keep the broader provider workflows running in CI as a separate regression gate:
- name: Run API integration workflows
run: |
npx fetchsandbox run "$FETCHSANDBOX_ID" --all --json \
> fetchsandbox-workflows.json
The PR receipt and CI check have different jobs. The receipt shows the bug existed and the proposed fix changed the measured behavior. CI keeps the provider workflows from drifting as more code lands.
Signed does not mean trusted
Verify every Paddle webhook signature. That is non-negotiable.
Then make a second decision for every field: where did this value originate, and is it authoritative for the action I am about to take?
For seat provisioning:
-
custom_datacan correlate the transaction - a server-side record decides the workspace
- paid
items[]decide the quantity -
event_idmakes processing idempotent
A valid signature can carry an unsafe instruction. The verifier has to test both the cryptography and the business trust boundary.
The full implementation guide is on FetchSandbox.
Top comments (0)