Webhooks
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, and more.
This page is intended as a top-level Webhooks section, since webhook events span multiple platform areas (widget flows and transaction activity) 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
- You register a webhook URL on your application in the Developer Portal.
- You select which event types to listen for (or subscribe to all of them).
- When a matching event occurs, Fluz sends an HTTP
POSTto your URL with a signed JSON payload. - Your server verifies the signature, acknowledges with a
2xx, and processes the event.
sequenceDiagram
participant Platform as Fluz Platform
participant Dispatcher as Webhook Dispatcher
participant You as Your Endpoint
Platform->>Dispatcher: Event occurs
Dispatcher->>Dispatcher: Match subscriptions and verify scopes
Dispatcher->>Dispatcher: Sign payload with HMAC
Dispatcher->>You: HTTP POST with JSON payload
You-->>Dispatcher: 200 OK
If your endpoint is unavailable or returns an error, Fluz retries up to 5 times with exponential backoff before marking the event as FAILED.
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: monitoring virtual card spend across connected accounts; real-time decline notifications; tracking deposit completions; receiving KYC 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.
Transaction events
Cover the full transaction lifecycle. Apply to all app types.
| Event | Description | Required scopes |
|---|---|---|
TRANSACTION_CREATE | A new transaction was created (e.g., virtual card purchase, deposit, transfer). | LIST_PAYMENT, LIST_PURCHASES |
TRANSACTION_UPDATE | A transaction's status or amount changed (e.g., settled, adjusted). | LIST_PAYMENT, LIST_PURCHASES |
TRANSACTION_DECLINE | A transaction was declined (e.g., insufficient funds, card controls). | LIST_PAYMENT, LIST_PURCHASES |
Deposit events
| Event | Description | Required scopes |
|---|---|---|
DEPOSIT_COMPLETE | A deposit from a funding source to a spend account completed. | MAKE_DEPOSIT |
Widget-specific events
Apply to widget and OAuth integrations where users interact through Fluz-embedded flows.
| Event | Description | Required scopes |
|---|---|---|
WIDGET_KYC_INITIATION | A user initiated identity verification (KYC). | VERIFY_KYC |
WIDGET_DEPOSIT_COMPLETE | A transfer from a customer to your app completed. | MAKE_PAYOUT_TRANSFER_SEND |
WIDGET_WITHDRAW_COMPLETE | A transfer from your app to a customer completed. | MAKE_PAYOUT_TRANSFER_RECEIVE |
WIDGET_PURCHASE_GIFT_CARD | A gift card purchase completed. | PURCHASE_GIFTCARD |
Naming note:WIDGET_DEPOSIT_COMPLETEandWIDGET_WITHDRAW_COMPLETEdescribe customer↔app transfers; theDEPOSIT/WITHDRAWwording 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. Leave the selection empty to act as a catch-all and receive every event your app has scopes for.
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:
| Header | Description |
|---|---|
Content-Type | Always application/json. |
X-HMAC-Signature | HMAC-SHA256 signature of the raw JSON body, signed with your app's API key. |
X-Event-ID | Unique UUID for this event — use it for deduplication. |
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
POSTrequests. - Respond with a
2xxwithin 30 seconds. Non-2xxresponses 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)
const express = require('express');
const crypto = require('crypto');
const app = express();
// Capture the raw body so the HMAC is computed over the exact bytes Fluz sent.
app.use('/webhooks/fluz', express.raw({ type: 'application/json' }));
function verifyFluzWebhook(rawBody, signature, apiKey) {
const expected = crypto.createHmac('sha256', apiKey).update(rawBody).digest('hex');
const received = Buffer.from(signature || '', 'hex');
const computed = Buffer.from(expected, 'hex');
return received.length === computed.length &&
crypto.timingSafeEqual(received, computed);
}
app.post('/webhooks/fluz', (req, res) => {
const signature = req.headers['x-hmac-signature'];
const eventId = req.headers['x-event-id'];
// req.body is a Buffer because of express.raw above
if (!verifyFluzWebhook(req.body, signature, process.env.FLUZ_API_KEY)) {
return res.status(401).send('Invalid signature');
}
// Acknowledge immediately, then process asynchronously
res.status(200).send('OK');
const event = JSON.parse(req.body.toString('utf8'));
handleEvent(eventId, event).catch(err =>
console.error('Webhook processing error:', err)
);
});Python (Flask)
import hmac, hashlib
from flask import Flask, request
app = Flask(__name__)
def verify_fluz_webhook(raw_body: bytes, signature: str, api_key: str) -> bool:
expected = hmac.new(api_key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
@app.post("/webhooks/fluz")
def fluz_webhook():
signature = request.headers.get("X-HMAC-Signature")
event_id = request.headers.get("X-Event-ID")
# request.get_data() returns the raw bytes — do NOT use request.json for verification
if not verify_fluz_webhook(request.get_data(), signature, FLUZ_API_KEY):
return "Invalid signature", 401
enqueue(event_id, request.get_json()) # process asynchronously
return "OK", 200Responding to webhooks
Your endpoint must:
- Respond with a
2xxstatus 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/5xxfor valid webhooks (this triggers retries).
Retry policy
| Behavior | Detail |
|---|---|
| Max attempts | 5 |
| Schedule | Exponential backoff |
| Trigger | Any non-2xx response or timeout |
| Timeout | 30 seconds per attempt |
| After all retries fail | Event is marked FAILED; no further delivery attempts. |
| Logging | Each attempt is logged individually for audit. |
New events resume delivery automatically once your endpoint recovers. To re-send events that already reached FAILED, contact support with the relevant X-Event-ID.
Event status lifecycle
| Status | Meaning |
|---|---|
CREATED | Event recorded and queued. |
IN_PROGRESS | Being matched to webhook subscriptions. |
NO_SUBSCRIBERS | No active webhooks matched this event. |
SUCCESS | Delivered successfully. |
FAILED | All retry attempts exhausted. |
Idempotency & ordering
Webhooks may be delivered more than once, and delivery order is not guaranteed.
- Deduplicate using the
X-Event-IDheader. 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.
const seen = new Set(); // use Redis or a database in production
async function handleEvent(eventId, event) {
if (seen.has(eventId)) return; // already processed — skip
seen.add(eventId);
switch (event.eventType) {
case 'TRANSACTION_CREATE': await onTransactionCreated(event); break;
case 'TRANSACTION_UPDATE': await onTransactionUpdated(event); break;
case 'TRANSACTION_DECLINE': await onTransactionDeclined(event); break;
case 'DEPOSIT_COMPLETE': await onDepositComplete(event); break;
case 'WIDGET_KYC_INITIATION': await onKycInitiated(event); break;
// ...handle remaining widget events
}
}Identifying the app and user
- User:
userIdis the Fluz user ID. For OAuth/widget events,externalReferenceIdmaps to your user identifier from the OAuth flow. - App: transaction payloads include
connectedAppIdandconnectedAppName. If you route multiple apps to one endpoint, branch onconnectedAppId.
Payload reference
Every payload includes an eventType. Field availability can vary by event; handlers should ignore unrecognized fields for forward compatibility.
Transaction created (TRANSACTION_CREATE)
TRANSACTION_CREATE)Fired for any new transaction — virtual card purchases, deposits, transfers, and more.
{
"eventType": "TRANSACTION_CREATE",
"recordId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"transactionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"accountId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"transactionType": "PURCHASE",
"amount": 42.99,
"destination": "Coffee Shop",
"source": "Virtual Card",
"status": "COMPLETED",
"channel": "VIRTUAL_CARD",
"connectedAppId": "your-app-id",
"connectedAppName": "Your App",
"createdAt": "2025-01-15T10:30:00.000Z",
"merchantName": "Coffee Shop",
"merchantCity": "New York",
"merchantState": "NY",
"merchantCountry": "US",
"cardLastFour": "1234",
"virtualCardId": "vc-uuid-here",
"cashbackRate": 0.05
}| Field | Type | Description |
|---|---|---|
eventType | String | Always TRANSACTION_CREATE. |
recordId | String (UUID) | Unique webhook event record ID. |
transactionId | String (UUID) | The transaction ID. |
accountId | String (UUID) | The Fluz account ID. |
userId | String (UUID) | The Fluz user ID. |
transactionType | String (enum) | e.g., PURCHASE. (Confirm full set.) |
amount | Number | Transaction amount in USD. |
destination | String | Where funds went (e.g., merchant name). |
source | String | Funding source (e.g., Virtual Card). |
status | String (enum) | e.g., COMPLETED. (Confirm full set — e.g., PENDING, SETTLED, DECLINED.) |
channel | String (enum) | e.g., VIRTUAL_CARD. (Confirm full set.) |
connectedAppId | String | The app the event is routed to. |
connectedAppName | String | Display name of the connected app. |
createdAt | String (ISO 8601) | When the transaction was created. |
merchantName / merchantCity / merchantState / merchantCountry | String | Merchant location details. |
cardLastFour | String | Last four digits of the card used. |
virtualCardId | String (UUID) | The virtual card ID, if applicable. |
cashbackRate | Number | Decimal cashback rate (e.g., 0.05 = 5%). |
Transaction updated (TRANSACTION_UPDATE)
TRANSACTION_UPDATE)Fired when a transaction's status or details change — for example, when a pending authorization settles.
{
"eventType": "TRANSACTION_UPDATE",
"recordId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"transactionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"accountId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"transactionType": "PURCHASE",
"amount": 42.99,
"status": "SETTLED",
"createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-01-16T08:00:00.000Z"
}Fields match TRANSACTION_CREATE where present, plus updatedAt (ISO 8601) marking when the change occurred.
Consistency check: confirmuserIdandconnectedAppId/connectedAppNameare present onTRANSACTION_UPDATE(andTRANSACTION_DECLINE) so consumers can identify the user and app consistently across the lifecycle.
Transaction declined (TRANSACTION_DECLINE)
TRANSACTION_DECLINE)Fired when a transaction is declined. Includes structured decline reasons your app can act on.
{
"eventType": "TRANSACTION_DECLINE",
"transactionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"transactionType": "PURCHASE",
"amount": 500.00,
"fluzAmount": 500.00,
"externalFundingAmount": 0,
"currency": "USD",
"accountId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"status": "DECLINED",
"merchantName": "Electronics Store",
"cardLastFour": "1234",
"virtualCardId": "vc-uuid-here",
"isCashBalanceUsed": true,
"isPrepaymentBalanceUsed": false,
"isRewardsBalanceUsed": false,
"isReserveBalanceUsed": false,
"transactionDateTime": "2025-01-15T10:30:00.000Z",
"declineTitle": "Transaction Declined",
"declineReason": "Insufficient funds",
"declineDescription": "Your account balance was not sufficient for this transaction.",
"declineCategory": "BALANCE"
}| Field | Type | Description |
|---|---|---|
amount | Number | Total attempted amount. |
fluzAmount | Number | Portion drawn from Fluz balance. |
externalFundingAmount | Number | Portion drawn from external funding. |
currency | String | ISO currency code (e.g., USD). |
status | String | DECLINED. |
isCashBalanceUsed / isPrepaymentBalanceUsed / isRewardsBalanceUsed / isReserveBalanceUsed | Boolean | Which balance type(s) the attempt drew from. |
transactionDateTime | String (ISO 8601) | When the decline occurred. |
declineTitle | String | Short, user-facing title. |
declineReason | String | Short reason (e.g., Insufficient funds). |
declineDescription | String | Longer, user-facing explanation. |
declineCategory | String (enum) | e.g., BALANCE. (Confirm full set — see Decline Codes.) |
Deposit complete (DEPOSIT_COMPLETE)
DEPOSIT_COMPLETE)Fired when a deposit from a funding source to a spend account completes.
{
"eventType": "DEPOSIT_COMPLETE",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"accountId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"externalReferenceId": "your-reference-123",
"amount": 50.00
}KYC initiation (WIDGET_KYC_INITIATION)
WIDGET_KYC_INITIATION)Fired when a user begins identity verification. Public/widget apps only.
{
"eventType": "WIDGET_KYC_INITIATION",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"accountId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"externalReferenceId": "your-reference-123"
}Transfer completed — customer to app (WIDGET_DEPOSIT_COMPLETE)
WIDGET_DEPOSIT_COMPLETE)Fired when a user transfers funds to your application.
{
"eventType": "WIDGET_DEPOSIT_COMPLETE",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"accountId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"externalReferenceId": "your-reference-123",
"amount": 25.50
}Transfer completed — app to customer (WIDGET_WITHDRAW_COMPLETE)
WIDGET_WITHDRAW_COMPLETE)Fired when your application transfers funds to a user.
{
"eventType": "WIDGET_WITHDRAW_COMPLETE",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"accountId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"externalReferenceId": "your-reference-123",
"amount": 15.00
}Gift card purchase (WIDGET_PURCHASE_GIFT_CARD)
WIDGET_PURCHASE_GIFT_CARD)Fired when a gift card purchase completes through the widget.
To be completed. Add theWIDGET_PURCHASE_GIFT_CARDpayload example and field table (e.g., merchant, amount, gift card identifier).
Common widget payload fields: userId (Fluz user ID), accountId (Fluz account ID), externalReferenceId (your user identifier from the OAuth flow), and amount where applicable.
Best practices
- Respond fast, process later. Return
200immediately and handle the event asynchronously to avoid timeouts and unnecessary retries. - Verify every request. Validate
X-HMAC-Signatureagainst the raw body using your API key before processing. - Deduplicate with event IDs. Track
X-Event-IDto handle retried/duplicate deliveries. - Accept unknown fields. Payloads may gain fields over time; ignore unrecognized ones rather than failing.
- Monitor your endpoint. Alert on repeated non-
2xxresponses — after 5 failures events are markedFAILED.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Not receiving any webhooks | Endpoint unreachable or returning errors | Confirm the URL is correct, publicly accessible over HTTPS, and returns 2xx. |
| Missing certain event types | App lacks the required scopes | Ensure your app holds the scopes listed for that event. |
| Public app not receiving events for a user | User hasn't authorized the required scopes | Confirm the user's OAuth grant includes the needed scopes. |
| Signature verification fails | Wrong API key, or verifying a re-serialized body | Use your app's API key and verify against the raw request body. |
| Duplicate deliveries | Retry after a timeout | Implement idempotency using X-Event-ID. |
| Events stopped coming | 5 consecutive failures exhausted retries | Fix your endpoint; new events resume automatically. Contact support to replay FAILED events. |
Need help?
- Setup assistance: [email protected]
- Technical issues: check your endpoint logs and contact support with the
X-Event-ID. - Scope questions: see Application Scopes and Decline Codes.
Updated about 16 hours ago
