Skip to main content

Webhooks

The API receives inbound webhooks from Infinia to observe transaction status, KYC completions, and deposit arrivals. Handler: apps/api/src/webhooks/infinia.ts.

Endpoint

POST /webhooks/infinia/:secret

Unauthenticated by session JWT. Verified by:

  1. Path secret:secret matched in constant time against INFINIA_WEBHOOK_SECRET. First-line defence.
  2. Signature header — a swappable verification strategy in InfiniaProvider.parseWebhook; if / when Infinia publishes an HMAC scheme, we plug it in here without touching the handler.

Every incoming payload is parsed into a normalised ProviderEvent and dispatched to a shared handleProviderEvent(db, ev) function.

Verification & processing

Idempotency: webhook_events unique on (provider, external_event_id). Infinia doesn't emit a globally-unique event id, so we synthesise one from sha256(kind + ":" + providerRef + ":" + updatedAt) — a replayed webhook is a silent no-op.

Normalised event shape

Every provider webhook is parsed into a ProviderEvent before hitting the shared handler:

type ProviderEvent =
| { kind: "off-ramp.completed"; providerRef: string; fiatAmount: string; fiatCurrency: string; endToEndId?: string }
| { kind: "off-ramp.failed"; providerRef: string; reason: string }
| { kind: "internal-transfer.completed"; providerRef: string; sourceAmount: number; destinationAmount: number; rate: number }
| { kind: "internal-transfer.failed"; providerRef: string; reason: string }
| { kind: "deposit.received"; providerAccountId: string; amount: number; currency: string; sourceReference?: string }
| { kind: "kyc.updated"; providerOwnerId: string; status: "completed" | "failed" };

Route handlers (apps/api/src/pix/, apps/api/src/withdraws/, apps/api/src/kyc/) don't see Infinia's raw payload shape — they consume the normalised event.

Events handled

Infinia eventNormalised ProviderEvent kindWhat the handler does
movement.created (credit on user's home account)deposit.receivedInserts a transactions row (type=deposit, status=completed). Balance is recomputed on next read.
internal_transfer.updated (COMPLETED)internal-transfer.completedRe-fetches the transfer (the callback is an untrusted nudge) and dispatches by which leg's id matched. Two-hop: leg-1 completion records the delivered USDC + exact home debit and starts leg 2; leg-2 completion (exact BRL delivered) triggers the payout; the reverse (refund) transfer's completion settles the refund. Single-hop: triggers the payout. The linked transactions row stays pending until the payout completes.
internal_transfer.updated (FAILED)internal-transfer.failedDispatches by leg. Leg-1 failure → pix_payments failed (home debit refunded, no net funds moved). Two-hop leg-2 failure → auto-unwind: refunding (reverse USDC→home) → refunded, or manual_review if the user can't be made whole. A failed reverse (refund) transfer → manual_review. transactions follows the pix_payments status (pending while refunding; failed/reversed once refunded, manual_review, or failed).
payout.updated (COMPLETED)off-ramp.completedMarks the linked transactions.status = completed; sets completed_at; for PIX, records pix_end_to_end_id on pix_payments.
payout.updated (FAILED / ERROR)off-ramp.failedFor withdraws: marks failed with reason. For PIX: marks the pix_payments row payout_failed (BRL parked in the user's own account) — a recoverable state resolved by the explicit POST /pix/payments/:id/retry, which re-issues the payout with the same originId. The webhook does not itself retry or auto-reverse. See payment-providers.
account.status_updated (PROVISIONINGACTIVE)(no normalised kind — handled inline)Flips provider_accounts.status = 'active' for the matching account id. The mobile picks it up on next GET /me.

KYC status change. Infinia's public OpenAPI doesn't list an owner.status_updated webhook. Until confirmed, KYC completion is detected by polling GET /v1/accounts/owners/{uuid}/ (see KYC status flow). If a webhook exists and we get it wired up, the kyc.updated normalised event slot is ready for it.

Any Infinia event we don't have a mapping for is still recorded in webhook_events (with payload intact) but otherwise ignored — the parseWebhook returns null and the handler skips the dispatch step.

Retention

Raw webhook payloads contain third-party PII (movement details include other-side bank data, PIX keys, verified identity fields on KYC events, etc.). To limit the exposure surface:

  • 90-day default retention on webhook_events rows past processed_at.
  • Rows still referenced by an unresolved transaction (e.g. a payment stuck in settling) are retained until the transaction reaches a terminal state.
  • Nightly retention cron enforces this — see privacy → retention.

Configuring Infinia

Per-resource callback_url is set when creating the resource (owner, account, payout, internal transfer). Point them at:

https://<workers-url>/webhooks/infinia/<INFINIA_WEBHOOK_SECRET>

The INFINIA_WEBHOOK_SECRET value is a Cloudflare Worker secret set via wrangler secret put. Rotate by generating a new value, calling Infinia to update the callback_url on all active resources, then wrangler secret put INFINIA_WEBHOOK_SECRET with the new value.

Retry policy. Infinia retries webhooks on non-2xx responses. Our handler is idempotent (see the flowchart above) so retries are safe — repeated deliveries of the same event are silently no-op'd.

Debugging

  • Every received webhook lands in webhook_events with the full raw payload, event type, and processing timestamp — start there.
  • Cloudflare Workers logs (wrangler tail) show parseWebhook errors and handler exceptions. PII scrubbing helper is applied — request bodies are not logged.
  • Reconciliation cron runs nightly; if a webhook is missed (network failure, our worker down), the cron catches the state drift on the next run.