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, and (on Glow+) short-link click + lifecycle events.
The shape never varies:
POST /your-webhook HTTP/1.1
Content-Type: application/json
X-GenieOS-Event: send.delivered
X-GenieOS-Delivery: whd_…
X-GenieOS-Timestamp: 1750000000
X-GenieOS-Signature: t=1750000000,v1=8e44b2…
{
"id": "whd_…",
"event": "send.delivered",
"workspaceId": "ws_…",
"occurredAt": 1750000000123,
"data": {
"sendId": "snd_…",
"templateKey": "order_confirm",
"to": "ada@example.com",
"status": "delivered",
"connectorProvider": "mailersend",
"providerMessageId": "…",
"origin": "api",
"metadata": { "order_id": "ord_8a72c0" }
}
}Always verify before trusting.
Event names are `send.*`
Lifecycle events use send.queued, send.delivered, send.opened,
send.clicked, send.bounced, send.complained, send.unsubscribed,
and send.failed. There is no transactional.* dual-emit.
Subscribing
Scope required: webhooks:manage.
const sub = await gos.webhooks.create({
url: 'https://yourapp.com/genieos',
events: [
'send.delivered',
'send.bounced',
'sequence_run.enrolled',
],
description: 'Production webhook',
});
console.log(sub.secret); // store, never log to STDOUT in prod.The signing secret is shown once at create time. Subsequent
GET /v1/webhooks responses mask it.
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.
Headers to read:
| Header | Meaning |
|---|---|
X-GenieOS-Event | Event name (send.delivered, …) |
X-GenieOS-Delivery | Per-delivery id (same as body id) |
X-GenieOS-Signature | t=<unix-seconds>,v1=<hex> |
X-GenieOS-Timestamp | Same unix seconds as t |
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyGenieOsWebhook(opts: {
rawBody: Buffer | string;
signatureHeader: string;
secret: string;
toleranceSec?: number;
}) {
const tolerance = opts.toleranceSec ?? 300;
const match = /^t=(\d+),v1=([0-9a-f]+)$/i.exec(opts.signatureHeader);
if (!match) throw new Error('bad_signature_header');
const t = Number(match[1]);
const v1 = match[2]!;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > tolerance) {
throw new Error('timestamp_out_of_tolerance');
}
const body = typeof opts.rawBody === 'string' ? opts.rawBody : opts.rawBody.toString('utf8');
const expected = createHmac('sha256', opts.secret).update(`${t}.${body}`).digest('hex');
const a = Buffer.from(v1, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Error('bad_signature');
return JSON.parse(body);
}import hmac, hashlib, time, json
def verify_genieos_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance=300):
# X-GenieOS-Signature: t=<unix>,v1=<hex>
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t = int(parts["t"])
v1 = parts["v1"]
if abs(int(time.time()) - t) > tolerance:
raise ValueError("timestamp_out_of_tolerance")
expected = hmac.new(
secret.encode(), f"{t}.{raw_body.decode()}".encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(v1, expected):
raise ValueError("bad_signature")
return json.loads(raw_body)// Parse X-GenieOS-Signature as t=<unix>,v1=<hex>,
// HMAC-SHA256 secret over fmt.Sprintf("%d.%s", t, rawBody),
// compare with hmac.Equal, reject |now-t| > 300s.# Same Stripe-style envelope: sign "#{t}.#{raw_body}" with the secret.// Same Stripe-style envelope: hash_hmac('sha256', "$t.$rawBody", $secret).Send lifecycle events
| Event | When |
|---|---|
send.queued | GenieOS accepted the send (API / sandbox). Not mailbox delivery. |
send.delivered | ESP confirmed delivery (or sandbox simulation) |
send.opened | Open tracked |
send.clicked | Click tracked (data.url when available) |
send.bounced | Soft or hard bounce (data.bounceType) |
send.complained | Spam complaint |
send.unsubscribed | List / tracked unsubscribe |
send.failed | Provider error after accept |
data always carries at least sendId, templateKey, to, status,
and connectorProvider. Optional: metadata, origin,
providerMessageId, and for sandbox keys
simulation: { sandbox: true, scenario: "…" }.
Sandbox sends never contact a mailbox. They emit send.queued, then
a marked synthetic terminal event (default send.delivered) after ~2
seconds. See Quickstart.
Retries and backoff
If your endpoint returns anything other than 2xx, GenieOS retries:
1m, 5m, 30m, 2h, 8h, 24hSix attempts, then deadletter. Dedupe on X-GenieOS-Delivery (stable
across retries of the same delivery).
Filters and per-event subscriptions
Each subscription has an events array. Empty / omitted = all events.
events: ['social.post.published', 'social.post.failed', 'send.delivered']Organic social lifecycle events: social.post.created |
social.post.scheduled | social.post.published | social.post.failed |
social.post.deleted.
Short-link events (Glow+)
Link webhooks require a paid plan (Glow and above). Spark workspaces use in-product analytics only. Endpoint limits: Glow / Ignite 2, Star 10, Constellation unlimited.
| Event | When |
|---|---|
link.clicked | Qualifying click / QR scan on a short link |
link.created | Short link minted |
link.updated | Destination, schedule, password, rules, etc. changed |
link.archived | Link archived |
link.abuse_flagged | Abuse review opened |
link.disabled_for_abuse | Link disabled after abuse |
link.reinstated | Link restored after review |
events: ['link.clicked', 'link.created', 'link.updated']data for link.clicked includes the link id, slug / host, and click
context (geo / device when available). Lifecycle payloads carry the
link id and the fields that changed.
What can go wrong
| Status | Cause | Fix |
|---|---|---|
| 401 / bad signature | Wrong secret or truncated body | Use the raw request body; store create-time secret |
| Silent drop | Event not in events[] | Subscribe to the exact send.* name |
| No sandbox terminal | Old docs assumed queued-only | Subscribe to send.delivered; wait ~2s |