Docs/Webhooks

Webhooks

Get notified in real-time when payments are completed, failed, or pending.

Overview

Webhooks let your server receive automatic HTTP POST notifications when events happen in your Fivo account. Instead of polling the API, you register a URL and Fivo sends you the event data as it happens.

Every webhook request is signed with HMAC-SHA256 so you can verify it came from Fivo.

Setup

Configure webhooks from your page:Dashboard → Webhooks

  1. Click Add Webhook
  2. Enter your endpoint URL (must be HTTPS)
  3. Select which events to subscribe to
  4. Save: Fivo generates a signing secret for you
!

Requirements

  • URL must use HTTPS
  • URL cannot point to private/internal IP addresses (SSRF protection)
  • Your endpoint must respond with a 2xx status within 5 seconds

Events

EventDescription
payment.completedPayment was confirmed on-chain and credited to your wallet
payment.failedPayment failed (transaction reverted or expired)
refund.createdA refund has been created for a payment
refund.completedRefund completed on-chain and sent to customer
refund.failedRefund failed (check failure_reason in data)

Payload

Every webhook sends a JSON payload with the event type, a timestamp, and the event data:

Payment Events

payment.completed payload
{
  "event": "payment.completed",
  "timestamp": "2026-03-04T10:30:45.123Z",
  "data": {
    "payment_id": "fivo_live_abc123",
    "amount": "100.00",
    "currency": "USDC",
    "status": "completed",
    "tx_hash": "0xabc...def",
    "from_address": "0x1234...5678",
    "to_address": "0xabcd...ef01",
    "source_chain": "ETH",
    "destination_chain": "BASE",
    "is_cross_chain": true,
    "merchant_transfer_tx_hash": "0x9876...5432",
    "customer_email": "customer@example.com",
    "reference": "ORD-2026-001",
    "metadata": { "product_id": "plan_pro" }
  }
}

Refund Events

refund.completed payload
{
  "event": "refund.completed",
  "timestamp": "2026-03-04T10:35:20.456Z",
  "data": {
    "refund_id": "refund-uuid",
    "payment_id": "payment-uuid",
    "amount": "49.99",
    "currency": "USDC",
    "status": "completed",
    "destination_address": "0xCustomer...",
    "blockchain": "BASE",
    "reason": "requested_by_customer",
    "tx_hash": "0xabc..."
  }
}

Payment Data Fields

data object (payment events)

payment_idstringRequired

Fivo payment ID

amountstringRequired

Payment amount (decimal string)

currencystringRequired

Token symbol: USDC or EURC

statusstringRequired

Payment status: pending, completed, or failed

tx_hashstring | nullRequired

On-chain transaction hash

from_addressstring | nullRequired

Payer's wallet address

to_addressstring | nullRequired

Merchant's wallet address

source_chainstring | nullRequired

Source blockchain (e.g. ETH, BASE)

destination_chainstring | nullRequired

Destination blockchain (null for same-chain)

is_cross_chainbooleanRequired

Whether the payment was bridged cross-chain

merchant_transfer_tx_hashstringOptional

TX hash of the transfer to merchant wallet (cross-chain only)

customer_emailstring | nullOptional

Customer email if provided during checkout

referencestring | nullOptional

Merchant reference (e.g. order ID)

metadataobject | nullOptional

Custom metadata attached to the payment

Request Headers

Fivo includes these headers with every webhook request:

Headers

X-Fivo-SignaturestringRequired

HMAC-SHA256 signature with sha256= prefix (see Verifying Signatures)

X-Fivo-EventstringRequired

Event type (e.g. payment.completed)

X-Fivo-TimestampstringRequired

Unix timestamp in seconds when the request was sent

X-Fivo-TeststringOptional

Set to "true" for test webhooks sent from the dashboard

Verifying Signatures

Always verify the header to confirm the request came from Fivo. The signature is computed as using your webhook secret as the key, prefixed with .X-Fivo-SignatureHMAC-SHA256(timestamp + "." + body)sha256=.

Your webhook secret looks like: whsec_a1b2c3d4e5f6...

verify-webhook.js
const crypto = require('crypto');

function verifyWebhook(req, secret) {
  const signature = req.headers['x-fivo-signature'];
  const timestamp = req.headers['x-fivo-timestamp'];
  const body = JSON.stringify(req.body);

  // 1. Reject if timestamp is older than 5 minutes (replay protection)
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
  if (age > 5 * 60) {
    throw new Error('Webhook timestamp too old');
  }

  // 2. Compute expected signature: HMAC-SHA256(timestamp + "." + body)
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.' + body)
    .digest('hex');

  // 3. Compare using timing-safe comparison
  if (signature.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    throw new Error('Invalid webhook signature');
  }

  return JSON.parse(body);
}
i

Timing-safe comparison

Always use instead of to prevent timing attacks.crypto.timingSafeEqual()===

Retries & Auto-disable

If your endpoint returns a non-2xx status or times out, Fivo increments a failure counter. After consecutive failures, the webhook is automatically disabled to prevent further failed deliveries.10

You can re-enable a disabled webhook from the dashboard at any time. The failure counter resets on any successful delivery.

Testing

Use the button on your page to send a test event to your endpoint. Test webhooks include the header so your server can distinguish them from real events.Send testWebhooksX-Fivo-Test: true

Delivery logs are visible on the webhook detail page: you can inspect the payload, response status, and response time for every delivery.

Full Example (Express)

server.js
const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = process.env.FIVO_WEBHOOK_SECRET;

app.post('/webhooks/fivo', (req, res) => {
  try {
    // 1. Verify signature
    const signature = req.headers['x-fivo-signature'];
    const timestamp = req.headers['x-fivo-timestamp'];
    const body = JSON.stringify(req.body);

    const expected = 'sha256=' + crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(timestamp + '.' + body)
      .digest('hex');

    if (signature.length !== expected.length ||
        !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 2. Replay protection
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
    if (age > 5 * 60) {
      return res.status(401).json({ error: 'Timestamp too old' });
    }

    // 3. Handle event
    const { event, data } = req.body;

    switch (event) {
      case 'payment.completed':
        console.log('Payment received:', data.amount, data.currency);
        console.log('From:', data.from_address, 'on', data.source_chain);
        console.log('Customer:', data.customer_email);
        console.log('Reference:', data.reference);
        // Update your order status, send confirmation, etc.
        break;
      case 'payment.failed':
        console.log('Payment failed:', data.payment_id);
        break;
      case 'refund.created':
        console.log('Refund created:', data.refund_id);
        break;
      case 'refund.completed':
        console.log('Refund completed:', data.refund_id);
        break;
      case 'refund.failed':
        console.log('Refund failed:', data.refund_id);
        break;
    }

    res.status(200).json({ received: true });
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

app.listen(3000);