Webhooks


Webhooks for OAuth Applications

Webhooks allow your application to receive real-time notifications when events occur in user accounts. When enabled, Fluz will send HTTP POST requests to your configured endpoint URL with event details.

Available Events

Webhooks are sent based on the scopes granted by the user during OAuth authorization. Events are only delivered if the user has granted the required scope.

Event TypeRequired ScopeDescription
WIDGET_KYC_INITIATIONVERIFY_KYCTriggered when a user initiates KYC verification from the widget
WIDGET_DEPOSIT_COMPLETEMAKE_PAYOUT_SENDTriggered when a user successfully sends (deposits) into the application
WIDGET_WITHDRAW_COMPLETEMAKE_PAYOUT_RECEIVETriggered when a user successfully receives (withdraws) from the application
TRANSACTION_CREATELIST_PAYMENT, LIST_PURCHASESTriggered when user initiates a transaction
TRANSACTION_UPDATELIST_PAYMENT, LIST_PURCHASESTriggered when transaction initiated by user is updated
TRANSACTION_DECLINELIST_PAYMENT, LIST_PURCHASESTriggered when transaction initiated by user is declined

Webhook Configuration

You can configure webhooks for your application through the developer portal:

  1. Navigate to For DevelopersOAuthWebhooks
  2. Click Add new URL
  3. Enter your HTTPS webhook endpoint URL
  4. Select which event types you want to receive (or leave empty for all events)
  5. Click Create Webhook

Requirements

When configuring your webhook:

  • ✅ Must use HTTPS (HTTP endpoints will be rejected)
  • ✅ Must be a publicly accessible URL
  • ✅ Must respond to POST requests
  • ✅ Must implement signature verification (see below)
  • ✅ Should respond within 30 seconds

Managing Webhooks

  • Add multiple webhooks - You can configure multiple webhook URLs for the same application
  • Update events - Delete and recreate a webhook to change subscribed events
  • Remove webhooks - Click the "Remove" button next to any webhook to delete it

Webhook Delivery

HTTP Request Format

POST /your/webhook/endpoint HTTP/1.1
Host: your-app.com
Content-Type: application/json
X-HMAC-Signature: <hmac_sha256_signature>
X-Event-ID: <event_correlation_id>
{
  "userId": "550e8400-e29b-41d4-a716-446655440000",
  "accountId": "550e8400-e29b-41d4-a716-446655440001",
  "externalReferenceId": "550e8400-e29b-41d4-a716-446655440002",
  "eventType": "WIDGET_DEPOSIT_COMPLETE",
  "amount": 50.00
}

Headers


HeaderDescription
Content-TypeAlways application/json
X-HMAC-SignatureHMAC SHA-256 signature of the request body for verification
X-Event-IDUnique identifier for this event (UUID format)

Event Payloads

WIDGET_KYC_INITIATION

Sent when a KYC verification is initiated in the widget.

{
  "userId": "550e8400-e29b-41d4-a716-446655440000",
  "accountId": "550e8400-e29b-41d4-a716-446655440001",
  "externalReferenceId": "550e8400-e29b-41d4-a716-446655440002",
  "eventType": "WIDGET_KYC_INITIATION",
  "appId": "550e8400-e29b-41d4-a716-446655440003"
}

FieldTypeDescription
userIdString (UUID)The Fluz user ID
accountIdString(UUID)The Fluz account ID
externalReferenceIdStringYour application's user identifier( from OAuth flow)
appIdString (UUID)Your application ID
eventTypeStringAlways WIDGET_KYC_INITIATION

WIDGET_DEPOSIT_COMPLETE

Sent when a user successfully deposits or sends money to the application.

{
  "userId": "550e8400-e29b-41d4-a716-446655440000",
  "accountId": "550e8400-e29b-41d4-a716-446655440001",
  "externalReferenceId": "550e8400-e29b-41d4-a716-446655440002",
  "appId": "550e8400-e29b-41d4-a716-446655440003",
  "eventType": "WIDGET_DEPOSIT_COMPLETE",
  "amount": 50.00
}

