# AcelleMail Webhook Signature Verification and Replay Protection

> When you receive a webhook from AcelleMail (or 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. This guide walks the HMAC-SHA256 signature verification pattern AcelleMail uses internally for Ads webhook reception, the constant-time-compare hazard most operators get wrong on first attempt, the replay-protection cache pattern, and how to apply the same techniques to your own outbound integrations.

- Canonical URL: https://acellesend.com/kb/articles/acellemail-webhook-signature-verification-and-replay-protection
- Author: Tom Becker, Developer Editor
- Category: Developer Guide — REST API, webhooks, customization, extending source code, plugins
- Content type: reference · Difficulty: advanced
- Tags: Acellemail
- Published: 2026-07-04 · Last updated: 2026-08-15
- Reading time: 11 min
- Publisher: AcelleMail Knowledge Base, https://acellesend.com/kb

---

## 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:

1. Read the raw request body **before parsing it as JSON** (crucial — JSON.stringify of the parsed object produces different bytes than the original).
2. Compute the expected HMAC using your stored secret + the raw body.
3. **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`](https://github.com/acelle/acelle) is the source-of-truth example. Two key methods:

### `verifyMeta($rawBody, $signatureHeader, $appSecret)`

```php
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:

1. **`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).

2. **`hash_equals($expected, $signatureHeader)`** is a **constant-time comparison**. NEVER use `===` or `==` for signature comparison. See the next section.

3. **`$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)`

```php
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:

1. 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).
2. First time we see that ID, record it in cache with a TTL.
3. 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:**

```php
if ($expected === $received) {  // ← TIMING ATTACK
    return true;
}
```

**Good:**

```php
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:

```javascript
// 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](https://acellesend.com/kb/articles/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:

```php
$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**:

```php
// 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)

```php
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

```javascript
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)

```python
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.

### Go

```go
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:

```go
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:

```php
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:

```javascript
// 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

- [Getting Started with the AcelleMail REST API](https://acellesend.com/kb/articles/getting-started-with-the-acellemail-rest-api) — API auth (Bearer tokens — different from webhook secrets)
- [REST API Authentication and Endpoints](https://acellesend.com/kb/articles/rest-api-authentication-and-endpoints) — full endpoint reference
- [Webhook Events Reference](https://acellesend.com/kb/articles/webhook-events-reference) — full outbound event catalog
- [Using API Triggers for Custom Automation Workflows](https://acellesend.com/kb/articles/using-api-triggers-for-custom-automation-workflows) — when YOU send events INTO AcelleMail
- [Extending AcelleMail Source Code](https://acellesend.com/kb/articles/extending-acellemail-source-code) — adding custom outbound webhooks
- [Post-Install Hardening Checklist](https://acellesend.com/kb/articles/post-install-hardening-checklist) — security baseline that includes webhook setup

---

## Related articles

- [Webhook Events Reference](https://acellesend.com/kb/articles/webhook-events-reference): AcelleMail has three distinct webhook systems — customer-lifecycle webhooks (SaaS billing events), per-campaign webhooks (open/click/bounce on a specific campaign), and inbound webhooks from sending providers (SES SNS, Mailgun events). This reference documents each, with the real event names from the Acelle source — not the made-up ones some older guides list.
- [Getting Started with the AcelleMail REST API](https://acellesend.com/kb/articles/getting-started-with-the-acellemail-rest-api): AcelleMail ships a REST API that exposes nearly every operation the admin UI offers — create lists, add subscribers, send campaigns, fire automations, read reports. This guide walks the 4 things you need to make your first successful API call: where to find your token, how to send it, what to call first, and how to debug when it doesn't work.
- [REST API Authentication and Endpoints](https://acellesend.com/kb/articles/rest-api-authentication-and-endpoints): The complete reference for AcelleMail's REST API — all resources, all routes, the per-endpoint request/response shapes, and the scoping rules. Use this alongside the getting-started guide; this article is the lookup table.
- [Extending AcelleMail Source Code](https://acellesend.com/kb/articles/extending-acellemail-source-code): When the REST API + webhooks aren't enough, AcelleMail's open Laravel codebase lets you extend it directly — add custom fields, integrate a custom sending provider, listen to internal events, override Blade views. This guide walks the upgrade-safe extension patterns, the project structure as it actually is (not what older docs say), and the discipline that keeps your customisations alive through Acelle upgrades.
- [BIMI with VMC — Cost, Process, and Whether It's Worth It](https://acellesend.com/kb/articles/bimi-with-vmc-cost-walkthrough): VMC (Verified Mark Certificate) costs $1,500/yr per domain and takes 4-8 weeks to obtain. This guide walks the cost math, the trademark requirements, the alternative paths, and a decision framework for whether VMC is right for your sending program.
