Webhooks

Receive real-time HTTP POST notifications when delivery events occur on your emails. Webhooks are the primary way to track delivery status — there is no public polling endpoint.

Events

Subscribe to any combination of these seven events when creating a webhook in the dashboard.

EventWhen it fires
email.sentEmail accepted by the delivery provider and handed off for sending
email.deliveredReceiving mail server confirmed delivery to the recipient's inbox
email.bouncedEmail could not be delivered. bounceReason is "Permanent" (bad address — recipient is auto-suppressed) or "Transient" (temporary failure)
email.complainedRecipient marked the email as spam. Address is automatically suppressed
email.failedAll delivery attempts failed after retries
email.openedRecipient opened the email (tracked via pixel; may undercount due to image-blocking proxies)
email.clickedRecipient clicked a tracked link in the email
automation.run.startedA contact entered an automation
automation.run.completedTheir sequence ended. endedReason says how: completed, or an exit such as unsubscribed, bounced or topic_opt_out
automation.run.failedA step failed permanently and the rest of the sequence did not run. lastError says why

The three automation.run.* events carry a different body from the email.* events, described under automation payloads below. A run is not a message: it may have sent several emails or none, and it has no subject to report.

Payload format

Every webhook is a POST request with Content-Type: application/json. The body always contains these fields:

FieldTypeDescription
eventstringEvent type, e.g. "email.delivered"
emailIdstringUUID of the email that triggered this event
tostring[]Recipient address(es)
subjectstringEmail subject line
statusstringCurrent email status — matches the event name minus the email. prefix
timestampstringISO 8601 event time. Fixed at enqueue — does not change across retry attempts
messageIdstring · optionalDelivery message ID (opaque). Present on sent, delivered, bounced, complained, failed. Omitted on opened and clicked
bounceReasonstring · optional"Permanent" or "Transient". Present only on email.bounced
domainIdstring · optionalUUID of the sending domain. Present when the email has an associated verified domain
domainstring · optionalHuman-readable domain name, e.g. "mail.yourdomain.com". Present alongside domainId

Example payloads

Delivered

{
  "event": "email.delivered",
  "emailId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "to": ["customer@example.com"],
  "subject": "Your order is confirmed",
  "status": "delivered",
  "timestamp": "2026-07-05T12:00:00.000Z",
  "messageId": "0102018a2b3c4d5e-6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c-000000",
  "domainId": "d1e2f3a4-b5c6-7890-abcd-ef1234567890",
  "domain": "mail.yourdomain.com"
}

Bounced (permanent)

{
  "event": "email.bounced",
  "emailId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "to": ["bad-address@example.com"],
  "subject": "Your order is confirmed",
  "status": "bounced",
  "timestamp": "2026-07-05T12:00:01.000Z",
  "messageId": "0102018a2b3c4d5e-6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c-000000",
  "bounceReason": "Permanent",
  "domainId": "d1e2f3a4-b5c6-7890-abcd-ef1234567890",
  "domain": "mail.yourdomain.com"
}

Opened / clicked (no messageId)

{
  "event": "email.opened",
  "emailId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "to": ["customer@example.com"],
  "subject": "Your order is confirmed",
  "status": "opened",
  "timestamp": "2026-07-05T12:05:00.000Z",
  "domainId": "d1e2f3a4-b5c6-7890-abcd-ef1234567890",
  "domain": "mail.yourdomain.com"
}

Handler example

Your endpoint must be publicly accessible and return a 2xx status within 10 seconds. Always return 200 even for events you don't handle — a non-2xx triggers retries.