FieldTypeDescription
userIdString (UUID)The Fluz user ID
accountIdString(UUID)The Fluz account ID
externalReferenceIdStringYour application's user identifier( from OAuth flow)
appIdString (UUID)Your application ID
eventTypeStringAlways WIDGET_DEPOSIT_COMPLETE
amountNumberThe deposit amount in USD(e.g., 50, 100.50)

WIDGET_WITHDRAW_COMPLETE

Send when a user successfully withdraws or receives money from the application

{
  "userId": "550e8400-e29b-41d4-a716-446655440000",
  "accountId": "550e8400-e29b-41d4-a716-446655440001",
  "externalReferenceId": "550e8400-e29b-41d4-a716-446655440002",
  "appId": "550e8400-e29b-41d4-a716-446655440003",
  "eventType": "WIDGET_WITHDRAW_COMPLETE",
  "amount": 25.00
}
FieldTypeDescription
userIdString (UUID)The Fluz user ID
accountIdString(UUID)The Fluz account ID
externalReferenceIdStringYour application's user identifier( from OAuth flow)
appIdString (UUID)Your application ID
eventTypeStringAlways WIDGET_WITHDRAW_COMPLETE
amountNumberThe deposit amount in USD(e.g., 25, 100.50)

TRANSACTION_CREATE & TRANSACTION_UPDATE

Sends transaction initiated by user (TRANSACTION_CREATE - initiated with PENDING status, TRANSACTION_UPDATE - when there is an update like PENDING -> SETTLED status change )

