What this is for#
When you receive a webhook from AcelleMail — or when AcelleMail receives one from a third-party event source like Meta — the receiver has no built-in trust that the request came from where it claims. The Internet just hands HTTP requests to whatever URL is published. Without signature verification, anyone who finds your webhook URL can POST arbitrary payloads to it.
This guide walks the HMAC-SHA256 signature verification pattern AcelleMail uses internally (and that you should adopt for your own custom webhook receivers), the replay-protection cache pattern that prevents duplicate processing, and the constant-time-compare hazard that most operators get wrong on first attempt.
The canonical source is app/Services/Ads/WebhookSignatureVerifier.php in AcelleMail. This guide explains the design and shows equivalent implementations in PHP, Node.js, Python, and Go.
What signature verification actually does#
When AcelleMail (or any properly-designed event source) sends you a webhook, it includes a header like:
X-Webhook-Signature: sha256=a3b6f5e8c7d2…
The signature is computed as:
signature = HMAC-SHA256(secret_key, raw_request_body)
Both sides know the secret_key (you configured it when registering the webhook). To verify, you:
- Read the raw request body before parsing it as JSON (crucial — JSON.stringify of the parsed object produces different bytes than the original).
- Compute the expected HMAC using your stored secret + the raw body.
- Constant-time compare the expected signature against the received signature.
If they match, the request was signed by someone who knows the secret — almost certainly the legitimate sender. If they don't match, reject with 401.
This protects against:
- Spoofed requests — attackers can't fake a valid signature without the secret.
- Modified payloads — if anything in the body changed in transit, the signature breaks.
- Stale credentials — rotating the secret immediately invalidates all previously-issued signatures (so leaked URLs aren't enough on their own).
It does not protect against:
- Replay attacks — a captured valid request can be re-sent verbatim. Replay protection (covered below) is a separate concern.
- Secret-key exfiltration — once an attacker has your secret, they can sign arbitrary requests. Treat the secret like a password.
- Endpoint discovery — signature verification doesn't hide that your endpoint exists.
The canonical pattern (PHP, from AcelleMail source)#
AcelleMail's app/Services/Ads/WebhookSignatureVerifier.php is the source-of-truth example. Two key methods:
verifyMeta($rawBody, $signatureHeader, $appSecret)#
public function verifyMeta(string $rawBody, ?string $signatureHeader, ?string $appSecret): bool
{
$enforce = (bool) config('ads.webhooks.enforce_signature', false);
if ($appSecret === null || $appSecret === '') {
// Misconfigured install — can't verify. Observation mode still
// lets events through for dev/staging where the secret isn't set.
return !$enforce;
}
if ($signatureHeader === null || $signatureHeader === '') {
return !$enforce;
}
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $appSecret);
if (hash_equals($expected, $signatureHeader)) {
return true;
}
return !$enforce;
}
Three things to notice:
-
hash_hmac('sha256', $rawBody, $appSecret) computes the HMAC. The order of arguments is algorithm, data, key. Passing them in the wrong order silently produces wrong output (no error, just wrong bytes).
-
hash_equals($expected, $signatureHeader) is a constant-time comparison. NEVER use === or == for signature comparison. See the next section.
-
$enforce flag toggles between "reject on mismatch" (production) and "log + accept" (observation mode for staging). This makes safe rollout possible — you can deploy verification in observation mode, watch logs for false-positive rejections, fix the issue, then enable enforcement. AcelleMail's ads.webhooks.enforce_signature config flag drives this.
recordAndCheckFresh($platform, $eventId)#
public function recordAndCheckFresh(string $platform, string $eventId): bool
{
$key = "ads_webhook_replay:{$platform}:{$eventId}";
$ttl = (int) config('ads.webhooks.replay_protection_ttl_seconds', 300);
if (Cache::has($key)) {
return false; // already seen
}
Cache::put($key, 1, $ttl);
return true;
}
Replay protection lives next to signature verification. The pattern:
- Each webhook event has a unique
eventId from the sender (Meta sends id in their payload; AcelleMail can use the campaign uid or webhook delivery uid).
- First time we see that ID, record it in cache with a TTL.
- Subsequent calls within the TTL return
false → caller should ACK (return 200) but not re-process.
AcelleMail's default TTL is 300 seconds (5 minutes). This is a balance:
- Too short (60s) — slow-retrying senders may legitimately resend after that, and we'd double-process.
- Too long (24h) — cache memory grows; legitimate retries beyond TTL would fail (rare).
5 minutes works for ~99% of webhook senders (most retry within 30-60 seconds; few re-send the same event minutes later).
The constant-time-compare hazard#
This is the bug ~50% of first-time webhook-verifier implementations have.
Bad:
if ($expected === $received) { // ← TIMING ATTACK
return true;
}
Good:
if (hash_equals($expected, $received)) { // ← CONSTANT-TIME
return true;
}
The problem with ===: PHP's string compare short-circuits on the first mismatched byte. By measuring how long the compare takes, an attacker can probe one byte at a time, eventually deriving the entire valid signature without ever knowing the secret. This is the classic timing-attack vulnerability.
hash_equals() compares all bytes regardless of where mismatches occur, taking constant time. Same applies to similar functions in other languages:
- Node.js:
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)) (both must be same length; pre-pad if needed)
- Python:
hmac.compare_digest(expected, received) (works on bytes or strings)
- Go:
subtle.ConstantTimeCompare([]byte(expected), []byte(received)) == 1
- Ruby:
Rack::Utils.secure_compare(expected, received) or OpenSSL.fixed_length_secure_compare(...)
If your language doesn't ship a primitive, implement it carefully:
// Don't write your own unless you have to — and study existing implementations first.
function constantTimeEquals(a, b) {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i);
return result === 0;
}
Receiving outbound webhooks from AcelleMail#
AcelleMail emits outbound webhooks for these events (from config/webhook_events.php):
| Event |
Trigger |
Payload params |
new_subscription |
A customer activates a subscription plan |
customer_id, plan_id |
cancel_subscription |
A customer cancels their subscription |
customer_id, plan_id |
new_customer |
A new customer is created |
customer_id |
change_plan |
A customer switches subscription plans |
customer_id, old_plan_id, new_plan_id |
terminate_subscription |
Subscription ends (typically billing failure) |
customer_id, plan_id |
automation_webhook |
An Automation webhook node fires |
automation_id + any custom merge data from the automation |
These are SaaS-lifecycle events from AcelleMail's billing layer, NOT per-subscriber email events (those go through the bounce/click/open tracking system, see REST API Authentication and Endpoints).
Setting up a receiver#
In your AcelleMail admin: Settings → Webhooks → New webhook. Fields:
- Event — pick from the list above.
- HTTP endpoint — your receiver URL.
- Secret — a random 32+ character string. SAVE THIS; you'll configure your receiver with it.
- Active — toggle on.
The default Webhook::newDefault() (Webhook.php:21) sets:
$webhook->setting_retry_times = 2; // retry twice on failure (3 attempts total)
$webhook->setting_retry_after_seconds = 900; // 15 minute delay between retries
If your endpoint is unreachable on first attempt, AcelleMail retries 15 minutes later, and again 15 minutes after that. After 3 total failures, the event is dropped.
This means your receiver must:
- ACK quickly (return 200 within ~5 seconds; longer and AcelleMail may time out).
- Be idempotent (the same event might be delivered multiple times if your earlier ACK was lost).
- Be reasonably available (down for >45 minutes = lost events).
For high-throughput receivers, the standard pattern is ACK fast, process async:
// In your webhook receiver controller:
public function handle(Request $request)
{
$raw = $request->getContent();
$signature = $request->header('X-Acelle-Signature');
// 1. Verify signature (your stored secret)
if (!$this->verifySignature($raw, $signature)) {
return response('Invalid signature', 401);
}
// 2. Queue for async processing
ProcessAcelleWebhook::dispatch($raw);
// 3. ACK immediately
return response('OK', 200);
}
Then your queue worker does the actual business logic. This pattern keeps response times in the millisecond range and protects you from slow downstream services blocking the webhook ACK.
Signature-verify implementations across languages#
The same HMAC-SHA256 pattern, four languages:
PHP (Laravel-flavored)#
function verifyAcelleSignature(string $rawBody, ?string $sigHeader, string $secret): bool
{
if (empty($sigHeader)) return false;
// Expect format: "sha256=<hex>"
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $sigHeader);
}
Node.js#
const crypto = require('crypto');
function verifyAcelleSignature(rawBody, sigHeader, secret) {
if (!sigHeader) return false;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const expBuf = Buffer.from(expected);
const sigBuf = Buffer.from(sigHeader);
if (expBuf.length !== sigBuf.length) return false;
return crypto.timingSafeEqual(expBuf, sigBuf);
}
Express body parsing gotcha: app.use(express.json()) consumes the body before your handler runs, so req.body is the parsed object — not raw bytes. Use express.raw({ type: 'application/json' }) instead, then parse JSON yourself after verifying.
Python (Flask/FastAPI)#
import hmac, hashlib
def verify_acelle_signature(raw_body: bytes, sig_header: str | None, secret: str) -> bool:
if not sig_header:
return False
expected = "sha256=" + hmac.new(
secret.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, sig_header)
Flask gotcha: use request.get_data(as_text=False) to get raw bytes — request.json already parses.
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func VerifyAcelleSignature(rawBody []byte, sigHeader, secret string) bool {
if sigHeader == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sigHeader))
}
net/http gotcha: r.Body is a one-shot stream. Read it once into a buffer, then re-wrap for downstream handlers:
body, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(body))
Replay protection in your receiver#
Mirror AcelleMail's pattern in your own receiver:
function isFreshAcelleEvent(string $eventId, int $ttlSec = 300): bool
{
$key = "acelle_webhook_replay:{$eventId}";
if (Cache::has($key)) {
return false; // already seen
}
Cache::put($key, 1, $ttlSec);
return true;
}
// In the handler:
if (!isFreshAcelleEvent($eventId)) {
return response('OK (duplicate)', 200); // ACK but don't re-process
}
// ... process the event
If your stack doesn't have a Cache abstraction, Redis works:
// Returns 1 if SET (fresh), 0 if NX failed (already seen).
const ok = await redis.set(`acelle_webhook_replay:${eventId}`, 1, 'EX', 300, 'NX');
if (ok !== 'OK') {
return res.status(200).send('OK (duplicate)');
}
For low-volume webhooks, a database table with a unique constraint on event_id works too — insert, catch the unique-violation, ACK without processing.
Common operator mistakes#
Reading parsed body for HMAC. JSON.stringify(req.body) reorders keys or drops whitespace, producing different bytes than the original. The signature will always fail. Always verify against the raw body.
Logging the secret. Don't log webhook secrets in error messages. A signature-failure log entry should include the request signature header (received), the path, the timestamp, the IP — NEVER the expected signature or the secret. Log levels: warn for mismatches; info for successful verifications (sample 1% or skip).
Trying to verify before middleware reads the body. Most web frameworks consume the request body in middleware. By the time your verification handler runs, raw bytes are gone. Configure your framework to expose raw bytes specifically on the webhook endpoint (Express raw middleware, Flask request.get_data(cache=True), etc.).
Using = instead of hash_equals() in test suites. Tempting because the test secret is short and "no one will time us." But the habit transfers to production code through copy-paste. Always use the constant-time primitive.
Rotating the secret without coordination. If you rotate AcelleMail's webhook secret, the AcelleMail side has the new value but your receiver still has the old one — every webhook fails until you update your receiver. Plan rotations with a brief overlap period if possible, OR rotate in one atomic operation if not.
Ignoring the enforce_signature flag in dev. Some operators run signature-verification in observation mode forever ("it's just for dev"). Then production silently lets in unverified requests. Always set enforce=true in production environment-specific config.
Not handling the case where you have multiple webhooks per event. AcelleMail supports multiple webhook configurations per event type. Each has its own secret. Your central receiver may need to look up the right secret based on path or webhook ID, not assume a single global secret.
Operational best practices#
- Store the secret in environment variables, not in code or config files. Use a secret manager (AWS Secrets Manager, HashiCorp Vault, doppler.com) for production rotation.
- Allow IP allowlisting as defense-in-depth (in addition to signature). Don't replace signature with IP-check — IPs change, and signature is stronger.
- Monitor signature failures via metrics. Sudden spike = either misconfiguration on the sender side or active attack. Investigate.
- Test rotation drills periodically. Annually if not more often. Practice the rotation, document the runbook, time it.
- Have a panic-rotation runbook. If a secret leaks, you should be able to rotate AcelleMail + receivers in under 10 minutes.
Related reading#