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:
- Path secret —
:secretmatched in constant time againstINFINIA_WEBHOOK_SECRET. First-line defence. - 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 event | Normalised ProviderEvent kind | What the handler does |
|---|---|---|
movement.created (credit on user's home account) | deposit.received | Inserts a transactions row (type=deposit, status=completed). Balance is recomputed on next read. |
internal_transfer.updated (COMPLETED) | internal-transfer.completed | Re-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.failed | Dispatches 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.completed | Marks 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.failed | For 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 (PROVISIONING → ACTIVE) | (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_eventsrows pastprocessed_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_eventswith the full raw payload, event type, and processing timestamp — start there. - Cloudflare Workers logs (
wrangler tail) showparseWebhookerrors 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.