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
- Click Add Webhook
- Enter your endpoint URL (must be HTTPS)
- Select which events to subscribe to
- 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
| Event | Description |
|---|---|
payment.completed | Payment was confirmed on-chain and credited to your wallet |
payment.failed | Payment failed (transaction reverted or expired) |
refund.created | A refund has been created for a payment |
refund.completed | Refund completed on-chain and sent to customer |
refund.failed | Refund 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
{
"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
{
"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_idstringRequiredFivo payment ID
amountstringRequiredPayment amount (decimal string)
currencystringRequiredToken symbol: USDC or EURC
statusstringRequiredPayment status: pending, completed, or failed
tx_hashstring | nullRequiredOn-chain transaction hash
from_addressstring | nullRequiredPayer's wallet address
to_addressstring | nullRequiredMerchant's wallet address
source_chainstring | nullRequiredSource blockchain (e.g. ETH, BASE)
destination_chainstring | nullRequiredDestination blockchain (null for same-chain)
is_cross_chainbooleanRequiredWhether the payment was bridged cross-chain
merchant_transfer_tx_hashstringOptionalTX hash of the transfer to merchant wallet (cross-chain only)
customer_emailstring | nullOptionalCustomer email if provided during checkout
referencestring | nullOptionalMerchant reference (e.g. order ID)
metadataobject | nullOptionalCustom metadata attached to the payment
| Name | Type | Required | Description |
|---|---|---|---|
payment_id | string | Required | Fivo payment ID |
amount | string | Required | Payment amount (decimal string) |
currency | string | Required | Token symbol: USDC or EURC |
status | string | Required | Payment status: pending, completed, or failed |
tx_hash | string | null | Required | On-chain transaction hash |
from_address | string | null | Required | Payer's wallet address |
to_address | string | null | Required | Merchant's wallet address |
source_chain | string | null | Required | Source blockchain (e.g. ETH, BASE) |
destination_chain | string | null | Required | Destination blockchain (null for same-chain) |
is_cross_chain | boolean | Required | Whether the payment was bridged cross-chain |
merchant_transfer_tx_hash | string | Optional | TX hash of the transfer to merchant wallet (cross-chain only) |
customer_email | string | null | Optional | Customer email if provided during checkout |
reference | string | null | Optional | Merchant reference (e.g. order ID) |
metadata | object | null | Optional | Custom metadata attached to the payment |
Request Headers
Fivo includes these headers with every webhook request:
Headers
X-Fivo-SignaturestringRequiredHMAC-SHA256 signature with sha256= prefix (see Verifying Signatures)
X-Fivo-EventstringRequiredEvent type (e.g. payment.completed)
X-Fivo-TimestampstringRequiredUnix timestamp in seconds when the request was sent
X-Fivo-TeststringOptionalSet to "true" for test webhooks sent from the dashboard
| Name | Type | Required | Description |
|---|---|---|---|
X-Fivo-Signature | string | Required | HMAC-SHA256 signature with sha256= prefix (see Verifying Signatures) |
X-Fivo-Event | string | Required | Event type (e.g. payment.completed) |
X-Fivo-Timestamp | string | Required | Unix timestamp in seconds when the request was sent |
X-Fivo-Test | string | Optional | 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...
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);
}Timing-safe comparison
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)
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);