// Verify the signature BEFORE parsing the body
app.post("/webhooks/quolle",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["quolle-signature"];
    if (!sig || !verifyWebhookSignature(req.body.toString(), sig, process.env.QUOLLE_WEBHOOK_SECRET)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const { event, emailId, to, status, bounceReason } = JSON.parse(req.body.toString());

    switch (event) {
      case "email.delivered":
        await db.email.update({ id: emailId, status: "delivered" });
        break;

      case "email.bounced":
        if (bounceReason === "Permanent") {
          await db.contactList.markUndeliverable(to[0]);
        }
        break;

      case "email.complained":
        await db.subscription.unsubscribe(to[0]);
        break;
    }

    res.sendStatus(200); // always return 200
  }
);
from flask import Flask, request

app = Flask(__name__)

@app.route("/webhooks/quolle", methods=["POST"])
def quolle_webhook():
    sig = request.headers.get("Quolle-Signature", "")
    if not verify_webhook_signature(request.data, sig, WEBHOOK_SECRET):
        return {"error": "Invalid signature"}, 401

    data = request.json
    event = data.get("event")
    email_id = data.get("emailId")

    if event == "email.delivered":
        update_email_status(email_id, "delivered")
    elif event == "email.bounced":
        if data.get("bounceReason") == "Permanent":
            remove_from_list(data["to"][0])
    elif event == "email.complained":
        unsubscribe(data["to"][0])

    return "", 200  # always return 200
<?php
Route::post('/webhooks/quolle', function (Request $request) {
    $rawBody = $request->getContent();
    $sig     = $request->header('Quolle-Signature', '');
    if (!verifyWebhookSignature($rawBody, $sig, config('services.quolle.webhook_secret'))) {
        return response()->json(['error' => 'Invalid signature'], 401);
    }

    $event   = $request->input('event');
    $emailId = $request->input('emailId');
    $to      = $request->input('to.0');

    match ($event) {
        'email.delivered'  => Email::where('remote_id', $emailId)
                                   ->update(['status' => 'delivered']),
        'email.bounced'    => $request->input('bounceReason') === 'Permanent'
                                   ? Contact::where('email', $to)->delete()
                                   : null,
        'email.complained' => Subscription::where('email', $to)
                                          ->update(['active' => false]),
        default => null,
    };

    return response()->json(['ok' => true]); // always return 200
});

Automation run payloads

The automation.run.* events describe one contact's journey through a sequence rather than one message.

{
  "event": "automation.run.completed",
  "automationId": "a1b2c3d4-...",
  "automationName": "Welcome series",
  "runId": "r1s2t3u4-...",
  "contactId": "c1d2e3f4-...",
  "contactEmail": "ada@example.com",
  "status": "completed",
  "endedReason": "unsubscribed",
  "timestamp": "2026-08-22T21:04:11.000Z"
}

endedReason is the field worth reading. A run that reached its last step and one that stopped because the contact unsubscribed are both completed — the status says the run is over, the reason says whether anything went wrong. On automation.run.failed you also get lastError.

Cancelling a run, or archiving the automation it belongs to, fires nothing. You did that on purpose, and a webhook telling you what you just did is noise.

Verifying signatures

Every webhook request includes an Quolle-Signature header. Verify it before processing the event to confirm the request came from Quolle and was not replayed or tampered with.

Always verify before acting. An unverified webhook handler is an unauthenticated endpoint — anyone can POST to it and trigger your business logic.

How it works

The header format is:

Quolle-Signature: t=1751720400,v1=abc123def456…

Where t is a Unix timestamp (seconds) stamped at delivery time, and v1 is an HMAC-SHA256 hex digest of the string {t}.{rawBody} using your webhook secret.

Your secret starts with whsec_ and is shown once when you create the webhook in the dashboard. Store it immediately — it cannot be retrieved again.

Quolle re-stamps the timestamp on each retry attempt, so the 5-minute tolerance window is measured from the actual delivery time, not the original enqueue time. Do not reject retried events because the original timestamp is old.

Verification code

