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.

What this is for

AcelleMail has three distinct webhook systems, and they're often confused. This reference documents each, with the real event names from the Acelle source (per config/webhook_events.php and the webhooks migration) — not the invented event names some older third-party guides list.

System Direction Use case Configured at
Customer-lifecycle webhooks Outbound (Acelle → your app) SaaS billing events — when a customer signs up, changes plan, cancels Admin → Webhooks
Per-campaign webhooks Outbound (Acelle → your app) When a specific campaign sends / errors Campaign → Edit → Webhooks tab
Sending-provider notifications Inbound (sending provider → Acelle) Bounce + complaint events from SES / Mailgun / SendGrid /api/v1/notification/bounce + /feedback (auto-configured per sending server)

Pick the right one for your use case before reading further.

System 1 — Customer-lifecycle webhooks (the SaaS billing system)

This is the system most people mean when they ask about Acelle webhooks. Used by SaaS operators to keep their CRM, analytics, or billing system in sync with Acelle's customer + subscription state.

Configure at Admin → Webhooks → New Webhook. Each webhook subscribes to one event and posts to a URL of your choice.

Available events

Per config/webhook_events.php — this is the authoritative list:

Event When it fires Params posted
new_subscription A customer starts a paid subscription on a plan customer_id, plan_id
cancel_subscription A customer cancels (subscription remains active until period end) customer_id, plan_id
terminate_subscription A subscription ends (after cancel grace period, or hard end) customer_id, plan_id
change_plan A customer moves between plans customer_id, old_plan_id, new_plan_id
new_customer A customer account is created customer_id
automation_webhook A "webhook" action fires inside an Acelle automation flow automation_id

subscriber.created, campaign.opened, campaign.clicked etc. are NOT events in this system. Older docs sometimes list them; they don't exist. For subscriber + campaign events, use system 2 (per-campaign webhooks) or poll the API.

Webhook delivery shape

POST https://your-app.example.com/webhooks/acelle
Content-Type: application/x-www-form-urlencoded

customer_id=123&plan_id=45

Plus any auth header you configured (see below).

Webhook configuration

The webhook form lets you set:

  • Name — your label
  • Event — one of the six above
  • Request method — POST (default), GET, PUT, PATCH, DELETE
  • Request URL — your endpoint
  • Authentication — None, Bearer Token, Basic Auth, or Custom Header
  • Headers — extra headers (e.g. X-Source: acellemail)
  • Body typeform-urlencoded (default), JSON, raw text
  • Body params — the event payload params (auto-populated) or your custom mapping
  • Retry on failure — number of retries + delay between attempts
  • Status — Active / Inactive

A webhook only fires when its status = active. Disable to pause without deleting.

Triggering an automation_webhook from a flow

Inside an automation flow, you can add a "Webhook" action. When a subscriber reaches that step, the configured automation_webhook fires with the automation_id (and optionally subscriber merge tags in the body).

This is the bridge from automation flows to external systems — fire a webhook to your CRM when a lead reaches "qualified", or to Slack when a high-value subscriber takes a key action.

System 2 — Per-campaign webhooks

For events about a specific campaign (sent, opened, clicked, bounced, complained, unsubscribed), use the per-campaign webhook system.

Configure at Campaign → Edit → Webhooks tab (or via the campaign-creation wizard). Each campaign can have multiple webhooks; each subscribes to one event.

Available campaign events

The per-campaign webhook table (campaign_webhooks) supports:

  • campaign.sent — campaign send completed
  • campaign.open — a subscriber opened the email
  • campaign.click — a subscriber clicked a tracked link
  • campaign.bounce — a delivery failure (hard / soft)
  • campaign.complaint — a spam complaint
  • campaign.unsubscribe — a recipient unsubscribed via the campaign

Payload includes the subscriber email, campaign uid, and event-specific fields (clicked URL, bounce reason, etc.).

When to use this vs polling the API

Pattern Use when
Per-campaign webhook You need real-time per-event reaction (push to Slack on bounce; sync to CRM on click)
Poll /api/v1/campaigns/{uid}/...-log/download You need a batch import of all events for a campaign; or your app can't accept inbound HTTP

