Webhooks
One signed envelope, exponential-backoff retries, and a 5-line verifier in any language.
Webhooks are how GenieOS pushes state changes back to your service — deliveries, opens, bounces, sequence transitions, schema contract changes.
The shape never varies:
POST /your-webhook HTTP/1.1
Content-Type: application/json
MailGenius-Event: transactional.delivered
MailGenius-Delivery-Id: dlv_01JABCXYZ...
MailGenius-Timestamp: 1750000000000
MailGenius-Signature: t=1750000000000,v1=8e44b2...
{
"id": "evt_01JABC...",
"type": "transactional.delivered",
"created_at": "2026-04-20T11:34:00.123Z",
"data": {
"message_id": "msg_01JABC...",
"to": "ada@example.com",
"template": { "name": "order_confirm", "version": 4 },
"metadata": { "order_id": "ord_8a72c0" }
}
}Always verify before trusting.
Subscribing
const sub = await mg.webhooks.create({
url: 'https://yourapp.com/genieos',
events: [
'transactional.delivered',
'transactional.bounced',
'sequence.transitioned',
],
description: 'Production webhook',
});
console.log(sub.signing_secret); // whsec_... — store, never log to STDOUT in prod.The signing_secret is shown once. Store it in your secrets manager.
You can rotate it (creating a second secret valid in parallel for 24
hours) any time:
await mg.webhooks.rotateSecret(sub.id);Verifying — the canonical 5 lines
The signature is HMAC-SHA256 over ${timestamp}.${rawBody} keyed with
your signing secret. Compare in constant time. Reject anything outside a
5-minute window.
import { verifyWebhook } from 'genieos';
app.post('/genieos', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = verifyWebhook({
payload: req.body, // Buffer
header: req.header('MailGenius-Signature')!,
secret: process.env.GENIEOS_WEBHOOK_SECRET!,
});
handle(event);
res.sendStatus(204);
} catch {
res.sendStatus(400);
}
});from flask import Flask, request, abort
from genieos import verify_webhook
app = Flask(__name__)
@app.post("/genieos")
def webhook():
try:
event = verify_webhook(
payload=request.get_data(),
header=request.headers["MailGenius-Signature"],
secret=os.environ["GENIEOS_WEBHOOK_SECRET"],
)
except Exception:
abort(400)
handle(event)
return "", 204package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func verify(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
parts := strings.Split(r.Header.Get("MailGenius-Signature"), ",")
var ts, sig string
for _, p := range parts {
kv := strings.SplitN(p, "=", 2)
if kv[0] == "t" { ts = kv[1] }
if kv[0] == "v1" { sig = kv[1] }
}
n, _ := strconv.ParseInt(ts, 10, 64)
if time.Since(time.UnixMilli(n)) > 5*time.Minute {
w.WriteHeader(400); return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("GENIEOS_WEBHOOK_SECRET")))
mac.Write([]byte(ts + "." + string(body)))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(sig)) {
w.WriteHeader(400); return
}
w.WriteHeader(204)
}require 'openssl'
post '/genieos' do
body = request.body.read
parts = request.env['HTTP_MAILGENIUS_SIGNATURE'].split(',').map { |p| p.split('=', 2) }.to_h
halt 400 if (Time.now.to_i * 1000 - parts['t'].to_i).abs > 5 * 60 * 1000
mac = OpenSSL::HMAC.hexdigest('sha256', ENV['GENIEOS_WEBHOOK_SECRET'], "#{parts['t']}.#{body}")
halt 400 unless Rack::Utils.secure_compare(mac, parts['v1'])
status 204
end<?php
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_MAILGENIUS_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $kv) {
[$k, $v] = explode('=', $kv, 2);
$parts[$k] = $v;
}
if (abs(intval($parts['t']) - intval(microtime(true) * 1000)) > 300_000) {
http_response_code(400); exit;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $body, getenv('GENIEOS_WEBHOOK_SECRET'));
if (!hash_equals($expected, $parts['v1'])) {
http_response_code(400); exit;
}
http_response_code(204);Always read the raw body
Most frameworks parse JSON before your handler sees it. Re-serialised
JSON does not produce the same bytes as the original payload — and the
signature is over the raw bytes. Use express.raw(), Flask\u2019s
get_data(), raw io.ReadAll, etc.
Retries and backoff
If your endpoint returns anything other than 2xx, GenieOS retries on
this schedule:
1s, 5s, 25s, 2m, 10m, 1h, 6h, 24hThat\u2019s 8 attempts over 31 hours. Your endpoint must be
idempotent on MailGenius-Delivery-Id — see "At-least-once delivery"
below.
Per-subscription delivery state is queryable:
GET /v1/webhooks/{id}/deliveries?status=failed&limit=20Each delivery includes attempt, next_attempt_at, the response status
your endpoint returned, and a 1 KB head-truncated copy of the response
body so you can debug from the dashboard without tailing your logs.
Replay
To replay a single delivery:
POST /v1/webhooks/{id}/deliveries/{delivery_id}/replayTo replay everything that failed in the last hour:
genie webhooks replay --since 1h --status failedReplays carry a fresh MailGenius-Delivery-Id and are signed with the
current secret (so a replayed event after a rotation uses the new
secret).
At-least-once delivery
Webhooks are at-least-once: in failure modes you may receive the same
evt_* twice. Dedupe on either:
MailGenius-Delivery-Id(every retry uses the same delivery id), or- The semantic id inside
data(message_id,order_idif you put it in metadata).
The simplest correct pattern:
const seen = await db.deliveries.findUnique({ where: { id: deliveryId } });
if (seen) return res.sendStatus(204);
await db.$transaction([
db.deliveries.create({ data: { id: deliveryId } }),
applyEvent(event),
]);
res.sendStatus(204);Filters and per-event subscriptions
Each subscription has an events array — a list of event types to
deliver. Use prefixes to subscribe to families:
events: ['transactional.*', 'sequence.transitioned']You can also attach filters that match data.* paths:
events: ['transactional.bounced'],
filter: {
'metadata.environment': 'production', // only forward prod bounces
},Filtering happens server-side before dispatch, so failed-filter events don\u2019t count against your subscription\u2019s retry budget.
What can go wrong
| Status | Cause | Fix |
|---|---|---|
| 400 | Signature mismatch | Wrong secret; or you re-serialised the body |
| 408 | Your endpoint took longer than 30s | Acknowledge fast; do work in a background job |
| 410 | Subscription was deleted; replay denied | Recreate the subscription and re-enable replay |
| 422 | Webhook URL fails reachability check | URL must be HTTPS, public, return < 30s |