qwicmail
qwicmail docs

Webhooks

Receive delivery, bounce, complaint, and click events at an HTTPS endpoint of your choosing. HMAC-SHA256 signed; retried on 5xx; replay protection out of the box. The signature shape is intentionally close to Stripe's so reference code translates with minimal change.

Register an endpoint

One endpoint receives events for all your sending domains. Register multiple if you want fan-out (e.g. one for production analytics, one for an internal Slack notifier). Endpoint management lives on the REST API page; this guide focuses on receiving.

Event types

TypeWhen
email.acceptedAPI accepted the submission.
email.queuedMessage persisted to the sender queue.
email.deliveredReceiving MTA returned 2xx on DATA.
email.soft_bouncedTransient SMTP failure (4xx) — will be retried.
email.hard_bouncedPermanent failure (5xx or async DSN). Recipient is added to the suppression list.
email.complainedRecipient marked the message as spam (FBL report). Suppressed.
email.failedExhausted all retries.
email.openedTracking pixel was fetched.
email.clickedA tracked link was followed.
email.unsubscribedOne-click List-Unsubscribe was actioned.

Payload shape

JSON
{
  "id":          "f47ac10b-58cc-...",
  "type":        "email.delivered",
  "occurred_at": "2026-05-26T09:07:42.123Z",
  "message_id":  "8a3f1f5b-d2c5-...",
  "smtp_code":   250,
  "detail":      { ... }
}

Three headers are set on every POST:

HeaderValue
X-Qwicmail-EventEvent type, e.g. email.delivered.
X-Qwicmail-Delivery-IdPer-delivery UUID. Re-sent unchanged on retries — use it to dedupe.
X-Qwicmail-SignatureSigned timestamp + body. See below.

Verifying signatures

The X-Qwicmail-Signature header is a comma-separated list of a Unix timestamp and a hex-encoded HMAC-SHA256:

HTTP header
X-Qwicmail-Signature: t=1748246862,v1=8e5a3...c91d

The signed payload is "<timestamp>.<raw-body>". To verify:

  1. Parse the header into t and v1.
  2. Reject if |now - t| exceeds your tolerance (recommended: 5 minutes — protects against replay).
  3. Compute hex(HMAC-SHA256(secret, "<t>." + raw_body)).
  4. Compare with v1 using a constant-time comparator.

Use the raw bytes of the request body — re-marshalling JSON will change whitespace and break the signature.

import express from "express";
import { Qwicmail } from "@qwicmail/sdk";

const qm = new Qwicmail({ apiKey: process.env.QWICMAIL_API_KEY! });
const app = express();

// Important: receive the raw body, NOT the parsed JSON object.
app.post("/hooks/qwicmail",
  express.raw({ type: "application/json" }),
  (req, res) => {
    try {
      const evt = qm.webhooks.verify({
        body:      req.body,                                    // Buffer
        signature: req.header("X-Qwicmail-Signature")!,
        secret:    process.env.QWICMAIL_WEBHOOK_SECRET!,
        toleranceSeconds: 300,
      });
      // evt is the typed event payload
      console.log(evt.type, evt.message_id);
      res.sendStatus(200);
    } catch (e) {
      res.sendStatus(400);
    }
  });
from flask import Flask, request, abort
from qwicmail import Qwicmail, WebhookVerificationError

qm = Qwicmail(api_key=os.environ["QWICMAIL_API_KEY"])
app = Flask(__name__)

@app.post("/hooks/qwicmail")
def hook():
    try:
        evt = qm.webhooks.verify(
            body=request.get_data(),                          # raw bytes
            signature=request.headers["X-Qwicmail-Signature"],
            secret=os.environ["QWICMAIL_WEBHOOK_SECRET"],
            tolerance_seconds=300,
        )
    except WebhookVerificationError:
        abort(400)
    print(evt.type, evt.message_id)
    return "", 200
