Introduction

The Zippy Sandbox is a self-service testing tool that emulates the Zippy Pay gateway so you can exercise the integration end-to-end without touching production infrastructure. This guide is a companion to the official Zippy Pay API documentation — it adds sandbox-specific details and ready-to-paste snippets, but never contradicts the production contract. If anything here disagrees with the official docs, the official docs win.

What the sandbox emulates

  • Pay-in and pay-out endpoints with the same validation rules as production.
  • RS256 JWT authentication for pay-out.
  • Asynchronous callbacks to your merchant URLs with the production payload shape.

Sandbox-only extras (not part of production)

  • Manual accept / reject actions from the Transactions UI, where accepting a pending transaction opens a modal that lets you confirm or alter the amount that travels in the callback. Used to drive the integration check flows.
  • "Resend Callback" button on the transaction detail page, used to exercise your idempotency logic without changing any id.

What it does NOT do

  • Move real money or talk to banks, card networks, or PSPs.
  • Enforce rate limits, throttling, or anti-fraud rules present in production.

Use the Integration Check page at any time to see which integration steps your backend has already exercised.

Quick start

  1. Open the Settings page and load your RSA-2048 public key (PEM) and a custom secret_key. The sandbox seeds defaults so you can start testing immediately.
  2. Configure your own callback URLs in Settings — pay-in and pay-out URLs are separate.
  3. Create a pay-in using the snippet below. Copy, paste, send.
  4. Open the Transactions page, select your transaction, and accept or reject it. A callback is dispatched to the URL you configured.
  5. Verify in Callbacks that the payload reached your backend and that your backend responds with 2xx.
Loading...

Integration flows

The full round-trip has four phases.

1. Pay-in

Merchant posts a pay-in request. The sandbox returns a hosted checkout URL. The customer is redirected there; the operator (you, in the sandbox) accepts or rejects it. A callback is sent to callback_url_payin with the MD5-signed payload.

2. Pay-out

Merchant posts a pay-out request with a Bearer JWT signed with the merchant's private key (RS256). The sandbox verifies the JWT using the PEM you uploaded in Settings. The payload must contain at least id (mirroring transactionId) and amount, otherwise the request is refused as manipulated.

3. Transaction status lookup

GET /merchants/:merchantId/transactions/:id returns the current state of a transaction by its merchantRequestId. Protected with MD5 signing — MD5(merchantId + transactionId + secret_key) — in the X-Signature header. The merchantId travels in the URL, so no separate header is needed. Use it as a fallback to the asynchronous callbacks (reconciliation jobs, status refresh after a retry, etc.).

Idempotency & retries

A re-posted pay-in or pay-out request with the same transactionId does not create a second transaction:

  • Pay-in: while the transaction is still pending, the sandbox returns the same zippyId and checkout URL on every retry — safe to retry after a network timeout. Once the transaction is resolved, a further retry is rejected with 403 { status: "error", description: "requires a new transactionId for this payIn" }.
  • Pay-out: per the official docs, any reuse of transactionId is rejected with 403 { status: "error", description: "requires a new transactionId for this payOut" }.
  • Callback resend: use the Resend Callback button on the transaction detail page (or POST /sandbox/transactions/:id/resend-callback) to re-deliver the same callback payload — the MERCHANTREQUESTID and ZIPPYID stay exactly the same, letting you exercise your idempotency logic without mutating any state.

4. Callback verification

Every resolved transaction produces a callback to the URL you configured in Settings. Always verify the SIGN field before trusting the payload — the verification snippet is in the Security section.

Callback HTTP Headers

The sandbox sends the callback as a JSON POST. Your backend should expect:

  • Content-Type: application/json — always sent by the sandbox; the body is a JSON object containing MERCHANTREQUESTID, ZIPPYID, AMOUNT, CODE, SIGN and related fields.

No Authorization header is sent. Authentication is enforced through the MD5 SIGN field in the body (see Security).

