Webhooks

HMAC-SHA256 signed event delivery for platform events.

Webhooks

Events (license activations, account provisioning, billing invoices) are delivered to your endpoint as signed POST requests. Always verify the signature (in constant time) before trusting the payload. There is no SDK helper for this yet, so verify manually — recompute the HMAC and compare. The samples below show the full algorithm in every language.

Manage endpoints over the API. Create, list, retrieve, update, delete, and rotate signing secrets through the live /v1/webhook_endpoints CRUD API (see Registering a webhook endpoint below). The Dashboard drives the same API if you prefer a UI.

import crypto from 'node:crypto';
import express from 'express';

const secret = process.env.ISA_WEBHOOK_SECRET ?? '';
const TOLERANCE_SECONDS = 5 * 60;

const app = express();

// Mount BEFORE express.json(): the HMAC is computed over the raw bytes, so the
// route needs the untouched Buffer, not parsed JSON.
app.post('/webhooks/zyins', express.raw({ type: 'application/json' }), (req, res) => {
  // X-ZyINS-Signature carries both the timestamp and the digest:
  //   t=<unix-seconds>,v1=<hex-hmac-sha256>
  const header = req.header('X-ZyINS-Signature') ?? '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
  const ts = parseInt(parts.t ?? '', 10);
  const receivedHex = parts.v1 ?? '';
  if (!ts || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) {
    res.status(400).end();
    return;
  }

  const signed = `${ts}.${(req.body as Buffer).toString('utf8')}`;
  const expected = crypto.createHmac('sha256', secret).update(signed).digest();
  const received = Buffer.from(receivedHex, 'hex');
  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    res.status(400).end();
    return;
  }

  const event = JSON.parse((req.body as Buffer).toString('utf8'));
  // event.type names the event; event.id is the at-least-once dedupe key.
  console.log('webhook', event.type, event.id);
  res.status(204).end();
});

The full event catalog lives in the API reference under the
Webhooks (Outbound) tag.

Registering a webhook endpoint

Create an endpoint with POST /v1/webhook_endpoints. Pass the HTTPS url and the events you want; an empty or omitted events array subscribes to every event type. The response includes the new endpoint's whep_-prefixed id and its signing_secret — the secret is returned only on this response, so store it in a secrets manager immediately.

curl -X POST https://zyins.isaapi.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $ISA_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"url":"https://api.acme-agency.com/zyins/webhook","events":["prequalify.completed"]}'

Manage the endpoint over the rest of its lifecycle through the same resource:

OperationReference
Create an endpointPOST /v1/webhook_endpoints
List endpointsGET /v1/webhook_endpoints
Retrieve one endpointGET /v1/webhook_endpoints/{id}
Update url, events, enabled, or descriptionPATCH /v1/webhook_endpoints/{id}
Delete an endpointDELETE /v1/webhook_endpoints/{id}
Rotate the signing secretPOST /v1/webhook_endpoints/{id}/rotate_signing_secret
Send a synthetic test eventPOST /v1/webhook_endpoints/{id}/test

The Dashboard (Webhooks → Add endpoint) calls the same API if you prefer a UI; it displays the signing secret once, on save.

Obtaining your signing secret

The signing_secret (a whsec_-prefixed value) is returned only twice: in the create response and in every rotate response. Retrieving an endpoint never returns it. Store it in a secrets manager (AWS Secrets Manager, HashiCorp Vault, 1Password) the moment you receive it. Never commit it to source control.

Lost the secret before storing it? Rotate it with POST /v1/webhook_endpoints/{id}/rotate_signing_secret to mint a fresh one — update your verifier before the next delivery window so you don't miss events mid-rotation.

Local development

Your handler runs on localhost, but the webhook delivery system can only reach public URLs. Use a tunnel to expose your local handler:

  1. Run your handler on a local port — e.g. localhost:4242.
  2. Open a tunnel: ngrok http 4242. ngrok prints a public HTTPS URL
    like https://abc123.ngrok-free.app.
  3. Register that URL with POST /v1/webhook_endpoints.
    Capture the signing_secret from the response.
  4. Trigger a synthetic delivery with
    POST /v1/webhook_endpoints/{id}/test.
    The signed webhook.test event arrives at your local handler with the same
    X-ZyINS-Signature header you will see in production, so you can exercise
    your verifier end to end.

A cloudflared tunnel or any other HTTPS tunnel works the same way;
ngrok is just the most common.

Endpoint requirements

  • Accept POST over https://. Plain http:// is rejected.
  • Respond 2xx within 20 seconds. Slower responses are treated as failures and retried.
  • Read the raw request body bytes before any JSON parsing. Frameworks that re-serialize JSON change whitespace, breaking the HMAC validation. Always capture the raw bytes first.

The endpoint does not need a bearer token. The signature is your authentication mechanism.

Request headers

Every outbound webhook delivery carries these headers:

HeaderDescription
Content-TypeAlways application/json.
X-ZyINS-SignatureEmission timestamp and HMAC-SHA256 digest, combined as t=<unix-seconds>,v1=<hex>.

The event id and event type live in the JSON body (id and type), not in
headers. Deduplicate on the body's id.

