Six operations require an Idempotency-Key header. A request to one without it is rejected with
422 and common.validation_failed, naming the header.
| Requires one | Why |
|---|---|
POST /messages |
Sends a real person an email and can spend a credit. |
POST /messages/batch |
The same, up to a hundred times. |
POST /messages/{id}/retract |
Cancels queued sends and reports how many. |
POST /messages/{id}/sms |
Spends a credit. |
POST /messages/{id}/whatsapp |
Spends a credit. |
POST /recipients/{email}/whatsapp/erase |
Deletes data and issues a receipt. |
Nothing else does, and that is the rule rather than an oversight: the header is required exactly where repeating a call would repeat an effect somebody can see. Setting a recipient’s number twice leaves one number, and revoking a key twice leaves it revoked, so those are safe to repeat on their own terms.
Sending the header anyway on an operation that does not require it is harmless. Both SDKs do, on
every POST.
curl https://api.beaconbox.eu/api/v1/messages \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4711-shipped" \
-d '{"recipient_email": "buyer@example.com",
"subject": "Your order has shipped",
"body": "Tracking XY123456789EE."}'What happens on a repeat
Same key, same body. The stored response is replayed, with the status code it originally returned. Nothing is written and nothing is sent a second time.
Same key, different body. 409 with idempotency.key_reused. Replaying would answer a
different update with the old result and never deliver the new one, so this is a hard error rather
than a quiet replay.
Same key, still in flight. 409 with idempotency.request_in_progress. Retry; it will replay
the stored response once the first call finishes.
Keys are scoped to your business and to the operation. The same key on a push and on a retract are two different things and do not collide.
A key is remembered for 24 hours. That is the window a replay answers inside; past it the key is forgotten, and the same key sent again is a new request that does the work a second time. It is sized for a retry, not for deduplicating a week of pushes.
A key per intent
The SDKs generate a key per call and reuse it across their own retries, which is what makes an SDK retry safe. You can do better by supplying your own.
Derive it from the thing that makes the intent unique. Usually the order and the step.
# The order and the step, not a fresh UUID. A retry of this exact
# intent from anywhere collapses onto the same key.
curl https://api.beaconbox.eu/api/v1/messages \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4711-dispatched" \
-d '{"recipient_email": "buyer@example.com",
"subject": "Your order is on its way",
"body": "Collected by the courier.",
"reference": "#A-10294",
"order_status": "shipped",
"kind": "updateable"}'def notify_dispatched(order) -> None:
client.messages.push(
recipient_email=order.email,
subject="Your order is on its way",
body=f"Collected by the courier. Tracking {order.tracking}.",
reference=order.reference,
order_status="shipped",
kind="updateable",
idempotency_key=f"order-{order.id}-dispatched",
)function notifyDispatched(BeaconBox\BeaconBoxClient $client, Order $order): void
{
$client->messages->push(
new BeaconBox\Model\MessagePush(
recipientEmail: $order->email,
subject: 'Your order is on its way',
body: "Collected by the courier. Tracking {$order->tracking}.",
reference: $order->reference,
orderStatus: BeaconBox\Enum\OrderStatus::Shipped,
kind: BeaconBox\Enum\MessageKind::Updateable,
),
idempotencyKey: "order-{$order->id}-dispatched",
);
}When the connection dies
An error with no response is the case all of this exists for. It is not proof the work did not happen: the request may have been processed and the answer lost on the way back.
The correct response is to retry with the same key. If the first attempt landed, you get its response replayed. If it did not, the work happens now. Either way the customer gets one message.
Both SDKs do this for you, with exponential backoff and full jitter, retrying connection failures,
429 and 5xx, and never retrying a 4xx, because sending the same wrong request again asks the
same question.
# Retrying by hand: same key, same body.
for attempt in 1 2 3; do
code=$(curl -s -o /tmp/out.json -w '%{http_code}' \
https://api.beaconbox.eu/api/v1/messages \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4711-dispatched" \
-d @push.json) && [ "$code" -lt 500 ] && break
sleep $((attempt * 2))
done
cat /tmp/out.jsonfrom beaconbox import APIConnectionError, BeaconBox, RetryPolicy
# Inside a job runner that already retries, turn the SDK's own schedule off so
# the two do not multiply.
client = BeaconBox(retry_policy=RetryPolicy(max_retries=0))
try:
client.messages.push(..., idempotency_key="order-4711-dispatched")
except APIConnectionError:
# Let the job runner retry. The same key makes that safe.
raiseuse BeaconBox\Exception\ApiConnectionException;
use BeaconBox\RetryPolicy;
// Inside a queue worker that already retries, turn the SDK's schedule off.
$client = new BeaconBox\BeaconBoxClient(
apiKey: getenv('BEACONBOX_API_KEY'),
retryPolicy: new RetryPolicy(maxRetries: 0),
);
try {
$client->messages->push($push, idempotencyKey: 'order-4711-dispatched');
} catch (ApiConnectionException $e) {
throw $e; // let the worker retry; the key makes that safe
}Batches
A batch takes one key for the whole call. Replaying it replays the whole batch response, including the per-item failures, rather than re-running the items that succeeded. See Batches.
