Idempotency
Networks fail. If a booking request times out, you can't tell whether Reeds received it. An Idempotency-Key lets you send the same request again without creating or charging twice.
Which requests need a key
Send an Idempotency-Key header (any unique string up to 200 characters) on every request that creates a delivery or moves money:
POST /deliveriesPOST /deliveries/{id}/bookPOST /deliveries/{id}/cancelPOST /wallet/fund
These requests are refused without a key, with 400 IDEMPOTENCY_KEY_REQUIRED. The header is also accepted on every other POST.
curl -X POST https://api.getreeds.co/shipping/v1/deliveries \
-H "Authorization: Bearer $REEDS_ACCESS_TOKEN" \
-H "Idempotency-Key: order-10045-delivery-1" \
-H "Content-Type: application/json" \
-d '{ "quote_id": "qt_2bX9Wn4K", "reference": "ORDER-10045" }'
How it works
- Keys are scoped to your account and the mode (Test or Live), and kept for 24 hours.
- Same key, same body: Reeds returns the original response with the header
Idempotent-Replayed: true. Nothing is created or charged twice. - Same key, different body:
422IDEMPOTENCY_KEY_REUSED. - Same key while the first request is still running:
409REQUEST_IN_PROGRESS. Wait a moment and retry.
What is saved, and what releases the key
Reeds saves and replays a response only when the same request would always get it again.
| Response | What happens to the key |
|---|---|
2xx success | Saved. Retrying replays the response. |
400, 404, 422 | Saved. Retrying replays the error. |
401, 402, 403, 409, 429 | Released. The same request can succeed once the cause is fixed (wallet funded, address confirmed, overdue invoice paid), so retry it with the same key. |
5xx | Released only if nothing was saved. See below. |
After fixing a 400 or 422, send the corrected request with a new key.
A 5xx after the work was saved
For a 5xx, the key's record is written in the same database transaction as the work. So:
- If nothing was saved, the key is released and your retry runs the request again.
- If the work was saved and the request failed afterwards (while queueing a webhook, for example), your retry replays the saved result like any success.
Either way, retrying a 5xx with the same key is safe. You never get a duplicate.
Choose good keys
Use a key that identifies the attempt, such as order-10045-delivery-1, or a UUID you store with the order.
- Generate the key once, before the first attempt, and reuse it for every retry of that attempt.
- Don't reuse your order number as the key if one order can have several deliveries. Put the order number in
referenceinstead. - Don't generate a new key on each retry. That defeats the purpose.
Retry loop with backoff
This example retries on network errors, 429 and 5xx, waits longer each time (and honours Retry-After), and reuses the same key.
- cURL
- Node.js
- PHP
- Python
KEY="order-10045-delivery-1"
for attempt in 1 2 3 4 5; do
STATUS=$(curl -s -o response.json -w '%{http_code}' \
-X POST https://api.getreeds.co/shipping/v1/deliveries \
-H "Authorization: Bearer $REEDS_ACCESS_TOKEN" \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{ "quote_id": "qt_2bX9Wn4K", "reference": "ORDER-10045" }')
# Done unless it is a retryable status (000 means a network error)
case "$STATUS" in 000|429|5??) ;; *) break ;; esac
sleep $((2 ** attempt))
done
cat response.json
import { randomUUID } from 'node:crypto';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function bookWithRetry(payload, key = randomUUID(), maxAttempts = 5) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetch('https://api.getreeds.co/shipping/v1/deliveries', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.REEDS_ACCESS_TOKEN}`,
'Idempotency-Key': key, // the same key on every attempt
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (res.status !== 429 && res.status < 500) return res.json();
const retryAfter = Number(res.headers.get('Retry-After'));
await sleep((retryAfter || 2 ** attempt) * 1000);
} catch (err) {
// Network error: we don't know if Reeds got it, so retry with the same key.
await sleep(2 ** attempt * 1000);
}
}
throw new Error('Gave up after retries');
}
const result = await bookWithRetry(
{ quote_id: 'qt_2bX9Wn4K', reference: 'ORDER-10045' },
'order-10045-delivery-1',
);
<?php
function book_with_retry(array $payload, string $key, int $maxAttempts = 5): array {
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$retryAfter = 0;
$ch = curl_init('https://api.getreeds.co/shipping/v1/deliveries');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('REEDS_ACCESS_TOKEN'),
'Idempotency-Key: ' . $key, // the same key on every attempt
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) {
if (stripos($line, 'Retry-After:') === 0) $retryAfter = (int) trim(substr($line, 12));
return strlen($line);
},
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// $status is 0 on a network error
if ($body !== false && $status !== 429 && $status < 500) {
return json_decode($body, true);
}
sleep($retryAfter ?: 2 ** $attempt);
}
throw new RuntimeException('Gave up after retries');
}
$result = book_with_retry(
['quote_id' => 'qt_2bX9Wn4K', 'reference' => 'ORDER-10045'],
'order-10045-delivery-1'
);
import os
import time
import requests
def book_with_retry(payload, key, max_attempts=5):
for attempt in range(1, max_attempts + 1):
try:
res = requests.post(
"https://api.getreeds.co/shipping/v1/deliveries",
headers={
"Authorization": f"Bearer {os.environ['REEDS_ACCESS_TOKEN']}",
"Idempotency-Key": key, # the same key on every attempt
},
json=payload,
timeout=30,
)
if res.status_code != 429 and res.status_code < 500:
return res.json()
wait = int(res.headers.get("Retry-After", 0)) or 2 ** attempt
except requests.RequestException:
# Network error: we don't know if Reeds got it, so retry with the same key.
wait = 2 ** attempt
time.sleep(wait)
raise RuntimeError("Gave up after retries")
result = book_with_retry(
{"quote_id": "qt_2bX9Wn4K", "reference": "ORDER-10045"},
"order-10045-delivery-1",
)
For 402 and 403, fix the cause first (fund the wallet, pay the invoice), then send the request again with the same key. Don't loop on them automatically.