Skip to Content
Webhooks

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

EventWhen it’s sent
payment_intent.preauthorisedA payment intent has been authorised (hold placed), not yet captured.
payment_intent.capturedA payment intent has been captured (funds taken).
payment_intent.refundedA captured payment intent has been refunded.
payment_intent.failedA payment intent failed.
group_payment.partially_paidSome - but not all - of a group’s money has been captured. Holds alone never trigger this (use payment_intent.preauthorised).
group_payment.paidAll slots of a group payment have been paid.
group_payment.partially_refundedSome captured intents of a group payment have been refunded.
group_payment.refundedAll captured intents of a group payment have been refunded.
group_payment.expiredThe 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:

HeaderValue
Payylo-Signaturet=<unixSeconds>,v1=<hex HMAC-SHA256>
Payylo-Event-IdLogical event id (shared per event)
Payylo-Event-TypeThe 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 small

Wire 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.refunded with expiry_refund: true in 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), like total_amount in 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.

Last updated on