The callback AMOUNT may differ from the amount you sent in the original request. Per the official docs, the callback AMOUNT is the final processed amount and is the authoritative value for settlement. Differences can be legitimate (taxes, fees, currency conversion) or indicate tampering. Your backend should: (1) treat the callback AMOUNT as the source of truth for the actual amount processed, and (2) apply your own business policy when it diverges from the requested amount (flag for review, require manual approval, reject, etc.). To simulate this scenario, accept a pending transaction in the sandbox Transactions UI and change the pre-filled amount before confirming — the callback will carry the altered value while the transaction record keeps the original.
Finalization callbacks can arrive more than once for the same transaction, and the final status can change. A transaction that arrived with CODE = 12 (error) may be followed by a later callback with CODE = 0 (approved), and the reverse is also possible (an approved transaction can later be reversed to error). Per the official docs, "transaction statuses may change after the initial callback response." Your backend must: (1) key persistence on MERCHANTREQUESTID (or ZIPPYID) and idempotently accept multiple callbacks for the same transaction, (2) update the stored status to reflect the latest callback rather than locking on the first one, and (3) only trigger final business effects (fulfillment, payout release, refunds) on the authoritative latest state. Use the sandbox Callbacks page to re-send a callback manually and verify your idempotency + state-transition logic.

API reference

POST /pay

Create a pay-in transaction.

HTTP Headers

  • RequiredContent-Type: application/json
Loading...

POST /getPayOutParams

Return the bank list, account types, and document types available for pay-out in a given country. Call it before a pay-out so your UI surfaces valid bankId, typeAccountId, and typeDocumentId values — pay-outs with unknown values are rejected with 400.

HTTP Headers

  • RequiredContent-Type: application/json
Loading...

POST /payOut

Create a pay-out transaction. Requires a Bearer JWT signed with your RSA private key (RS256). The JWT payload must match the request body on id and amount.

HTTP Headers

  • RequiredContent-Type: application/json
  • RequiredAuthorization: Bearer <JWT> (RS256-signed; see Security for the key-pair generation snippet)
Loading...

GET /merchants/:merchantId/transactions/:id

Fetch the current state of a transaction by its merchantRequestId (same value you sent as transactionId). The merchantId is taken from the URL path and must match the one assigned to your sandbox instance.

HTTP Headers

  • RequiredX-Signature: <md5_lowercase_hex> — MD5 of merchantId + transactionId + secret_key (lowercase hex).
Loading...

Response (200)

On success, the gateway returns a JSON body with the following 6 fields:

  • code (number) — Zippy 3-state status code: 0 = success, 9 = pending, 12 = error.
  • country (string) — ISO-3166 alpha-2 country code, e.g. CL.
  • currency (string) — Currency code of the transaction, e.g. CLP.
  • amount (string) — Amount serialized as a string to preserve decimal precision, e.g. "1000.00".
  • transactionId (string) — Echo of the merchantRequestId you sent.
  • zippyId (string) — Internal Zippy transaction identifier.

Errors

All error responses share the shape { "error": "<message>" }. The possible error strings are exact and byte-stable:

  • 401 signature is required — the X-Signature header is missing.
  • 401 invalid merchant id — the :merchantId path segment does not match the merchant assigned to your sandbox instance.
  • 401 merchant not configured — your merchant has no secret_key configured. Set one in Settings.
  • 401 invalid signature — the MD5 you sent does not match md5(merchantId + transactionId + secret_key) in lowercase hex.
  • 404 transaction not found — no transaction with that merchantRequestId exists under your merchant.

Channels

A channel is the concrete payment method that a merchant exposes for a direction (PayIn or PayOut) in a given country. The payMethod field (PayIn) or payoutMethod field (PayOut) identifies which channel processes the transaction. Supported channels depend on the (country, direction) pair — they are not global, and the PayIn catalogue is broader than the PayOut catalogue.

Naming convention. Channel codes are camelCase (bankTransfer, wallet, bankCard, pix, SPEI, …); the direction is labelled PayIn / PayOut; countries travel as ISO-3166-1 alpha-2 (CL, PE, CO, BR, …); the currency is derived from the country (ISO-4217). The canonical catalogue lives in the official Zippy Pay documentation; the sandbox emulates the subset defined in src/common/enums.ts.

PayIn methods

Zippy supports multiple PayIn methods, and their availability varies by country. Use this table to identify which payment methods are currently supported in each country.

All methods listed below refer to PayIn operations (the payMethod field of POST /pay).

MethodSupported countries
bankCardCL, PE, AR, BO, BR, EC, CO, PA, GT, CR, MX, PY, UY, SV, HN, DO, TT, NI
bankTransferCL, PE, AR, EC, CO, PY
cashPE, EC, CO, PA, GT, CR, AR
machCL
walletBO, CO
mobileMoneyEC
transfiyaCO
binancepayAR
pixBR
SPEIMX
friGT
khipuAR

PayIn request body shape

