Skip to main content
Webhooks let your application receive real-time notifications when events happen on the Fluz platform. Instead of polling for changes, you register an HTTPS endpoint and Fluz pushes event data to you the moment something occurs — a virtual card transaction, a declined purchase, a completed deposit, a user linking their account via OAuth, and more.
This page covers all webhook events, which span multiple platform areas (transaction activity, deposits, widget flows, and OAuth account linking) rather than belonging to any single feature.
Webhooks work across all application types on the Fluz platform: private apps operating on your own account, public OAuth apps acting on behalf of other users via API, and embedded widget apps.

How it works

  1. You register a webhook URL on your application in the Developer Portal.
  2. You select which event types to listen for (or subscribe to all of them).
  3. When a matching event occurs, Fluz sends an HTTP POST to your URL with a signed JSON payload.
  4. Your server verifies the signature, acknowledges with a 2xx, and processes the event.
If your endpoint is unavailable or returns an error, Fluz retries up to 5 times with exponential backoff before giving up on that delivery.

Webhooks by app type

How events are routed to your app depends on your application model.

Private apps — your own account

Webhooks fire for activity on your own account. Any transaction, decline, or deposit on accounts you own triggers webhooks registered on your app. Common use cases: notifications when a virtual card transaction is authorized or settled; real-time alerting on declines; monitoring deposits and balance changes on your spend accounts.

Public apps (OAuth) — acting on behalf of users

Webhooks fire for activity on accounts that have authorized your app. When a user grants your app access via OAuth, their events are routed to your registered webhooks — provided the user’s OAuth grant includes the required scopes. This works whether the user interacts through your direct API integration or an embedded widget; the key requirement is an OAuth relationship between the user and your app. Common use cases: knowing when a user has linked (or re-linked) their account to your app; monitoring virtual card spend across connected accounts; real-time decline notifications; tracking deposit completions; receiving KYC and KYB status updates.

Widget apps

Widget apps are a specialized public app. Events route the same way (based on the OAuth relationship), and widget apps additionally support widget-specific events like transfer completions and gift card purchases.

Event types

Fluz only delivers an event if your application — and, for public/OAuth apps, the individual user’s OAuth grant — holds the required scopes. Events with no required scopes (such as OAUTH_USER_LINKED) are delivered to any subscribed public/OAuth app.

Transaction events

Staging only: externalReferenceId on transaction eventsTRANSACTION_CREATE, TRANSACTION_UPDATE and TRANSACTION_DECLINE carry externalReferenceId in the staging environment only. Production deliveries do not include it yet; this page will be updated when it is rolled out.
Cover the full transaction lifecycle. Apply to all app types.

Deposit events

KYC & KYB events

Apply to public/OAuth and widget apps. Like OAUTH_USER_LINKED, these events have no private-app equivalent — they only fire for accounts connected via an OAuth relationship, and only when your app’s (and the user’s) OAuth grant includes the required scope.

Funding source events

Staging onlyFUNDING_SOURCE_STATUS_UPDATE is currently delivered only in the staging environment. It is not yet emitted in production; production apps will not receive it until it is rolled out, and this page will be updated when it is.
Apply to all app types. Delivered for users tied to your application — an OAuth user who granted LIST_PAYMENT, or any user on an account that owns a private app. One event type covers all three cases; branch on changeType (ADDED, UPDATED, DELETED) rather than on the event name. The user’s Fluz cash balance is not a funding source and never produces this event. The event is emitted from the change to the funding source itself, so it fires wherever the change was made — the Fluz mobile app, the web app, or an embedded widget.

OAuth events

Apply to public/OAuth and widget apps. OAUTH_USER_LINKED is delivered only to public/OAuth app types — private-app subscribers do not receive it. Because it has no scope requirement, you receive it for every user who links or re-links to your app, regardless of which scopes they grant. Use it to provision or update your local record of the connected user, capture the granted scope set, and map your own user identifier via externalReferenceId.

Widget-specific events

Apply to widget and OAuth integrations where users interact through Fluz-embedded flows.
📘 Naming note: WIDGET_DEPOSIT_COMPLETE and WIDGET_WITHDRAW_COMPLETE describe customer↔app transfers; the DEPOSIT/WITHDRAW wording is historical. Treat “deposit” as “customer → app” and “withdraw” as “app → customer.”

Setting up webhooks

1. Open the Developer Portal

Go to the Developer Portal and select your application.

2. Open the Webhooks section

  • OAuth apps: OAuth tab → Webhook URLs.
  • Widget apps: Widget tab → Webhook URLs.
  • API / private apps: the Webhook URLs section in your app settings.

