Receive Webhooks¶
This guide takes you from no integration to a running receiver that verifies every delivery and survives a secret rotation without losing an event.
The endpoint reference is the Notifications API; this page is the part that lives in your codebase.
1. Register a destination¶
From the console, open Webhooks and add an endpoint, or call the API directly:
curl -X POST https://sandbox.qpay.localhost/api/issuer/notifications/notifications/endpoints \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://api.yourbank.com/qpay/webhooks",
"description": "Push notification backend",
"event_types": ["transaction.*", "invoice.created"]
}'
The response contains your signing secret:
Store the secret now
This is the only time it exists in plaintext. No endpoint returns it again — if it is lost, your only option is to rotate.
Your URL must be https and must resolve to a public address. QPay refuses to connect to loopback, private (RFC1918) or link-local addresses, and it enforces that at connection time, not just at registration, so a hostname that later resolves to a private address is refused then too.
2. Verify every delivery¶
Each delivery arrives as a POST with these headers:
X-QPay-Event-Id: 3a1c0000-1111-2222-3333-444455556666
X-QPay-Event-Type: transaction.authorized
X-QPay-Signature: t=1786069330,v1=f869383656ec…
Content-Type: application/json
The signature is HMAC-SHA256 over "<timestamp>.<raw body>", hex-encoded.
Three things decide whether your verifier is correct:
- Sign the raw body bytes, exactly as received — before any JSON parsing. Re-serializing a parsed body reorders keys and changes whitespace, and the digest will not match.
- Check every
v1=entry, not just the first. The header carries more than one during a rotation (see below). This is the single most common integration bug, and it only shows up when you rotate. - Compare in constant time, and reject timestamps outside a tolerance of about five minutes to bound replay.
import hmac, hashlib, time
def verify(header: str, raw_body: bytes, my_secrets: list[str]) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > 300:
return False
# Every v1= entry, not just the first.
signatures = [p[3:] for p in header.split(",") if p.startswith("v1=")]
signed = f"{timestamp}.".encode() + raw_body
for secret in my_secrets:
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, s) for s in signatures):
return True
return False
Note that my_secrets is a list. That is what makes the next section painless.
3. Acknowledge, then process¶
Return 2xx as soon as the delivery is durable on your side — write it to a queue and answer. Anything else, including a redirect, is treated as a failure and retried on a ladder that spans roughly 45 hours.
Because delivery is at-least-once and unordered:
- Deduplicate on
X-QPay-Event-Id. The same business outcome always carries the same id, so a redelivery is safe to discard. - Do not infer order from arrival. Reconcile from each payload's own
statusandoccurred_at.
4. Rotate the secret without dropping an event¶
This is the procedure the show-once secret is designed around, and it requires no coordination with QPay and no downtime — provided your verifier loops over every v1= entry as above.
When you rotate, QPay keeps the previous secret valid for 24 hours. During that window every delivery is signed with both secrets, and the header carries one v1= entry per secret, newest first:
So a receiver still holding only the old secret keeps verifying, and one that has already switched to the new secret verifies too.
The procedure:
- Rotate in the console (or
POST …/secret/rotate) and copy the new secret. - Add it to your configuration alongside the existing one — a list, not a replacement. Deploy whenever suits you; there is no race to win.
- After the window has passed, drop the old secret.
The one way to get this wrong
A verifier that reads only the first v1= entry will reject every delivery from the moment you rotate until you deploy — because the first digest belongs to the new secret, which you do not have yet. The gap you are trying to avoid is created by the verifier, not by the timing. Loop over all entries and the gap does not exist.
If you overrun the 24 hours, deliveries signed only with the new secret start failing your verification. As long as you answer non-2xx, each one stays on the retry ladder for roughly 45 further hours, so deploying late still lets the queue drain. Past that they exhaust, and sustained failure disables the endpoint — re-enable it in the console and use Reenviar on the affected rows in the delivery log.
Optional: mutual TLS¶
If your security policy requires QPay to authenticate at the TLS layer as well, an endpoint can be configured to present a client certificate. Supply both halves at registration or on update:
{
"url": "https://api.yourbank.com/qpay/webhooks",
"event_types": ["transaction.*"],
"client_certificate_pem": "-----BEGIN CERTIFICATE-----\n…",
"client_key_pem": "-----BEGIN PRIVATE KEY-----\n…"
}
The key is write-only: it is stored encrypted and no route returns it. Reading an endpoint shows only mutual_tls_enabled and client_certificate_expires_at.
This adds to the signature, it does not replace it
Most receivers terminate TLS at a load balancer, and past that hop the connection identity is gone — your application still cannot tell a genuine delivery from something injected inside your perimeter. The HMAC signature can, so keep verifying it. mTLS is worth its cost when your policy requires the gate in front of the application, not as a way to skip step 2.
Expiry is a total outage with no grace period
Unlike the signing secret, a client certificate has no window where old and new are both valid. When it lapses, every delivery fails the handshake and the retry ladder cannot tell that apart from your service being down. Watch client_certificate_expires_at, which the API returns and the console displays, and replace the pair before it runs out:
curl -X PATCH …/endpoints/{id} -d '{
"replace_mutual_tls": true,
"client_certificate_pem": "…",
"client_key_pem": "…"
}'
replace_mutual_tls is required — without it the certificate fields are ignored. Sending it with both PEMs omitted removes mutual TLS entirely.
Enabling it is safe and reversible: TLS presents a client certificate only when the server asks for one, so configuring it against a receiver that does not require mTLS changes nothing.
5. Test the whole path¶
Use Enviar teste in the console, or POST …/endpoints/{id}/test. It sends an endpoint.test event through the real pipeline — same signature, same retries, same delivery log — so a success proves the entire path, not a simulation of it.
Then watch the delivery log: status, attempt count, the receiver's HTTP status and error text, and when the next attempt is due. Concluded deliveries can be re-sent from there; the event keeps its original id, so a receiver that already processed it will deduplicate.
What a transaction event looks like¶
{
"id": "71062122-217c-5d7a-930d-f5d1b0d65b53",
"type": "transaction.authorized",
"occurred_at": "2026-08-06T14:02:09Z",
"api_version": "2026-08",
"tenant_id": "b7a9…",
"issuer_id": 7,
"data": {
"transaction": {
"status": "authorized",
"billing_amount": "4590",
"billing_currency_code": "986",
"merchant": { "name": "PADARIA SILVA", "city": "SAO PAULO" },
"transaction_at": "2026-08-06T14:02:09Z"
},
"card": { "card_id": 42, "pan_last4": "1234", "reference_id": "cliente-4711" },
"cardholder": { "cardholder_id": "9d2f…" }
}
}
That is everything needed to render "Compra aprovada · R$ 45,90 · PADARIA SILVA · cartão final 1234" and route it to a device — via cardholder_id, or via reference_id if you key devices by your own customer identifier.
A few conventions worth knowing:
- Amounts are decimal strings in minor units, and currency codes are strings too (
"986"), because ISO 4217 numeric codes can carry a leading zero that an integer would destroy. - Absent fields are omitted, not null. A card created outside the QPay ledger has no
cardholderblock at all; that is ordinary, not an error. api_versionchanges only on a breaking payload change. New fields can appear at any time, so ignore ones you do not recognise.- The card number is never sent beyond a masked form and the last four digits, and cardholder personal data is never included — you already hold it, keyed by
cardholder_idor your ownreference_id.