Delivery happens after your push returns, so it cannot be in the response. Webhooks are how it reaches you.
Registering an endpoint
curl https://api.beaconbox.eu/api/v1/webhook-endpoints \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/beaconbox"}'# No event_types: receive everything, including types added later.
endpoint = client.webhook_endpoints.create("https://example.com/hooks/beaconbox")
print(endpoint.secret) # shown once, like an API key// No eventTypes: receive everything, including types added later.
$endpoint = $client->webhookEndpoints->create('https://example.com/hooks/beaconbox');
echo $endpoint->secret; // shown once, like an API keyAn empty or omitted event_types means every event, and that is the recommended setting: a narrow
subscription is how an event type added later silently passes your integration by. Branch on
event.type in your handler and ignore what you do not recognise.
The url must be HTTPS and must resolve to a public address. Private, loopback and link-local
addresses are refused, and redirects are never followed, because a public URL that redirects to an
internal one is the cheapest way around a firewall.
The signing secret is returned once, at creation, and never again. Listing endpoints returns it masked.
The envelope
Every delivery is the same four fields, serialized with sorted keys and no whitespace.
{
"data": {
"message_id": "v55favd767qu",
"recipient_email": "buyer@example.com"
},
"id": "evt_9k2x4m7qd81a",
"occurred_at": "2026-10-02T20:19:44.903112+00:00",
"type": "message.bounced"
}data carries what the event is about. Fields in it vary by type; message_id and
recipient_email are on every message event.
Verifying a delivery
Every delivery carries three headers:
| Header | Carries |
|---|---|
X-BeaconBox-Signature |
t=<unix>,v1=<hex> |
X-BeaconBox-Event |
The event type, for routing before parsing |
X-BeaconBox-Delivery |
The event id, the same value as id in the body |
The signature is HMAC-SHA256 over "<t>.<raw body>" using your endpoint secret.
# Verifying by hand, for a language with no SDK. t and v1 come from the header.
# Split "t=1790000000,v1=abc..." into the two parts first.
T=$(printf '%s' "$SIG_HEADER" | sed 's/.*t=\([0-9]*\).*/\1/')
V1=$(printf '%s' "$SIG_HEADER" | sed 's/.*v1=\([0-9a-f]*\).*/\1/')
BODY=$(cat request-body.json)
EXPECTED=$(printf '%s.%s' "$T" "$BODY" \
| openssl dgst -sha256 -hmac "$BEACONBOX_WEBHOOK_SECRET" -r \
| cut -d' ' -f1)
# Real code compares in constant time and rejects a stale $T as well.
[ "$EXPECTED" = "$V1" ] && echo ok || echo rejectimport os
from beaconbox import WebhookEventType, WebhookVerificationError, webhooks
@app.post("/hooks/beaconbox")
async def hook(request):
try:
event = webhooks.verify(
await request.body(), # the RAW body, byte for byte
request.headers["X-BeaconBox-Signature"],
os.environ["BEACONBOX_WEBHOOK_SECRET"],
)
except WebhookVerificationError:
return Response(status_code=400)
if already_handled(event.id):
return {"ok": True}
if event.type == WebhookEventType.MESSAGE_BOUNCED:
mark_address_bad(event.data["recipient_email"])
record_handled(event.id)
return {"ok": True}use BeaconBox\Enum\WebhookEventType;
use BeaconBox\Exception\WebhookVerificationException;
use BeaconBox\Webhooks;
try {
$event = Webhooks::verify(
rawBody: file_get_contents('php://input'), // the RAW body, byte for byte
signatureHeader: $_SERVER['HTTP_X_BEACONBOX_SIGNATURE'] ?? '',
secret: getenv('BEACONBOX_WEBHOOK_SECRET') ?: '',
);
} catch (WebhookVerificationException) {
http_response_code(400);
return;
}
if (alreadyHandled($event->id)) {
http_response_code(200);
return;
}
if ($event->type === WebhookEventType::MessageBounced) {
markAddressBad($event->data['recipient_email']);
}
recordHandled($event->id);
http_response_code(200);The helpers also check the timestamp, which is inside the signed string, so a captured delivery
cannot be replayed later. They compare in constant time, and they raise
WebhookVerificationError on a malformed or hostile signature rather than letting an unhandled
exception out, so a single except really does answer 400 to everything.
Deduplicate on the event id
Deliveries are retried, so the same event.id can arrive more than once. A delivery that times
out on your side may well have been processed.
Store handled ids and skip repeats. X-BeaconBox-Delivery carries that same id, so you can
deduplicate from the header before parsing the body if you would rather. It is the event’s id and
not the attempt’s: a retry of one event arrives with the id it had the first time, which is what
makes it usable for this.
What you can subscribe to
Only outcomes you could not already know. There is no message.created: you just made that call
and got the result.
| Event | Means |
|---|---|
message.delivered |
A mailbox provider accepted the email. |
message.opened |
The recipient followed the link and the inbox loaded. |
message.bounced |
The address rejected it. |
message.failed |
The send failed. |
message.complained |
Marked as spam. |
message.not_sent |
No email was attempted, with a reason. |
sms.delivered, sms.failed, sms.rejected |
The text’s outcome. |
whatsapp.delivered, whatsapp.failed, whatsapp.rejected |
The WhatsApp message’s outcome. |
whatsapp.read |
They opened it. No email or SMS equivalent exists. |
whatsapp.inbound_message |
They replied. |
credits.low_balance |
The balance is running down. |
plan.past_due |
A payment failed. The paid period still has time on it. |
plan.lapsed |
Email sending has stopped. |
plan.allowance_exceeded |
The included email allowance is used up. |
The three to wire up before you need them
message.not_sent is the per-message half of the same story, and tells you an email was never
attempted so you can stop waiting for delivery on it.
credits.low_balance is the one that stops a paid channel going quiet without anybody
noticing.
Delivery behaviour
Your endpoint should answer 2xx quickly and do the work afterwards. A slow handler looks the
same as a broken one.
Failed deliveries are retried. An event whose retries all fail counts once against the endpoint, and 20 of those in a row disable it instead of letting it be retried for ever. A single success resets the count.
Both the dashboard and the API tell you.

