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.sent Email accepted by the delivery provider and handed off for sending
email.delivered Receiving mail server confirmed delivery to the recipient's inbox
email.bounced Email could not be delivered. bounceReason is "Permanent" (bad address — recipient is auto-suppressed) or "Transient" (temporary failure)
email.complained Recipient marked the email as spam. Address is automatically suppressed
email.failed All delivery attempts failed after retries
email.opened Recipient opened the email (tracked via pixel; may undercount due to image-blocking proxies)
email.clicked Recipient clicked a tracked link in the email

Payload format

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

FieldTypeDescription
event string Event type, e.g. "email.delivered"
emailId string UUID of the email that triggered this event
to string[] Recipient address(es)
subject string Email subject line
status string Current email status — matches the event name minus the email. prefix
timestamp string ISO 8601 event time. Fixed at enqueue — does not change across retry attempts
messageId string · optional Delivery message ID (opaque). Present on sent, delivered, bounced, complained, failed. Omitted on opened and clicked
bounceReason string · optional "Permanent" or "Transient". Present only on email.bounced
domainId string · optional UUID of the sending domain. Present when the email has an associated verified domain
domain string · optional Human-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
});

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