Webhooks
Receive real-time HTTP notifications when email delivery events occur — delivered, bounced, or failed — sent directly to your server.
How it works
When an email changes status, Quolle sends a POST request to your
configured webhook URL with a JSON payload describing the event.
Your server must return a 2xx response within 10 seconds.
Webhooks are powered by AWS SNS, which means notifications are delivered reliably and in near real-time. You can configure multiple webhook endpoints, each subscribed to different events.
Events
| Event | When it fires |
|---|---|
| email.sent | Email accepted by the delivery provider and handed off for sending |
| email.delivered | Receiving mail server confirmed successful delivery to the recipient's inbox |
| email.bounced | Email could not be delivered. Can be permanent (bad address) or transient (full mailbox) |
| email.failed | All send attempts failed after retries |
| email.complained | Recipient marked the email as spam. Address is automatically suppressed |
| email.opened | Recipient opened the email (coming soon) |
Payload format
Every webhook POST has this JSON body:
{
"event": "email.delivered",
"emailId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"to": ["customer@example.com"],
"subject": "Welcome!",
"status": "delivered",
"timestamp": "2026-06-19T12:00:00.000Z"
}
For bounce events, additional fields are included:
{
"event": "email.bounced",
"emailId": "uuid",
"to": ["bad-address@example.com"],
"subject": "Welcome!",
"status": "bounced",
"bounceType": "Permanent",
"bounceSubType": "NoEmail",
"timestamp": "2026-06-19T12:00:01.000Z"
}
Setting up a webhook
Via the dashboard
Create the endpoint
Go to Webhooks → Add webhook. Enter your server's HTTPS URL and select the events you want to receive.
Implement the handler
Your endpoint must be publicly accessible (not localhost) and return a
200 status within 10 seconds.
Test it
Send a test email and watch your server logs. Delivery events arrive within seconds of the email being processed.
Via the API
curl -X POST https://api.quolle.com/v1/webhooks \
-H "Authorization: Bearer qle_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/email",
"events": ["email.delivered", "email.bounced", "email.complained"]
}'
Example webhook handler
app.post("/webhooks/email", express.json(), async (req, res) => {
const { event, emailId, to, status, bounceType } = req.body;
switch (event) {
case "email.delivered":
// Mark as delivered in your DB
await db.email.update({ id: emailId, status: "delivered" });
break;
case "email.bounced":
if (bounceType === "Permanent") {
// Remove from your mailing list permanently
await db.contactList.markUndeliverable(to[0]);
}
break;
case "email.complained":
// Honour the unsubscribe request immediately
await db.subscription.unsubscribe(to[0]);
break;
}
// Always return 200 — even if you don't handle the event
res.sendStatus(200);
});
from flask import Flask, request
app = Flask(__name__)
@app.route("/webhooks/email", methods=["POST"])
def email_webhook():
data = request.json
event = data.get("event")
if event == "email.delivered":
# Update status in your database
update_email_status(data["emailId"], "delivered")
elif event == "email.bounced":
if data.get("bounceType") == "Permanent":
# Remove from mailing list
remove_from_list(data["to"][0])
elif event == "email.complained":
unsubscribe(data["to"][0])
return "", 200 # Always return 200
<?php
// routes/api.php
Route::post('/webhooks/email', function (Request $request) {
$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('bounceType') === 'Permanent'
? Contact::where('email', $to)->delete()
: null,
'email.complained' => Subscription::where('email', $to)
->update(['active' => false]),
default => null,
};
return response()->json(['ok' => true]);
});
Retry policy
If your server returns a non-2xx response, times out, or is unreachable, Quolle retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 30 seconds |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
After 3 failed retries the event is dropped. Make your handler idempotent — the same event may be delivered more than once.
200. A non-200 response triggers retries and wastes both parties' resources.
Security: verifying signatures
Every webhook request includes an Avianise-Signature header.
Verify this signature to confirm the request came from Avianise and has not been tampered with.
The signature format is:
Avianise-Signature: t=1750000000,v1=abc123def456…
Where t is a Unix timestamp (seconds) and v1 is an
HMAC-SHA256 signature of {timestamp}.{rawBody} using your webhook secret.
Verification examples
const crypto = require("crypto");
function verifySignature(rawBody, signatureHeader, secret) {
const parts = signatureHeader.split(",");
const t = parts.find(p => p.startsWith("t="))?.slice(2);
const v1 = parts.find(p => p.startsWith("v1="))?.slice(3);
if (!t || !v1) return false;
// Reject requests older than 5 minutes (replay protection)
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(v1),
Buffer.from(expected)
);
}
// Express — use raw body middleware
app.post("/webhooks/email",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["avianise-signature"];
const secret = process.env.WEBHOOK_SECRET;
if (!verifySignature(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, hashlib, time
from flask import Flask, request
def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in signature.split(","))
t = parts.get("t", "")
v1 = parts.get("v1", "")
if not t or not v1:
return False
if abs(time.time() - float(t)) > 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)
@app.route("/webhooks/email", methods=["POST"])
def email_webhook():
sig = request.headers.get("Avianise-Signature", "")
if not verify_signature(request.data, sig, WEBHOOK_SECRET):
return {"error": "Invalid signature"}, 401
event = request.json
# handle event…
return "", 200
<?php
function verifySignature(string $rawBody, string $signature, string $secret): bool {
$parts = [];
foreach (explode(',', $signature) as $part) {
[$k, $v] = explode('=', $part, 2);
$parts[$k] = $v;
}
if (empty($parts['t']) || empty($parts['v1'])) return false;
if (abs(time() - (int)$parts['t']) > 300) return false;
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
Route::post('/webhooks/email', function (Request $request) {
$rawBody = $request->getContent();
$sig = $request->header('Avianise-Signature', '');
if (!verifySignature($rawBody, $sig, config('avianise.webhook_secret'))) {
return response()->json(['error' => 'Invalid signature'], 401);
}
// handle event…
return response()->json(['ok' => true]);
});
Local development
To receive webhooks on your local machine during development, use a tunnel tool like ngrok:
ngrok http 3000
# Forwarding: https://abc123.ngrok.io → localhost:3000
# Register https://abc123.ngrok.io/webhooks/email in the dashboard