Webhooks
Migrando de um evento legado? Veja o guia de migração.
Webhooks are how Orbita Pay pushes status updates to your backend in real time. Use them for fulfillment — never rely on polling alone. If a delivery is missed, use the pull/reconciliation API to catch up.
#0. Start here (most common questions)
#One URL or one URL per event?
One URL. In the dashboard (Integrations → Webhooks → New endpoint) you register one HTTPS endpoint and check multiple events. Orbita Pay sends a separate POST for each occurrence, always to the same URL.
| Some other PSPs | Orbita Pay |
|---|---|
/webhook/cashin, /webhook/cashout, /webhook/refund (path per type) | https://your-shop.com/hooks/liqfy + event list at registration |
| One HTTP route per event | One route, many events; the type field (or legacy event) distinguishes them |
Recommended generic URL:
https://your-domain.com/hooks/liqfyAvoid paths like /api/charge/created unless your router requires it — the path does not select the event; the dashboard checkboxes (or the events array in the API) do.
#What does “all events on one URL” look like?
You do not receive one payload with every event type. You receive one POST per status change. Branch on the type:
const type = body.type || body.event; // canonical uses type; legacy uses event
switch (type) {
case 'charge.paid':
case 'payment.completed': // legacy
// fulfill order
break;
case 'charge.failed':
case 'charge.expired':
// cancel / expire
break;
case 'charge.refunded':
case 'payment.refunded': // legacy alias — same refund
// handle refund
break;
default:
// unknown type: 200 OK and ignore
}
res.sendStatus(200); // respond 2xx quickly#Refunds — which event?
| Need | Event | Family |
|---|---|---|
| Charge paid | charge.paid | canonical |
| Charge failed / expired | charge.failed / charge.expired | canonical |
| Charge refund | charge.refunded (legacy alias payment.refunded) | canonical |
| Payout settled / failed | payout.paid / payout.failed | canonical (not a refund) |
Subscribe to charge.refunded on the same endpoint if you need refunds — it fires for every refund path (merchant-initiated via the API, BACEN MED, and the automatic payer-CPF devolução). payment.refunded is the legacy alias for the same event and is still delivered to endpoints subscribed to it. Do not confuse with payout.* (wallet withdrawal).
The refund object carries the refunded amount and its origin:
{
"id": "evt_…",
"object": "event",
"type": "charge.refunded",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "ch_01JABCDEF",
"object": "charge",
"amount": 1000,
"currency": "BRL",
"status": "refunded",
"payment_method": "pix",
"amount_refunded": 1000,
"settlement": { "end_to_end_id": "E0000…" },
"refund": {
"amount": 1000,
"currency": "BRL",
"origin": "MERCHANT",
"reason": "customer request",
"end_to_end_id": "E0000…"
}
}
}
}refund.origin is MERCHANT for a merchant/admin-initiated refund (BACEN MED flows through the same path) or AUTOMATIC_PAYER_RESTRICTION for the automatic Pix devolução triggered when a settled payer's CPF/CNPJ did not match the charge's expected payer. amount_refunded is the total refunded so far (equal to refund.amount for a single refund; larger across partial refunds). The legacy payment.refunded delivery carries the same facts flat in data (refundAmount, currency, origin, reason, endToEndId).
#Several accounts / shops on the same URL
Fine. Each Orbita Pay account has its own keys and endpoints, but your server URL can be shared. Segment using the payload (data.object.id = ch_…, metadata you set when creating the charge, etc.).
#Dashboard path (no API)
- Integrations → Webhooks → New endpoint
- Generic HTTPS URL (e.g.
…/hooks/liqfy) - Check at least:
charge.created,charge.paid,charge.failed,charge.expired(+charge.refundedif you need refunds) - Save the secret (shown once)
- Click Test and confirm your server returned 2xx
#1. The canonical event envelope
New integrations subscribe to the canonical events (charge.*, payout.*). Every delivery is an HTTPS POST to your registered URL with this shape:
{
"id": "evt_5f8a3c1e9b2d4a6f8e0c1b3d5f7a9c1e",
"object": "event",
"api_version": "2026-07-23",
"type": "charge.paid",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "ch_01JABCDEF",
"object": "charge",
"amount": 1000,
"currency": "BRL",
"status": "paid",
"payment_method": "pix",
"settlement": { "end_to_end_id": "E00000000202607231431abcdef1234" }
}
}
}id(evt_…) is the business-event id — stable across every delivery attempt and every endpoint fan-out. Deduplicate on it.data.objectis the same publiccharge/payoutshape the REST API returns (serialized through the same allowlist asGET /v1/charges/:id— no provider name, cost, secret, or raw PSP payload can appear here).- Always handle unknown
typevalues gracefully (200 OK+ ignore) so adding new event types never breaks you.
#Event catalog
GET /v1/webhooks/event-catalog returns the live, authoritative list (no auth required):
curl https://liqfy.com.br/v1/webhooks/event-catalog| Event | When it fires |
|---|---|
charge.created | Cobrança criada (Pix gerado, aguardando pagamento). |
charge.paid | Cobrança paga e confirmada. |
charge.failed | Cobrança falhou ou foi cancelada. |
charge.expired | Cobrança expirou sem pagamento. |
charge.refunded | Cobrança reembolsada (total ou parcial) — reembolso do lojista, MED ou devolução automática (trava de CPF). |
payout.created | Saque solicitado. |
payout.paid | Saque liquidado com sucesso. |
payout.failed | Saque falhou ou foi rejeitado. |
payment.refunded | Legacy alias for charge.refunded — still delivered to endpoints subscribed to it. |
Subscribe to the canonical names in events when you register your endpoint. For refunds, include charge.refunded.
Disputes (MED) do not fire a
charge.*webhook — the charge was paid, and a MED is not a charge failure. Detect a dispute via the Disputes panel, the email, or by re-reading the charge status (disputed). See Disputes and MED.
A late
charge.paidcan follow acharge.expired. If the acquirer confirms a payment only after the charge already expired (delayed webhook / lagging status API), Orbita Pay opens a manual verification case and, once an operator settles it, deliverscharge.paidfor that same charge. The latercharge.paidis the final state — treat it as paid, even though you had receivedcharge.expiredbefore. Never rely oncharge.expiredbeing terminal.
#2. Register your endpoint
Endpoint POST /v1/webhooks/endpoints
Headers
apikey: lq_live_...
Content-Type: application/jsonBody
{
"url": "https://merchant.example.com/hooks/liqfy",
"events": ["charge.paid", "charge.failed", "charge.expired"]
}Response 201 Created
{
"id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
"url": "https://merchant.example.com/hooks/liqfy",
"events": ["charge.expired", "charge.failed", "charge.paid"],
"secret": "b8f3a9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
"status": "ACTIVE"
}Save the
secretimmediately. It is shown only at creation time and is required to verify every incoming webhook. We never display it again.
#Legacy events (still supported)
Existing endpoints subscribed to the legacy family keep working, unchanged, with no cutoff date announced yet:
| Legacy event | Canonical replacement |
|---|---|
payment.completed | charge.paid |
payment.failed | charge.failed |
payment.expired | charge.expired |
payment.refunded | charge.refunded |
withdrawal.completed | payout.paid |
withdrawal.failed | payout.failed |
Legacy topic aliases —
payment.status_changedexpands to["payment.completed", "payment.failed"];withdrawal.status_changedexpands to["withdrawal.completed", "withdrawal.failed"]. Both are still accepted at registration time.
A legacy-subscribed endpoint keeps receiving the historical wire format for the exact same business event — the underlying occurrence is the same, only the envelope and event name differ per subscription:
{
"event": "payment.completed",
"data": {
"transactionId": "a1b2c3d4-e5f6-4789-9abc-def012345678",
"amount": 24900,
"status": "PAID",
"previousStatus": "WAITING_PAYMENT",
"paidWith": "PIX",
"platformFee": 200,
"occurredAt": "2026-07-23T14:31:00.000Z"
}
}providerFee/netAmount are deliberately never forwarded — they would let you back-calculate Orbita Pay's own PSP cost.
#Account/KYC events (legacy envelope only)
kyc.submitted, kyc.approved, kyc.rejected, and a pair of account-lifecycle events also fire and always use the legacy { event, data } envelope (they are not charge.*/payout.*, so they never get the canonical evt_ wrapper or the t=,v1= signature).
⚠️ Known naming gap: the account-lifecycle events currently emit an internal field name verbatim in their payload — a tracked glossary violation, not an intentional part of the public contract. See the migration guide's known gap and followups for the exact event and field names. Do not build a permanent integration against that field name.
#3. Verify the signature
Every delivery is signed with HMAC-SHA256 over the raw request body using your endpoint's secret. There are two schemes, chosen automatically by event family — your verifier should support both if you have any legacy subscription active.
| Header | Scheme | Applies to |
|---|---|---|
X-Liqfy-Signature | t=<unix-seconds>,v1=<hex> — signed payload is "<t>.<rawBody>" | Canonical events (charge.*, payout.*) |
X-Liqfy-Signature | sha256=<hex> — signed payload is the raw body alone | Legacy events (payment.*, withdrawal.*, kyc.*, and the account-lifecycle events above) |
X-Liqfy-Delivery-Id | Opaque id, stable across every retry of the same delivery | Both |
X-Liqfy-Event-Type | Mirrors the delivered event name | Both |
The canonical scheme embeds a timestamp in the signed material specifically so you can reject replays outside a tolerance window (recommended: 5 minutes) — the legacy scheme has no timestamp and cannot do this.
#Node.js (Express) — canonical scheme
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.LIQFY_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verifyCanonical(rawBody, header, secret) {
const [tPart, v1Part] = header.split(',');
const t = Number(tPart?.split('=')[1]);
const v1 = v1Part?.split('=')[1];
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
'/hooks/liqfy',
// capture raw body — express.json() strips it
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('X-Liqfy-Signature') || '';
if (!verifyCanonical(req.body.toString('utf8'), signature, SECRET)) {
return res.status(401).send('invalid signature');
}
const { id: eventId, type, data } = JSON.parse(req.body.toString('utf8'));
// 200 OK FAST. Do work in a background queue.
res.status(200).end();
queue.enqueue({ eventId, type, data, deliveryId: req.header('X-Liqfy-Delivery-Id') });
},
);Prefer not to hand-roll this?
@liqfy/node'sliqfy.webhooks.verify(rawBody, signature, secret)/.parse(...)handle both schemes (canonical and legacy) for you — seepackages/sdk-node/README.md.
#PHP — legacy scheme (existing payment.*/withdrawal.* subscriptions)
$secret = getenv('LIQFY_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_LIQFY_SIGNATURE'] ?? '';
$expect = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expect, $sig)) {
http_response_code(401);
exit('invalid signature');
}
$body = json_decode($raw, true);
http_response_code(200);
// queue $body for processing#Python (Flask) — legacy scheme
import hmac, hashlib, os
from flask import request, abort
SECRET = os.environ['LIQFY_WEBHOOK_SECRET'].encode()
@app.post('/hooks/liqfy')
def liqfy_hook():
raw = request.get_data() # bytes, untouched
sig = request.headers.get('X-Liqfy-Signature', '')
expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
payload = request.get_json()
# respond 200 fast, process async
return '', 200#4. Delivery guarantees, retries and DLQ
- Success Any
2xxresponse confirms the delivery and stops retries. - Timeout 30 seconds. A slower response counts as a failure.
- Retry schedule Exponential backoff (
1s × 2^attempt), capped at 6 hours between attempts (WEBHOOK_BACKOFF_CAP_MS, default21_600_000). - Maximum attempts 15 (
WEBHOOK_MAX_ATTEMPTS). After the last failed attempt the delivery moves toCANCELLED(dead-lettered) — it is not deleted, and can be replayed manually. - 429 handling If your endpoint returns
429with aRetry-Afterheader (seconds) or a JSON body{ "retry_after": <seconds> }, that value is honored (capped at 5 minutes) instead of the blind exponential backoff. - Delivery id stability
X-Liqfy-Delivery-Idis identical across every attempt of the same delivery — use it as your primary dedup key.
⚠️ A delivery may arrive more than once. Always make your handler idempotent by deduplicating on
X-Liqfy-Delivery-Id(or the canonical event'sid/evt_…).
#Idempotent handler pattern
async function handle({ deliveryId, eventId, type, data }) {
// Atomic insert — fails if we've seen this delivery before
const inserted = await db.processedWebhooks.insertIgnore({
id: deliveryId ?? eventId,
receivedAt: new Date(),
});
if (!inserted) return; // already handled
if (type === 'charge.paid') {
await orders.markPaid(data.object.id, data.object);
}
}#5. Pull / reconciliation API
If a push was missed (your receiver was down for hours), pull the events you missed instead of losing them.
curl "https://liqfy.com.br/v1/webhooks/events?since=2026-07-23T00:00:00Z&limit=50" \
-H "apikey: $LIQFY_API_KEY"{
"data": [
{ "eventType": "charge.paid", "payload": { "...": "..." }, "status": "DELIVERED", "lastStatusCode": 200, "createdAt": "2026-07-23T14:31:00.000Z" }
],
"nextCursor": "MjAyNi0wNy0yM1QxNDozMTowMC4wMDBafGRlbF8xMjM="
}- Cursor pagination on
(createdAt desc, id desc)— passnextCursorback ascursorfor the next page; treat it as an opaque token. - Filters:
since,until(ISO 8601),status,eventType. - Scoped strictly to your own endpoints — never returns platform-internal deliveries.
#6. Testing an endpoint
POST /v1/webhooks/endpoints/:id/test sends a synchronous, non-persisted sample delivery so you can check status, latency, and signature handling.
curl -X POST "https://liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>/test" \
-H "apikey: $LIQFY_API_KEY"The test delivery currently always sends the legacy
payment.completedsample (sha256=signature) regardless of which events the endpoint is subscribed to — it exercises connectivity and signature handling, not the canonical envelope specifically.
#7. Production checklist
- Endpoint is HTTPS with a valid TLS certificate.
- Signature is verified on the raw body, before JSON parsing.
- Comparison uses constant-time (e.g.
timingSafeEqual/hash_equals/hmac.compare_digest). - Your verifier supports both signature schemes if any endpoint is still subscribed to a legacy event.
- Handler responds
2xxin under 5 seconds. Heavy work goes to a queue. - Idempotency on
X-Liqfy-Delivery-Id(orevt_…for canonical events). - Unknown
type/eventnames are ignored gracefully (200 OK, no error). - Secret is loaded from a secret manager — never committed.
- An alert fires if no webhook is received within an expected window; use the pull API as a backstop.
#8. Operations
#List recent deliveries
curl "https://liqfy.com.br/v1/webhooks/deliveries?limit=25" \
-H "apikey: $LIQFY_API_KEY"Each entry includes the attempt count, last status code, last response body, and the next retry timestamp.
#Replay a failed or cancelled delivery
curl -X POST "https://liqfy.com.br/v1/webhooks/deliveries/<DELIVERY_ID>/replay" \
-H "apikey: $LIQFY_API_KEY"Resets the attempt counter and requeues immediately. Bulk replay is available at POST /v1/webhooks/deliveries/replay-bulk with an optional { status, endpointId, limit } filter (default limit 50, max 500).
#Rotate a secret
curl -X POST "https://liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>/rotate-secret" \
-H "apikey: $LIQFY_API_KEY"The new secret is returned once.
Rotation is an instantaneous server-side swap — every webhook Orbita Pay signs after a successful rotate-secret call uses the new secret. There is no server-side overlap window.
To rotate without dropped events, your verifier must temporarily accept both the old and new secret during your deploy:
// Try new first, fall back to old. Drop OLD_SECRET after the deploy soaks.
const ok = verify(req, NEW_SECRET) || verify(req, OLD_SECRET);
if (!ok) return res.status(401).end();Order of operations:
- Call rotate-secret → store the new secret alongside the old.
- Deploy your verifier with both secrets active.
- Soak for at least one minute (any in-flight retries clear).
- Remove the old secret on the next deploy.
#Update or delete an endpoint
curl -X PATCH "https://liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "apikey: $LIQFY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://merchant.example.com/hooks/liqfy-v2", "status": "ACTIVE"}'
curl -X DELETE "https://liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "apikey: $LIQFY_API_KEY"PATCH updates only the fields you send (url, events, status: ACTIVE/INACTIVE) and never returns the secret. DELETE permanently stops all future deliveries to that endpoint.
#Stats
curl "https://liqfy.com.br/v1/webhooks/stats" \
-H "apikey: $LIQFY_API_KEY"Returns delivery counts by state (PENDING, PROCESSING, DELIVERED, FAILED, CANCELLED) plus total.
#FAQ
Q: Can I have multiple endpoints? A: Yes. Register as many as you want — useful for separating staging, production, and observability sinks.
Q: Can an endpoint mix canonical and legacy events?
A: Yes. events on a single endpoint can include both families (e.g. ["charge.paid", "withdrawal.failed"]); each delivered event uses the envelope/signature scheme that matches its own name.
Q: Will payment.*/withdrawal.* events stop working?
A: Not yet, and no cutoff date has been announced. See the migration guide for the current state of the deprecation policy.