Developers
An API that shows its working.
Create a payment in one call, get told exactly what happened, and see what it cost, line by line. REST and JSON, with a TypeScript SDK.
{
"id": "pay_01k7f3m9q2v8x4c6n0b5h1t7wz",
"object": "payment",
"amount": 38600,
"currency": "ZAR",
"status": "succeeded",
"payment_method_type": "card",
"description": "Order #2048",
"metadata": { "order_id": "2048" },
"amount_refunded": 0,
"fee": {
"currency": "ZAR",
"total": 795,
"costs": 359,
"margin": 436,
"vat": 57,
"margin_excl_vat": 379,
"price_plan": "plan_01k6z8c3r4w5t2y9n7b1d0m6qe",
"lines": [
{ "code": "interchange", "kind": "cost",
"label": "Card issuer (interchange)", "amount": 224 },
{ "code": "scheme", "kind": "cost",
"label": "Card scheme", "amount": 58 },
{ "code": "acquirer", "kind": "cost",
"label": "Acquiring bank", "amount": 77 },
{ "code": "rano", "kind": "margin",
"label": "rano", "amount": 436 }
]
},
"net": 37805,
"next_action": null,
"last_error": null,
"cancellation_reason": null,
"livemode": false,
"created": 1791532800
}Quick start
Three steps from test keys to a working payment.
Get your test keys
Request access and we’ll send you a secret key starting
sk_test_. Test mode needs no paperwork. Keep the key on your server, never in the browser.Create a payment
Amounts are in cents. Send an idempotency key so a retry can never charge twice.
curl https://api.rano.co.za/v1/payments \ -H "Authorization: Bearer $RANO_SECRET_KEY" \ -H "Idempotency-Key: order-2048" \ -H "Content-Type: application/json" \ -d '{ "amount": 38600, "currency": "ZAR", "payment_method_type": "card", "confirm": true }'Read the result
The payment comes back with its
statusand the full fee breakdown, like the object at the top of this page. Listen for webhooks to hear about anything that changes after the call returns.
API basics
REST and JSON, shaped the way most developers already expect. If you’ve used a modern payments API, nothing here will surprise you.
One base URL
Every request goes to https://api.rano.co.za with your secret key in an Authorization: Bearer header. Requests and responses are JSON.
Amounts are integers in cents
R386.00 is 38600, always sent with a currency. No floats, no rounding surprises.
IDs tell you what they are
Payments start pay_, refunds rfnd_, events evt_. They sort by time and can’t be guessed.
Retries are safe
Send an Idempotency-Key header when you create, confirm, cancel or refund. Repeating the request within 24 hours returns the original result instead of charging twice. The SDK adds one for you.
Test and live keys are separate
sk_test_ keys only ever reach the simulator. sk_live_ keys move real money, once your business is verified.
Payments have honest states
requires_payment_method, requires_action, processing, succeeded, failed, canceled. If a bank times out, we say processing and find out, rather than guess.
Endpoints
Lists return the newest first and take limit (1 to 100, default 10) and starting_after to page through.
| Method | Path | What it does |
|---|---|---|
POST | /v1/payments | Create a payment, and confirm it in the same call |
GET | /v1/payments | List payments, newest first |
GET | /v1/payments/:id | Retrieve a payment |
POST | /v1/payments/:id/confirm | Confirm a payment |
POST | /v1/payments/:id/cancel | Cancel a payment before it’s paid |
POST | /v1/refunds | Refund a payment |
GET | /v1/refunds | List refunds for a payment |
GET | /v1/refunds/:id | Retrieve a refund |
GET | /v1/balance | Your available and pending balance |
GET | /v1/events | List events, optionally by type |
GET | /v1/events/:id | Retrieve an event |
POST | /v1/webhook_endpoints | Add a webhook endpoint |
GET | /v1/webhook_endpoints | List webhook endpoints |
DELETE | /v1/webhook_endpoints/:id | Remove a webhook endpoint |
Fees on every payment
Every payment carries a fee object: the total, what went to banks and card networks (costs), what we kept (margin), and one line per party that took a share. The breakdown is fixed when the payment is made, so later price changes never rewrite history.
| Code | Kind | Appears on | What it is |
|---|---|---|---|
interchange | cost | Card payments | The customer’s card issuer. Set by the card network. |
scheme | cost | Card payments | Visa or Mastercard network fees. |
acquirer | cost | Card payments | The acquiring bank that processes the card. |
bank | cost | PayShap and EFT | Clearing and bank costs, flat per payment. |
rano | margin | Every payment | Our fee. The only part we keep. |
VAT at 15% applies to our margin only and is already in it: fee.vat shows how much, and fee.margin_excl_vat the rest. net is what reaches your bank account: amount minus fee.total.
Webhooks
We POST an event to your endpoint whenever a payment or refund changes. Every event is signed and retried with backoff for about three days. Subscribe to the types you need, or * for all of them.
POST /webhooks/rano HTTP/1.1
Content-Type: application/json
Rano-Signature: t=1791532800,v1=5f0c9e2a7b…
{
"id": "evt_01k7f3m9t5r2y8d4p6e0a3s9gq",
"object": "event",
"type": "payment.succeeded",
"livemode": false,
"created": 1791532800,
"data": {
"object": { "id": "pay_01k7f3m9q2v8x4c6n0b5h1t7wz", "status": "succeeded", … }
}
}Verify the signature
The Rano-Signature header holds a Unix timestamp t and v1, a hex HMAC-SHA256 of <t>.<body> using your endpoint’s secret. The SDK checks it for you, including rejecting timestamps more than five minutes old to block replays.
import { Rano } from "@rano/sdk";
const rano = new Rano(process.env.RANO_SECRET_KEY);
// Throws if the signature doesn't match. Pass the body exactly as received.
const event = rano.webhooks.constructEvent(
rawBody,
req.headers["rano-signature"],
process.env.RANO_WEBHOOK_SECRET,
);Events
payment.created- A payment was created.
payment.requires_action- Waiting on the customer: 3D Secure, approval in their banking app, or an EFT.
payment.succeeded- Money is confirmed. Safe to fulfil the order.
payment.failed- The payment was declined or failed. last_error says why.
payment.canceled- You canceled the payment, or the customer didn’t approve it in time. cancellation_reason says which.
refund.created- A refund was created and is on its way to the bank.
refund.succeeded- The refund has been sent back to the customer.
refund.failed- The bank rejected the refund. failure_code says why.
- Delivery is at least once. Use the event
idto ignore duplicates. - Order isn’t guaranteed. Each event carries the object as it was when the event happened. When order matters, retrieve the object for its latest state.
- Reply with any
2xxquickly, then do slow work in the background. - Missed one? Every event can be fetched again with
GET /v1/eventsorGET /v1/events/:id.
Test mode
A full copy of rano running against simulated banks. Pick the outcome with the last two digits of the amount in cents. Declines come back as a payment with status: failed and the reason in last_error.code.
| Cents end in | Example | Outcome | You’ll see |
|---|---|---|---|
01 | 10001 | Declined | failed · insufficient_funds |
02 | 10002 | Declined | failed · do_not_honour |
05 | 10005 | Times out, then succeeds | processing → succeeded |
06 | 10006 | Times out, then fails | processing → failed |
07 | 10007 | Times out before the bank sees it | processing → failed · processing_error |
08 | 10008 | Card needs 3D Secure | requires_action |
Anything else | 38600 | Card succeeds; PayShap and EFT wait for the customer | succeeded · requires_action |
PayShap and EFT in test mode
PayShap and EFT payments wait for the customer, just like in real life, unless the amount picks one of the outcomes above. In test mode, play the customer with a test helper: approve or decline. Test helpers don’t exist in live mode. Refunds follow the same rule: an amount ending in 01 is rejected, and 05 times out, then succeeds.
// PayShap and EFT payments wait for the customer in test mode too.
await rano.testHelpers.payments.approve("pay_01k7f3m9q2v8x4c6n0b5h1t7wz");
// Or have the customer say no.
await rano.testHelpers.payments.decline("pay_01k7f3m9q2v8x4c6n0b5h1t7wz");Errors
Every error has the same shape: a type for broad handling, a code for your logic, a message written for whoever is debugging, and the param at fault, if any.
{
"error": {
"type": "invalid_request_error",
"code": "parameter_missing",
"message": "currency is required.",
"param": "currency"
}
}| Type | HTTP | Meaning |
|---|---|---|
invalid_request_error | 400, 404, 409 | A parameter is missing or wrong, the object doesn’t exist, or it’s in the wrong state. param says which. |
authentication_error | 401 | The API key is missing, revoked or for the wrong mode. |
idempotency_error | 400, 409, 422 | A key was reused with a different request, or the first request is still running. |
api_error | 500 | Something went wrong on our side. Safe to retry with the same key. |
Common codes
parameter_missing- A required parameter wasn’t sent.
parameter_invalid- A parameter has the wrong type or format.
parameter_unknown- A parameter we don’t recognise. Check the spelling.
amount_too_small- The fee would be at least the whole payment. Use a larger amount.
payment_method_unavailable- That payment method isn’t available for this currency yet.
payment_unexpected_state- The payment can’t do that from its current status.
resource_missing- No object with that ID on your account.
api_key_invalid- The API key isn’t recognised or has been revoked.
api_key_wrong_mode- A live key sent to test mode, or the other way round.
idempotency_key_reused- The same Idempotency-Key with a different request body.
Declines aren’t errors
A declined payment is a normal result, not an HTTP error. You get 200 and the payment, with status: failed and last_error holding a code for your logic and a sentence you can show your customer.
{
"id": "pay_01k7f3n2c8w5j9r1x6m4d0a7kp",
"object": "payment",
"amount": 10001,
"status": "failed",
"last_error": {
"code": "insufficient_funds",
"message": "The customer's account has insufficient funds."
},
…
}SDKs and tooling
Both come with your test keys. The SDK is for Node.js on your server, where your secret key stays.
- TypeScript SDK
- @rano/sdk: typed methods for payments, refunds, events and webhook endpoints, webhook verification, idempotency keys and automatic retries.
- OpenAPI spec
- One file describing every endpoint, object and error, served at GET /openapi.json.
Request test keys.
Leave your email and we’ll send sandbox keys to early-access developers as we onboard them.