Engineering notesView as Markdown ↗

Trust the signed account, not the webhook's metadata

A payment provider signs the connected account that owns a charge. That field is authoritative; metadata travelling alongside the event is not. Bind the payment to the signed account, refuse any metadata that disagrees, and clamp the recorded amount to what the provider actually received.

On a platform that processes payments for many businesses, a payment webhook must answer one question safely: which business does this money belong to? The answer has to come from the field the provider cryptographically signs — the connected account that owns the charge — and never from free-form metadata riding alongside the event.

We learned this the direct way. A webhook resolved the owning business from a metadata.tenant_id field. Because metadata can be set by whoever creates the charge, a connected business could attribute a payment to a different business — and an inflated amount field could record more than was actually captured.

What the fix looks like

Three rules, enforced at the webhook boundary:

  1. Bind to the signed account. Resolve the business from the provider-signed account, which maps to exactly one connected business. That mapping is the source of truth.
  2. Refuse contradictory metadata. If metadata names a different business than the signed account, reject the event and log it — do not silently prefer one over the other.
  3. Clamp the amount. Record at most what the provider says it received. A metadata-supplied figure that does not reconcile to the actual amount received is ignored.

Only events with no connected account — the platform billing itself — fall back to trusting metadata, because the platform created that charge on its own account.

The test that locks it

The guardrail ships with a spec test written before the fix and confirmed failing first: feed a signed event for account A carrying metadata that names business B, then assert the handler resolves to A, refuses B, and records no more than the amount actually received. A security invariant without a failing-first test is a hope, not a guarantee.

The general pattern

Whenever a provider signs some fields and leaves others open, treat the signed set as authoritative and everything else as untrusted input. This is the payments-specific face of the same principle behind multi-tenant MCP safety: isolation has to be enforced on a value the caller cannot forge.

Related

Last updated 2026-06-26