Skip to content
QAdocs
PTEN
Go to dashboard

Webhooks

Migrando de um evento legado? Veja o guia de migração.

Webhooks are how Neozentry QA 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. Neozentry QA sends a separate POST for each occurrence, always to the same URL.

Some other PSPsNeozentry QA
/webhook/cashin, /webhook/cashout, /webhook/refund (path per type)https://your-shop.com/hooks/qa + event list at registration
One HTTP route per eventOne route, many events; the type field (or legacy event) distinguishes them

Recommended generic URL:

text
https://your-domain.com/hooks/qa

Avoid 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:

js
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?

NeedEventFamily
Charge paidcharge.paidcanonical
Charge failed / expiredcharge.failed / charge.expiredcanonical
Charge refundcharge.refunded (legacy alias payment.refunded)canonical
Payout settled / failedpayout.paid / payout.failedcanonical (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:

json
{
  "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 Neozentry QA 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)

  1. Integrations → Webhooks → New endpoint
  2. Generic HTTPS URL (e.g. …/hooks/qa)
  3. Check at least: charge.created, charge.paid, charge.failed, charge.expired (+ charge.refunded if you need refunds)
  4. Save the secret (shown once)
  5. 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:

json
{
  "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.object is the same public charge/payout shape the REST API returns (serialized through the same allowlist as GET /v1/charges/:id — no provider name, cost, secret, or raw PSP payload can appear here).
  • Always handle unknown type values 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):

bash
curl https://qa.liqfy.com.br/v1/webhooks/event-catalog
EventWhen it fires
charge.createdCobrança criada (Pix gerado, aguardando pagamento).
charge.paidCobrança paga e confirmada.
charge.failedCobrança falhou ou foi cancelada.
charge.expiredCobrança expirou sem pagamento.
charge.refundedCobrança reembolsada (total ou parcial) — reembolso do lojista, MED ou devolução automática (trava de CPF).
payout.createdSaque solicitado.
payout.paidSaque liquidado com sucesso.
payout.failedSaque falhou ou foi rejeitado.
payment.refundedLegacy 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.paid can follow a charge.expired. If the acquirer confirms a payment only after the charge already expired (delayed webhook / lagging status API), Neozentry QA opens a manual verification case and, once an operator settles it, delivers charge.paid for that same charge. The later charge.paid is the final state — treat it as paid, even though you had received charge.expired before. Never rely on charge.expired being terminal.

#2. Register your endpoint

Endpoint POST /v1/webhooks/endpoints

Headers

text
apikey: qa_live_...
Content-Type: application/json

Body

json
{
  "url": "https://merchant.example.com/hooks/qa",
  "events": ["charge.paid", "charge.failed", "charge.expired"]
}

Response 201 Created

json
{
  "id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
  "url": "https://merchant.example.com/hooks/qa",
  "events": ["charge.expired", "charge.failed", "charge.paid"],
  "secret": "b8f3a9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
  "status": "ACTIVE"
}

Save the secret immediately. 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 eventCanonical replacement
payment.completedcharge.paid
payment.failedcharge.failed
payment.expiredcharge.expired
payment.refundedcharge.refunded
withdrawal.completedpayout.paid
withdrawal.failedpayout.failed

Legacy topic aliasespayment.status_changed expands to ["payment.completed", "payment.failed"]; withdrawal.status_changed expands 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:

json
{
  "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 Neozentry QA'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.

HeaderSchemeApplies to
X-QA-Signaturet=<unix-seconds>,v1=<hex> — signed payload is "<t>.<rawBody>"Canonical events (charge.*, payout.*)
X-QA-Signaturesha256=<hex> — signed payload is the raw body aloneLegacy events (payment.*, withdrawal.*, kyc.*, and the account-lifecycle events above)
X-QA-Delivery-IdOpaque id, stable across every retry of the same deliveryBoth
X-QA-Event-TypeMirrors the delivered event nameBoth

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

js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.QA_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/qa',
  // capture raw body — express.json() strips it
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('X-QA-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-QA-Delivery-Id') });
  },
);

Prefer not to hand-roll this? @qa/node's qa.webhooks.verify(rawBody, signature, secret) / .parse(...) handle both schemes (canonical and legacy) for you — see packages/sdk-node/README.md.

#PHP — legacy scheme (existing payment.*/withdrawal.* subscriptions)

php
$secret = getenv('QA_WEBHOOK_SECRET');
$raw    = file_get_contents('php://input');
$sig    = $_SERVER['HTTP_X_QA_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

python
import hmac, hashlib, os
from flask import request, abort

SECRET = os.environ['QA_WEBHOOK_SECRET'].encode()

@app.post('/hooks/qa')
def qa_hook():
    raw = request.get_data()  # bytes, untouched
    sig = request.headers.get('X-QA-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 2xx response 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, default 21_600_000).
  • Maximum attempts 15 (WEBHOOK_MAX_ATTEMPTS). After the last failed attempt the delivery moves to CANCELLED (dead-lettered) — it is not deleted, and can be replayed manually.
  • 429 handling If your endpoint returns 429 with a Retry-After header (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-QA-Delivery-Id is 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-QA-Delivery-Id (or the canonical event's id / evt_…).

#Idempotent handler pattern

js
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.

bash
curl "https://qa.liqfy.com.br/v1/webhooks/events?since=2026-07-23T00:00:00Z&limit=50" \
  -H "apikey: $QA_API_KEY"
json
{
  "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) — pass nextCursor back as cursor for 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.

bash
curl -X POST "https://qa.liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>/test" \
  -H "apikey: $QA_API_KEY"

The test delivery currently always sends the legacy payment.completed sample (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 2xx in under 5 seconds. Heavy work goes to a queue.
  • Idempotency on X-QA-Delivery-Id (or evt_… for canonical events).
  • Unknown type/event names 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

bash
curl "https://qa.liqfy.com.br/v1/webhooks/deliveries?limit=25" \
  -H "apikey: $QA_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

bash
curl -X POST "https://qa.liqfy.com.br/v1/webhooks/deliveries/<DELIVERY_ID>/replay" \
  -H "apikey: $QA_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

bash
curl -X POST "https://qa.liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>/rotate-secret" \
  -H "apikey: $QA_API_KEY"

The new secret is returned once.

Rotation is an instantaneous server-side swap — every webhook Neozentry QA 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:

js
// 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:

  1. Call rotate-secret → store the new secret alongside the old.
  2. Deploy your verifier with both secrets active.
  3. Soak for at least one minute (any in-flight retries clear).
  4. Remove the old secret on the next deploy.

#Update or delete an endpoint

bash
curl -X PATCH "https://qa.liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>" \
  -H "apikey: $QA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://merchant.example.com/hooks/qa-v2", "status": "ACTIVE"}'

curl -X DELETE "https://qa.liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>" \
  -H "apikey: $QA_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

bash
curl "https://qa.liqfy.com.br/v1/webhooks/stats" \
  -H "apikey: $QA_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.