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

1

Create the endpoint

Go to Webhooks → Add webhook. Enter your server's HTTPS URL and select the events you want to receive.

2

Implement the handler

Your endpoint must be publicly accessible (not localhost) and return a 200 status within 10 seconds.

3

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:

AttemptDelay
1st retry30 seconds
2nd retry5 minutes
3rd retry30 minutes

After 3 failed retries 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 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.

Your webhook secret is shown once when you create the webhook. Save it immediately — you cannot retrieve it again from the dashboard or API.

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