http.HandleFunc("/hooks/qwicmail", func(w http.ResponseWriter, r *http.Request) {
    raw, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "read", http.StatusBadRequest)
        return
    }
    evt, err := qm.Webhooks.Verify(qwicmail.VerifyParams{
        Body:             raw,
        Signature:        r.Header.Get("X-Qwicmail-Signature"),
        Secret:           os.Getenv("QWICMAIL_WEBHOOK_SECRET"),
        ToleranceSeconds: 300,
    })
    if err != nil {
        http.Error(w, "bad signature", http.StatusBadRequest)
        return
    }
    log.Println(evt.Type, evt.MessageID)
    w.WriteHeader(http.StatusOK)
})
require "sinatra"
require "qwicmail"

qm = Qwicmail::Client.new(api_key: ENV.fetch("QWICMAIL_API_KEY"))

post "/hooks/qwicmail" do
  raw = request.body.read
  begin
    evt = qm.webhooks.verify(
      body:              raw,
      signature:         request.env["HTTP_X_QWICMAIL_SIGNATURE"],
      secret:            ENV.fetch("QWICMAIL_WEBHOOK_SECRET"),
      tolerance_seconds: 300,
    )
  rescue Qwicmail::WebhookVerificationError
    halt 400
  end
  puts "#{evt.type} #{evt.message_id}"
  status 200
end
<?php
use Qwicmail\Client;
use Qwicmail\WebhookVerificationError;

$qm  = new Client(getenv("QWICMAIL_API_KEY"));
$raw = file_get_contents("php://input");

try {
    $evt = $qm->webhooks->verify([
        "body"              => $raw,
        "signature"         => $_SERVER["HTTP_X_QWICMAIL_SIGNATURE"] ?? "",
        "secret"            => getenv("QWICMAIL_WEBHOOK_SECRET"),
        "tolerance_seconds" => 300,
    ]);
} catch (WebhookVerificationError $e) {
    http_response_code(400);
    exit;
}
error_log("{$evt->type} {$evt->messageId}");
http_response_code(200);
@PostMapping(path = "/hooks/qwicmail",
             consumes = MediaType.ALL_VALUE)
public ResponseEntity<Void> hook(@RequestBody byte[] raw,
                                 @RequestHeader("X-Qwicmail-Signature") String sig) {
    try {
        Event evt = qm.webhooks().verify(VerifyParams.builder()
            .body(raw)
            .signature(sig)
            .secret(System.getenv("QWICMAIL_WEBHOOK_SECRET"))
            .toleranceSeconds(300)
            .build());
        log.info("{} {}", evt.type(), evt.messageId());
    } catch (WebhookVerificationException e) {
        return ResponseEntity.badRequest().build();
    }
    return ResponseEntity.ok().build();
}

Detail shapes by event type

  • hard_bounced / soft_bounced{ "source": "async_dsn", "status": "5.1.1", "diagnostic": "..." }
  • complained{ "source": "arf", "feedback_type": "abuse" }
  • opened{ "source": "pixel", "user_agent": "...", "ip": "..." }
  • clicked{ "source": "redirect", "tracked_link_id": "...", "url": "..." }
  • unsubscribed{ "source": "list-unsubscribe", "method": "one-click" }
  • failed{ "reason": "max_attempts", "smtp_message": "..." }

Retries

Non-2xx responses (or transport errors) are retried with exponential backoff: 30s, 2m, 10m, 30m, 1h, 2h, 4h, 8h — eight attempts spanning about 16 hours. After the final attempt the delivery is marked failed and not retried. Receivers should aim for < 5s response time; we time individual POSTs out at 10s.

If your handler does anything more involved than persisting the event, do that work after returning 200. The 10-second budget is for your endpoint to confirm receipt; the actual processing belongs in a background queue.

Testing locally

During development, point a webhook at https://<your-tunnel>/hooks/qwicmail via ngrok / Cloudflare Tunnel / similar, then send a test event from the portal's webhook detail page. The portal also stores the last 100 deliveries per endpoint (request, response, retries) for debugging.