Skip to main content

Webhooks

Webhooks push event notifications to your server as payments move through their lifecycle. ZendFi signs every webhook delivery with HMAC-SHA256 so you can verify authenticity, and retries failed deliveries with exponential backoff.

How It Works

Webhook Configuration

Set your webhook URL in the ZendFi Dashboard or during zendfi init with the CLI. Every webhook registered under your merchant account receives all event types.

Event Types

All events your webhook endpoint can receive:

Payment Events

EventDescription
PaymentCreatedA new payment has been created
PaymentConfirmedPayment confirmed on-chain
PaymentFailedPayment failed or was rejected
PaymentExpiredPayment expired before completion

Payment Intent Events

EventDescription
PaymentIntentCreatedPayment intent created
PaymentIntentRequiresPaymentIntent awaiting customer payment
PaymentIntentSucceededIntent completed successfully
PaymentIntentCanceledIntent was cancelled
PaymentIntentFailedIntent failed

Settlement Events

EventDescription
SettlementCompletedFunds settled to merchant wallet
SettlementFailedSettlement transfer failed

Withdrawal Events

EventDescription
WithdrawalInitiatedWithdrawal request submitted
WithdrawalCompletedWithdrawal confirmed on-chain
WithdrawalFailedWithdrawal failed

Subscription Events

EventDescription
SubscriptionCreatedNew subscription started
SubscriptionCancelledSubscription cancelled
SubscriptionRenewedSubscription successfully renewed
SubscriptionPaymentFailedSubscription renewal payment failed

Installment Events

EventDescription
InstallmentPaidInstallment payment confirmed
InstallmentLateInstallment past due date
InstallmentDefaultedInstallment defaulted (past grace period)
InstallmentPlanCompletedAll installments paid
EventDescription
PaymentLinkCreatedPayment link generated
PaymentLinkUsedCustomer paid via payment link

Invoice Events

EventDescription
InvoiceCreatedInvoice created
InvoiceSentInvoice emailed to customer
InvoicePaidInvoice payment confirmed

Webhook Payload Structure

Every webhook delivery is an HTTP POST with a JSON body:
{
  "event": "PaymentConfirmed",
  "payment": {
    "id": "pay_test_abc123",
    "merchant_id": "merch_xyz789",
    "amount_usd": 49.99,
    "status": "confirmed",
    "transaction_signature": "5UfDuKxNgqH...",
    "customer_wallet": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
    "payment_token": "USDC",
    "mode": "test",
    "description": "Order #1234",
    "metadata": {"order_id": "1234"},
    "splits": null,
    "created_at": "2026-03-01T12:00:00Z",
    "expires_at": "2026-03-01T12:15:00Z"
  },
  "settlement": null,
  "withdrawal": null,
  "timestamp": "2026-03-01T12:05:00Z"
}

Payload Fields

event
string
The event type (see table above).
payment
object
Payment data. Present for payment, payment link, installment, and subscription events.Key fields: id, merchant_id, amount_usd, status, transaction_signature, customer_wallet, payment_token, mode, description, metadata, splits, created_at, expires_at.
settlement
object
Settlement data. Present for SettlementCompleted and SettlementFailed.Key fields: id, payment_id, merchant_id, amount_usd, settlement_token, settlement_amount, transaction_signature, merchant_wallet, status.
withdrawal
object
Withdrawal data. Present for withdrawal events.Key fields: id, merchant_id, to_address, from_address, amount, token, transaction_signature, status.
timestamp
string
ISO 8601 timestamp of when the event was generated.

Signature Verification

Every webhook includes a x-zendfi-signature header with the format:
t=1709294700,v1=5d41402abc4b2a76b9719d911017c592...
The signature is computed as:
  1. Create the signed payload: {timestamp}.{json_body}
  2. Compute HMAC-SHA256 using your webhook secret
  3. Compare the hex-encoded result to the v1= value

Verification Examples

import crypto from 'crypto';

function verifyWebhook(payload: string, signature: string, secret: string): boolean {
  const parts = signature.split(',');
  if (parts.length !== 2) return false;

  const timestamp = parts[0].replace('t=', '');
  const providedSig = parts[1].replace('v1=', '');

  // Reject signatures older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
  if (age > 300 || age < -60) return false;

  const signedPayload = `${timestamp}.${payload}`;
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expectedSig),
    Buffer.from(providedSig)
  );
}

Using the SDK

The SDK provides built-in verification:
import { ZendFiClient } from '@zendfi/sdk';

const zendfi = new ZendFiClient({ apiKey: 'zfi_test_...' });

const isValid = zendfi.verifyWebhook(rawBody, signatureHeader);
Or use the framework-specific handlers:
import { createExpressWebhookHandler } from '@zendfi/sdk/webhooks/express';

app.post('/webhooks/zendfi',
  express.raw({ type: 'application/json' }),
  createExpressWebhookHandler({
    secret: process.env.ZENDFI_WEBHOOK_SECRET!,
    onPaymentConfirmed: (payment) => {
      console.log('Payment confirmed:', payment.id);
    },
    onPaymentFailed: (payment) => {
      console.log('Payment failed:', payment.id);
    },
  })
);

Delivery and Retries

ZendFi uses exponential backoff for failed webhook deliveries:
AttemptDelayTotal Elapsed
1Immediate0s
230 seconds30s
32 minutes~2.5 min
410 minutes~12.5 min
51 hour~1 hour
After all retry attempts are exhausted, the webhook is marked as exhausted and moved to the dead letter queue. You will receive an email alert if you have notifications enabled.

Webhook Statuses

StatusDescription
pendingQueued for delivery
deliveredSuccessfully delivered (received 2xx response)
failedDelivery attempt failed, will retry
exhaustedAll retry attempts failed

List Webhook Events

GET /api/v1/webhooks
Returns all webhook events for the authenticated merchant.
const events = await zendfi.listWebhookEvents();

Retry a Webhook

POST /api/v1/webhooks/{id}/retry
Manually retry a failed or exhausted webhook delivery.
id
string
required
Webhook event ID.
curl -X POST https://api.zendfi.tech/api/v1/webhooks/wh_abc123/retry \
  -H "Authorization: Bearer zfi_test_your_key"

Verify Webhook Configuration

POST /api/v1/webhooks/verify
Test your webhook signature verification by sending a payload and signature. Returns whether the signature is valid. Useful during development.
payload
string
required
The raw webhook payload body.
signature
string
required
The signature string to verify (t=...,v1=...).

Response

{
  "valid": true,
  "message": "Webhook signature is valid",
  "timestamp_age_seconds": 12
}

Best Practices

Never trust webhook payloads without verifying the HMAC signature. This prevents spoofed events from triggering actions in your system.
Process webhook data asynchronously. Return a 200 response immediately and handle the business logic in a background job. ZendFi interprets slow responses as failures.
Webhooks may be delivered more than once. Use the payment.id or event ID for idempotency checks before processing.
Parse the JSON body only after verifying the signature against the raw string. Parsing and re-serializing can change whitespace and break the signature check.