{
  "recordId": "cec87136-38d5-4001-8eb9-310f330520a2",
  "user": "Amir Test",
  "accountId": "ff30139d-54ae-4483-8683-a07f6edbf13b",
  "amount": -58,
  "initialAmount": 58,
  "bonusCashbackRate": 0,
  "cardDisplayName": "",
  "cardLastFour": "0184",
  "cashBalanceAvailableBalance": 0,
  "cashBalanceDepositIds": null,
  "cashBalanceTotalBalance": 0,
  "cashback": 0.87,
  "cashbackRate": 1.5,
  "category": "Miscellaneous Specialty Retail",
  "challengeLogoUrl": null,
  "channel": null,
  "conversionRate": 1,
  "createdAt": "2026-05-06T16:55:16.176Z",
  "depositIds": null,
  "description": "Uber",
  "descriptorId": "2f30bea0-33a9-4b4b-95ce-b19662821d45",
  "destination": "Uber",
  "eventType": "TRANSACTION_CREATE",
  "externalFundingSourceActivity": -58,
  "fee": 0,
  "fluzBalanceActivity": 0.87,
  "giftCardPrepaymentBalanceAvailableBalance": 1002537.605,
  "giftCardPrepaymentBalanceTotalBalance": 1002537.605,
  "invitedAccountId": null,
  "isCashBalanceAffected": false,
  "isGiftCardBalanceAffected": false,
  "isReserveBalanceAffected": false,
  "isSeatBalanceAffected": true,
  "liabilityId": null,
  "logoUrl": "https://storage.googleapis.com/fluz-fluz-file-uploads-prod-ricuyxowbwlfprel/UBER-logo.jpg",
  "merchantId": "9f83fa82-435e-4553-9525-8c7871825f74",
  "merchantCategoryCode": 5999,
  "merchantCategoryDescription": "Miscellaneous Specialty Retail",
  "merchantCity": "No_city",
  "merchantCountry": "USA",
  "merchantIndustry": "Travel",
  "merchantName": "Uber",
  "note": null,
  "originalCurrencyAmount": 5800,
  "originalCurrencyCode": "USD",
  "otherCashBalanceAvailableBalance": 419594.8,
  "otherCashBalanceTotalBalance": 419594.8,
  "platformInstitutionLogo": null,
  "referenceId": "1047143",
  "reserveBalanceAvailableBalance": 0,
  "reserveBalanceTotalBalance": 0,
  "seatBalanceAvailableBalance": 1708.644,
  "seatBalanceTotalBalance": 1708.644,
  "source": "ACH Plaid Silver Standard 0.1% Interest Saving 1111 [$58.00]",
  "sourceType": null,
  "status": "PENDING",
  "transactionId": "cec87136-38d5-4001-8eb9-310f330520a2",
  "transactionType": "Virtual Card Purchase",
  "transferId": null,
  "usedUserCashBalanceId": null,
  "userId": "c6606c0e-f22f-4eee-85e5-17be6ae407bc",
  "virtualCardId": "7761e937-b9d7-4c71-b74e-2d9bbc9249ac",
  "virtualCardProgram": "HIGHNOTE_CFSB_MASTERCARD",
  "withdrawIds": null
}
FieldTypeDescription
recordIdUUID!Transaction record ID
userString!Fluz user full name
accountIdUUID!Fluz account ID
userIdUUIDFluz user ID
transactionIdUUID!Fluz transaction ID
transactionTypeString!Transaction type (e.g., purchase, deposit, withdrawal)
amountNumber!Transaction amount in USD (e.g., 25, 100.50)
initialAmountNumberInitial transaction amount in USD (e.g., 25, 100.50)
destinationStringDestination of funds
sourceStringSource of funds
externalFundingSourceActivityNumberAmount involving an external funding source
fluzBalanceActivityNumberAmount involving Fluz balance
feeNumberFee charged on the transaction
cashbackNumberCashback earned on the transaction
giftCardPrepaymentBalanceAvailableBalanceNumberGift card prepayment available balance after transaction
giftCardPrepaymentBalanceTotalBalanceNumberGift card prepayment total balance after transaction
cashBalanceAvailableBalanceNumberAvailable cash balance after transaction
cashBalanceTotalBalanceNumberTotal cash balance after transaction
seatBalanceAvailableBalanceNumberAvailable seat (rewards) balance after transaction
seatBalanceTotalBalanceNumberTotal seat (rewards) balance after transaction
reserveBalanceAvailableBalanceNumberAvailable reserve balance after transaction
reserveBalanceTotalBalanceNumberTotal reserve balance after transaction
otherCashBalanceAvailableBalanceNumberOther cash available balance after transaction
otherCashBalanceTotalBalanceNumberOther cash total balance after transaction
statusAllTransactionsStatus!Other cash total balance after transaction. Possible values: PENDING, SETTLED
referenceIdUUIDExternal or cross-service reference ID
descriptionStringTransaction description
noteStringUser-provided or system-generated note
categoryStringTransaction category
cardLastFourStringLast four digits of a card used
cardDisplayNameStringDisplay name of the card used
originalCurrencyAmountNumberTransaction amount in the original (foreign) currency
originalCurrencyCodeStringISO currency code of the original currency
conversionRateNumberExchange rate applied for currency conversion
merchantIdUUIDInternal merchant ID
merchantNameStringMerchant name
merchantCountryStringMerchant country
merchantStateStringMerchant state/region
merchantCityStringMerchant city
merchantIndustryStringMerchant industry classification
merchantCategoryCodeNumberMerchant Category Code (MCC)
merchantCategoryDescriptionStringMCC description
merchantCategoryLogoUrlStringURL of the merchant category logo
merchantCategoryImageUrlStringURL of the merchant category image
descriptorIdStringDescriptor ID (enriched merchant data)
virtualCardIdStringID of the virtual card used
virtualCardProgramStringVirtual card program/provider name
cashbackRateNumberCashback rate applied to the transaction
bonusCashbackRateNumberBonus cashback rate (e.g., promo or challenge-based) Channel & Display
channelStringChannel through which the transaction originated
sourceTypeStringType of the funding source used
logoUrlStringLogo URL for the transaction display
platformInstitutionLogoStringLogo of the platform/institution involved
challengeLogoUrlStringLogo URL for an associated challenge
invitedAccountIdUUIDAccount ID of an invited user (for referral-related transactions)
expectedClearedDateStringExpected date when the transaction clears
liabilityIdUUIDAssociated liability record identifier
isGiftCardBalanceAffectedBooleanWhether the gift card prepayment balance was affected
isCashBalanceAffectedBooleanWhether the cash balance was affected
isSeatBalanceAffectedBooleanWhether the seat (rewards) balance was affected
isReserveBalanceAffectedBooleanWhether the reserve balance was affected
isRefundedBySystemBooleanWhether the transaction was automatically refunded by the system
cashBalanceDepositIds[UUID]Related cash balance deposits IDs
depositIds[UUID]Related deposits IDs
withdrawIds[UUID]Related withdrawals IDs
transferIdUUIDRelated transfer ID
connectedAppIdUUID!Connected OAuth application ID
connectedAppNameString!Connected OAuth application display name
usedUserCashBalanceIdUUID!ID of user cash balance used
spendAccountNicknameStringNickname of the spend account used
isPrivateBooleanWhether the transaction is private (hidden from social feed)
createdAtDate!Timestamp when the record was created
updatedAtDateTimestamp when the record was last updated

