# Errors and retries

> One way to handle errors against /api/public: read the code, decide whether to send the call again, wait the right time and send the same Idempotency-Key again.

Sooner or later Rivr answers with an error, or not at all. This guide gives one way to handle that which works against `/api/public`: you read `code` and never the text, decide whether to send the call again, wait `Retry-After` or back off yourselves, and send the same `Idempotency-Key` again so that a retry after a lost answer never does anything twice. The Node example is a file that Rivr's CI runs against a fake server, so the code you copy has been tested.

- Market: Sweden (`se`). Norway differs in currency, payment methods and what happens when nobody pays: https://developers.rivr.io/guides/errors-retries.md?market=no
- Language: English (`en`). Svenska: https://developers.rivr.io/guides/errors-retries.md?lang=sv
- 8 min read · 7 steps
- Page in the portal: https://developers.rivr.io/guides/errors-retries

Every example targets the sandbox (`https://api.test.rivr.io`) and needs test credentials. A platform client's tokens come from `https://auth.test.rivr.io`, an organisation key's from `https://auth.test.rivr.tech`.

## 1. Read the code, never the text

*Rivr → Journal system*

Every error from Rivr has a stable `code`, for example `order_paid`, `rate_limited` or `idempotency_mismatch`, and a `type` that is the address of the code's page under Error codes. The texts (`title`, `detail`, `message`) are for humans and may change. Program against `code`.

**`/api/public`.** Every error answer has a body with `code`, `type` and `message`, in the sandbox and in production since 2026-10-25. The operations' own errors are the JSON error object `propertyName`, `message`, `errorCode`, `code` and `type`: `errorCode` has the same value as `code` and stays for integrations that already read it.

**Errors without a `code`.** The `429` from the call limit on `/api/public` happens before the API itself has decided anything. There the HTTP status decides.

**Why:** `code` is the stable part of the answer. `title`, `detail` and `message` may change, so a system that matches on a sentence stops working the day the sentence is corrected.

**What your system does:** Read `code` from the body and let it, not the status alone, decide what your system does. Keep `requestId` when there is one (in the key check, the same as the `Rivr-Request-Id` header) and quote it when you contact Rivr. A `code` you don't recognise: look it up under Error codes, or let the status decide.

**Response (excerpt)**

```
HTTP/1.1 409 Conflict
Content-Type: application/json
Retry-After: 1

{
  "propertyName": "orderId",
  "message": "The order was modified concurrently by another request. Retry the request.",
  "errorCode": "order_modified_concurrently",
  "code": "order_modified_concurrently",
  "type": "https://developers.rivr.io/errors/order_modified_concurrently"
}
```

