> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fluz.app/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.

<Callout icon="📘" theme="info">
  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.
</Callout>

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.

```mermaid
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_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](https://app.fluz.app/for-developers) 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 `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)

```javascript
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)

```python
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", 200
```

## 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

| 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-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.

```javascript
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:** `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`.

## 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`)

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

```json
{
  "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`)

Fired when a transaction's status or details change — for example, when a pending authorization settles.

```json
{
  "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:** confirm `userId` and `connectedAppId`/`connectedAppName` are present on `TRANSACTION_UPDATE` (and `TRANSACTION_DECLINE`) so consumers can identify the user and app consistently across the lifecycle.

### Transaction declined (`TRANSACTION_DECLINE`)

Fired when a transaction is declined. Includes structured decline reasons your app can act on.

```json
{
  "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](https://docs.fluz.app/docs/decline-codes).)* |

### Deposit complete (`DEPOSIT_COMPLETE`)

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

```json
{
  "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`)

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

```json
{
  "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`)

Fired when a user transfers funds to your application.

```json
{
  "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`)

Fired when your application transfers funds to a user.

```json
{
  "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`)

Fired when a gift card purchase completes through the widget.

> 🚧 **To be completed.** Add the `WIDGET_PURCHASE_GIFT_CARD` payload 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 `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.
* **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 failures events are marked `FAILED`.

## 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:** <support@fluz.app>
* **Technical issues:** check your endpoint logs and contact support with the `X-Event-ID`.
* **Scope questions:** see [Application Scopes](https://docs.fluz.app/docs/application-scopes) and [Decline Codes](https://docs.fluz.app/docs/decline-codes).