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" }
  }'
FieldTypeDescription
amountinteger, requiredCents. Minimum is set by your fees (about R5).
merchant_referencestring, requiredYour order or invoice number (max 100).
currencystringOnly ZAR.
descriptionstringShown on checkout (max 255).
customerobjectemail, name. Email gets a receipt.
success_url, cancel_urlURLhttps required in live mode.
metadataobjectUp to 20 string key/values. Returned on the payment and in webhooks.
expires_in_minutesinteger5 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.

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 → trialing or active once 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_..."
  }
}
CodeHTTPType
invalid_request400invalid_request_error
parameter_missing400invalid_request_error
parameter_invalid400invalid_request_error
amount_too_small400invalid_request_error
amount_too_large400invalid_request_error
invalid_json400invalid_request_error
authentication_required401authentication_error
invalid_api_key401authentication_error
api_key_expired401authentication_error
connection_revoked401authentication_error
secret_key_required403permission_error
live_mode_not_enabled403permission_error
merchant_suspended403permission_error
resource_not_found404invalid_request_error
idempotency_key_reused409idempotency_error
idempotency_request_in_progress409idempotency_error
payment_not_refundable409invalid_request_error
refund_exceeds_payment409invalid_request_error
insufficient_available_balance409invalid_request_error
invalid_state_transition409invalid_request_error
transaction_limit_exceeded422risk_error
daily_limit_exceeded422risk_error
monthly_limit_exceeded422risk_error
velocity_limit_exceeded422risk_error
rate_limited429rate_limit_error
too_many_resources409invalid_request_error
provider_not_configured503api_error
provider_error502api_error
internal_error500api_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 | expired

In 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_endpoints with { "url", "enabled_events": ["payment.successful"] } returns the endpoint and its signing secret (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) and GET /events/:id, for polling or to confirm a webhook.
  • GET /chargebacks: disputes raised by customers' banks.
  • payment_methods on POST /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=S256

They 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 apps

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