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.
// 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).
| Code | HTTP | Meaning |
|---|---|---|
INVALID_API_KEY | 401 | Missing, malformed, or revoked API key |
INSUFFICIENT_SCOPE | 403 | A read key attempted a non-GET request |
company_profile_required | 403 | Submit your company profile in /app — the one gate on sandbox access. Shaped {"detail":{"code":…}} |
approval_pending | 403 | Manual approval outstanding. Applies to rsend_live_ keys only — the sandbox does not wait on it |
approval_declined | 403 | Your organisation was declined; carries a reason. Blocks both environments |
UNSUPPORTED_TOKEN | 400 | Token is not enabled on the requested chain |
UNSUPPORTED_CHAIN | 400 | Chain has no settlement path at all |
TESTNET_ONLY / MAINNET_ONLY | 400 | Key environment does not match the requested chain |
INVALID_STATE | 400 | Action not allowed from the intent’s current state (cancel, resolve) |
INVALID_STATUS | 400 | Unknown status filter on the transactions list |
WEBHOOK_INACTIVE | 400 | Cannot test-fire a deactivated webhook |
INTENT_NOT_FOUND | 404 | No payment intent with that id for your account and environment |
WEBHOOK_NOT_FOUND | 404 | No webhook with that id for your account and environment |
SETTLEMENT_IN_FLIGHT | 409 | Cancel attempted after an on-chain payment was already observed |
DUPLICATE_REQUEST_IN_FLIGHT | 409 | Same idempotency key still being processed — see below |
IDEMPOTENCY_KEY_REUSED | 409 | Same idempotency key sent with a different request body — see below |
SETTLEMENT_WALLET_MISSING | 422 | No recipient given and no settlement wallet configured |
SETTLEMENT_WALLET_AMBIGUOUS | 422 | The owner wallet maps to more than one organisation — pass an explicit recipient |
SPLIT_UNAVAILABLE | 422 | Split payments are not enabled on that chain |
WEBHOOK_URL_FORBIDDEN | 422 | Webhook URL is not HTTPS, or resolves to a private/reserved address |
RATE_LIMIT_EXCEEDED | 429 | Per-endpoint limit hit. Body is {error, retry_after} |
KEY_RATE_LIMIT_EXCEEDED | 429 | Your key’s global per-minute limit hit |
MONTHLY_LIMIT_EXCEEDED | 429 | Key’s monthly intent quota reached (unlimited unless one is set) |
RATE_LIMIT_UNAVAILABLE | 503 | Rate limiting is fail-closed and cannot verify right now — retry |
SERVICE_TEMPORARILY_UNAVAILABLE | 503 | Idempotency cannot be verified right now — retry with backoff |
BACKEND_UNREACHABLE | 502 | The request exceeded the 25s edge timeout, or the API was unreachable |
INTERNAL_ERROR | 500 | Unexpected 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.
| Endpoint | Limit |
|---|---|
POST /payment-intent (create) | 100 / minute |
POST /payment-intent/{id}/cancel · /resolve | 10 / minute |
GET /payment-intent/{id} | 60 / minute |
GET /transactions | 60 / minute |
POST /webhook/register | 5 / hour |
POST /webhook/test | 10 / minute |
Public checkout status (/api/v1/public/payment-intent/{id}) | 20 / minute per IP |
| Global, across all endpoints | 100 / 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
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
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:
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" }'| Surface | Guarantee |
|---|---|
| Repeated request | A 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 duplicate | A retry arriving while the first is still in flight waits briefly, then returns 409 DUPLICATE_REQUEST_IN_FLIGHT rather than racing. |
| Other merchants | Keys 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 call | The 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 deliveries | Each carries a stable X-RSend-Delivery-Id. Dedupe on it — a retried delivery is the same event, not a new one. |
| Settlement matching | A 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
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.