TRANSACTION_DECLINE

Sends declined transaction payload (when transaction was declined for whatever reason)

{
  "transactionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "transactionType": "PURCHASE",
  "amount": 25.99,
  "fluzAmount": 0,
  "externalFundingAmount": 25.99,
  "currency": "USD",
  "sourceRecordType": "VirtualCardTransaction",
  "sourceRecordId": "d4e5f6a7-b890-1234-cdef-567890abcdef",
  "accountId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "userId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
  "source": "CARD",
  "destination": "Merchant Name",
  "status": "DECLINED",
  "merchantId": "e5f6a7b8-9012-3456-def0-1234567890ab",
  "descriptorId": "f6a7b890-1234-5678-ef01-234567890abc",
  "liabilityId": "a7b89012-3456-7890-f012-34567890abcd",
  "vendorName": "Highnote",
  "merchantName": "Amazon.com",
  "merchantCountry": "US",
  "merchantCity": "Seattle",
  "merchantState": "WA",
  "logoUrl": "https://cdn.fluz.app/merchants/amazon-logo.png",
  "category": "Shopping",
  "cardLastFour": "4321",
  "cardDisplayName": "My Virtual Card",
  "virtualCardId": "b8901234-5678-90ab-0123-4567890abcde",
  "virtualCardProgram": "HIGHNOTE",
  "channel": "MOBILE_APP",
  "externalFundingSourceType": "BANK_CARD",
  "externalFundingSourceId": "c9012345-6789-0abc-1234-567890abcdef",
  "fundingSourceSubtype": "DEBIT",
  "isCashBalanceUsed": false,
  "isPrepaymentBalanceUsed": false,
  "isRewardsBalanceUsed": false,
  "isReserveBalanceUsed": false,
  "transactionDateTime": "2025-03-19T12:00:00.000Z",
  "spendAccountNickname": "Daily Spending",
  "declineTitle": "Transaction Declined",
  "declineReason": "Insufficient spend limit",
  "declineDescription": "Your virtual card spend limit has been exceeded for this period.",
  "declineCtaText": "Review Spend Limits",
  "declineCategory": "SPEND_LIMIT",
  "offerId": "d0123456-7890-abcd-2345-67890abcdef0",
  "issuer": "Highnote",
  "bin": "423456",
  "limitAmount": "500.00",
  "limitDuration": "MONTHLY",
  "lockOnNextUse": "false",
  "lockDate": null,
  "bankAccountNickname": "Chase Checking",
  "bankAccountLastFour": "7890",
  "isPrivate": true
}
FieldTypeDescription
transactionIdUUID!Fluz transaction ID
transactionTypeString!Type of transaction that was declined (e.g., PURCHASE)
amountNumber!Total transaction amount that was attempted
fluzAmountNumber!Portion of the amount sourced from Fluz balance
externalFundingAmountNumber!Portion of the amount sourced from an external funding source
currencyString!ISO currency code (e.g., USD)
sourceRecordTypeStringType of the originating record (e.g., VirtualCardTransaction)
sourceRecordIdUUIDOriginating record ID
sourceStringSource of the attempted funds (e.g., CARD)
destinationStringIntended destination of the funds (e.g., merchant name)
statusDeclinedTransactionStatus!Decline status (enum, e.g., DECLINED)
merchantIdUUIDInternal merchant identifier
descriptorIdUUIDDescriptor record ID (enriched merchant data)
liabilityIdUUIDAssociated liability record ID
vendorNameStringName of the card-issuing vendor (e.g., Highnote)
merchantNameStringMerchant name (may be null if unknown)
merchantCountryStringMerchant country
merchantStateStringMerchant state/region
merchantCityStringMerchant city
logoUrlStringLogo URL for the transaction display
categoryStringTransaction category label
cardLastFourStringLast four digits of the card used
cardDisplayNameStringDisplay name of the card used
virtualCardIdUUIDID of the virtual card involved
virtualCardProgramStringVirtual card program/provider name (e.g., HIGHNOTE)
channelStringChannel through which the transaction originated (e.g., MOBILE_APP)
externalFundingSourceTypeStringType of external funding source (e.g., BANK_CARD)
externalFundingSourceIdStringIdentifier of the external funding source
fundingSourceSubtypeStringSubtype of the funding source (e.g., DEBIT, CREDIT)
isCashBalanceUsedBoolean!Whether the cash balance was involved in the attempt
isPrepaymentBalanceUsedBoolean!Whether the gift card prepayment balance was involved
isRewardsBalanceUsedBoolean!Whether the rewards (seat) balance was involved
isReserveBalanceUsedBoolean!Whether the reserve balance was involved
transactionDateTimeString!ISO 8601 timestamp of the transaction attempt
spendAccountNicknameStringNickname of the spend account used
declineTitleStringShort title for the decline (e.g., "Transaction Declined")
declineReasonStringMachine/human-readable decline reason
declineDescriptionStringLonger explanation of the decline cause
declineCtaTextStringCall-to-action text shown to the user (e.g., "Review Spend Limits")
declineCategoryStringCategory of the decline (e.g., SPEND_LIMIT, FRAUD)
offerIdUUIDAssociated offer ID
issuerStringCard issuer name
binStringBank Identification Number (first 6 digits of the card)
limitAmountStringSpend limit amount
limitDurationStringDuration window of the spend limit (e.g., MONTHLY)
lockOnNextUseStringWhether the card is set to lock on next use
lockDateStringDate the card was/will be locked
bankAccountNicknameStringNickname of the linked bank account
bankAccountLastFourStringLast four digits of the linked bank account
isPrivateBooleanWhether the transaction is private (hidden from social feed)