GET /api/v1/webhook-endpoints returns the same thing to your own code: disabled,
consecutive_failures, last_success_at and last_error for each endpoint. If deliveries stop
arriving, that is what says why.
curl https://api.beaconbox.eu/api/v1/webhook-endpoints \
-H "Authorization: Bearer $BEACONBOX_API_KEY" \
| jq '.items[] | {url, disabled, consecutive_failures, last_error}'for endpoint in client.webhook_endpoints.list():
if endpoint.disabled:
alert_ops(f"BeaconBox webhook disabled: {endpoint.url} ({endpoint.last_error})")foreach ($client->webhookEndpoints->list() as $endpoint) {
if ($endpoint->disabled) {
alertOps("BeaconBox webhook disabled: {$endpoint->url} ({$endpoint->lastError})");
}
}Send nothing back in the body; it is not read.
Testing an endpoint
POST /api/v1/webhook-endpoints/{id}/test sends one sample ping event and reports what came
back. There is a button for it in Settings too.
Two things make it safe to lean on. The event type is ping rather than a real one, so a handler
that acts on message.* cannot mistake a test for a genuine delivery. And a test does not touch
the endpoint’s health counters, so trying one against an endpoint you are repairing cannot be what
disables it.
curl -X POST https://api.beaconbox.eu/api/v1/webhook-endpoints/wep_7k2x9m4qd1/test \
-H "Authorization: Bearer $BEACONBOX_API_KEY"result = client.webhook_endpoints.test("wep_7k2x9m4qd1")
if not result.delivered:
print(result.status_code, result.error, f"{result.duration_ms}ms")$result = $client->webhookEndpoints->test('wep_7k2x9m4qd1');
if (!$result->delivered) {
echo $result->statusCode, ' ', $result->error, ' ', $result->durationMs, 'ms';
}