Skip to content

Re-issue missing Adyen transfer webhooks

Use this guide when

  • A customer reports a transfer state stuck on received / authorised, or missing in the Spain transactions screen.
  • list_missing_transfer_webhooks reports a stale gap (see Step 1).
  • An internal alert on TransferUpdate sequence gaps fires.

If you want to understand why webhooks go missing or what reconstruction does to the data, read Webhook processing first.

Info

Adyen transfer webhooks can fail to be processed end-to-end — typically because our controller returned non-2xx and Adyen exhausted its retries, less often because the request never reached us. Either way the outcome is the same: no TransferUpdate row → no transfer_update_topic event → downstream PaymentRequest / ledger / EsTransactionDetails / TransferEvent writes don't happen. The two commands below let you reconstruct the missing webhook from sibling payloads (and optionally a dashboard payload) and re-inject it through adyen_transfer_topic. See Webhook processing for the full list of failure modes.

Prerequisites

  • Shell on the country app (e.g. APP=es_api).
  • Read access to payment_gateway.transfer_update.
  • (Optional) Adyen Customer Area access for dashboard payloads.

Steps

1. Find the gap

alan run --scope backend -- env APP=es_api flask payment_gateway adyen \
    list-missing-transfer-webhooks --min-age-hours=24

Each row reports a transfer with a confirmed gap: workspace external_transfer_id type max=N known=K latest=<ts> missing=[…]. Pick the transfer you want to patch.

Full flag reference: commands.md.

2. (Optional) Grab dashboard payloads

If the dashboard still has the event log for the missing sequence, grab the payload — it contributes per-sequence detail that siblings can't supply: balances, categoryData.validationFacts, and the exact creationDate / updatedAt. Without it, those end up null in the synthetic payload (events still get real ids from the next sibling).

Adyen's Customer Area retains event logs for 30 days. If the gap is older than that, skip this step — sibling-only reconstruction is your only option.

How:

  1. Open Adyen Customer Area → Developers → Event logs (direct: https://ca-live.adyen.com/ca/ui/developers/event-logs/ ⧉).
  2. Filter to the affected transfer id and the missing sequence numbers.
  3. Copy each webhook body verbatim into a JSON array file, e.g. payloads.json:
[
  { "type": "balancePlatform.transfer.created", "data": { ... } },
  { "type": "balancePlatform.transfer.updated", "data": { ... } }
]

The dashboard redacts PII (***, partial ***...arn). The merge layer falls back to sibling-derived values for redacted leaves automatically — no manual cleanup needed.

3. Dry-run

alan run --scope backend -- env APP=es_api flask payment_gateway adyen \
    reissue-missing-transfer-webhooks \
    --workspace-key=<wk> --external-transfer-id=<id> \
    [--dashboard-payloads-file=payloads.json] --dry-run

The command logs the full synthetic payload per sequence. Inspect:

  • data.sequenceNumber matches the missing seq.
  • data.status and data.events[].id look right.
  • data.description starts with ALAN_EXTRAPOLATED_WEBHOOK from=….
  • The merchant block has the values you expect (with sibling-fill for redacted fields).

Tip

For a clean diff against your dashboard payload, capture the log output to a file and pipe through jq with --sort-keys (and a null-stripping filter) before diffing.

4. Publish

alan run --scope backend -- env APP=es_api flask payment_gateway adyen \
    reissue-missing-transfer-webhooks \
    --workspace-key=<wk> --external-transfer-id=<id> [--dashboard-payloads-file=payloads.json]

Drop --dry-run. The command publishes through adyen_transfer_topic; subscribers run as if the webhook had arrived live.

5. Verify

Re-run Step 1 — the gap should be gone.

Check the marker on the new TransferUpdate row:

SELECT sequence_number, raw->>'description' AS description
FROM payment_gateway.transfer_update
WHERE workspace_key = '<wk>' AND external_transfer_id = '<id>'
  AND raw->>'description' LIKE 'ALAN_EXTRAPOLATED_WEBHOOK%';

Spot-check downstream effects:

  • PaymentRequest.status has advanced.
  • New TransferEvent rows are present.
  • (Spain) EsTransactionDetails row is now visible in the user transactions screen.

Troubleshooting

The real webhook arrived after I re-injected. No-op. record_transfer_update is idempotent via the unique constraint + ON CONFLICT DO NOTHING.

Sequence 1 is missing. Synthesis uses the next sibling's events[0] as anchor. createdAt is the transfer's true creation time; creationDate comes from the next sibling (close enough). Marker still lands.

Trailing gap (sequence > max known). Not detected — list_missing_transfer_webhooks only finds gaps below MAX(sequence_number). Siblings can't reconstruct events for a sequence beyond their own. Either wait for delivery, or pull the dashboard payload and craft the publish call manually.

Gap is older than 30 days. Dashboard payloads are no longer available. Sibling-only reconstruction works, but data.balances will be null (no arithmetic reconstruction of cumulative state). TransferEvent rows still get created because event ids are real.

Wrong sequence number requested. --sequence-numbers rejects values not present in the gap set. Re-run Step 1 to see the actual gaps.

See also