Webhooks
ACE SEND posts events — a message delivered, read or failed, a low balance — to an HTTPS address of yours. Every delivery is signed with your secret, following Standard Webhooks.
Set up
In the console, Settings → API and webhooks (owners and developers):
- Enter the address. It must be
https://on port 443, with a domain name (not an IP address) that resolves to public addresses only. Redirects are not followed. - Choose the events.
- Copy the secret (
whsec_…) to your server. - Send a test event — a
test.pinggoes to the address right away, whatever events are chosen.
The account has one webhook address. The console shows every delivery of the last 30 days with its attempts and answers, and can retry a failed one.
Events
| Event | When |
|---|---|
message.sent | Message sent |
message.delivered | Message delivered |
message.read | Message read |
message.failed | Message failed |
template.status | Template reviewed |
balance.low | Balance is low |
campaign.finished | Campaign finished |
test.ping | Test event (sent on demand, cannot be chosen) |
message.failed covers the statuses rejected, failed, expired and cancelled; data.status says which. Events of test messages carry "test": true in the data.
The request
http
POST /hooks/ace-send HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: ACE-SEND-Webhooks/1
webhook-id: whd_CgdWeHQNPm2BJ3HKzjYWt
webhook-timestamp: 1793620804
webhook-signature: v1,o61V81Bi612VDBDvxqFnc13T6m90nu59Ymx5ju/Tud4=
{"type":"message.delivered","timestamp":"2026-11-02T12:00:04+00:00","data":{"id":"msg_CgdWeHQN4sPaZnXfh7kP5","status":"delivered", …}}Every body has the same envelope:
| Field | |
|---|---|
type | The event, e.g. message.delivered. |
timestamp | When the event happened, UTC. |
data | The event data. For message.* it is the message as GET /v1/messages/{id} returns it, without events. |
For message.*, data.status is the status of this event. The rest of data is read when the webhook is sent and may already be newer.
Verify the signature
Check every delivery before you trust it:
- Take
webhook-id,webhook-timestampand the raw body — the bytes as received, before any JSON parsing. - Refuse a
webhook-timestampmore than 5 minutes away from your clock: it protects against replayed requests. - Compute HMAC-SHA256 of
{webhook-id}.{webhook-timestamp}.{body}with the key = the secret afterwhsec_, base64-decoded. Base64-encode the result. webhook-signatureholds one or more signatures separated by spaces, eachv1,<base64>. The delivery is genuine if one of them equals yours. Compare in constant time.
js
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 5 * 60
/**
* Checks an ACE SEND webhook (Standard Webhooks). Pass the raw body exactly as received: a parsed and
* re-serialized JSON no longer matches the signature.
*
* @param {string} secret the whsec_… secret of the webhook address
* @param {Record<string, string | undefined>} headers the request headers, lower-case names
* @param {string} body the raw request body
* @param {number} [now] Unix time in seconds
* @returns {boolean}
*/
export function verifyWebhook(secret, headers, body, now = Math.floor(Date.now() / 1000)) {
const id = headers['webhook-id']
const timestamp = headers['webhook-timestamp']
const signatures = headers['webhook-signature']
if (!id || !timestamp || !signatures) return false
// An old timestamp may be a replayed request.
if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS)
return false
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest()
// The header may carry several signatures separated by spaces: any valid v1 one is enough.
return signatures.split(' ').some((entry) => {
const [version, signature] = entry.split(',')
if (version !== 'v1' || !signature) return false
const given = Buffer.from(signature, 'base64')
return given.length === expected.length && timingSafeEqual(given, expected)
})
}py
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(secret: str, headers: dict, body: bytes, now: int | None = None) -> bool:
"""Checks an ACE SEND webhook (Standard Webhooks).
Pass the raw body exactly as received: a parsed and re-serialized JSON no longer matches the signature.
`headers` uses lower-case names.
"""
webhook_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not webhook_id or not timestamp or not signatures:
return False
# An old timestamp may be a replayed request.
now = int(time.time()) if now is None else now
if not timestamp.isdigit() or abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{webhook_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
# The header may carry several signatures separated by spaces: any valid v1 one is enough.
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return Falsephp
<?php
const TOLERANCE_SECONDS = 5 * 60;
/**
* Checks an ACE SEND webhook (Standard Webhooks). Pass the raw body exactly as received
* (file_get_contents('php://input')): a decoded and re-encoded JSON no longer matches the signature.
*
* @param array<string, string> $headers the request headers, lower-case names
*/
function verifyWebhook(string $secret, array $headers, string $body, ?int $now = null): bool
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if ($id === '' || $timestamp === '' || $signatures === '') {
return false;
}
// An old timestamp may be a replayed request.
$now ??= time();
if (! ctype_digit($timestamp) || abs($now - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
if ($key === false) {
return false;
}
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
// The header may carry several signatures separated by spaces: any valid v1 one is enough.
foreach (explode(' ', $signatures) as $entry) {
[$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $signature)) {
return true;
}
}
return false;
}Libraries of Standard Webhooks (standardwebhooks for JavaScript, Python, PHP, Go, Java, Ruby and others) verify ACE SEND webhooks too.
Test vector
Your check must accept this delivery (with the clock at 1793620804, or the timestamp check turned off):
| Secret | whsec_AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcY |
webhook-id | whd_CgdWeHQNPm2BJ3HKzjYWt |
webhook-timestamp | 1793620804 |
| Body | {"type":"message.delivered","timestamp":"2026-11-02T12:00:04+00:00","data":{"id":"msg_CgdWeHQN4sPaZnXfh7kP5","status":"delivered"}} |
webhook-signature | v1,o61V81Bi612VDBDvxqFnc13T6m90nu59Ymx5ju/Tud4= |
Answer fast
Answer with any 2xx within 5 seconds (3 of them for the connection). The body of your answer is ignored. Save the event and process it afterwards, in a queue of your own.
Any other answer — 3xx, 4xx, 5xx — a timeout or a connection error is a failed attempt.
Retries
A failed delivery is retried, up to 8 attempts in about 10.5 hours:
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Wait before it | — | 5 min | 10 min | 20 min | 40 min | 80 min | 160 min | 320 min |
Times are approximate (up to a minute later). After the 8th failure the delivery is failed; you can still retry it from the console. Each attempt has a fresh webhook-timestamp and signature.
The address is never turned off automatically. If your server was down, the missed events are in GET /v1/messages — use it to reconcile.
Duplicates and order
- Duplicates are possible. All attempts of one delivery share its
webhook-id: remember the ids you processed (a day is enough) and answer2xxto a repeat without processing it again. - Order is not guaranteed. A
message.sentcan arrive aftermessage.delivered. Use the order of statuses, not the order of arrival, and never move a message back to an earlier status.
Rolling the secret
Roll the secret in the console issues a new one and stops the old one at once: deliveries from then on, retries included, are signed with the new secret. Put the new secret on your server right after rolling. A delivery your server refuses in between is not lost: it is retried, the first time after 5 minutes. If you keep the old secret in your check next to the new one until the switch is done, nothing fails at all.