For most external-system integrations, webhooks win — they're real-time and lower-cost than polling.

System 3 — Sending-provider inbound notifications

The third system is for inbound webhooks — your sending provider (Amazon SES via SNS, Mailgun event hooks, SendGrid event webhook) posts bounce + complaint events to AcelleMail.

You usually don't configure these directly — Acelle auto-configures them when you set up the sending server (e.g. SES SNS topic + subscription is created during the SES sending-server wizard).

Routes:

  • POST /api/v1/notification/bounce — receives bounce events
  • POST /api/v1/notification/feedback — receives spam-complaint events

If you're building a custom sending provider integration, you'd POST events to these endpoints in the documented format. Otherwise, ignore — Acelle handles it transparently.

Signature verification (where applicable)

For the System 1 customer-lifecycle webhooks, signature verification depends on what auth method you configured:

  • Bearer Token — Acelle sends Authorization: Bearer <your-token>. Your endpoint checks the header matches the token you set. Simple, recommended.
  • Basic Auth — Acelle sends Authorization: Basic <base64(user:pass)>. Your endpoint validates as usual.
  • Custom Header — Acelle sends a custom header with a static value (e.g. X-Webhook-Secret: my-shared-secret). Verify equality with constant-time comparison.

Acelle's webhook system does NOT use HMAC signature on the body by default (unlike Stripe / GitHub). The auth header is the entire security mechanism. Make sure your endpoint is HTTPS-only so the auth header isn't exposed in transit.

If you need HMAC body signing, implement it via the Custom Header option — configure a header that contains the HMAC of the body computed in a Laravel listener you add via extending source.

Retry behaviour

The webhook config lets you set:

  • setting_retry_times — how many times to retry on non-2xx response
  • setting_retry_after_seconds — wait time between retries

Recommended defaults: 3 retries with 60-second backoff. Past 3 retries, the webhook is marked failed and won't fire again until manually re-enabled.

Your endpoint MUST:

  • Return 2xx within 30 seconds to count as success
  • Be idempotent — Acelle may retry on transient failures; your endpoint should handle duplicate deliveries gracefully (key on customer_id + event + timestamp)
  • Acknowledge quickly + process async — if your processing takes > 5s, accept the webhook, enqueue a job, return 200 immediately. Slow webhook handlers cause retry storms.

Verifying webhooks work

# 1. Set up a temporary catch-all endpoint at https://webhook.site/
#    (or use ngrok to forward to localhost)

# 2. Configure a webhook in Admin → Webhooks → New
#    Event: new_customer
#    URL: <your webhook.site URL>

# 3. Trigger the event — create a new test customer in the admin

# 4. Check webhook.site — you should see the POST with customer_id in the body

If nothing arrives:

  • Check Admin → Webhooks → Logs for the delivery attempt and the response code Acelle saw
  • Verify your endpoint returned 2xx
  • Verify webhook status is "Active"
  • Check the queue is processing (webhooks are dispatched via the same queue as the rest of Acelle — see Setting Up Queue Workers)

Common issues

What you see Likely cause Fix
No webhook fires when expected Webhook status = Inactive, or event mismatch Verify status is Active; verify the event matches what you actually triggered
Endpoint receives one webhook many times Endpoint returned non-2xx; Acelle retried Make endpoint return 2xx faster; or fix the underlying issue causing the error response
Webhook payload missing custom fields Customer-lifecycle webhooks only carry customer_id / plan_id. Subscriber data isn't included Use customer_id to fetch the customer via API for additional data
Webhook arrives without auth header Auth method = None Reconfigure with Bearer / Basic / Custom Header
Endpoint says "signature invalid" You enabled HMAC verification but Acelle doesn't ship HMAC Use header-based auth instead, OR implement HMAC via a custom listener (extending source)
Webhooks back up after a sending storm Queue worker pool saturated Increase worker numprocs (see scaling guide)
Per-campaign webhook fires before "sent" event for transactional sends Race condition between send + tracking event Process events idempotently; don't assume strict ordering
Need a subscriber.created event Doesn't exist in customer-lifecycle webhooks Use per-campaign webhook for campaign.sent, OR poll the subscribers API for new records

