StatusPrivate betaPublic testnets — test tokens, no real valueMainnet rolls out per jurisdiction

After payment

Errors

Errors return a predictable JSON envelope with a machine-readable code. Status codes follow HTTP conventions, and write operations are designed to be safe to retry.

The error envelope

Every error carries a stable error code and a human-readable message. Branch on error, show message. There are two wrappings, depending on whether the error came from a route or from the request pipeline — write your parser to accept both.

the two shapes
// Route errors (validation, not-found, state conflicts) — nested under "detail":
{
  "detail": {
    "error": "INTENT_NOT_FOUND",
    "message": "Payment intent 'pi_…' not found"
  }
}

// Pipeline errors (auth, rate limiting, idempotency) — flat:
{
  "error": "INVALID_API_KEY",
  "message": "Valid API key required"
}

// The approval gate is a special case — a code, no message:
{ "detail": { "code": "approval_pending" } }

A parser that handles both

const e = body.detail ?? body, then read e.error (or e.code for the approval gate). Aligning these two shapes is a known open item on our side; until then, that one line covers every response.

Status conventions

Standard HTTP semantics: 2xx success, 4xx a problem with your request, 5xx a problem on our side or upstream (retry those).

CodeHTTPMeaning
INVALID_API_KEY401Missing, malformed, or revoked API key
INSUFFICIENT_SCOPE403A read key attempted a non-GET request
company_profile_required403Submit your company profile in /app — the one gate on sandbox access. Shaped {"detail":{"code":…}}
approval_pending403Manual approval outstanding. Applies to rsend_live_ keys only — the sandbox does not wait on it
approval_declined403Your organisation was declined; carries a reason. Blocks both environments
UNSUPPORTED_TOKEN400Token is not enabled on the requested chain
UNSUPPORTED_CHAIN400Chain has no settlement path at all
TESTNET_ONLY / MAINNET_ONLY400Key environment does not match the requested chain
INVALID_STATE400Action not allowed from the intent’s current state (cancel, resolve)
INVALID_STATUS400Unknown status filter on the transactions list
WEBHOOK_INACTIVE400Cannot test-fire a deactivated webhook
INTENT_NOT_FOUND404No payment intent with that id for your account and environment
WEBHOOK_NOT_FOUND404No webhook with that id for your account and environment
SETTLEMENT_IN_FLIGHT409Cancel attempted after an on-chain payment was already observed
DUPLICATE_REQUEST_IN_FLIGHT409Same idempotency key still being processed — see below
IDEMPOTENCY_KEY_REUSED409Same idempotency key sent with a different request body — see below
SETTLEMENT_WALLET_MISSING422No recipient given and no settlement wallet configured
SETTLEMENT_WALLET_AMBIGUOUS422The owner wallet maps to more than one organisation — pass an explicit recipient
SPLIT_UNAVAILABLE422Split payments are not enabled on that chain
WEBHOOK_URL_FORBIDDEN422Webhook URL is not HTTPS, or resolves to a private/reserved address
RATE_LIMIT_EXCEEDED429Per-endpoint limit hit. Body is {error, retry_after}
KEY_RATE_LIMIT_EXCEEDED429Your key’s global per-minute limit hit
MONTHLY_LIMIT_EXCEEDED429Key’s monthly intent quota reached (unlimited unless one is set)
RATE_LIMIT_UNAVAILABLE503Rate limiting is fail-closed and cannot verify right now — retry
SERVICE_TEMPORARILY_UNAVAILABLE503Idempotency cannot be verified right now — retry with backoff
BACKEND_UNREACHABLE502The request exceeded the 25s edge timeout, or the API was unreachable
INTERNAL_ERROR500Unexpected server error

Request-body validation failures (a bad address, an amount ≤ 0, a split whose shares don't sum to 10000) return the standard FastAPI 422 with a detail array naming the offending field.

Rate limits

Limits are per API key on the merchant API, over a sliding window. They are generous enough that a normal integration never sees them — you would have to be polling hard or looping.

EndpointLimit
POST /payment-intent (create)100 / minute
POST /payment-intent/{id}/cancel · /resolve10 / minute
GET /payment-intent/{id}60 / minute
GET /transactions60 / minute
POST /webhook/register5 / hour
POST /webhook/test10 / minute
Public checkout status (/api/v1/public/payment-intent/{id})20 / minute per IP
Global, across all endpoints100 / minute per key

Exceeding a limit returns 429 with a retry_after value in seconds. The API also emits X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and Retry-After headers.

Those headers aren’t visible on the sandbox base URL

The sandbox base URL routes through the RSends edge, which returns the status and body verbatim but does not forward response headers other than Content-Type. The limits and the 429 bodies are real; only the header hints are missing. Read retry_after from the body and back off on it.

Rate limiting fails closed

If the limiter cannot verify a request, the API answers 503 RATE_LIMIT_UNAVAILABLE rather than letting it through unchecked. It is transient — retry with backoff.

Idempotency & retry-safety

Network calls fail mid-flight; you should be able to retry without doubling anything. Send an X-Idempotency-Key header (any opaque string you generate — an order id plus an attempt counter works well) on any POST:

idempotent create
curl -X POST https://pay.rsends.io/api/backend/api/v1/merchant/payment-intent \
  -H "Authorization: Bearer rsend_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: ORD-1024" \
  -d '{ "amount": 100, "currency": "USDC", "chain": "base_sepolia" }'
SurfaceGuarantee
Repeated requestA retry with the same key replays the original response instead of re-running the call. The record is kept 24 hours; successful responses are what get replayed.
Concurrent duplicateA retry arriving while the first is still in flight waits briefly, then returns 409 DUPLICATE_REQUEST_IN_FLIGHT rather than racing.
Other merchantsKeys are scoped to your account and environment. Another merchant sending the same key string cannot reach your record, and you cannot reach theirs.
Key reused for a different callThe same key with a different request body is 409 IDEMPOTENCY_KEY_REUSED — never the first call’s response, and never a second intent. Retries must resend the original body unchanged; a genuinely new request needs a new key.
Webhook deliveriesEach carries a stable X-RSend-Delivery-Id. Dedupe on it — a retried delivery is the same event, not a new one.
Settlement matchingA given on-chain payment matches one intent once. The indexer will not record it twice, and a reversed payment is signalled with payment.reversed.

Retry 5xx and 429, not 4xx

A 5xx or 429 is worth retrying with exponential backoff. A 4xx (other than rate limits) means the request itself needs fixing — retrying it unchanged will fail the same way.

The chain is the ultimate backstop

If a webhook is lost or an API call is ambiguous, you are never stuck guessing: read the PaymentMade event for your reference on BaseScan and reconcile directly. See Reporting.