3. Add a webhook URL

Click Add new URL and enter your HTTPS endpoint (e.g., https://api.yourapp.com/webhooks/fluz).

4. Select events

Choose the event types you want to receive.

5. Save

Click Create Webhook. Your endpoint begins receiving events immediately.

Managing webhooks

  • Multiple endpoints — you can register more than one webhook URL per application.
  • Change subscribed events — delete the webhook and recreate it with the new event selection.
  • Remove a webhook — click Remove next to it. The webhook is archived immediately and stops receiving events.

Receiving webhooks

Request format

Every webhook is delivered as an HTTP POST with these headers: The body is a JSON object, and every payload includes an eventType field identifying the event.

Endpoint requirements

  • HTTPS only — plain HTTP endpoints are rejected at registration time.
  • Publicly accessible and able to accept POST requests.
  • Respond with a 2xx within 30 seconds. Non-2xx responses or timeouts trigger retries.
  • Verify the HMAC signature on every request.

Verifying signatures

Every delivery includes an X-HMAC-Signature header — an HMAC-SHA256 hash of the raw JSON body, signed with your application’s API key. Always verify it before trusting a payload.
⚠️ Verify against the raw request body. Compute the HMAC over the exact bytes Fluz sent — do not re-serialize the parsed JSON. Re-stringifying can reorder keys or change whitespace and cause valid signatures to fail. The examples below capture the raw body for this reason.

Node.js (Express)

Python (Flask)

Responding to webhooks

Your endpoint must:
  • Respond with a 2xx status within 30 seconds.
  • Return quickly — acknowledge first, then process asynchronously.
  • Be reachable over HTTPS. Your endpoint must not:
  • Respond with redirects (3xx).
  • Respond with 4xx/5xx for valid webhooks (this triggers retries).

Retry policy

New events resume delivery automatically once your endpoint recovers. To re-send events whose retries were already exhausted, contact support with the relevant X-Event-ID.

Idempotency & ordering

Webhooks may be delivered more than once, and delivery order is not guaranteed.
  • Deduplicate using the X-Event-ID header. Persist processed IDs (Redis or a database in production) and skip events you’ve already handled.
  • Order by data, not arrival. If sequence matters, order by payload timestamps (createdAt, updatedAt, transactionDateTime) and event IDs.

Identifying the app and user

  • User: userId is the Fluz user ID. For OAuth/widget events, externalReferenceId maps to your user identifier from the OAuth flow.
  • App: transaction payloads include connectedAppId and connectedAppName. If you route multiple apps to one endpoint, branch on connectedAppId. For OAUTH_USER_LINKED, the app is identified by appId.

Payload reference

Every payload includes an eventType. Field availability can vary by event; handlers should ignore unrecognized fields for forward compatibility.
📘 A note on status values. For created/updated transactions the status field takes one of PENDING, SETTLED, or FAILED. Declined transactions carry a status of DECLINED (or FAILED). Earlier drafts of this page showed COMPLETED as a status — that value is not emitted; use SETTLED to detect a finalized transaction.

Transaction created (TRANSACTION_CREATE)

Fired for any new transaction — virtual card purchases, deposits, transfers, and more.

Transaction updated (TRANSACTION_UPDATE)

Fired when a transaction’s status or details change — for example, when a pending authorization settles.
Fields match TRANSACTION_CREATE where present, plus updatedAt (ISO 8601) marking when the change occurred. userId, externalReferenceId (staging only for now), connectedAppId, and connectedAppName are carried through the same way as on TRANSACTION_CREATE, so you can identify the user and app consistently across the lifecycle. A status transition to SETTLED is the signal that a previously pending transaction has finalized. isReversal carries the same meaning here as on TRANSACTION_CREATE. See Reversals vs refunds.

Transaction declined (TRANSACTION_DECLINE)

Fired when a transaction is declined. Includes structured decline reasons your app can act on.
Additional optional fields may be present depending on the transaction (e.g. merchantId, merchantCity, merchantState, merchantCountry, cardDisplayName, virtualCardProgram, channel, bankAccountNickname, bankAccountLastFour). Ignore any you don’t use.

Linking the events of one purchase

A virtual card purchase usually produces more than one transaction event. Two fields tie them together:
  • referenceId is the Fluz purchase id. The authorization, its settlement, any additional settlement leg and any return of that purchase all carry the same value. Group on it.
  • transactionId identifies one ledger record. A pending authorization and its settlement share it, because the settlement is delivered as a TRANSACTION_UPDATE of the same record. Anything Fluz records separately gets a new transactionId.
The sequence for a typical purchase: Example: an 8.54 authorization arrives as TRANSACTION_CREATE on transactionId A with status: PENDING. The merchant clears 8.56. You receive a TRANSACTION_UPDATE on A with status: SETTLED and amount: -8.54, then a TRANSACTION_CREATE on a new transactionId B with status: SETTLED and amount: -0.02. All three carry referenceId: "11323677". The net charge for the purchase is the sum of the latest amount per transactionId under that referenceId: -8.56. Rules that follow from this:
  • Key your records by transactionId, but reconcile by referenceId. A TRANSACTION_CREATE with status: SETTLED and no earlier pending event is normal: it is an additional leg or a return of a purchase you already hold under the same referenceId.
  • When an authorization is never captured and the hold is released, you receive two events: a TRANSACTION_UPDATE that moves the pending purchase to SETTLED, and a Virtual Card Return with the same referenceId and a positive amount. They net to zero. Treat the pair as the release of the pending amount, not as a purchase that completed.
  • A Virtual Card Return for a merchant refund that the merchant submitted as a new transaction, rather than against the original authorization, may carry no referenceId. Match those by virtualCardId, amount and merchant.

Reversals vs refunds

A reversal releases an authorization hold before any money is captured. The cardholder’s available balance goes back up, but no funds ever left and none come back. A refund happens after a capture: money moved, and the merchant sent it back. isReversal tells the two apart: Use this field instead of transactionType wherever it is present. Card issuers label these events inconsistently, so one issuer’s type value for a merchant refund is another’s for a pre-capture reversal, and the type alone cannot tell you whether money actually moved. Virtual Card Return events carry the field too: true for a released hold, false for a merchant refund. A small number of returns still arrive without it; for those use referenceId and the original authorization to tell them apart. Which event carries a reversal depends on what was reversed:
  • A card verification void is recorded as its own transaction, so it arrives as TRANSACTION_CREATE with a negative amount and then updates. These are the small checks a merchant runs to confirm a card is valid, which we void straight away. They carry isReversal: true.
  • A released pre-capture hold arrives as a separate transaction with transactionType Virtual Card Return and a positive amount, carrying the purchase’s referenceId. The original purchase is updated to SETTLED at the same time, so the two net to zero. The return carries isReversal: true whether Fluz or the card issuer released the hold.
  • A merchant refund also arrives with transactionType Virtual Card Return and carries isReversal: false. Match it against the original purchase by referenceId when present, otherwise by virtualCardId, amount and merchant.
A reversal is not a failure, but it does need handling: release the hold you recorded for the matching authorization, or it stays open on your side forever. Do not post it as a new outflow, since no money moved.

Reconciling amounts

Reconcile on amount. It is the USD amount that moved on that event, so the latest amount per transactionId, summed under one referenceId, is the net for the purchase however many settlement legs or returns it has. originalCurrencyAmount does not add up that way. It is the amount the card issuer reports as requested for the event. On a return that is the amount still on hold before the return, so it repeats what earlier events already carried and a sum of it double counts. Example: a 39.69 authorization whose hold is released in two steps, as when part of an order goes out of stock and the rest is released later. The amount values net to 0.00 because nothing was captured. The originalCurrencyAmount values describe what was on hold at each step, not money that moved.

Deposit complete (DEPOSIT_COMPLETE)

Fired when a deposit from a funding source to a spend account completes.

OAuth user linked (OAUTH_USER_LINKED)

Fired when a user completes OAuth linking to your application — on both the initial link and on subsequent updates (for example, when the user re-authorizes with a different set of scopes). Public/OAuth and widget apps only. No scope is required to receive this event.
Because OAUTH_USER_LINKED fires again on re-link/updates, treat it as an upsert: create the connected user on first receipt, and refresh the stored scope set and contact details on later deliveries. emailAddress and phoneNumber reflect the user’s profile at the moment of the link; a later profile change does not trigger a new event on its own.
Staging onlyemailAddress and phoneNumber on OAUTH_USER_LINKED are currently delivered only in the staging environment. Production deliveries do not include them yet; this page will be updated when they are rolled out.
In rare cases Fluz can’t load the contact details when the event is sent, and emailAddress and phoneNumber are then absent from the payload. Treat an absent field as unknown and keep any value you already store. Only an explicit null means the user has no value.

Spend account access

When a user links to your app, the permissions step lets them grant access to all of their spend accounts or only to the ones they choose. spendAccountAccess tells you which of the two the link ended up with, so you can start acting on a spend account — for example, calling getSpendAccountPaymentInstructions to produce deposit instructions — without a follow-up lookup right after the link.
  • Restricted grant. restricted is true and userCashBalanceIds lists the spend accounts the user selected when linking. The IDs are currently delivered in staging only; production deliveries do not include them yet. includesFutureSpendAccounts tells you whether that list is fixed:
    • false — the grant covers exactly these spend accounts. The list changes only if the user re-links, which sends a new OAUTH_USER_LINKED event.
    • true — the operator let the user include spend accounts they open later. Each new spend account is added to the grant automatically, but opening one does not fire this event, so userCashBalanceIds is accurate only as of the link. Call getWallet with the user’s access token for the current list.
  • Unrestricted grant. restricted is false and userCashBalanceIds is null. The grant covers every spend account the user has now and any they open later. Fluz does not enumerate the current spend accounts in the payload, because opening a new spend account does not fire this event and a snapshot would go stale. Call getWallet with the user’s access token for the current list.
Only IDs are included. Nicknames and balances are not, because they can change without firing this event — read them from getWallet, whose balances.userCashBalances is already filtered to the spend accounts the link permits. The event fires again with the updated value whenever the grant changes: the user re-consents, the granted scopes change, or the granted spend accounts change even when the scopes stay the same (for example, the user swaps one spend account for another). Treat the latest delivery as authoritative and replace, rather than merge, the stored ID list. Treat this field as a convenience. Webhook deliveries can arrive late or out of order, so if you need to be certain which spend accounts a link permits, call getWallet — it is the source of truth.
Staging onlyspendAccountAccess on OAUTH_USER_LINKED is currently delivered only in the staging environment. Production deliveries do not include it yet; this page will be updated when it is rolled out.

KYC initiation (WIDGET_KYC_INITIATION)

Fired when a user begins identity verification. Public/widget apps only.

Transfer completed — customer to app (WIDGET_DEPOSIT_COMPLETE)

Fired when a user transfers funds to your application.

Transfer completed — app to customer (WIDGET_WITHDRAW_COMPLETE)

Fired when your application transfers funds to a user.

Gift card purchase (WIDGET_PURCHASE_GIFT_CARD)

Fired when a gift card purchase completes through the widget.
Common widget payload fields: userId (Fluz user ID), accountId (Fluz account ID), externalReferenceId (your user identifier from the OAuth flow), and amount where applicable.

KYC status update (KYC_STATUS_UPDATE)

Fired when a connected user’s KYC status changes. Public/OAuth and widget apps only; requires the VERIFY_KYC scope. Every payload shares a common envelope; the verificationData object’s shape is discriminated by verificationType, shown per-type below.
Emitted when the status change resulted from SSN-based verification.
legalAddress, when present (SSN and DOCUMENTS types only), has the shape { streetAddressLine1, streetAddressLine2 (optional), city, state, postalCode, country }.

KYB status update (KYB_STATUS_UPDATE)

Fired when a connected business’s KYB status changes. Public/OAuth apps only; requires the REGISTER_BUSINESS scope.

Funding source status update (FUNDING_SOURCE_STATUS_UPDATE)

Fired when a user adds, renames or removes a funding source — a bank account, a bank card, or a PayPal account. Requires the LIST_PAYMENT scope. Staging only for now — not yet delivered in production.
UPDATED fires only when a field in this payload actually changed — renaming a source or a status change produces one; routine internal record-keeping does not. Removing a funding source does not delete the row, it marks it inactive, so a DELETED event carries the terminal status (INACTIVE, DISABLED or REMOVED) rather than the absence of a record. Rely on changeType to decide that a source is gone. Full card and bank account numbers are never included in this payload.

Best practices

  • Respond fast, process later. Return 200 immediately and handle the event asynchronously to avoid timeouts and unnecessary retries.
  • Verify every request. Validate X-HMAC-Signature against the raw body using your API key before processing.
  • Deduplicate with event IDs. Track X-Event-ID to handle retried/duplicate deliveries.
  • Treat enum-like fields as open strings. New transactionType, channel, and declineCategory values can appear over time; branch on the values you care about and tolerate unknowns.
  • Upsert on OAUTH_USER_LINKED. It can fire more than once per user; refresh the stored scope set and spendAccountAccess each time rather than assuming first-link-only semantics.
  • Key funding sources by fundingSourceId. One event type carries all three change types; branch on changeType and order same-source events by changedAt.
  • Accept unknown fields. Payloads may gain fields over time; ignore unrecognized ones rather than failing.
  • Monitor your endpoint. Alert on repeated non-2xx responses — after 5 failed attempts an event’s delivery is abandoned.

Troubleshooting

Need help?