Webhooks
Payylo sends outbound webhooks so your application is notified the moment a payment changes state - a booking engine can confirm a reservation without polling. Every delivery is signed so you can verify it came from Payylo.
Event types
| Event | When it’s sent |
|---|---|
payment_intent.preauthorised | A payment intent has been authorised (hold placed), not yet captured. |
payment_intent.captured | A payment intent has been captured (funds taken). |
payment_intent.refunded | A captured payment intent has been refunded. |
payment_intent.failed | A payment intent failed. |
group_payment.partially_paid | Some - but not all - of a group’s money has been captured. Holds alone never trigger this (use payment_intent.preauthorised). |
group_payment.paid | All slots of a group payment have been paid. |
group_payment.partially_refunded | Some captured intents of a group payment have been refunded. |
group_payment.refunded | All captured intents of a group payment have been refunded. |
group_payment.expired | The group passed expires_at without completing - see Group expiry and automatic refunds. |
Events fire regardless of the payment provider - cards (Stripe, Rede, Redsys) and the Venezuelan rails (Mercantil, BNC, Banesco, Zelle) all emit the same events.
payment_intent.captured deliveries are enriched with invoice-grade data when
available: the payer’s document (cédula), the bank reference, and the applied
exchange-rate / tax snapshots - enough to bill from the webhook alone.
Registering an endpoint
Register endpoints via POST /v1/webhooks. The signing secret is returned
exactly once, at creation - store it immediately, it cannot be retrieved later.
Omit event_types (or pass []) to receive all events.
curl https://api.payylo.com/v1/webhooks \
-H "Authorization: Bearer sk_test_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/payylo-webhook",
"event_types": ["group_payment.paid", "group_payment.refunded"]
}'
# Response includes "secret": "whsec_…" - save it now, shown only once.Manage endpoints with GET /v1/webhooks, GET/PATCH/DELETE /v1/webhooks/{id} -
full request/response shapes are in the
API Reference .
Event payload
Payylo POSTs a JSON body to your URL:
{
"id": "f1e2d3c4-...",
"type": "group_payment.paid",
"created": "2026-06-20T16:57:23.000Z",
"data": {
"group_payment_id": "eda0165c-...",
"state": "paid"
}
}Headers sent with every delivery:
| Header | Value |
|---|---|
Payylo-Signature | t=<unixSeconds>,v1=<hex HMAC-SHA256> |
Payylo-Event-Id | Logical event id (shared per event) |
Payylo-Event-Type | The event type |
Verifying the signature
The signature is HMAC-SHA256(secret, "{timestamp}.{rawBody}"), hex-encoded.
Verify it against the raw request body (never re-serialize). Also reject old
timestamps to prevent replay. Any language with an HMAC library can do this -
here in Node:
import { createHmac } from 'node:crypto';
const [, t, v1] = header.match(/t=(\d+),v1=([0-9a-f]+)/) ?? [];
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`) // rawBody = the exact bytes received
.digest('hex');
const valid = expected === v1; // and check Math.abs(now - t) is smallWire it into your handler, verifying before parsing the body:
app.post(
'/payylo-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
const header = req.header('Payylo-Signature') ?? '';
const secret = process.env.PAYYLO_WEBHOOK_SECRET!; // whsec_…
const [, t, v1] = header.match(/t=(\d+),v1=([0-9a-f]+)/) ?? [];
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
if (expected !== v1) return res.status(400).end();
const event = JSON.parse(rawBody);
switch (event.type) {
case 'group_payment.paid':
// fulfil the reservation
break;
case 'group_payment.refunded':
// handle the refund
break;
}
res.json({ received: true });
}
);Group expiry and automatic refunds
A daily sweep closes group payments that pass expires_at without
completing. For each expired group:
- Card holds are released automatically. Preauthorised intents are voided at the provider - no money ever moved, nobody is charged.
- Captured money on API-refundable rails (cards, Mercantil) is refunded
automatically back to the account that paid. Each auto-refund emits
payment_intent.refundedwithexpiry_refund: truein the payload. - Captured money on Zelle, BNC and Banesco is flagged for manual reconciliation - those rails have no API reversal, so the refund happens out-of-band and the intents surface in your dashboard’s manual-ops queue.
- Reported-but-unverified Zelle transfers are left untouched in the verification queue - the payer claims money is in flight, so the claim isn’t destroyed. A transfer that verifies later still can’t reopen the group; it lands in the refund queue instead.
Once processed, the group transitions to expired (a terminal state) and a
group_payment.expired event fires summarising the outcome:
{
"id": "f1e2d3c4-...",
"type": "group_payment.expired",
"created": "2026-06-23T03:00:00.000Z",
"data": {
"group_payment_id": "eda0165c-...",
"state": "expired",
"total_amount": 30000,
"currency": "USD",
"collected_amount": 10000,
"refunds_auto": 1,
"refunds_due": 0,
"zelle_pending_review": 0
}
}collected_amount- how much had been captured before expiry, in minor units (cents), liketotal_amountin webhook payloads.refunds_auto- number of captured intents refunded automatically.refunds_due- number of captured intents flagged for your manual refund.zelle_pending_review- reported Zelle transfers still awaiting verification.
The merchant also receives a summary email whenever the sweep released holds, moved money back, or left something needing action.
Retries
If your endpoint doesn’t return a 2xx, Payylo retries with exponential backoff
(1m, 5m, 30m, 2h, 5h, 10h - 6 attempts) before marking the delivery failed.
Make your handler idempotent: the same event may be delivered more than once.