Skip to content

Adyen transfer webhook processing

This page explains how Adyen transfer webhooks reach our system, what they trigger, and how delivery can fail. It does not contain procedural steps — see Re-issue missing webhooks for that.

The delivery contract

Adyen sends one webhook per transfer state change. The contract:

  • Each webhook carries a data.sequenceNumber, monotonically increasing from 1 per transfer.
  • The data.events array is cumulative: a webhook at sequence N contains all N events that have happened so far. Earlier events are repeated unchanged on later webhooks.
  • Adyen retries on non-2xx responses (limited attempts / window). After exhaustion the webhook is dropped.
  • Past webhook payloads are visible in Customer Area → Developers → Event logs, with a 30-day retention.

Our processing pipeline

flowchart TD
    A[Adyen] -->|HTTP POST + HMAC| W[Webhook controller]
    W -->|publish| T1[adyen_transfer_topic]
    T1 -->|subscribe| S[TransferTopicSubscriber]
    S -->|dispatch by type/category| P[Card/Bank/Account processor]
    P -->|insert| U[(TransferUpdate)]
    P -->|insert| E[(TransferEvent)]
    P -->|publish| T2[transfer_update_topic]
    T2 -->|subscribe| D1[PaymentRequest status update]
    T2 -->|subscribe| D2[Ledger entries]
    T2 -->|subscribe| D3[ES TransactionDetails<br/>+ push notification]
Hold "Alt" / "Option" to enable pan & zoom

A webhook arrives at the HMAC-verified controller and gets published to adyen_transfer_topic. The topic fans out to several independent subscribers — the one relevant here is TransferTopicSubscriber, which dispatches by (data.type, data.category) to the right processor (card / bank / account). The processor records the TransferUpdate row, persists per-event TransferEvent rows, and emits to transfer_update_topic. Downstream consumers in payment_gateway and es/healthy_benefits pick it up from there.

The topic between ingest and processing decouples retry/error behavior from delivery acknowledgement.

Other subscribers on adyen_transfer_topic

adyen_authorisation_relay (in authorizations/adapters/adyen/) also listens — it handles the authorization relay flow (separate concern, not covered here). Re-injecting a synthetic webhook will fan out to every subscriber on the topic, including the auth relay's on_payment_event path. For most reconstructed transfers the authorization is already finalized, so this is a no-op in practice; verify on a case-by-case basis if you're unsure.

What depends on TransferUpdate

Anything downstream of the transfer_update_topic emission only fires if the TransferUpdate row lands. Concretely:

  • PaymentRequest status advances (pendingsucceeded / failed).
  • Ledger entries get written.
  • TransferEvent rows persist per accounting event of the transfer.
  • (Spain) EsTransactionDetails row is created by the card_payment_update_notification consumer — this is what powers the user transactions screen.
  • Other pub/sub consumers subscribing to transfer_update_topic are notified.

If the webhook is lost, none of the above happens for that sequence number.

How webhooks can be missed

Four named failure modes:

  1. Network loss between Adyen and us. The request never reaches the webhook controller. Adyen retries, but if the connectivity issue persists past Adyen's retry budget, the webhook is dropped.
  2. Our controller returns non-2xx. A bug, a transient error, a deploy window. Adyen retries up to its limit, then drops. The body lands in Adyen's event logs but is never processed on our side.
  3. Out-of-order delivery. Adyen does not guarantee strict ordering. Sequence N+1 can arrive before sequence N. Gaps may close on their own once the late delivery lands, so a fresh gap is not necessarily a missing webhook.
  4. Processor error after ack. We acknowledge the webhook but fail before persisting the TransferUpdate. This shouldn't happen — the pubsub contract forbids one subscriber's failure from impacting another's transaction — so if you see it, treat it as a bug to fix upstream of the missing-webhook tooling.

Detecting a missed webhook

Patterns to look for:

  • Gap in transfer_update.sequence_number for a given (workspace_key, external_transfer_id).
  • MAX(occurred_at) for the transfer is older than the late-delivery window (typically 6–24h).
  • Customer-facing symptoms: transfer stuck in a non-final state, or missing in the transactions screen.

The list_missing_transfer_webhooks command surfaces gaps that match these criteria — see the how-to.

Reconstruction strategy (high level)

When you find a confirmed gap, the synthetic-webhook reconstruction lets you re-emit it through adyen_transfer_topic so the downstream chain runs as if Adyen had delivered it.

  • Stable fields (transfer id, account holder, payment instrument, counterparty) come from any sibling — they're identical across all sequences of the same transfer.
  • Sequence-specific fields (status, events, eventId) are derived from the next sibling's cumulative events list, sliced to [:N]. Adyen's 1-1 mapping (the N-th event of any sibling with seq ≥ N is the event that triggered sequence N) gives us the right values.
  • Optional Adyen dashboard payload fills per-sequence detail that siblings cannot supply: balances, categoryData.validationFacts, exact creationDate / updatedAt. Redacted leaves (***, partial ***...arn) fall back to the sibling-derived values.
  • Marker on data.description: ALAN_EXTRAPOLATED_WEBHOOK from=<seqs> [dashboard] | <original>. Survives the dataclasses_json roundtrip; queryable via raw->>'description' LIKE 'ALAN_EXTRAPOLATED_WEBHOOK%'.
  • Idempotency: re-firing after the real webhook eventually arrives is a no-op. The unique constraint on (workspace_key, external_transfer_id, sequence_number) + ON CONFLICT DO NOTHING in the broker make double-injection safe.

Algorithm details live in synthetic_webhook.

See also