Skip to content

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 issuer tenant. See Authentication.
  • Your client must hold manage-ledger-callback to register anything, or view-ledger-callback to read the configuration. See Ledger roles.
  • A reachable HTTPS endpoint (or a plan to build one) that implements the operations in Step 2.
export TOKEN="eyJhbGci..."
export BASE="https://sandbox.qpay.quecto.com.br"

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:

curl -s $BASE/api/issuer \
  -H "Authorization: Bearer $TOKEN" | jq '.auth_url'

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:

{
  "secret": "whsec_9f3a1c8e7b2d4056a1b2c3d4e5f60718"
}

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:

X-QPay-Signature: t=1785312000,v1=3ba9f0c1d2e3...
  • t is a Unix timestamp in seconds.
  • v1 is 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:

curl -s $BASE/api/issuer/ledger-callback \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "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:

cd issuer/simulator
go run main.go -operation=run-transaction-server

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.