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, 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:

HeaderMeaning
X-GenieOS-EventEvent name (send.delivered, …)
X-GenieOS-DeliveryPer-delivery id (same as body id)
X-GenieOS-Signaturet=<unix-seconds>,v1=<hex>
X-GenieOS-TimestampSame 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

EventWhen
send.queuedGenieOS accepted the send (API / sandbox). Not mailbox delivery.
send.deliveredESP confirmed delivery (or sandbox simulation)
send.openedOpen tracked
send.clickedClick tracked (data.url when available)
send.bouncedSoft or hard bounce (data.bounceType)
send.complainedSpam complaint
send.unsubscribedList / tracked unsubscribe
send.failedProvider 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, 24h

Six 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.

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.

EventWhen
link.clickedQualifying click / QR scan on a short link
link.createdShort link minted
link.updatedDestination, schedule, password, rules, etc. changed
link.archivedLink archived
link.abuse_flaggedAbuse review opened
link.disabled_for_abuseLink disabled after abuse
link.reinstatedLink 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

StatusCauseFix
401 / bad signatureWrong secret or truncated bodyUse the raw request body; store create-time secret
Silent dropEvent not in events[]Subscribe to the exact send.* name
No sandbox terminalOld docs assumed queued-onlySubscribe to send.delivered; wait ~2s

On this page