Signature protocol

X-ZyINS-Signature is a single header carrying two comma-separated fields:
t (the emission timestamp in Unix seconds) and v1 (the hex digest). Parse
both from that one header — there is no separate timestamp header.

parsed:           t=<unix-seconds>, v1=<hex>     (from X-ZyINS-Signature)
signing_payload = t + "." + raw_body_bytes
signature       = HMAC-SHA256(signing_payload, your_signing_secret)

Verification passes when hex(signature) equals the v1 field.

Reject the delivery if:

  1. The t field differs from your clock by more than 5 minutes
    (sync to NTP). This is replay protection.
  2. The recomputed HMAC does not match the v1 field in constant time.
LanguageConstant-time comparator
Gosubtle.ConstantTimeCompare (preferred); crypto/hmac.Equal is equivalent
Nodecrypto.timingSafeEqual
Pythonhmac.compare_digest
PHPhash_equals
C# / .NETCryptographicOperations.FixedTimeEquals

Verification — code samples

// Node 18+. Express handler with raw-body middleware. There is no SDK
// verifier yet, so this rolls the algorithm by hand.
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const TOLERANCE_SECONDS = 5 * 60;

function verifyWebhook(rawBody: Buffer, sigHeader: string, secret: string): boolean {
  const parts = Object.fromEntries(sigHeader.split(',').map((p) => p.split('=', 2)));
  const ts = parseInt(parts.t ?? '', 10);
  const receivedHex = parts.v1 ?? '';
  if (!ts || !receivedHex) return false;
  if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false;

  const signed = `${ts}.${rawBody.toString('utf8')}`;
  const expected = crypto.createHmac('sha256', secret).update(signed).digest();
  const received = Buffer.from(receivedHex, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

// Usage in Express:
app.post('/webhooks/zyins', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebhook(
    req.body,
    req.header('X-ZyINS-Signature') ?? '',
    process.env.ZYINS_WEBHOOK_SECRET!,
  );
  if (!ok) { res.status(401).end(); return; }
  // Handle req.body as Buffer — parse JSON here after verifying.
  const event = JSON.parse(req.body.toString());
  // ... deduplicate on event.id, dispatch on event.type ...
  res.status(200).end();
});
# Python 3.11+. FastAPI / Flask handler.
import hmac
import hashlib
import time

TOLERANCE_SECONDS = 5 * 60

def verify_webhook(raw_body: bytes, sig_header: str, secret: str) -> bool:
    # X-ZyINS-Signature is "t=<unix-seconds>,v1=<hex>".
    fields = dict(part.split("=", 1) for part in sig_header.split(",") if "=" in part)
    try:
        ts = int(fields["t"])
        received_hex = fields["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False
    signed = f"{ts}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received_hex)
// Go 1.22+.
package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "fmt"
    "strings"
    "strconv"
    "time"
)

const toleranceSeconds = 5 * 60

// Verify checks the X-ZyINS-Signature header against rawBody and secret.
// The header is "t=<unix-seconds>,v1=<hex>". Returns nil when valid.
func Verify(rawBody []byte, sigHeader, secret string) error {
    var tsField, v1Field string
    for _, part := range strings.Split(sigHeader, ",") {
        key, value, found := strings.Cut(part, "=")
        if !found {
            continue
        }
        switch key {
        case "t":
            tsField = value
        case "v1":
            v1Field = value
        }
    }
    ts, err := strconv.ParseInt(tsField, 10, 64)
    if err != nil || ts == 0 {
        return fmt.Errorf("webhook: missing t field in X-ZyINS-Signature")
    }
    if diff := time.Now().Unix() - ts; diff > toleranceSeconds || diff < -toleranceSeconds {
        return fmt.Errorf("webhook: timestamp outside tolerance window")
    }
    received, err := hex.DecodeString(v1Field)
    if err != nil {
        return fmt.Errorf("webhook: v1 field in X-ZyINS-Signature is not valid hex")
    }
    mac := hmac.New(sha256.New, []byte(secret))
    fmt.Fprintf(mac, "%d.", ts)
    mac.Write(rawBody)
    expected := mac.Sum(nil)
    if len(received) != len(expected) || subtle.ConstantTimeCompare(expected, received) != 1 {
        return fmt.Errorf("webhook: signature mismatch")
    }
    return nil
}
<?php
// PHP 8.2+.

function verify_webhook(string $rawBody, string $sigHeader, string $secret): bool
{
    // X-ZyINS-Signature is "t=<unix-seconds>,v1=<hex>".
    $fields = [];
    foreach (explode(',', $sigHeader) as $part) {
        [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
        $fields[$key] = $value;
    }
    $ts = (int) ($fields['t'] ?? '');
    $receivedHex = $fields['v1'] ?? '';
    if ($ts === 0 || $receivedHex === '' || abs(time() - $ts) > 300) {
        return false;
    }
    $signed   = $ts . '.' . $rawBody;
    $expected = hash_hmac('sha256', $signed, $secret);
    return hash_equals($expected, $receivedHex);
}

// Usage in a plain PHP handler:
$rawBody   = file_get_contents('php://input');   // MUST read before any framework parsing
$sigHeader = $_SERVER['HTTP_X_ZYINS_SIGNATURE'] ?? '';

if (!verify_webhook($rawBody, $sigHeader, getenv('ZYINS_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit;
}
$event = json_decode($rawBody, true);
// ... deduplicate on $event['id'], dispatch on $event['type'] ...
http_response_code(200);

Event catalog

Events documented in the OpenAPI spec under Webhooks (Outbound):

Event typeFires when
license.activatedA license finishes activation. Use to provision downstream CRM or entitlement systems.
license.deactivatedA license transitions to inactive (deactivated). Use to remove seats or downgrade entitlements.
license.lockedA license becomes locked after abuse or admin action. Use to immediately suspend access.
account.createdA new acct_* record is provisioned. Use to kick off onboarding emails or CRM inserts.
account.closedAn account is permanently closed — credentials revoked, data scheduled for deletion. Use to trigger downstream cleanup.
account.suspendedAn account is suspended (reversible); existing sessions are revoked within 24 hours. Use to gate downstream access.
account.token_rotatedAn API token was rotated. Update any cached credential references.
account.token_revokedAn API token was revoked. Stop using the affected token immediately.
billing.invoice.generatedA finalized invoice artifact is ready. Use to push to accounting systems or notify finance.
billing.credits.addedCredits were added to an account's balance. Use to update internal usage or billing dashboards.
prequalify.completedA prequalification finished processing. Use for async result delivery or audit logging.
email.deliveredA transactional email was delivered. Use to update communication logs.
email.failedA transactional email failed to deliver. Use to alert or retry through your own channel.
webhook.testA synthetic event from POST /v1/webhook_endpoints/{id}/test. Use to verify your endpoint and signature wiring.
webhook.delivery_failedA webhook delivery to one of your endpoints exhausted its retries and was dead-lettered. Use to investigate the endpoint.

Event envelope

Every webhook delivery has this shape:

{
  "id": "evt_01HZK2N5GQR9T8X4B6FJW3Y1AS",
  "object": "event",
  "type": "license.activated",
  "api_version": "2026-05-14",
  "livemode": true,
  "created_at": "2026-05-14T14:32:01Z",
  "data": { ... }
}
  • id — Unique event identifier. Use this for deduplication.
  • object — Always "event".
  • type — Event name from the catalog (below).
  • livemodetrue for live events, false for test mode.
  • data — Event-specific payload per the API reference.

Delivery semantics

At-least-once guarantee. A 2xx response tells the platform to stop retrying. Network failures, timeouts, or 5xx responses trigger a retry.

Deduplicate on id. Persist processed event IDs in your database or cache (TTL ≥ 30 days). Ignore any event you've already handled.

Per-account ordering. Events for the same account arrive in the order they were emitted. Events across different accounts may interleave.

Retry schedule

Failed deliveries retry on exponential backoff over 24 hours:

AttemptDelay from previous
1immediate
21 minute
35 minutes
430 minutes
52 hours
66 hours
712 hours

After 7 failed attempts, the event is dead-lettered. Event payloads are purged after 30 days.

Replay a delivery with POST /v1/events/{event_id}/redeliver. By default it redelivers to every endpoint that originally received the event; pass an endpoint id to target a single one. List and inspect past deliveries with GET /v1/events and GET /v1/events/{event_id}.

What to return

  • 2xx — Success. The platform stops retrying.
  • 4xx — Permanent failure. The platform will not retry. Use this when the event references a resource your system deliberately deleted, so retries are pointless.
  • 5xx or timeout — Transient failure. The platform will retry per the schedule above.

Important: Only return 2xx after you've durably committed the event to storage. If your handler crashes between sending the 2xx response and writing to the database, the event is lost silently.

Common gotchas

1. Capture raw body before parsing. Every framework (Express, FastAPI, Laravel, Gin) buffers or re-serializes the request body. HMAC is computed over the exact bytes sent — whitespace changes break the signature. Always read the raw bytes first, then parse JSON from those bytes.

2. Verify in constant time. Never use string equality (sig == expected) — it leaks signature bytes through timing attacks. Use the comparators in the table above (e.g. crypto.timingSafeEqual in Node).

3. Deduplicate on id. The platform retries on any non-2xx response, so you may receive the same event multiple times. Persist processed event IDs in a database or cache with TTL ≥ 30 days.

4. Reject stale timestamps. Without the 5-minute tolerance check, recorded requests can be replayed indefinitely. If your clock is more than 5 minutes off NTP, fix it.

5. 4xx means permanent failure. When you return 4xx, the platform stops retrying (intentionally). Use it only for real permanent failures (deleted resource, wrong account). For transient errors, return 5xx instead and let the retry schedule handle recovery.

Idempotency exemption

Webhook receivers don't need an Idempotency-Key header. The platform uses event.id as the deduplication key instead. (See the idempotency guide for the inverse: handling idempotency in your requests to the platform.)

See also

  • Authentication — the same signing primitive applied
    to outbound API requests.
  • API reference — every event payload schema under the
    Webhooks (Outbound) tag.