How to Connect Your Own Ledger¶
If you already run a ledger — a core banking system, an accounting engine, your own double-entry service — you do not have to migrate it onto QPay. Instead, you implement the Ledger Callback API on your side and register where QPay should call you.
This guide covers the registration and the security setup. The alternative is using QPay's internal ledger, which requires no implementation work from you at all.
Time to complete: ~15 minutes, plus implementing the contract
API: Issuer API — base URL https://sandbox.qpay.quecto.com.br
Prerequisites¶
- An access token for an
issuertenant. See Authentication. - Your client must hold
manage-ledger-callbackto register anything, orview-ledger-callbackto read the configuration. See Ledger roles. - A reachable HTTPS endpoint (or a plan to build one) that implements the operations in Step 2.
Overview¶
flowchart TD
A[Implement the contract] --> B[Register the callback base URL]
B --> C[Import your TLS trust anchor]
C --> D[Generate a signing secret]
D --> E[Verify signatures on your side]
E --> F[Read the configuration back]
F --> G[Point auth_url at your ledger]
G --> H[Test against the Issuer Simulator]
Step 1 — Understand the switch¶
Your issuer record carries an auth_url field, and it alone decides which ledger the processor calls.
auth_url |
The processor calls |
|---|---|
| Empty | QPay's internal ledger |
| Set | Your ledger, at the registered callback configuration |
The choice is reversible: sending an empty string clears auth_url and routes authorization straight back to QPay's internal ledger. Nothing else about your integration changes.
Check where you stand today:
Register the callback configuration before switching over
Do Steps 3–5 first, then Step 7. An issuer with auth_url set but no callback configuration registered still works — but the calls go out over plain HTTP, unsigned and with no TLS trust pinned. That is a legacy compatibility path, not a destination.
Step 2 — Implement the contract¶
The Ledger Callback API has eight operations. QPay is always the client — it never exposes callback endpoints of its own for this flow. Six come from the processor during real-time authorization; two come from clearing, after the fact.
| Operation | Method | Path | Success | Called by | Purpose |
|---|---|---|---|---|---|
| Authorization | POST | /authorization |
200 | Processor | Approve or decline a purchase and place a hold |
| Cancelation | POST | /cancellation |
200 | Processor | Void a hold that has not settled |
| Reversal | PUT | /reversal |
204 | Processor | Undo a transaction, before or after settlement |
| Balance inquiry | POST | /balance-inquiry |
200 | Processor | Report ledger and available balances |
| Account verification | POST | /account-verification |
200 | Processor | Confirm the account exists and is active |
| Report of denial | POST | /report-denied |
204 | Processor | Notify you that QPay declined locally |
| Settlement | POST | /settlement |
200 | Clearing | Convert a hold into settled debt or a debit |
| Refund | POST | /refund |
200 | Clearing | Post a refund, with or without an original |
Every processor-facing request carries the amount, the transaction ID, the original transaction ID, a transaction UUIDv7, the account for the card application, and your ledger's float account. The transaction UUIDv7 is your idempotency key — network retries reuse it, so the same UUID must never be applied twice.
HTTP 200 does not mean success
On /cancellation, /settlement and /refund, a failure is reported as HTTP 200 with a declining response_code, not as an error status. Any client of this contract — and any implementation of it — must read response_code rather than treating the status code as the verdict. /authorization, /balance-inquiry and /account-verification return errors as 5xx.
The ResponseCode enum is the whole contract
The values enumerated in the spec — APPROVED, INSUFFICIENT_BALANCE, EXPIRED_CARD, RESTRICTED_CARD, ISSUER_NOT_CAPABLE_TO_PROCESS and the rest — are the complete set. Returning anything outside it is not a valid response.
Amounts are n12 zero-padded strings, with decimal places determined by the currency: R$ 1,000.02 is "000000100002". Currencies are ISO 4217 numeric codes.
Step 3 — Register the callback base URL¶
curl -s -X PUT $BASE/api/issuer/ledger-callback \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"base_url": "https://ledger.myissuer.com/callbacks"}' | jq .
Example response:
{
"base_url": "https://ledger.myissuer.com/callbacks",
"has_tls_certificate": false,
"created_at": "2026-07-29T14:02:11Z",
"updated_at": "2026-07-29T14:02:11Z"
}
QPay appends the operation paths from Step 2 to this base URL.
Step 4 — Import your TLS trust anchor¶
Register either your ledger's server certificate or the CA certificate that signed it. QPay pins outbound calls to it, which is what upgrades the connection from plain HTTP to verified HTTPS.
The PEM must be base64-encoded in the request body:
CERT_B64=$(base64 -w 0 ledger-server.pem)
curl -s -X PUT $BASE/api/issuer/ledger-callback/certificate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"certificate_pem\": \"$CERT_B64\"}" | jq .
Example response:
{
"base_url": "https://ledger.myissuer.com/callbacks",
"has_tls_certificate": true,
"tls_certificate_expires_at": "2027-07-29T00:00:00Z",
"created_at": "2026-07-29T14:02:11Z",
"updated_at": "2026-07-29T14:07:35Z"
}
Watch tls_certificate_expires_at and re-import before it passes.
Step 5 — Generate a signing secret¶
QPay signs the body of every outbound call to your ledger with a shared secret that only you and QPay hold.
curl -s -X POST $BASE/api/issuer/ledger-callback/secret/rotate \
-H "Authorization: Bearer $TOKEN" | jq .
Example response:
The secret is shown exactly once
It cannot be retrieved again after this response. Store it in your secret manager immediately. There is no console UI for rotation today — use this endpoint. Call it again to rotate; the same response shape returns the new secret.
GET /issuer/ledger-callback will afterwards report has_active_secret: true and secret_last_rotated_at, but never the secret itself.
Step 6 — Verify the signature on your side¶
Every request QPay sends your ledger carries:
tis a Unix timestamp in seconds.v1is the lowercase hex HMAC-SHA256 of the string"<t>.<raw request body>", keyed with your secret.
Verify it against the raw body bytes, before any JSON parsing or re-serialisation — re-encoding changes key order and whitespace and will break the digest. Compare with a constant-time comparison, and reject requests whose t is outside your tolerance window to prevent replay.
import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp, signature = parts["t"], parts["v1"]
if abs(int(time.time()) - int(timestamp)) > tolerance_seconds:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
Verification is yours to enforce
QPay signs every request, but only your ledger can reject an unsigned or badly signed one. Treat a missing or invalid X-QPay-Signature as a rejection — otherwise the signature provides no protection.
Step 7 — Point auth_url at your ledger¶
With the callback configuration in place, switch authorization over:
curl -s -X PUT $BASE/api/issuer/auth-url \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"auth_url": "https://ledger.myissuer.com/callbacks"}' | jq '.auth_url'
To go back to QPay's internal ledger, send an empty string:
curl -s -X PUT $BASE/api/issuer/auth-url \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"auth_url": ""}' | jq '.auth_url'
Confirm the full configuration at any time:
{
"base_url": "https://ledger.myissuer.com/callbacks",
"has_tls_certificate": true,
"tls_certificate_expires_at": "2027-07-29T00:00:00Z",
"has_active_secret": true,
"secret_last_rotated_at": "2026-07-29T14:11:02Z",
"created_at": "2026-07-29T14:02:11Z",
"updated_at": "2026-07-29T14:07:35Z"
}
Step 8 — Test against the Issuer Simulator¶
The bundled Issuer Simulator is a working reference implementation of the six processor-facing operations, backed by simple JSON file storage. Run it to see the exact request shapes QPay sends and the responses it expects, and to compare against your own implementation:
It serves the contract on port 8085. Point a local auth_url at it to exercise the authorization path end to end without touching your production ledger.
The simulator covers the processor operations
It implements the six operations the processor calls. /settlement and /refund come from clearing and are not part of the simulator's surface — implement and test those against your own ledger.
Related¶
- Ledger Callback API reference — the full interactive contract, every field documented
- QPay Ledger Core — what a ledger is responsible for, and the internal alternative
- Use the QPay Ledger — the no-implementation path
- Ledger roles — which role each endpoint requires