**In the portal:** [All error codes](https://developers.rivr.io/errors)

## 2. Should the call be sent again?

*Rivr → Journal system*

Rivr says whether the same call should be sent again, in different ways depending on where the error comes from.

**The key check in front of `/api/public`.** The errors it answers itself (`key_invalid`, `organization_required`, `organization_invalid`, `organization_not_accessible`, `route_not_available` and `access_check_unavailable`) have `retryWhen` as the last member and the header `Rivr-Should-Retry` since public-api 0.28.0 (2026-10-29). The header is `true` only for `same_request`, so a client that only wants to know whether to retry automatically need not read the body. Only `access_check_unavailable` is `same_request`.

**`/api/public` otherwise.** The operations' answers have `code` but neither `retryWhen` nor `Rivr-Should-Retry`. Look the code up in `/errors.json`: it has `retryWhen` for every code, including the ones that exist only on `/api/public`. Fetch the file once and keep it.

The values: **`same_request`** means send the very same call again, unchanged. **`later`**: the same call can succeed, but only once something outside it has changed, for example that another payment has finished. **`after_fix`**: change the call first. **`never`**: don't send it again, read the current state (the order, for example) and act on that.

**Why:** The status isn't enough. On `/api/public` a `409` is both `order_changed` (`same_request`: send the same call again) and `payment_in_progress` (`later`: only once the other payment has finished). The error catalog has read each code's own advice, so `retryWhen` is not guessed from the status.

**What your system does:** Read `Rivr-Should-Retry` first: `true` means send the call again. If it is `false` or missing, read `retryWhen` in the body, and if that is missing too, look `code` up in `/errors.json`. With no `code` at all the status decides: a `429` or `5xx` can succeed in a while, a `4xx` can't until the call has changed. Only send again a call that is safe to send again, see Idempotency-Key below.

> **Warning.** A `401` is not always an expired token. `authentication_unavailable` is `401` for historical reasons but means Rivr could not check the token just now: `same_request`, with the same token. Don't replace the token and don't ask the user to sign in again.

**Response (excerpt)**

```
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
Retry-After: 5
Rivr-Should-Retry: true

{
  "type": "https://developers.rivr.io/errors/access_check_unavailable",
  "title": "The access check is unavailable right now. Try again shortly.",
  "status": 503,
  "code": "access_check_unavailable",
  "detail": "The access check is unavailable right now. Try again shortly.",
  "instance": "/api/public/orders/7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14/status",
  "requestId": "5f0c2a7e-9b41-4d3a-8e6f-1c2d3b4a5e6f",
  "retryWhen": "same_request"
}
```

## 3. Wait the right time

*Journal system*

**If the answer has `Retry-After`: wait that many seconds, then send the same call.** Rivr sends it in whole seconds. It is on the `429` from the call limit (`rate_limited`, at least 1 second, on `/api/public`), on `503`s such as `access_check_unavailable`, `upstream_unavailable` (between 1 and 300 seconds) and `idempotency_unavailable`, and on `409`s such as `idempotency_in_progress` and `order_modified_concurrently`.

**If it is missing: back off yourselves.** Wait longer for each attempt, for example 1, 2, 4 and 8 seconds, with some random jitter so that clients that failed together don't come back together. Set a cap on how long you wait and on how many attempts you make, and give up instead of carrying on in a loop.

**Never more attempts on `never` and `after_fix`.** The same call gets the same answer until something has changed, and on `never` the answer is final: `idempotency_outcome_unknown` means Rivr doesn't know whether the refund was made, and another attempt doesn't answer that.

**Why:** A `429` has not done anything, since it is refused before the call reaches the service behind, so it is safe to send again. The limit is 1,200 calls a minute per token: a retry straight after a `429` just gets another one.

**What your system does:** Count the attempts per call. If `Retry-After` is longer than you want to keep a request open: put the call in your own queue and try again at that time.

**In the portal:** [Rate limits](https://developers.rivr.io/rate-limits)

## 4. Idempotency-Key: the same key, the same answer

*Journal system → Rivr*

An `Idempotency-Key` makes a call safe to send again: nothing happens twice. It exists today on:

**`/api/public`.** `POST /api/public/orders/{orderId}/refund` and `POST /api/public/orders/{orderId}/cancel`, in both the sandbox and production. In the sandbox also on `POST /api/public/orders/{orderId}/send-to-device` and `POST /api/public/subscriptions`, since platform release 2026.11.2.2782: production gets them with the platform's next release, and the changelog says when. Until then production ignores the header on those two. The header is optional but makes a retry safe. The key is 1 to 255 printable ASCII characters, a UUID is recommended, and it must be a random value without personal data. It applies to that very endpoint and to your API client (and the clinic, when the token is issued for one): the same value on `/cancel` is another key than on `/refund`.

**Creating an order** needs no key. `PUT /api/public/orders/{orderId}` is idempotent through the order id you choose: every request with the same id creates at most one order, so a retry after a lost answer gives the same order. The header is ignored there.

**Why:** Without a key you don't know what a lost answer means: the refund may or may not have been made. With the key you simply send the same call again.

> **Note.** The example below makes a new key for every run. In your code the key is made once per refund, before the first attempt, and sent unchanged on every retry. The code example at the end does that.

**Request:** `POST /api/public/orders/7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14/refund` · Merchant token · [Reference](https://developers.rivr.io/reference/api-public/refundOrder.md)

```bash
curl -X POST https://api.test.rivr.io/api/public/orders/7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14/refund \
  -H "Authorization: Bearer $MERCHANT_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)"
```

## 5. The same key gets the stored answer

*Rivr → Journal system*

The first answer with `2xx` is stored for 24 hours. If you send the same key again for the same order and with the same body, you get the stored answer back, with the header `Idempotent-Replayed: true`, instead of a second refund, cancellation, send or subscription request. An answer that refused the call (`4xx`) is not stored, so the same key can be used again once the cause is fixed.

If you send the same key for another order or with another body you get `422` `idempotency_mismatch`, and nothing is done. Use a new key for every new call: every new refund, cancellation, send or subscription request.

More errors come only when the header is there. `idempotency_key_invalid` (`400`): the key has the wrong form, send a new UUID. `idempotency_in_progress` (`409`): a request with the same key is still being processed, send the same call again after `Retry-After`. `idempotency_unavailable` (`503`): the key could not be checked and nothing was done, send the same call again after `Retry-After`. `idempotency_outcome_unknown` (`500`): the request failed after the change may have been made, and the same key gets the same answer for 24 hours.

**What your system does:** Make the key before the first attempt, store it with the call in your database, with the refund for example, and send exactly that on every retry, after a restart too, with exactly the same body: the body is compared byte for byte. If you get `idempotency_outcome_unknown`: read the state before you do anything more. After a cancellation, fetch the order with `GET /api/public/orders/{orderId}`: 32 (Cancelled) means it is done. After a refund, 4 (Completed) doesn't prove that nothing started, since the refund may be queued and card and Swish change the order only once the payment provider confirms. Read again after a while and follow `resolveWith` before you try with a new key. After a send to a device or a subscription request the order doesn't show whether it went through: a send doesn't change the order, and a subscription has no order. Then follow `resolveWith` on the code's page, which says what to look at instead.

**Response (excerpt)**

```
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "propertyName": "Idempotency-Key",
  "message": "The Idempotency-Key has already been used for a different request (another order or another request body). Nothing was done. Use a new key for every new request.",
  "errorCode": "idempotency_mismatch",
  "code": "idempotency_mismatch",
  "type": "https://developers.rivr.io/errors/idempotency_mismatch"
}
```

## 6. After a lost answer

*Journal system → Rivr*

A call can end with no answer at all: a timeout or a broken connection. Then you don't know whether Rivr did the work. This is how you send it again, by kind of call:

**With an `Idempotency-Key`** (refund, cancellation, and in the sandbox a send to a device and a subscription): send the same call again with exactly the same key and the same body. If Rivr had done the work you get the stored answer, otherwise it is done now. If the first request is still being processed you get `idempotency_in_progress`: wait `Retry-After`.

**Creating an order:** send the same `PUT` with the same order id. If two requests with the same id arrive at the same time, the one that loses gets `409` (`order_modified_concurrently`, with `Retry-After`), or in rare cases `500`: wait a moment and send again with the same id. A `PUT` on an unpaid order rewrites it and sends `Created` again, and a `PUT` on a cancelled or refunded order opens it again: give every new payment a new order id.

**Reads** (`GET`): just send them again.

**Other calls without a key:** check first. Fetch the order with `GET /api/public/orders/{orderId}` and see what happened before you send anything again. Cancellation is the exception: without a key a retry is safe as long as the order stays cancelled, and with a key even if the order has been opened again with `PUT` in the meantime.

**An error can leave the same question open.** Every code has `outcome` in `/errors.json` and on the code's page. `none`: this call did not do what you asked for and changed nothing else that you can see or that concerns money, and where there is an `outcomeNote`, it takes precedence. `done`: it has already happened. `partial`: it did not happen, but something else had already happened. `unknown`: the answer doesn't say whether what you asked for has happened, through this call or an earlier one. For those two, `outcomeNote` says what may have happened, and `resolveWith` gives, per operation, the read that shows the state and what in its answer decides, for example `GET /api/public/orders/{orderId}` after a refund. Read it before you send anything again.

> **Rule.** Never send a `POST` without an `Idempotency-Key` again after a lost answer. That can mean two refunds.

## 7. Code that does this

*Journal system*

The function `rivrFetch` does the steps above. It reads `Rivr-Should-Retry`, `retryWhen` or the code's value in `/errors.json`, waits `Retry-After` or backs off with jitter and a cap, and sends every attempt with the same body and the same `Idempotency-Key`. It sends again only a call that is safe to send again: `GET`, `PUT`, `DELETE` and a `POST` that has an `Idempotency-Key`. A `POST` without a key gets one attempt. The last answer is handed back, either a `2xx` or the error that ended the attempts, and you read `code` in it. At the bottom of the file is how it is used for a refund.

**Why:** If `Retry-After` is longer than the cap (30 seconds by default) the function hands the answer back instead of keeping the call open, so you can put it in a queue. That is the example's choice, not a rule from Rivr.

**Node**

```js
// Call Rivr, and call again when, and only when, Rivr's answer says another attempt helps.
// Node 18 or later, no dependencies. Rivr runs this exact file against a fake server in its CI.
//
//   Send again?   Rivr-Should-Retry: true   ->  yes (the key check in front of /api/public)
//                 retryWhen in the body     ->  same_request is yes, the other values are no
//                 a code but no retryWhen   ->  /errors.json has retryWhen for every code (/api/public's own errors)
//                 no code at all            ->  the status decides: 429 and 5xx yes, 4xx no
//   How long?     Retry-After seconds when the answer has it, otherwise 1, 2, 4, 8 seconds with jitter, up to a cap
//   What to send  the very same request every time: the same body and the same Idempotency-Key
//
// A call is sent again only when sending it twice does what sending it once does: GET, PUT, DELETE, and a POST that
// carries an Idempotency-Key. Put the key only on the operations that take it. Any other POST gets one attempt.
// In /api/public that is the refund and the cancellation. Send-to-device and POST /api/public/subscriptions take it in
// the sandbox, and in production only from the platform's next release: until then production ignores it there, so a
// retry of those two can make the call twice. Leave the key off them in production until the changelog says it's there.

export const ERRORS_JSON = "https://developers.rivr.io/errors.json";

let catalogOnce = null;

/** code -> retryWhen, read once from /errors.json. A catalog that can't be read only means the status decides. */
export function loadCatalog() {
  catalogOnce ??= fetch(ERRORS_JSON)
    .then((res) => res.json())
    .then((json) => new Map(json.errors.map((e) => [e.code, e.retryWhen])))
    .catch(() => {
      catalogOnce = null; // ask again next time
      return new Map();
    });
  return catalogOnce;
}

/** The error's JSON object, or {} when the body is empty or not JSON. */
async function errorBody(res) {
  try {
    const body = await res.clone().json();
    return body !== null && typeof body === "object" ? body : {};
  } catch {
    return {};
  }
}

/** "same_request" when the very same request should be sent again. Any other value means do not. */
async function retryWhenOf(res, catalog) {
  const body = await errorBody(res);
  const header = res.headers.get("rivr-should-retry");
  if (header === "true") return "same_request";
  if (typeof body.retryWhen === "string") return body.retryWhen;
  if (header === "false") return "never";
  if (typeof body.code === "string") {
    const known = (await catalog()).get(body.code);
    if (known) return known;
  }
  return res.status === 429 || res.status >= 500 ? "same_request" : "after_fix";
}

/** Rivr sends Retry-After in whole seconds. Anything else is ignored, and the backoff decides. */
function retryAfterMs(res) {
  const value = res.headers.get("retry-after")?.trim();
  return value && /^\d+$/.test(value) ? Number(value) * 1000 : null;
}

/** 1, 2, 4, 8 ... seconds, at most capMs, and somewhere between half and all of it so that clients drift apart. */
function backoffMs(attempt, baseMs, capMs, random) {
  const ceiling = Math.min(capMs, baseMs * 2 ** (attempt - 1));
  return Math.round(ceiling / 2 + (random() * ceiling) / 2);
}

/**
 * fetch with Rivr's retry rules. Returns the last response: a 2xx, or the error answer that ended the attempts. Read its
 * `code`, never its text. Throws only when no attempt got an answer at all.
 *
 * @param {string} url
 * @param {RequestInit} [init]       as for fetch; a body must be a string or a Buffer, so it can be sent again, and a
 *                                   signal of your own is replaced by the timeout of each attempt
 * @param {object} [options]
 * @param {number} [options.maxAttempts=5]  attempts in all, the first included
 * @param {number} [options.baseMs=1000]    the first wait when the answer has no Retry-After
 * @param {number} [options.capMs=30000]    the longest wait; a longer Retry-After ends the attempts (queue the call yourself)
 * @param {number} [options.timeoutMs=15000] how long one attempt may take before it counts as a lost answer
 * @returns {Promise<Response>}
 */
export async function rivrFetch(url, init = {}, options = {}) {
  const {
    maxAttempts = 5,
    baseMs = 1000,
    capMs = 30_000,
    timeoutMs = 15_000,
    catalog = loadCatalog,
    sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
    random = Math.random,
  } = options;
  const keyed = new Headers(init.headers).has("idempotency-key");
  const method = (init.method ?? "GET").toUpperCase();
  const repeatable = ["GET", "HEAD", "PUT", "DELETE"].includes(method) || keyed;
  const attempts = repeatable ? maxAttempts : 1;

  for (let attempt = 1; ; attempt++) {
    const last = attempt >= attempts;
    let res;
    try {
      res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
    } catch (err) {
      // No answer: a timeout or a broken connection. Rivr may or may not have done the work. Sending the same
      // request again, with the same Idempotency-Key, is what the key is for.
      if (last) throw err;
      await sleep(backoffMs(attempt, baseMs, capMs, random));
      continue;
    }
    if (res.ok || last) return res;
    if ((await retryWhenOf(res, catalog)) !== "same_request") return res;
    const wait = retryAfterMs(res) ?? backoffMs(attempt, baseMs, capMs, random);
    if (wait > capMs) return res;
    await res.body?.cancel();
    await sleep(wait);
  }
}

// In your app: one key per refund, made before the first attempt and stored with the refund, so that a restart
// sends the same key again.
//
// import { randomUUID } from "node:crypto";
//
// const key = randomUUID();
// await db.refunds.save(orderId, { idempotencyKey: key });
// const res = await rivrFetch(`https://api.test.rivr.io/api/public/orders/${orderId}/refund`, {
//   method: "POST",
//   headers: { Authorization: `Bearer ${merchantToken}`, "Idempotency-Key": key },
// });
// if (res.status !== 204) {
//   const problem = await res.json().catch(() => ({}));
//   throw new Error(`The refund ended with ${res.status} ${problem.code ?? "(no code)"}`); // log the code, never match on the text
// }
```

## Common pitfalls

- Reading `title`, `detail` or `message` to decide what happened. The texts may change, `code` does not.
- Making a new `Idempotency-Key` for every retry. Then every attempt is a new call and the protection is gone. Make the key once, before the first attempt.
- Sending `Idempotency-Key` on an operation that doesn't take it and believing the call is protected. The header exists only on the operations listed in the Idempotency-Key step, and on `PUT /api/public/orders/{orderId}` it is ignored.
- Treating every `401` as an expired token. `authentication_unavailable` is `401` but goes through with the same token in a while.
- Retrying a `4xx` in a loop. A `400` or `422` gets the same answer again until you have changed the call, and `never` is final.
- Fetching `/errors.json` for every error. The portal has a limit of 300 requests a minute for all clients together, and the file changes only with a deploy: fetch it once and keep it.

## Next guide

- [Cancel and refund](https://developers.rivr.io/guides/cancel-refund.md): Cancel an unpaid order, pay back a paid one — and wait for the webhook.
- [The checklist before the first clinic](https://developers.rivr.io/guides/go-live.md): What must be proven in test before a real clinic is switched on.
