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. 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, document types and (where applicable) account identifier 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

Request body

  • RequiredmerchantId (string): the merchant code assigned to your sandbox instance.
  • Requiredcountry (string, ISO-3166 alpha-2): the country whose pay-out catalogue you want, e.g. PE, CL, CO.
  • Optional but recommendedpayoutMethod (string, camelCase): e.g. bankTransfer, wallet. The sandbox accepts this field as optional to preserve backward compatibility with older integrations; new integrations should always send it so the sandbox rejects unsupported (country, payoutMethod) combinations early with 400 invalid payoutMethod for {country}. Available methods: …, instead of letting the validation fail later at the POST /payOut call. The response shape is the same for any valid method of the country (per-method response narrowing isn't modelled today); see Channels → PayOut methods for the list of supported channels per country.
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 listed in the tables below.

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
pixBR
SPEIMX
friGT

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:

Chile (CL) — documentId must be a valid RUT. Both POST /pay and POST /payOut validate documentId using the mod-11 algorithm when country === "CL". Accepted forms: XX.XXX.XXX-K, XXXXXXXX-K, XXXXXXXXK (with or without dots / hyphen, K case-insensitive). If the check digit doesn’t match, the response is 400 documentId is not a valid Chilean RUT (mod-11 checksum failed). This mirrors production. Example of a valid RUT: 78.076.505-4.
{
  "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
pixBR
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.

Other fields the merchant may include in the request body — for example objData.merchantUrl (the website where the user originally initiated the transaction) — are stored as part of the raw request and propagated to the callback context, but are not validated by the sandbox. Production may enforce additional constraints.

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: … — when the provided payoutMethod is not supported for the selected country.
    • payMethod field wrong - admissible field: … — when the provided payMethod is not supported for the selected country.

The "admissible" list mirrors the catalogue the sandbox emulates (shown in the PayIn and PayOut methods tables above), 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

PE is aligned with production. The catalogue below mirrors the response that /getPayOutParams returns in the live Zippy Pay gateway today (29 banks, typeAccountId: CC | CA, typeDocumentId: DNI, plus an accountIdentifier array for the CCI). The other PayOut countries in this sandbox still use a legacy shape (bank-name-as-bankId, numeric typeAccountId, numeric typeDocumentId) and will be aligned in future releases as production payloads are captured.

The classic LATAM withdrawal: send money to a beneficiary's bank account. In Peru, typeDocumentId is currently DNI only; typeAccountId distinguishes Cuenta Corriente (CC) vs Cuenta Ahorros (CA); accountIdentifier describes the account-number format (CCI = Código de Cuenta Interbancario, the 20-digit interbank account code).

Step 1. Discover the valid catalogue for PE:

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

// 200 OK
{
  "bankList": [
    { "bankId": "1", "bankName": "Banco Continental (BBVA)" },
    { "bankId": "2", "bankName": "Banco de Credito" },
    { "bankId": "3", "bankName": "Interbank" },
    { "bankId": "4", "bankName": "Scotiabank" },
    { "bankId": "5", "bankName": "Banco de Comercio" },
    { "bankId": "6", "bankName": "Banco Interamericano de Finanzas (BanBif)" },
    { "bankId": "7", "bankName": "Banco Pichincha" }
    // ...more supported banks (call /getPayOutParams for the full list —
    //    includes Banco de la Nación, MiBanco, Caja Arequipa/Cusco/…/Trujillo, YAPE, plin)
  ],
  "typeAccount": [
    { "typeAccountId": "CA", "type": "Cuenta Ahorros" },
    { "typeAccountId": "CC", "type": "Cuenta Corriente" }
  ],
  "customerId": [
    { "typeDocumentId": "DNI", "id": "DNI" }
  ],
  "accountIdentifier": [
    { "accountIdentifierType": "CCI", "name": "Código de Cuenta Interbancario" }
  ]
}

Step 2. Send the PayOut. Pick exact values from the catalogue above. Replace <YOUR_SIGNED_JWT> with the RS256 JWT you mint locally (the sandbox cannot mint it for you) and <YOUR_UNIQUE_TX> with your idempotency key:

Loading...

PayOut — Colombia, wallet

For the wallet channel (Nequi / DaviPlata / similar), the sandbox validates the same request fields used by the payout flow, including bankId, typeAccountId and numAccount. The difference is defined by the bank catalogue associated with the selected method, not by the request structure.

Step 1. Discover the catalogue for CO + wallet:

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

// 200 OK
{
  "bankList": [
    { "bankId": "DALE",     "bankName": "Dale" },
    { "bankId": "DAVIPLATA","bankName": "Daviplata" },
    { "bankId": "MOVII",    "bankName": "Movii" },
    { "bankId": "NEQUI",    "bankName": "Nequi" },
    { "bankId": "RAPPIPAY", "bankName": "Rappipay" },
    { "bankId": "UALA",     "bankName": "Uala" }
  ],
  "typeAccount": [
    { "typeAccountId": "CA", "type": "Cuenta Ahorros" }
  ],
  "customerId": [
    { "typeDocumentId": "CC",  "id": "Cédula de Ciudadanía" },
    { "typeDocumentId": "NIT", "id": "Número de Identificación Tributaria" }
  ]
}

Step 2. Send the PayOut. Set bankId to the wallet provider in uppercase (e.g. "NEQUI", "DAVIPLATA") and pass the user's phone in both numAccount and phone_number — the upstream PSP uses that to route the funds:

Loading...
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

Pix-key semantics in the PayOut shape. Pix routes by Pix key, not by destination bank. The BR+pix catalogue returned by /getPayOutParams has no bankList: bankId is neither required nor validated for Pix (if you send it, the sandbox ignores it). The Pix key value goes in numAccount, and typeAccountId represents the Pix key type"CPF" when the key is a national document number, "TF" when it's a phone number.

Step 1. Discover the catalogue for BR + pix:

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

// 200 OK
{
  "typeAccount": [
    { "typeAccountId": "CPF", "type": "Cadastro de Pessoas Físicas" },
    { "typeAccountId": "TF",  "type": "telefone" }
  ],
  "customerId": [
    { "typeDocumentId": "CPF", "id": "CPF" }
  ]
}

Step 2. Send the PayOut. Mapping the Pix flow to the PayOut request shape:

  • bankIdnot required and not validated for Pix. Omit it, or include it as a free-form hint (the sandbox ignores it).
  • typeAccountId — the Pix key type: "CPF" (national document number) or "TF" (telefone, phone number with country code).
  • typeDocumentId"CPF" (only option in the current catalogue).
  • numAccount — the Pix key value. Must match typeAccountId: a CPF (11 digits) when typeAccountId === "CPF", a phone number when typeAccountId === "TF".
  • phone_number — beneficiary phone, even if a different key is used.
Loading...

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.
  • Callbacks reflect the transaction state at the time of notification. Although uncommon, a callback may be superseded by a later one, in which case the latest received state should be considered the valid one.
  • 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 reflects the final amount paid by the end user (per the official docs), which may differ from the requested amount when the user adjusts it during the payment flow on the provider's side.
  • 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 persist permanently — the sandbox is append-only by design; failed requests stay queryable for cross-day debugging.