GenieOSdocs

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 "", 204
package 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, 24h

That\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=20

Each 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}/replay

To replay everything that failed in the last hour:

genie webhooks replay --since 1h --status failed

Replays 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_id if 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

StatusCauseFix
400Signature mismatchWrong secret; or you re-serialised the body
408Your endpoint took longer than 30sAcknowledge fast; do work in a background job
410Subscription was deleted; replay deniedRecreate the subscription and re-enable replay
422Webhook URL fails reachability checkURL must be HTTPS, public, return < 30s

On this page