The body of POST /pay has common fields (merchantId, transactionId, amount, email, name, documentId, timestamp, url_OK, url_ERROR) and three values that change per channel: country, currency (inferred from country) and payMethod. The full multi-language snippet lives under API reference → POST /pay; below is the channel-specific minimal shape:

{
  "country": "BR",            // any country listed above for the chosen payMethod
  "currency": "BRL",          // ISO-4217 of the country
  "payMethod": "pix",         // any code from the table (camelCase)
  // ...common fields...
  "objData": {
    "phone": "+55XXXXXXXXXXX", // required by some countries
    "merchantUrl": "https://example.com"
  }
}

PayOut methods

Each country supports specific withdrawal methods based on the local payment infrastructure. The PayOut catalogue is narrower than the PayIn catalogue — fewer methods, fewer countries. All methods listed below refer to PayOut operations (the payoutMethod field of POST /payOut).

MethodSupported countries
bankTransferCL, PE, EC, CO, GT
walletCO
binancepayAR
cbucvuAR
SPEIMX

Common PayOut request shape

Every POST /payOut request must carry the JWT in the Authorization header (see Security) and, regardless of payoutMethod, the sandbox validates the following fields:

  • merchantId, transactionId, amount, email, name, timestamp — common to every transaction.
  • country, currency, payoutMethod — identify which channel handles the withdrawal.
  • bankId, numAccount, typeAccountId, typeDocumentId, documentId, phone_number — beneficiary data. The valid values for bankId, typeAccountId and typeDocumentId come from /getPayOutParams.
  • objData.merchantUrl — caller URL, propagated to the callback.

Discovering enabled channels

The PayOut table above tells you which methods exist per country, but a given merchant only has a subset enabled, and for each (country, payoutMethod) the valid bank list, account types and document types are different. Always discover them at runtime — never hard-code from a snapshot of the catalogue.

  • POST /getPayOutParams — returns the valid bankList, typeAccount (typeAccountId codes) and customerId (typeDocumentId codes) for a (country, payoutMethod) pair. The sandbox uses the exact same data structure as production.
  • The sandbox rejects with 400 and:
    • invalid payoutMethod for {country}. Available methods: … (PayOut) when the channel is not in PAYOUT_METHODS[country].
    • payMethod field wrong - admissible field: … (PayIn) when the channel is not in PAYIN_METHODS[country].

The "admissible" list mirrors the catalogue the sandbox emulates (src/common/enums.ts), which in turn mirrors developers.zippy-app.com.

PayOut examples

The three examples below highlight the differences between flows that look similar on the wire but have very different beneficiary semantics. All three start with a /getPayOutParams call to retrieve the per-country catalogue.

PayOut — Peru, bankTransfer

The classic LATAM withdrawal: send money to a beneficiary's bank account. The typeDocumentId in Peru distinguishes DNI / CE / RUC; typeAccountId distinguishes Cuenta Corriente vs Cuenta de Ahorro.

Step 1. Discover the valid catalogue for PE:

POST /getPayOutParams
{ "merchantId": "<your-id>", "country": "PE", "payoutMethod": "bankTransfer" }

// 200 OK
{
  "bankList": [
    { "bankName": "Banco de la Nacion",       "bankId": "Banco de la Nacion" },
    { "bankName": "Banco Continental",        "bankId": "Banco Continental" },
    { "bankName": "Banco de Credito del Peru","bankId": "Banco de Credito del Peru" },
    { "bankName": "Interbank",                "bankId": "Interbank" },
    { "bankName": "Scotiabank Peru",          "bankId": "Scotiabank Peru" },
    { "bankName": "Banco Pichincha",          "bankId": "Banco Pichincha" },
    { "bankName": "Banco Falabella Peru",     "bankId": "Banco Falabella Peru" },
    { "bankName": "Banco GNB",                "bankId": "Banco GNB" }
  ],
  "typeAccount": [
    { "type": "Cuenta Corriente",  "typeAccountId": "0" },
    { "type": "Cuenta de Ahorro",  "typeAccountId": "1" }
  ],
  "customerId": [
    { "typeDocumentId": "1", "id": "DNI" },
    { "typeDocumentId": "2", "id": "CE" },
    { "typeDocumentId": "3", "id": "RUC" }
  ]
}

Step 2. Send the PayOut. Pick exact values from the catalogue above:

POST /payOut
Authorization: Bearer <your-signed-JWT>

