Every error is the same shape.
{ "error_code": "message.not_found" }A validation failure adds a detail array describing which field and why.
{
"error_code": "common.validation_failed",
"detail": [
{
"type": "string_too_long",
"loc": ["body", "subject"],
"msg": "String should have at most 300 characters",
"input": "..."
}
]
}Status codes
| Status | When |
|---|---|
401 |
Key missing, malformed or revoked. |
403 |
Authenticated, but not permitted. Also a business that is no longer active. |
404 |
No such id. Also what another business’s id looks like. |
409 |
Idempotency key reused with a different body, or a request still in flight. |
413 |
The request body is larger than the endpoint accepts. |
422 |
A malformed field, or text that cannot be stored. |
429 |
Rate limited. |
5xx |
Our problem. Retry with the same idempotency key. |
Codes worth handling
Most integrations need four branches and a fallback.
| Code | Status | What to do |
|---|---|---|
business.invalid_api_key |
401 | Stop and alert. Retrying will not help. |
idempotency.key_reused |
409 | A bug in your key derivation. Alert, do not retry. |
idempotency.request_in_progress |
409 | Retry shortly; it will replay the first result. |
common.validation_failed |
422 | Log detail and fix the caller. Never retry. |
message.not_found |
404 | The id is wrong, or belongs to another business. |
Everything else is worth logging with its code and moving on.
Bodies too large, and text that cannot be stored
A body over the limit is refused before it is read, with request.too_large and a 413. The
limit is 2 MiB, or 8 MiB on POST /messages/batch, which fits a full batch of maximum-length
messages. It is checked before authentication, so it does not depend on your key.
Text holding a NUL character (\u0000 in JSON, %00 in a URL) or an unpaired UTF-16
surrogate is refused anywhere: any body field, path or query parameter. It is an ordinary
common.validation_failed with the field in detail. If such text ever slips past that check,
the database refuses it and the answer is a 422 with request.unstorable_input instead.
Strip the character and send again.
Neither is worth retrying unchanged. Neither spends the idempotency key, so a corrected request can reuse it.
# The code is the thing to read. jq makes that explicit.
curl -s -o /tmp/r.json -w '%{http_code}\n' \
https://api.beaconbox.eu/api/v1/messages/nope \
-H "Authorization: Bearer $BEACONBOX_API_KEY"
jq -r '.error_code' /tmp/r.jsonfrom beaconbox import (
AuthenticationError,
BeaconBoxError,
ConflictError,
InvalidRequestError,
)
try:
result = client.messages.push(..., idempotency_key=key)
except AuthenticationError:
alert_ops("BeaconBox key rejected")
raise
except InvalidRequestError as exc:
log.error("beaconbox rejected the push", extra={"code": exc.error_code})
raise # a retry sends the same wrong request
except ConflictError as exc:
if exc.error_code == "idempotency.request_in_progress":
raise RetryLater from exc
raise
except BeaconBoxError as exc:
log.warning("beaconbox call failed", extra={"code": exc.error_code})
raiseuse BeaconBox\Exception\AuthenticationException;
use BeaconBox\Exception\BeaconBoxException;
use BeaconBox\Exception\ConflictException;
use BeaconBox\Exception\InvalidRequestException;
try {
$result = $client->messages->push($push, idempotencyKey: $key);
} catch (AuthenticationException $e) {
alertOps('BeaconBox key rejected');
throw $e;
} catch (InvalidRequestException $e) {
$log->error('beaconbox rejected the push', ['code' => $e->errorCode]);
throw $e; // a retry sends the same wrong request
} catch (ConflictException $e) {
if ($e->errorCode === 'idempotency.request_in_progress') {
throw new RetryLater(previous: $e);
}
throw $e;
} catch (BeaconBoxException $e) {
$log->warning('beaconbox call failed', ['code' => $e->errorCode]);
throw $e;
}Exceptions in the SDKs
Both map the same statuses to the same shapes, so an integration with a service in each language handles a bad ten minutes the same way.
| Python | PHP | When |
|---|---|---|
AuthenticationError |
AuthenticationException |
401 |
PermissionDeniedError |
PermissionException |
403 |
ResourceMissingError |
ResourceMissingException |
404 |
ConflictError |
ConflictException |
409 |
InvalidRequestError |
InvalidRequestException |
422 |
RateLimitError |
RateLimitException |
429, after the SDK retried |
ServerError |
ServerException |
5xx, after the SDK retried |
APIConnectionError |
ApiConnectionException |
No answer at all |
WebhookVerificationError |
WebhookVerificationException |
A delivery could not be proven ours |
All inherit from BeaconBoxError and BeaconBoxException respectively, and all carry
error_code / errorCode.
Three things that are not errors
Code that treats any of these as a failure will retry something that already succeeded.
No answer at all
APIConnectionError is the one case that needs saying twice: it is not proof the work did not
happen. The request may have been processed and the response lost.
Retry it with the same idempotency key. That is the whole reason the key is mandatory.