FAQ

Why no subscriber.created event? Acelle's customer-lifecycle webhook system was designed for SaaS billing — it covers events the operator cares about (new customer, plan change, cancellation), not events the customer would care about (new subscriber on their list). For subscriber events, use the per-campaign webhook system (system 2) or poll the API.

Can I add custom events? Yes — fire a custom event from a Laravel listener (added via extending source), then add it to config/webhook_events.php. It'll appear in the webhook config UI.

Do webhooks count against API rate limits? No — webhooks are outbound from Acelle, not API calls from your code.

Can the webhook payload be JSON instead of form-urlencoded? Yes — set "Body type" to JSON in the webhook config. The same param keys are posted, just JSON-encoded.

How do I retry a manually-failed webhook? Admin → Webhooks → Logs → click the failed delivery → "Retry". Or trigger the original event again.

Where can I see what URL Acelle is calling? Admin → Webhooks → click the webhook → see configured URL + recent delivery log.

Can I forward webhook payloads to multiple endpoints? Configure multiple webhooks for the same event, each with a different URL. Each fires independently.

Related articles

17 Kommentare

5 Kommentare

  1. cw.dev.sh
    Built a Cloudflare Worker to bridge Shopify → AcelleMail webhooks last year. The signature verification pattern in this article is the same one I used. ~30 lines total.
    1. admin (bearbeitet)
      Appreciate the data point. Your numbers align with what our larger-volume customers report; helpful to see a third confirmation.
  2. tranminh.devop…
    Webhook signature verification is the kind of thing most docs hand-wave. The constant-time-compare warning is genuinely important — Ive seen production codebases with == on signatures.
    1. admin
      Appreciate it. If anything in this needs updating, ping us — we revisit articles every few months.
    2. admin (bearbeitet)
      Glad it landed. Drop suggestions in the comments and we'll incorporate them on the next refresh
    3. admin (bearbeitet)
      Thanks. Pass it along if it helps your team. anyway
  3. m.schmidt78
    Can webhook secrets be rotated without downtime, or is there an overlap mechanism? We rotate everything quarterly
    1. admin
      We're aware of the silent-bail-out on deleted customers — there's an open issue for it. Workaround for now: monitor the campaign:rerun log for absence of expected log lines, alert when silent for > 20 min.
    2. admin (bearbeitet)
      Same answer as above for SaaS-tenant — works the same way per-tenant, with the caveat that the cron must be set per-customer (not just system-wide).
  4. rafa.silva.br
    What's the recommended retry policy for failed deliveries on the receiver side? We see ~1% transient failures and aren't sure if we should auto-retry or rely on AcelleMail's redelivery
    1. admin
      Depends on your version. 5.x supports it natively; 4.x needs a config flag set in `.env`. Well note this caveat in the article on the nex pass.
    2. admin (bearbeitet)
      Good question — and one that comes up often enough we should add an FAQ section. Short answer: yes for the common case; the exception is when you're running custom plugins that override the default behavior
    3. admin (bearbeitet)
      We don't recommend that approach in production. It works in dev but has subtle race conditions under concurrent load. Stick with the documented pattern.
    4. admin (bearbeitet)
      good question. The campaign:rerun audit writes to laravel.log only when the audit decides to force-resume — pure noop runs are silent. We'll add an info-level heartbeat in a future Acelle release to make it easier to monitor
    5. admin (bearbeitet)
      right — for rds specifically, you can change wait_timeout via the parameter group without a reboot if it's set as 'dynamic'. most defaults are.
  5. ravi.kumar.del…
    If you're on Node, use the `crypto.timingSafeEqual)` instead of `===` for signature compare. The article mentions it but it's worth emphasizing — this is a real CVE pattern.
    1. admin
      Good tip. The Cloudflare-outbound-rate-limit case is something we hadn't documented.

More in Developer Guide