{
  "merchantId": "<your-id>",
  "transactionId": "<your-unique-tx>",
  "country": "PE",
  "currency": "PEN",
  "payoutMethod": "bankTransfer",
  "amount": "150.00",
  "email": "beneficiario@example.com",
  "name": "Juan Perez Lopez",
  "typeDocumentId": "1",                // "1" = DNI per the catalogue
  "documentId": "75741239",
  "bankId": "Banco de la Nacion",       // exact value from bankList
  "typeAccountId": "0",                 // "0" = Cuenta Corriente
  "numAccount": "00120000123456789012",
  "phone_number": "+51987654321",
  "timestamp": "1726000000",
  "objData": { "merchantUrl": "https://example.com" }
}

PayOut — Colombia, wallet

Colombia has two PayOut channels: bankTransfer (regular cash-out) and wallet (Nequi / DaviPlata / similar). Important: at the wire level the sandbox validates the same fields for both — including bankId, typeAccountId and numAccount. The semantic difference lives in the catalogue you query for each method, not in the request shape.

Step 1. Discover the catalogue for CO + wallet:

POST /getPayOutParams
{ "merchantId": "<your-id>", "country": "CO", "payoutMethod": "wallet" }

// 200 OK
{
  "bankList": [
    { "bankName": "Nequi",          "bankId": "Nequi" },
    { "bankName": "Bancolombia",    "bankId": "Bancolombia" },
    // …+5 more banks (Banco de Bogota, Davivienda, BBVA, Banco de Occidente, Banco Popular)
  ],
  "typeAccount": [
    { "type": "Cuenta Corriente",  "typeAccountId": "0" },
    { "type": "Cuenta de Ahorro",  "typeAccountId": "1" }
  ],
  "customerId": [
    { "typeDocumentId": "1", "id": "CC" },   // Cédula de Ciudadanía
    { "typeDocumentId": "2", "id": "NIT" },
    { "typeDocumentId": "3", "id": "CE" }
  ]
}

Step 2. Send the PayOut. For Nequi-style wallets, set bankId to the wallet provider (e.g. "Nequi") and pass the user's phone in phone_number — that's how the upstream PSP routes the funds:

POST /payOut
Authorization: Bearer <your-signed-JWT>

{
  "merchantId": "<your-id>",
  "transactionId": "<your-unique-tx>",
  "country": "CO",
  "currency": "COP",
  "payoutMethod": "wallet",
  "amount": "50000.00",
  "email": "beneficiario@example.com",
  "name": "Maria Garcia Rojas",
  "typeDocumentId": "1",                // "1" = CC
  "documentId": "1020304050",
  "bankId": "Nequi",                    // wallet provider as bankId
  "typeAccountId": "1",                 // "1" = Cuenta de Ahorro
  "numAccount": "3001234567",           // for wallets this is typically the phone-as-account
  "phone_number": "+573001234567",
  "timestamp": "1726000000",
  "objData": { "merchantUrl": "https://example.com" }
}
Why wallet still needs bankId / numAccount / typeAccountId? The sandbox's PayOut validator runs the same required-field check across every payoutMethod. Sending a wallet PayOut without those fields returns 400 missing field bankId (or similar). The wallet provider IS the bankId; the user's phone (or phone-as-account-number) goes in numAccount. Future versions may relax this — for now treat the request shape as identical to bankTransfer and let the catalogue distinguish.

PayOut — Brazil, pix

Catalogue caveat. Per the official Zippy Pay catalogue, pix is currently a PayIn-only method in BR. The PayOut catalogue does not list BR as a supported country, so the sandbox emulator rejects POST /payOut requests for BR with:

400 invalid payoutMethod for BR. Available methods: (empty list)

The example below illustrates the request shape that would apply if Pix PayOut is enabled in the future. It is not executable against the current sandbox emulator.

Pix uses a Pix key (CPF / CNPJ / email / phone / random EVP) instead of a bank account number. When enabled, the expected mapping to the existing PayOut shape is:

  • bankId — destination bank (from /getPayOutParams BR bankList: Banco do Brasil, Bradesco, Itaú Unibanco, Caixa, Santander Brasil, Nubank, Banco Inter, Banrisul).
  • typeAccountId"0" Conta Corrente or "1" Conta Poupança.
  • typeDocumentId"1" CPF or "2" CNPJ.
  • numAccount — the Pix key (CPF/CNPJ/email/phone/EVP) typed as a string. Pix routes by key, not by branch+account number.
  • phone_number — beneficiary phone, even if a different key is used.
