> ## Documentation Index
> Fetch the complete documentation index at: https://docs.saasybill.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency

> Retry writes safely, without creating a second subscription or a second bill

Networks fail. A request can time out after Saasybill has already done the work, and your platform can fire the same signup event twice. Without protection, either one creates a second subscription and a second invoice.

An idempotency key makes a write safe to repeat. Send the same key with the same request and Saasybill does the work once.

***

## Send a key

Add an `Idempotency-Key` header to any `POST` request.

```bash theme={null}
curl https://app.saasybill.com/api/v1/subscriptions \
  -H "Authorization: Bearer $SAASYBILL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup_8f3a2c1d" \
  -d '{ "customer_code": "ACME-001", "plan_code": "TEAM-MONTHLY", "units": 10 }'
```

| Rule | Detail |
| - | - |
| Length | 1 to 255 characters. |
| Characters | Printable ASCII with no spaces. |
| Where it is required | [Create a subscription](/api-reference/subscriptions/create-a-subscription). Leaving it off answers `422 idempotency_key_required`. |
| Where it is optional | Every other `POST`: create a customer, change units, end, restore. |
| `GET` requests | Not needed. They don't change anything. |
| How long it lasts | 24 hours. |
| Scope | Per API key. Two keys can use the same string without clashing. |

<Tip>
  Use an id from your own system, such as your signup id, order id or event id. A random value generated on each attempt defeats the purpose, because a retry would send a different key.
</Tip>

***

## What happens on a repeat

Saasybill compares the method, the path and the body of the repeat with the first request.

| Situation | Result |
| - | - |
| Same key, same request, first one succeeded | The stored response is returned again, with the header `Idempotent-Replayed: true`. Nothing is done twice. |
| Same key, different request | `409 idempotency_conflict`. Use a new key for a different request. |
| Same key while the first request is still running | `409 request_in_progress`, with `Retry-After: 2`. Wait, then retry. |
| Same key, first request failed | The key is released. Send the request again, with the same key or a corrected body. |

Only a success is stored. A `4xx` that was refused before anything happened doesn't lock you out of your own key, so you can fix the body and resend under the same key.

<Note>
  A `201` from `POST /v1/subscriptions` counts as a success even when `invoice.xero.sync_status` is `failed`. The subscription and invoice exist, so a repeat returns that same response. It doesn't raise a second invoice.
</Note>

***

## Extra protection for subscriptions

A new subscription's id is derived from the API key and the idempotency key. If a server crashes after the subscription is saved but before the response is stored, your retry finds the subscription it already made and returns it. A crash can't turn into a second subscription.

***

## Retry pattern

Retry on timeouts, `429`, `500` and `502`, with the same key and the same body. Back off between attempts.

```javascript theme={null}
async function createSubscription(body, idempotencyKey) {
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      const response = await fetch("https://app.saasybill.com/api/v1/subscriptions", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.SAASYBILL_API_KEY}`,
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body: JSON.stringify(body),
      });

      const retryable = response.status === 429 || response.status >= 500 || response.status === 409;
      if (!retryable) return response;

      // 409 is only worth retrying while the first request is still running.
      if (response.status === 409) {
        const { error } = await response.clone().json();
        if (error.code !== "request_in_progress") return response;
      }

      const wait = Number(response.headers.get("Retry-After")) || 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
    } catch {
      // A network error or timeout: the request may have gone through. Retry with the same key.
      await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
    }
  }
  throw new Error("Saasybill did not respond after 5 attempts");
}
```
