Your Paddle webhook verifies the Paddle-Signature header and receives a valid transaction.completed event. The handler reads custom_data.seats, grants that many seats, and returns 200.
The signature is valid. The customer paid for 5 seats. Your app granted 500.
Can you trust custom_data in a signed Paddle webhook?
No. A valid Paddle signature proves that Paddle delivered the webhook body without modification. It does not prove that every value inside custom_data came from a trusted server or matches what the customer paid for.
Paddle lets checkout code set customData. Paddle stores it on the transaction, may copy it to the subscription, and includes it in later webhook payloads. If browser code supplied seats: 500, that value can return inside a correctly signed event.
Use custom_data as metadata or an opaque correlation reference. Derive paid quantity from the transaction's billed items[], validate the expected server-known price ID, and resolve the workspace through a server-side record.
How does a valid webhook grant the wrong number of seats?
A signed webhook can grant the wrong number of seats when the handler confuses transport authenticity with business authority. Paddle authentically returns both the client-provided metadata and the provider-calculated line items; your code still has to choose the authoritative field.
This checkout pattern creates the bug:
Paddle.Checkout.open({
items: [
{
priceId: "pri_seat",
quantity: 5,
},
],
customData: {
workspaceId: "ws_123",
seats: 500,
},
});
The transaction bills 5 units. The arbitrary metadata claims 500.
After checkout, Paddle may deliver an event shaped like this:
{
"event_type": "transaction.completed",
"data": {
"custom_data": {
"workspaceId": "ws_123",
"seats": 500
},
"items": [
{
"price": {
"id": "pri_seat"
},
"quantity": 5
}
]
}
}
Both fields are inside the signed body. Only one records what Paddle billed.
What is wrong with reading seats from Paddle custom_data?
Reading entitlement quantity from custom_data lets a client-controlled value cross the checkout boundary and become authorization state. The payment provider signs the envelope, but signing does not upgrade arbitrary metadata into a billing fact.
A vulnerable handler often looks reasonable:
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,
);
}
The handler does three things correctly:
- It verifies the signature.
- It waits for
transaction.completed. - It performs the entitlement write on the server.
The trust boundary is still wrong. customData.seats originated in checkout metadata, not in the paid line item.
The same concern applies to workspaceId. A browser-provided workspace identifier should not directly authorize where a paid entitlement lands. Use an opaque checkout reference, resolve it against a server-created purchase intent, and verify that the completed Paddle transaction belongs to that intent.
Which Paddle field should determine seat quantity?
For quantity-based billing, determine the purchased quantity from the completed transaction's items[] entry whose price.id matches your server-configured seat price. The billed line item is the payment fact that should drive the entitlement.
One safer extraction pattern is:
function paidSeatQuantity(
data: Paddle.TransactionCompletedEvent["data"],
): number {
const item = data.items.find(
(candidate) =>
candidate.price?.id === process.env.PADDLE_SEAT_PRICE_ID,
);
const quantity = item?.quantity;
if (
!Number.isSafeInteger(quantity) ||
quantity < 1 ||
quantity > 10_000
) {
throw new Error("Invalid billed seat quantity");
}
return quantity;
}
The upper bound is your product rule, not a Paddle default. Pick a value that matches the largest quantity your application is prepared to provision and review unusually large purchases separately.
Then separate correlation from authority:
const checkoutRef = event.data.customData?.checkoutRef;
const purchase = await db.purchaseIntent.findUnique({
where: { checkoutRef },
});
if (!purchase) {
throw new Error("Unknown checkout reference");
}
const seats = paidSeatQuantity(event.data);
await applyEntitlement({
workspaceId: purchase.workspaceId,
providerEventId: event.eventId,
providerTransactionId: event.data.id,
seats,
});
The metadata points to a server-side record. The Paddle line item supplies the paid quantity. The verified event ID supplies the idempotency key.
Is transaction.completed the right event for provisioning?
Paddle documents transaction.completed as the point where transaction processing has finished and the transaction contains the details needed for provisioning. For subscription access, Paddle also recommends keeping local subscription state synchronized from subscription events and periodically reconciling it with the API.
The important distinction is not only which event you choose. It is which fields inside that event are authoritative:
| Field | Safe role |
|---|---|
event_id | Deduplicate webhook processing |
data.id | Identify the Paddle transaction |
data.items[].price.id | Map a paid line item to a server-known product |
data.items[].quantity | Determine billed quantity |
data.custom_data.checkoutRef | Correlate to a server-created purchase intent |
data.custom_data.seats | Untrusted for entitlement quantity |
data.custom_data.workspaceId | Untrusted for direct authorization |
This keeps payment facts, routing metadata, and application authorization from collapsing into one loosely typed object.
How should the entitlement write handle webhook retries?
Make the entitlement update idempotent on Paddle's event_id or the transaction plus operation type. Paddle uses at-least-once webhook delivery, so a correctly processed event may arrive again.
Do not implement the grant as an unguarded increment:
await db.workspace.update({
where: { id: workspaceId },
data: {
seatLimit: {
increment: seats,
},
},
});
If the webhook is retried, that code can turn 5 seats into 10.
Prefer one transaction that records the provider event and applies the entitlement:
await db.$transaction(async (tx) => {
const inserted = await tx.processedWebhook.createMany({
data: [{
provider: "paddle",
eventId: event.eventId,
}],
skipDuplicates: true,
});
if (inserted.count === 0) return;
await tx.workspace.update({
where: { id: purchase.workspaceId },
data: {
seatLimit: seats,
},
});
});
The exact database syntax will vary. The invariant should not:
5 units billed
500 claimed in custom_data
5 seats granted
same event delivered again
still 5 seats granted
How can you test this without making a real Paddle purchase?
Run a validly signed transaction.completed event where the paid line item quantity and the client metadata disagree. The test must inspect the resulting entitlement, not only the webhook's 200 response.
FetchSandbox carries a deterministic paddle_seat_quantity scenario for this exact boundary:
paid items[0].quantity = 5
custom_data.seats = 500
Paddle-Signature = valid
expected granted seats = 5
From Cursor or Claude Code with FetchSandbox MCP, ask:
./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.
prove_fix runs the same signed event against the still-broken application and a copy with the proposed diff:
buggy code → granted=500, billed=5 → invariant VIOLATED
fixed code → granted=5, billed=5 → invariant HELD
If the application cannot be exercised or the scenario cannot measure the entitlement, the result is inconclusive rather than green.
Inspect a measured Paddle seat-quantity receipt.
What should reviewers require before merging?
Before merging a Paddle provisioning handler, ask for evidence that covers all four trust boundaries:
- The raw request body is verified against
Paddle-Signature. - The workspace is resolved from a server-created purchase intent, not authorized directly by browser metadata.
- The entitlement quantity comes from the expected paid
items[]line, notcustom_data. - Replaying the same
event_iddoes not grant the entitlement twice.
A pull-request note can be short:
## Paddle entitlement proof
- Signed event: valid
- Paid seat quantity: 5
- Client metadata claim: 500
- Buggy result: 500 seats
- Fixed result: 5 seats
- Duplicate delivery: no additional grant
- Receipt: https://fetchsandbox.com/runs/fix-...?flow=...
Code review can then focus on the remaining human decisions: whether the price mapping is correct, whether the quantity cap matches the product, and whether the purchase-intent ownership check fits the application's tenant model.
The signature authenticates delivery, not every claim
Webhook signature verification is mandatory. It stops an attacker from forging a Paddle event directly.
It does not tell your application how to interpret fields that originally came from browser checkout code. Paddle can truthfully sign a transaction containing both “5 units were billed” and “the client attached seats: 500.” Your handler decides which one becomes an entitlement.
Treat custom_data as correlation metadata. Treat paid line items as payment facts. Resolve authorization on your server. Then prove the boundary with mismatched values before a real customer finds it.
Questions developers ask
Is Paddle custom_data secure?
Paddle transports custom_data inside its entities and signed webhooks, but the values may have been supplied through browser checkout code. Do not use arbitrary custom data as the source of truth for prices, quantities, roles, or tenant authorization.
Does a valid Paddle signature mean the seat count is valid?
No. A valid signature confirms that Paddle delivered the exact payload. It does not prove that a value inside custom_data matches the paid line-item quantity.
Can I store a workspace ID in Paddle custom data?
You can use an identifier for correlation, but an opaque server-created checkout reference is safer. Resolve that reference to the workspace on your server instead of treating a browser-provided workspace ID as authorization.
How do I prevent duplicate Paddle seat grants?
Persist Paddle's event_id with a unique constraint and apply the entitlement in the same database transaction. A repeated event should return success without applying the grant again.
Does FetchSandbox replace Paddle's sandbox?
No. FetchSandbox makes the mismatched-metadata failure deterministic and produces a proof receipt. Use Paddle's sandbox afterward to validate your real credentials, notification destination, and provider connection.