Verifying Webhook Signatures

Every webhook request includes an HMAC signature that you must verify to ensure the request came from Fluz.

Verification Steps

  1. Extract the signature from the X-HMAC-Signature header
  2. Compute the HMAC SHA-256 of the raw request body using your application's API key as the secret
  3. Compare your computed signature with the received signature

Example Verification (Node.js)

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, apiKey) {
  const computedSignature = crypto
    .createHmac('sha256', apiKey)
    .update(JSON.stringify(payload))
    .digest('hex');
  
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(computedSignature)
  );
}

// Express.js example
app.post('/webhook', express.json(), (req, res) => {
  const signature = req.headers['x-hmac-signature'];
  const eventId = req.headers['x-event-id'];
  
  if (!verifyWebhookSignature(req.body, signature, YOUR_API_KEY)) {
    return res.status(401).send('Invalid signature');
  }
  
  // Process the webhook
  const { eventType, userId, amount } = req.body;
  
  console.log(`Received ${eventType} for user $USERID`);
  
  // Always respond with 200 to acknowledge receipt
  res.status(200).send('OK');
});

Example Verification (Python)

import hmac
import hashlib
import json

def verify_webhook_signature(payload: dict, signature: str, api_key: str) -> bool:
    payload_string = json.dumps(payload, separators=(',', ':'))
    computed_signature = hmac.new(
        api_key.encode('utf-8'),
        payload_string.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(signature, computed_signature)

# Flask example
@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('X-HMAC-Signature')
    event_id = request.headers.get('X-Event-ID')
    payload = request.json
    
    if not verify_webhook_signature(payload, signature, YOUR_API_KEY):
        return 'Invalid signature', 401
    
    # Process the webhook
    event_type = payload['eventType']
    user_id = payload['userId']
    
    print(f"Received {event_type} for user USER_ID")
    
    # Always respond with 200
    return 'OK', 200

Responding to webhooks

Your endpoint must:

  • ✅ Respond with HTTP status 200-299 within 30 seconds
  • ✅ Return quickly (process events asynchronously if needed)
  • ✅ Be publicly accessible via HTTPS
  • ❌ Do NOT respond with redirects (3xx)
  • ❌ Do NOT respond with client errors (4xx) or server errors (5xx) for valid webhooks

Example Response Handler

app.post('/webhook', express.json(), async (req, res) => {
  const signature = req.headers['x-hmac-signature'];
  
  // 1. Verify signature
  if (!verifyWebhookSignature(req.body, signature, YOUR_API_KEY)) {
    return res.status(401).send('Invalid signature');
  }
  
  // 2. Respond immediately
  res.status(200).send('OK');
  
  // 3. Process asynchronously
  processWebhookAsync(req.body).catch(err => {
    console.error('Error processing webhook:', err);
  });
});

async function processWebhookAsync(payload) {
  const { eventType, userId, accountId, amount } = payload;
  
  switch (eventType) {
    case 'WIDGET_DEPOSIT_COMPLETE':
      await updateUserBalance(userId, amount);
      break;
    case 'WIDGET_WITHDRAW_COMPLETE':
      await recordWithdrawal(userId, amount);
      break;
    // ... handle other events
  }
}

Retry Behavior

If your endpoint fails to response with a 2xx status code, Fluz will automatically retry the webhook delivery:

  • Maximum attempts: 5
  • Retry schedule: Exponential backoff managed by Google Cloud Tasks
  • Timeout: 30 seconds per attempt

After 5 failed attempts, the webhook event is marked as FAILEDand delivery stops. Monitor your webhook endpoint logs to ensure you're receiving and processing events successfully.

Best Practices

Security

  • ✅ Always verify HMAC signatures before processing webhook data
  • ✅ Use HTTPS endpoints only
  • ✅ Keep your API key secret and rotate periodically
  • ❌ Do not expose your webhook endpoint to the public without authentication

Reliability

  • ✅ Respond with 200 immediately, then process asynchronously
  • ✅ Implement idempotency using the X-Event-ID header (events may be retried)
  • ✅ Store processed event IDs to prevent duplicate processing
  • ✅ Monitor webhook failures and set up alerting

Performance

  • ✅ Keep your endpoint fast (respond within 5 seconds ideally)
  • ✅ Use a queue for heavy processing
  • ✅ Scale your webhook endpoint to handle bursts

Idempotency

Webhooks may be delivered more than once. Use the X-Event-ID header to detect and handle duplicate deliveries:

const processedEvents = new Set(); // Use Redis/DB in production

app.post('/webhook', async (req, res) => {
  const eventId = req.headers['x-event-id'];
  
  // Check if already processed
  if (processedEvents.has(eventId)) {
    console.log(`Event ${eventId} already processed, skipping`);
    return res.status(200).send('OK');
  }
  
  // Verify signature
  if (!verifyWebhookSignature(req.body, req.headers['x-hmac-signature'], API_KEY)) {
    return res.status(401).send('Invalid signature');
  }
  
  // Respond immediately
  res.status(200).send('OK');
  
  // Mark as processed
  processedEvents.add(eventId);
  
  // Process event
  await processWebhookAsync(req.body);
});

FAQ

How do I know which user triggered the event?

Use the externalReferenceId field, which contains the user identifier you provided during the OAuth authorization flow. This maps to your application's user ID.

Can I receive webhooks for multiple applications?

Yes, configure separate webhook endpoints for each application, or use a single endpoint and differentiate by the appId field included in all webhook payloads.

What if my endpoint is temporarily down?

Fluz will retry the webhook up to 5 times with exponential backoff. After 5 failures, the event is marked as failed. Contact support if you need events re-sent.

Are webhooks sent in order?

Webhooks are sent as events occur, but delivery order is not guaranteed. Use timestamps and event IDs to order events if needed.

Need Help?

  • Setup assistance: Contact [email protected]
  • Technical issues: Check your webhook logs and contact support with the X-Event-ID
  • Scope questions: See the OAuth Scopes documentation