Quickstart
From nothing to a payment in five calls. You need your clientId,
clientSecret and the RSA private key from your provisioning — see the
overview if you are missing any of them.
Every example below runs against sandbox:
https://api.gateway.sandbox.retorna.app
When you go live, the only thing that changes is the host — and your
credentials, which are issued per environment. Production is
https://api.gateway.retorna.app and its scope is prod/full_access.
1. Get a token​
curl -X POST https://api.gateway.sandbox.retorna.app/oauth2/token \
-H "Authorization: Basic $(printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "scope=sandbox/full_access"
Response:
{ "access_token": "eyJraWQiOiJ...", "expires_in": 3600, "token_type": "Bearer" }
Cache it and reuse it until it is close to expiring. There is no refresh token — you just ask again.
2. Sign what you send​
This is the step people skip, and then everything returns 401. Every call from
here on needs two extra headers:
import { createSign } from "node:crypto";
const nonce = String(Date.now()); // epoch MILLISECONDS
const message = mode === "query"
? `${path}?${queryString}${nonce}` // GETs
: JSON.stringify(rawBody) + nonce; // POSTs — note the second encode
const signer = createSign("RSA-SHA256");
signer.update(message);
signer.end();
const signature = signer.sign(privateKeyPem, "base64");
Send signature and nonce alongside your Authorization header. The
request signing guide has the full rules, the
Python version, and the two mistakes that cost everyone an afternoon.
Every call below also needs User-Agent and Accept — see
required headers if you are on a client that
does not set them by default.
3. Check your routes​
What can you actually send today, and how much room is left in your limits?
curl "https://api.gateway.sandbox.retorna.app/me/routes?sourceCurrency=USDR" \
-H "Authorization: Bearer $TOKEN" \
-H "signature: $SIGNATURE" -H "nonce: $NONCE"
Response:
{
"routes": [
{
"name": "P2P_PHONE_TRANSFER-VE",
"method": "P2P_PHONE_TRANSFER",
"country": "VE",
"currency": "VES",
"rate": "852.79",
"fees": [ { "pricingRule": "PAYOUT_TYPE_FEE", "pricingType": "FEE", "percentageValue": 1, "flatValue": 0.25, "pricingLabel": "Comisión" } ],
"limits": { "per_transaction": { "min": "0", "max": "99000" } }
}
]
}
Read the limits at runtime instead of hardcoding them — they move with your consumption.
4. Lock a price​
POST /quotations, signed in body mode:
curl -X POST https://api.gateway.sandbox.retorna.app/quotations \
-H "Authorization: Bearer $TOKEN" \
-H "signature: $SIGNATURE" -H "nonce: $NONCE" \
-H "Content-Type: application/json" \
-d @quotation.json
quotation.json:
{
"source": { "currency": "USDR" },
"destination": {
"country": "VE",
"currency": "VES",
"payout_method": { "type": "P2P_PHONE_TRANSFER" }
},
"quote": { "mode": "SEND_EXACT", "amount": "10.00" }
}
Response:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"source": { "currency": "USDR", "decimals": 2, "gross_amount": "10.00", "net_amount": "9.90" },
"destination": { "country": "VE", "payout_method": { "type": "P2P_PHONE_TRANSFER" } },
"target": { "currency": "VES", "decimals": 2, "amount": "8442.62" },
"exchange_rate": { "value": "852.79", "quoted_at": "...", "expires_at": "..." },
"fees": { "total_in_source_currency": "0.10", "breakdown": [ ] }
}
Watch exchange_rate.expires_at: past it, the quotation is dead and you need a
new one. And keep destination.payout_method.type next to the order you are
about to build: the payment instructions must match it (bank_phone_account for
P2P_PHONE_TRANSFER, bank_account for BANK_TRANSFER), or the order is
rejected with 422 B2B_QUOTATION_MISMATCH.
5. Send the payment​
POST /orders, signed in body mode, with the quotation id and the
beneficiary. The X-Idempotency-Key header is required — pick something
unique and meaningful in your own system, because it comes back as
idempotency_key and is how you find this order later.
curl -X POST https://api.gateway.sandbox.retorna.app/orders \
-H "Authorization: Bearer $TOKEN" \
-H "signature: $SIGNATURE" -H "nonce: $NONCE" \
-H "X-Idempotency-Key: invoice-2026-00456" \
-H "Content-Type: application/json" \
-d @order.json
order.json:
{
"quotation_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"metadata": { "invoice_id": "INV-2026-00456", "batch_id": "BATCH-VE-032" },
"purpose": "FAMILY_SUPPORT",
"sender": {
"names": "Ana MarÃa",
"last_names": "Pérez Gómez",
"document": { "type": "CC", "id": "1020304050", "country": "CO" },
"country": "CO"
},
"destination": {
"receiver": {
"names": "Carlos Andrés",
"last_names": "RodrÃguez López",
"document": { "type": "V", "id": "12345678", "country": "VE" },
"country": "VE"
},
"payment_instructions": {
"bank_phone_account": {
"bank_name": "BNC_VE",
"phone_number": "+584121234567",
"document_id": "12345678"
}
}
}
}
bank_name is the API name from the catalog, not the bank's commercial name
— BNC_VE, never "BNC". The full list is in
bank entities.
Response:
{
"id": "cpg_txn_a1b2c3d4e5f6",
"idempotency_key": "invoice-2026-00456",
"status": "ACCEPTED",
"quotation_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"metadata": { "invoice_id": "INV-2026-00456", "batch_id": "BATCH-VE-032" },
"created_at": "2026-09-10T14:31:00.000Z",
"updated_at": "2026-09-10T14:31:00.000Z"
}
Abbreviated: the full order also echoes sender and destination and adds
execution and events. See create order.
6. Follow it​
Two ways, and you want the second one in production:
curl "https://api.gateway.sandbox.retorna.app/orders/{id}" \
-H "Authorization: Bearer $TOKEN" \
-H "signature: $SIGNATURE" -H "nonce: $NONCE"
Response:
{
"id": "cpg_txn_a1b2c3d4e5f6",
"idempotency_key": "invoice-2026-00456",
"status": "PENDING",
"quotation_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"metadata": { "invoice_id": "INV-2026-00456", "batch_id": "BATCH-VE-032" },
"created_at": "2026-09-10T14:31:00.000Z",
"updated_at": "2026-09-10T14:31:04.000Z"
}
Or let us tell you: every state change is delivered to your endpoint as a
webhook. Deduplicate on
eventId, trust timestamp over arrival order, and verify the signature before
you trust the body.
If something returns 401​
In order of how often it happens:
- The
nonceis in seconds, not milliseconds. - You re-serialized the JSON body after signing it — sign the exact bytes you send.
- You skipped the second encoding in body mode.
- The token expired.
A 401 with a code field is your signature; a 401 with an error field is
your token. That one field saves a lot of guessing.