A connection can drop after the API has made a change but before your system hears the answer. An idempotency key lets your system send the request again without making the change twice.
Send a key with every write
Every request that creates or changes something (POST, PUT or PATCH) must carry an Idempotency-Key header: a value your system makes up, 1 to 255 characters, unique to that one change. A UUID is a good key. A write with no key, an empty one or a longer one answers 400 idempotency_key_required and changes nothing.
curl -X POST "https://your-org.backed.giving/api/v2/funding_applications/1287/notes" \
-H "Authorization: Bearer $BACKED_TOKEN" \
-H "Idempotency-Key: 7d1f0c2e-5b8a-4f3e-9c61-2a4b8e9d0f13" \
-H "Content-Type: application/json" \
-d '{"body":"Budget checked against the supplier quote."}'What a retry gets
| You send | The API answers |
|---|---|
| The same key, method, path and body within 24 hours | The first request’s status and body again, with Idempotent-Replayed: true. Nothing is written a second time. |
| The same key with a different method, path or body | 422 idempotency_key_reused. Nothing is written. |
| The same key while the first request is still running | 409 idempotency_in_progress with Retry-After. Nothing is written: retry after that many seconds. |
- The path includes its query string. The body is compared as JSON, so the order of its properties does not matter.
- A key belongs to the credential that sent it: two credentials never share one.
- A refused write keeps nothing against its key, so you can send the same key again once you have fixed the request.
- After 24 hours the key is forgotten, and the same key is a new request.
If a key keeps answering 409 idempotency_in_progress, its first request stopped part-way. Check whether the record exists before you write again with a new key.
import { randomUUID } from 'node:crypto';
async function write(method, path, body, token) {
const key = randomUUID(); // one key per change, reused on every retry of it
for (let attempt = 1; ; attempt += 1) {
const response = await fetch(`https://your-org.backed.giving${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
'Idempotency-Key': key,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}).catch(() => null); // a dropped connection: retry with the same key
const retryable = response === null || [409, 429, 500, 503].includes(response.status);
if (!retryable || attempt === 5) return response;
const wait = Number(response?.headers.get('Retry-After') ?? 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
}If-Match: write at the version you read
A PATCH that updates a record must carry an If-Match header holding the record’s lock_version as your system last read it, bare or quoted (4 or "4"). If someone changed the record since, the update answers 412 precondition_failed and nothing changes: read the record again, then retry with its new lock_version and a new key.
curl -X PATCH "https://your-org.backed.giving/api/v2/registration_entities/311" \
-H "Authorization: Bearer $BACKED_TOKEN" \
-H "Idempotency-Key: 0b5e6f1a-93d2-4c47-8a1e-5f2d7c9b3e60" \
-H "If-Match: 4" \
-H "Content-Type: application/json" \
-d '{"founded_on":"2014-03-01"}'