How to Use Idempotency Keys
Idempotency keys prevent duplicate operations when a request is retried due to network failures, timeouts, or client-side errors. This is critical for endpoints that create resources or trigger financial transactions.
CLI users
These instructions are for direct HTTP API clients. The official Chainabit CLI creates and reuses idempotency identities automatically for supported mutation commands; do not pass a key to the CLI.
Why Idempotency Matters
Without idempotency protection, retrying a failed request can result in:
- Duplicate charges -- a customer is billed twice for the same purchase
- Duplicate resources -- two identical discounts are redeemed
- Inconsistent state -- parallel retries create conflicting records
Idempotency keys solve this by letting the server recognize repeated requests and return the original response instead of executing the operation again.
Environment Variables
Set these variables before running any example on this page:
export BASE_URL="https://api.chainabit.com/api/v1"
export TOKEN="your-access-token"Which Endpoints Require It
The following endpoints require (or strongly recommend) an Idempotency-Key header:
| Endpoint | Why |
|---|---|
POST /billing/checkout | Prevents duplicate charges |
POST /billing/discounts/redeem | Prevents redeeming a discount code twice |
Other POST endpoints accept the header but do not require it. It is good practice to include it on any mutating request where a duplicate would cause problems.
How to Use It
Send the Idempotency-Key header with a unique identifier. A UUID v4 is recommended.
Example: Checkout
Checkout also requires recent passkey re-authentication. On the first request without a qualifying proof, the API returns 403 STEP_UP_REQUIRED with error.details.challenge; no checkout is created. Complete the offered WebAuthn ceremony, then retry the same checkout request with the same idempotency key. The examples below show the request shape, not an immediately successful checkout.
curl -s -X POST "https://api.chainabit.com/api/v1/billing/checkout" \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"variantId": "3f2b9c1e-4d5a-4b6c-8d7e-9f0a1b2c3d4e"
}'const BASE_URL = process.env.BASE_URL;
const TOKEN = process.env.TOKEN;
const response = await fetch(`${BASE_URL}/billing/checkout`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
variantId: "3f2b9c1e-4d5a-4b6c-8d7e-9f0a1b2c3d4e",
}),
});
const { data } = await response.json();import requests
import uuid
import os
BASE_URL = os.environ["BASE_URL"]
TOKEN = os.environ["TOKEN"]
response = requests.post(
f"{BASE_URL}/billing/checkout",
headers={
"Authorization": f"Bearer {TOKEN}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"variantId": "3f2b9c1e-4d5a-4b6c-8d7e-9f0a1b2c3d4e"},
)
data = response.json()["data"]Response before re-authentication (IDs vary by request):
{
"error": {
"code": "STEP_UP_REQUIRED",
"message": "Billing management requires recent authentication",
"details": {
"code": "STEP_UP_REQUIRED",
"message": "Billing management requires recent authentication",
"challenge": {
"challengeId": "<challenge-id>",
"actionClass": "billing.manage",
"minimumAssurance": "phishing_resistant",
"acceptableMethods": ["webauthn"],
"maxAgeSeconds": 300
}
}
},
"meta": { "requestId": "<request-id>" }
}Checkout is created for the account the request acts in (selected with the x-workspace-id or x-account-id header). It requires the owner, admin, or billing role in that account; other members receive 403. Pass variantId (a variant UUID from the products list) or variantSystemKey.
If another checkout for the same plan and account is being created at the same moment, the request returns 409 with error code billing_checkout_in_progress; retry the request.
Example: Redeem a Discount
The discount is redeemed for the account the request acts in. This requires the owner, admin, or billing role in that account.
curl -s -X POST "https://api.chainabit.com/api/v1/billing/discounts/redeem" \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"code": "LAUNCH2026"
}'const BASE_URL = process.env.BASE_URL;
const TOKEN = process.env.TOKEN;
const response = await fetch(`${BASE_URL}/billing/discounts/redeem`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
code: "LAUNCH2026",
}),
});
const { data } = await response.json();import requests
import uuid
import os
BASE_URL = os.environ["BASE_URL"]
TOKEN = os.environ["TOKEN"]
response = requests.post(
f"{BASE_URL}/billing/discounts/redeem",
headers={
"Authorization": f"Bearer {TOKEN}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"code": "LAUNCH2026"},
)
data = response.json()["data"]Generating Idempotency Keys
Use any UUID v4 generator. Here are a few options:
# Linux / macOS
uuidgen// Node.js 19+ / modern browsers
crypto.randomUUID()
// Older Node.js
require('crypto').randomUUID()import uuid
print(uuid.uuid4())Rules
One key per logical operation. Generate a new key for each distinct user action. Do not reuse keys across different operations.
Same key = same request body. If you retry with the same idempotency key but a different request body, the server returns a
422 Unprocessable Entityerror.Keys expire after 24 hours. After expiry, the same key can be used for a new operation. Do not rely on this -- always generate fresh keys.
Keys are scoped to your account. Two different accounts can use the same key value without conflict.
Error Responses
| Status | Meaning |
|---|---|
200 | Idempotent replay -- returning the original response |
409 Conflict | A request with this key is currently being processed |
422 Unprocessable Entity | The key was already used with a different request body |
Conflict Example
If a previous request with the same key is still in progress:
{
"statusCode": 409,
"message": "A request with this idempotency key is already being processed",
"error": "Conflict"
}Wait briefly and retry. The original request will complete and subsequent retries will return the cached response.