Docs
Integration guide with copy-pasteable snippets pre-filled with your merchant settings.
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
- 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. - Configure your own callback URLs in Settings — pay-in and pay-out URLs are separate.
- Create a pay-in using the snippet below. Copy, paste, send.
- Open the Transactions page, select your transaction, and accept or reject it. A callback is dispatched to the URL you configured.
- 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
zippyIdand checkout URL on every retry — safe to retry after a network timeout. Once the transaction is resolved, a further retry is rejected with403 { status: "error", description: "requires a new transactionId for this payIn" }. - Pay-out: per the official docs, any reuse of
transactionIdis rejected with403 { 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 — theMERCHANTREQUESTIDandZIPPYIDstay 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 containingMERCHANTREQUESTID,ZIPPYID,AMOUNT,CODE,SIGNand related fields.
No Authorization header is sent. Authentication is enforced through the MD5 SIGN field in the body (see Security).
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.
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
- Required —
Content-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
- Required —
Content-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
- Required —
Content-Type: application/json - Required —
Authorization: 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
- Required —
X-Signature: <md5_lowercase_hex>— MD5 ofmerchantId + 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 themerchantRequestIdyou 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— theX-Signatureheader is missing. - 401
invalid merchant id— the:merchantIdpath segment does not match the merchant assigned to your sandbox instance. - 401
merchant not configured— your merchant has nosecret_keyconfigured. Set one in Settings. - 401
invalid signature— the MD5 you sent does not matchmd5(merchantId + transactionId + secret_key)in lowercase hex. - 404
transaction not found— no transaction with thatmerchantRequestIdexists 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.
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).
| Method | Supported countries |
|---|---|
bankCard | CL, PE, AR, BO, BR, EC, CO, PA, GT, CR, MX, PY, UY, SV, HN, DO, TT, NI |
bankTransfer | CL, PE, AR, EC, CO, PY |
cash | PE, EC, CO, PA, GT, CR, AR |
mach | CL |
wallet | BO, CO |
mobileMoney | EC |
transfiya | CO |
binancepay | AR |
pix | BR |
SPEI | MX |
fri | GT |
khipu | AR |
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).
| Method | Supported countries |
|---|---|
bankTransfer | CL, PE, EC, CO, GT |
wallet | CO |
binancepay | AR |
cbucvu | AR |
SPEI | MX |
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 forbankId,typeAccountIdandtypeDocumentIdcome 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 validbankList,typeAccount(typeAccountIdcodes) andcustomerId(typeDocumentIdcodes) for a(country, payoutMethod)pair. The sandbox uses the exact same data structure as production.- The sandbox rejects with
400and:invalid payoutMethod for {country}. Available methods: …(PayOut) when the channel is not inPAYOUT_METHODS[country].payMethod field wrong - admissible field: …(PayIn) when the channel is not inPAYIN_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" }
}
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
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/getPayOutParamsBR 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
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...
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
Authorizationheader is present and starts withBearer(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
idequal totransactionIdandamountequal to the bodyamount.
401 on GET /merchants/:merchantId/transactions/:id
- Confirm the URL includes your
merchantIdas the path segment after/merchants/. - Confirm the
X-Signatureheader is present. - Confirm you computed the signature with the same
secret_keycurrently active in Settings. - Confirm the signature is lowercase hex and uses MD5, not SHA-1.
Callback never reaches your backend
- Confirm
callback_url_payinorcallback_url_payoutis 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 finalCODEcan flip between 0 (approved) and 12 (error) after the initial delivery. - Make your callback handler idempotent: persist by
MERCHANTREQUESTID(orZIPPYID) 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
AMOUNTis 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
AMOUNTas 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/:idthat returns a4xxor5xxresponse 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 computedX-Signature. - Use the Copy as cURL button in the modal to get a ready-to-run
curlcommand 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.