Receive More API
A REST API for accepting ZAR pay-by-bank (Instant EFT) payments. JSON in, JSON out. All amounts are integers in cents: 15000 is R150.00.
Base URL: https://www.recievemore.com/v1
Quickstart
1. Sign up. Your sandbox is ready immediately. 2. Create a secret key under Developers → API keys. 3. Create a payment and redirect your customer to its checkout_url. 4. Listen for the payment.successful webhook and fulfil the order.
Authentication
Send your secret key as a bearer token. Secret keys start with test_sk_ or live_sk_. They're shown once when created and must only ever live on your server.
curl https://www.recievemore.com/v1/payments \
-H "Authorization: Bearer test_sk_..."Keys can be rolled (the old key keeps working for 24 hours) or revoked instantly from the dashboard.
Sandbox & live
The key decides the mode. Test keys only touch sandbox data and use a built-in bank simulator: no real money moves. Live keys are available once your account is approved. Sandbox and live data are completely separate: payments, balances, webhooks and keys.
Create a payment
POST /v1/payments returns a payment with a hosted checkout_url. Redirect the customer there. When they finish, we send them to your success_url (with ?payment_id=) or cancel_url.
curl -X POST https://www.recievemore.com/v1/payments \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1001" \
-d '{
"amount": 15000,
"merchant_reference": "order-1001",
"description": "2x Coffee beans",
"customer": { "email": "thandi@example.com" },
"success_url": "https://shop.example.com/thanks",
"cancel_url": "https://shop.example.com/cart",
"metadata": { "order_id": "1001" }
}'| Field | Type | Description |
|---|---|---|
amount | integer, required | Cents. Minimum is set by your fees (about R5). |
merchant_reference | string, required | Your order or invoice number (max 100). |
currency | string | Only ZAR. |
description | string | Shown on checkout (max 255). |
customer | object | email, name. Email gets a receipt. |
success_url, cancel_url | URL | https required in live mode. |
metadata | object | Up to 20 string key/values. Returned on the payment and in webhooks. |
expires_in_minutes | integer | 5 to 10080. Default 60. |
Retrieve & list
curl https://www.recievemore.com/v1/payments/pay_123 -H "Authorization: Bearer $SECRET_KEY"
curl "https://www.recievemore.com/v1/payments?limit=20&status=successful&starting_after=pay_123" \
-H "Authorization: Bearer $SECRET_KEY"Lists return { object: "list", data: [...], has_more }, newest first. Pass the last id as starting_after for the next page. Filter with status or merchant_reference.
Refunds
Omit amount to refund the remaining balance of the payment. Refunds come out of your available balance; if it isn't enough you'll get insufficient_available_balance.
curl -X POST https://www.recievemore.com/v1/refunds \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-order-1001" \
-d '{ "payment": "pay_123", "amount": 5000, "reason": "requested_by_customer" }'Payment statuses
created: the customer hasn't started yet.pending: they're at their bank.successful: money received. Fulfil the order.failed,cancelled,expired: final; no money moved.partially_refunded,refunded: after refunds.
Statuses only move forward. Never trust the redirect alone; confirm with a webhook or by retrieving the payment.
Payment links
No-code links you create in the dashboard: a fixed amount, an amount the customer chooses, or a product price (with an optional quantity). Links can have an image, an expiry date, a limit on units sold, up to five custom fields and a thank-you message or redirect. Every link has a QR code at https://www.recievemore.com/pay/<slug>/qr.
Create links over the API too: POST /payment_links with { "title": "Cake deposit", "amount": 120000 } (or "allow_custom_amount": true) returns the shareable url. GET /payment_links lists them.
Payments from a link carry payment_link, and each custom-field answer appears in metadata as cf_<field> (for example cf_delivery_address), so your payment.successful webhook has everything needed to fulfil the order.
Subscriptions (beta)
Sandbox only for now. Recurring collection uses DebiCheck debit orders, which needs the provider integration; in live mode these endpoints return 503 provider_not_configured.
Create products and prices in the dashboard (Billing → Products). Prices are VAT-inclusive. Then create a subscription and send the customer to mandate_url to approve the debit order:
Built in for every merchant, nothing to configure: the checkout shows the price, billing frequency and renewal terms above a required I agree to recurring billing box; customers get a reminder before each renewal (3 days for monthly plans, 7 for annual), a receipt for every successful charge, and a link to cancel online in every email. Proof of consent (time, IP, browser and the exact terms shown) is stored on the subscription and returned as consent.
curl -X POST https://www.recievemore.com/v1/subscriptions \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: signup-42" \
-d '{ "customer_email": "thandi@example.com", "price": "price_...", "coupon": "LAUNCH20", "return_url": "https://example.com/welcome" }'incomplete→trialingoractiveonce the mandate is approved and (without a trial) the first debit succeeds.- Each period we issue a sequentially numbered invoice in your business's name and collect it. A failed debit makes the subscription
past_due; we retry after 3, 5 and 7 days, then cancel. - Customers get a magic-link portal to see invoices, update their bank account or cancel. Plan changes are prorated by day.
Other endpoints: GET /subscriptions, GET /subscriptions/:id, POST /subscriptions/:id/cancel with { "at_period_end": true }, POST /customers (returns the existing customer for a known email), GET /customers, GET /customers/:id, GET /invoices/:id.
Listen for subscription.* and invoice.* webhooks rather than polling. In sandbox, the mandate page lets you approve, approve-but-fail-later (to see retries), or decline, and the subscription page has Advance to next billing event to skip ahead in time.
Webhooks
Add endpoints under Developers → Webhooks (separately for sandbox and live). We POST JSON events and retry failures with backoff for about 3 days (1m, 5m, 30m, 2h, 6h, 12h, 24h, 24h). Respond with any 2xx quickly and do the work asynchronously. Events can arrive more than once or out of order: de-duplicate on id and re-fetch the payment if order matters.
{
"id": "evt_...",
"object": "event",
"type": "payment.successful",
"livemode": false,
"api_version": "2026-10-01",
"created_at": "2026-10-06T12:00:00.000Z",
"data": { "object": { "id": "pay_...", "object": "payment", "status": "successful", "amount": 15000, ... } }
}Event types: payment.created, payment.pending, payment.successful, payment.failed, payment.cancelled, payment.expired, refund.created, refund.succeeded, refund.failed, payout.created, payout.paid, payout.failed, customer.created, subscription.created, subscription.updated, subscription.renewed, subscription.trial_ending, subscription.past_due, subscription.cancelled, invoice.created, invoice.paid, invoice.payment_failed, chargeback.opened.
Use Send test event on an endpoint's page to receive a signed webhook.test event at any time.
Verifying signatures
Every request has a ReceiveMore-Signature header: t=<unix>,v1=<hex>. Compute HMAC-SHA256 of `${t}.${rawBody}` with your endpoint's signing secret, compare in constant time, and reject timestamps older than 5 minutes. Use the raw request body, not re-serialised JSON.
import crypto from 'node:crypto';
export function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Idempotency
Send an Idempotency-Key header (any unique string up to 255 characters) on every POST. If the request is retried with the same key and body within 24 hours, you get the original response back (header Idempotent-Replayed: true) and nothing is created twice. The same key with a different body returns 409 idempotency_key_reused.
Errors
Errors use HTTP status codes and one JSON shape. Include request_id when contacting support.
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid",
"message": "amount: Invalid input: expected int, received number",
"param": "amount",
"request_id": "req_..."
}
}| Code | HTTP | Type |
|---|---|---|
invalid_request | 400 | invalid_request_error |
parameter_missing | 400 | invalid_request_error |
parameter_invalid | 400 | invalid_request_error |
amount_too_small | 400 | invalid_request_error |
amount_too_large | 400 | invalid_request_error |
invalid_json | 400 | invalid_request_error |
authentication_required | 401 | authentication_error |
invalid_api_key | 401 | authentication_error |
api_key_expired | 401 | authentication_error |
connection_revoked | 401 | authentication_error |
secret_key_required | 403 | permission_error |
live_mode_not_enabled | 403 | permission_error |
merchant_suspended | 403 | permission_error |
resource_not_found | 404 | invalid_request_error |
idempotency_key_reused | 409 | idempotency_error |
idempotency_request_in_progress | 409 | idempotency_error |
payment_not_refundable | 409 | invalid_request_error |
refund_exceeds_payment | 409 | invalid_request_error |
insufficient_available_balance | 409 | invalid_request_error |
invalid_state_transition | 409 | invalid_request_error |
transaction_limit_exceeded | 422 | risk_error |
daily_limit_exceeded | 422 | risk_error |
monthly_limit_exceeded | 422 | risk_error |
velocity_limit_exceeded | 422 | risk_error |
rate_limited | 429 | rate_limit_error |
too_many_resources | 409 | invalid_request_error |
provider_not_configured | 503 | api_error |
provider_error | 502 | api_error |
internal_error | 500 | api_error |
Rate limits
Per key: sandbox 25 requests/second (burst 50), live 100/second (burst 200). Over the limit you get 429 rate_limited with a Retry-After header. Responses include RateLimit-Limit and RateLimit-Remaining.
Testing
With a test key, the checkout sends customers to a sandbox bank page where you choose Approve, Decline or Cancel. The result flows through the real pipeline: signed provider webhook, status update, ledger, your webhooks.
For automated tests, skip the browser:
curl -X POST https://www.recievemore.com/v1/test/payments/pay_123/simulate \
-H "Authorization: Bearer test_sk_..." \
-H "Content-Type: application/json" \
-d '{ "outcome": "successful" }' # or failed | cancelled | expiredIn sandbox, successful payments sit in your pending balance until you click Settle pending funds on the dashboard overview (live funds settle when the bank confirms them). Run payout simulates a payout to your bank account.
Webhook endpoints on http://localhost are allowed in sandbox during development. Use the delivery log and the Resend button to debug your handler.
To test chargeback handling, POST /test/payments/:id/chargeback (optional amount and reason) on a successful sandbox payment sends a chargeback.opened event.
Integration endpoints
Used by plugins and automation tools; available to any secret key.
GET /account: the business behind the key (business_name,live_enabled).POST /webhook_endpointswith{ "url", "enabled_events": ["payment.successful"] }returns the endpoint and its signingsecret(shown once).GET /webhook_endpoints,GET/DELETE /webhook_endpoints/:id. Up to 100 per mode.GET /events?type=payment.successful&limit=20&starting_after=evt_…(newest first) andGET /events/:id, for polling or to confirm a webhook.GET /chargebacks: disputes raised by customers' banks.payment_methodsonPOST /payments: limit what the checkout offers, e.g.["instant_eft", "card", "capitec_pay"]. Methods not yet enabled on your account are skipped; at least one must be available (today:instant_eft).
Connect (OAuth) for platforms
Building a plugin or app that many businesses will use? Let them connect with one click instead of pasting keys. Send the merchant to:
https://www.recievemore.com/connect/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=https%3A%2F%2Fyourapp.com%2Fcallback&response_type=code&state=RANDOM&code_challenge=PKCE_S256_CHALLENGE&code_challenge_method=S256They sign in (or sign up), see your app's name and website, and approve. We redirect to redirect_uri with ?code=rmac_…&state=…; the code works once, for 10 minutes. Exchange it from your server:
curl -X POST https://www.recievemore.com/v1/oauth/token \
-d grant_type=authorization_code -d code=rmac_... \
-d client_id=YOUR_CLIENT_ID -d redirect_uri=https://yourapp.com/callback \
-d code_verifier=PKCE_VERIFIER # or -d client_secret=... for server appsKeys from a connection expire after 24 hours (401 api_key_expired): renew them with grant_type=refresh_token. Until the business is approved you get a test key only; once approved, the next renewal includes a live key. POST /oauth/revoke with token=<refresh token> disconnects. Merchants see and disconnect apps under Settings → Connected apps; disconnecting stops the keys and deletes the app's webhook endpoints.
Contact us to register an app (you get a client id and secret). Open-source store plugins use PKCE with no secret.