Easiest: use an official SDK. Every Quolle SDK ships a one-call verifier that handles the HMAC comparison and the replay-window check for you:
# Python
event = quolle.webhooks.verify(raw_body, request.headers["Quolle-Signature"], "whsec_…")
// PHP
$event = $quolle->webhooks->verify($rawBody, $_SERVER['HTTP_QUOLLE_SIGNATURE'], 'whsec_…');
// Go
err := quolle.VerifyWebhook(body, r.Header.Get("Quolle-Signature"), "whsec_…", 0)
# Ruby
event = quolle.webhooks.verify(request.body.read, request.env["HTTP_QUOLLE_SIGNATURE"], "whsec_…")
Each raises/returns an error on a bad signature or a stale timestamp. If you'd rather verify manually, the reference implementation is below.
const crypto = require("crypto");

function verifyWebhookSignature(rawBody, signatureHeader, secret) {
  const parts = signatureHeader.split(",");
  const tPart  = parts.find(p => p.startsWith("t="));
  const v1Part = parts.find(p => p.startsWith("v1="));
  if (!tPart || !v1Part) return false;

  const timestamp = parseInt(tPart.slice(2), 10);
  const sig = v1Part.slice(3);
  if (isNaN(timestamp)) return false;

  // Reject requests older than 5 minutes (replay protection)
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const message = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(message)
    .digest("hex");

  // Hex-decode both before comparing — required for timingSafeEqual
  const sigBuf = Buffer.from(sig, "hex");
  const expBuf = Buffer.from(expected, "hex");
  if (sigBuf.length !== expBuf.length) return false;
  return crypto.timingSafeEqual(sigBuf, expBuf);
}

// Express: use raw body middleware — JSON.parse first changes the byte representation
app.post("/webhooks/quolle",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["quolle-signature"];
    const secret = process.env.QUOLLE_WEBHOOK_SECRET;

    if (!sig || !verifyWebhookSignature(req.body.toString(), sig, secret)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const event = JSON.parse(req.body.toString());
    // handle event...
    res.sendStatus(200);
  }
);
import hmac
import hashlib
import time

def verify_webhook_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    t  = parts.get("t",  "")
    v1 = parts.get("v1", "")
    if not t or not v1:
        return False
    try:
        ts = int(t)
    except ValueError:
        return False
    # Reject requests older than 5 minutes (replay protection)
    if abs(time.time() - ts) > 300:
        return False

    message  = f"{t}.{raw_body.decode()}"
    expected = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1, expected)


# Flask: request.data gives the raw body before any parsing
from flask import Flask, request as flask_request
import os

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["QUOLLE_WEBHOOK_SECRET"]

@app.route("/webhooks/quolle", methods=["POST"])
def quolle_webhook():
    sig = flask_request.headers.get("Quolle-Signature", "")
    if not verify_webhook_signature(flask_request.data, sig, WEBHOOK_SECRET):
        return {"error": "Invalid signature"}, 401

    event = flask_request.json
    # handle event...
    return "", 200

Setup

Create and manage webhooks in the dashboard under Webhooks → Add webhook. Enter your server's HTTPS URL, choose the events to receive, and copy your secret immediately — it is shown only once.

Webhooks are available on Growth and above. Your endpoint must be publicly reachable (not localhost) and return 2xx within 10 seconds. See Local development below for testing locally.

Retry policy

If your server returns a non-2xx response, times out, or is unreachable, Quolle retries with exponential backoff. There are 5 total attempts (1 initial + 4 retries):

AttemptDelay after previous failure
1st retry~5 seconds
2nd retry~10 seconds
3rd retry~20 seconds
4th retry~40 seconds

After all 5 attempts fail, the event is dropped. Make your handler idempotent — the same event may be delivered more than once.

Always return 200. Even if you don't handle an event type, respond with 200. A non-200 burns retries and wastes both sides' resources.

Local development

To receive webhooks on your local machine, use a tunnel tool such as ngrok:

ngrok http 3000
# Forwarding: https://abc123.ngrok.io → localhost:3000
# Register https://abc123.ngrok.io/webhooks/quolle in the dashboard