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.
| Event | When 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:
| Field | Type | Description |
|---|---|---|
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.
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.
Verification code
# 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_…")
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.
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):
| Attempt | Delay 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.
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