POST /payOut
Authorization: Bearer <your-signed-JWT>

{
  "merchantId": "<your-id>",
  "transactionId": "<your-unique-tx>",
  "country": "BR",
  "currency": "BRL",
  "payoutMethod": "pix",
  "amount": "100.00",
  "email": "beneficiario@example.com",
  "name": "João Silva Souza",
  "typeDocumentId": "1",                // "1" = CPF
  "documentId": "12345678901",
  "bankId": "Nubank",                   // destination bank for the Pix transfer
  "typeAccountId": "0",                 // "0" = Conta Corrente
  "numAccount": "joao@example.com",     // ← Pix key (could be CPF, email, phone, or EVP)
  "phone_number": "+5511999999999",
  "timestamp": "1726000000",
  "objData": { "merchantUrl": "https://example.com" }
}

Security

About the naming: in the official Zippy Pay documentation this value is called key_callback. The sandbox labels it secret_key ("Secret Key") because it conveys the role of the credential more clearly to integrators. Both names refer to the same value.

Generate an RSA key pair for pay-out

Payout JWTs are signed with RS256. Produce a 4096-bit key pair with OpenSSL:

Loading...
Upload the contents of public.pem in Settings. Never send the private key to the sandbox — keep it only on your backend.

Verify the MD5 SIGN on every callback

The payout callback SIGN is computed as MD5(MERCHANTREQUESTID + AMOUNT + CODE + secret_key) per the official docs; the sandbox applies the same formula to pay-in callbacks as well for symmetry. Compute it on your side and compare before trusting the payload.

Loading...

Troubleshooting

When a sandbox call fails, check these first:

401 on POST /payOut

  • Confirm the Authorization header is present and starts with Bearer (note the trailing space).
  • Confirm the JWT is signed with RS256, not HS256.
  • Confirm the PEM uploaded in Settings matches the private key you used to sign.
  • Confirm the JWT payload has id equal to transactionId and amount equal to the body amount.

401 on GET /merchants/:merchantId/transactions/:id

  • Confirm the URL includes your merchantId as the path segment after /merchants/.
  • Confirm the X-Signature header is present.
  • Confirm you computed the signature with the same secret_key currently active in Settings.
  • Confirm the signature is lowercase hex and uses MD5, not SHA-1.

Callback never reaches your backend

  • Confirm callback_url_payin or callback_url_payout is set in Settings.
  • Inspect Callbacks to see response status and body as received by the sandbox.
  • Ensure your endpoint returns a 2xx — non-2xx is logged but not retried in the sandbox.

Duplicate callbacks for the same transaction

  • This is expected. Zippy Pay can send multiple finalization callbacks for the same MERCHANTREQUESTID, and the final CODE can flip between 0 (approved) and 12 (error) after the initial delivery.
  • Make your callback handler idempotent: persist by MERCHANTREQUESTID (or ZIPPYID) and let later callbacks update the stored status.
  • Gate irreversible business actions (fulfillment, payout release) on the latest authoritative state, not on the first callback received.
  • Use the Callbacks page in the sandbox to re-send a callback manually and validate your idempotency + state-transition logic.

Callback AMOUNT differs from the requested amount

  • This is expected behavior: the callback AMOUNT is the final processed amount (per the official docs), which may legitimately differ from what you requested due to fees, taxes, or currency conversion.
  • Persist the callback AMOUNT as the settled amount; do not overwrite it with your original request value.
  • Decide your own policy for when the divergence exceeds a threshold (flag for review, pause settlement, require manual approval). To simulate it, accept a pending transaction in the Transactions UI and change the pre-filled amount before confirming.

Inspecting a request that the gateway rejected

  • Every request to /pay, /payOut, /getPayOutParams, and /merchants/:merchantId/transactions/:id that returns a 4xx or 5xx response is captured automatically — request headers, body, query, and the response body the gateway returned.
  • Open the Transactions → Failed requests tab to browse them. Click a row to see the full detail.
  • Header values are displayed truncated for readability; use the Copy full button next to each header to copy the untouched value — especially useful for debugging the Authorization (JWT) header or the computed X-Signature.
  • Use the Copy as cURL button in the modal to get a ready-to-run curl command that reproduces the exact request you sent. Paste it in a terminal against the sandbox (or your local build) to iterate on the fix.
  • Captured failures are scoped to your merchant only and are cleaned up together with transactions after 24 hours.