Webhooks
Svix-convention signed HTTPS callbacks for platform events.
Last updated 11 Jul 2026
Operayde publishes signed HTTPS callbacks — "webhooks" — every time a platform event fires (an appliance goes offline, a virtual key is minted, a billing threshold is hit, an incident opens or resolves, an OTA rollout finishes, a daily audit anchor is published).
The delivery format follows the Svix v1 convention so off-the-shelf verifier libraries in most languages work out of the box.
Configure an endpoint
Go to Settings → Webhooks in the customer portal. Enter:
- A name (any label — helps when you have several).
- A destination URL — must be
https://. Internal targets (RFC1918, link-local, cloud metadata) are refused at dial time. - The event types you want to subscribe to.
On save, Operayde displays a signing secret exactly once in the
form whsec_<base64>. Copy it into your subscriber; you cannot
retrieve it later. Rotate it any time from the endpoint detail page —
both the current and previous secret stay valid until the next
rotation, giving you a rolling-deploy grace window.
Delivery format
Every POST carries three headers, following Svix v1:
| Header | Value |
|---|---|
webhook-id | msg_<uuid> — mirrors the payload's id field |
webhook-timestamp | Unix seconds when the delivery was signed |
webhook-signature | v1,<base64(hmac_sha256(secret, id.timestamp.body))> |
During rotation the signature header carries both signatures, space-separated:
webhook-signature: v1,<sig_current> v1,<sig_previous>
Your verifier only needs to succeed against one of them.
The body is a JSON envelope:
{
"id": "01H7X6DEMOA5RVQ8D3QF6M0Q9E",
"type": "key.created",
"created_at": "2026-07-11T10:00:00Z",
"tenant_id": "acme",
"data": { "...": "event-specific" }
}Retry policy
Failed deliveries (transport error or non-2xx response) are retried on this ladder:
5s · 30s · 2m · 15m · 1h · 6h · 6h · 6h
If no delivery succeeds within 24h of the first attempt the delivery is marked dead and no further attempts are made. Dead-lettered deliveries also emit a tenant notification through your configured notification channels so you can react.
Verification snippets
The exact bytes signed are the ASCII string
<webhook-id>.<webhook-timestamp>.<body>. HMAC-SHA-256 with the raw
secret bytes (i.e. the whsec_-prefixed string with the prefix
stripped, treated as bytes) yields the digest.
Node.js (crypto)
import crypto from "node:crypto";
/**
* Verify a webhook. `secret` is the whsec_-prefixed string you saved
* from the portal. `headers` is a plain object of the incoming
* request headers (all lower-case). `body` is the raw request body
* bytes as a Buffer or string — DO NOT re-serialise a parsed JSON
* object or the signature will not match.
*/
export function verifyWebhook(secret, headers, body) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigHeader = headers["webhook-signature"];
if (!id || !ts || !sigHeader) throw new Error("missing webhook headers");
const rawSecret = secret.startsWith("whsec_") ? secret.slice(6) : secret;
const key = Buffer.from(rawSecret, "base64");
const signed = `${id}.${ts}.${typeof body === "string" ? body : body.toString("utf8")}`;
const expected = crypto.createHmac("sha256", key).update(signed).digest("base64");
// sigHeader is space-separated "v1,<b64> [v1,<b64> …]"
for (const entry of sigHeader.split(" ")) {
const [scheme, sig] = entry.split(",");
if (scheme === "v1" && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return true;
}
}
throw new Error("no matching v1 signature");
}Python (hmac)
import base64, hmac, hashlib
def verify_webhook(secret: str, headers: dict, body: bytes) -> bool:
msg_id = headers["webhook-id"]
ts = headers["webhook-timestamp"]
sigs = headers["webhook-signature"]
raw = base64.b64decode(secret[6:] if secret.startswith("whsec_") else secret)
signed = f"{msg_id}.{ts}.".encode() + body
expected = base64.b64encode(hmac.new(raw, signed, hashlib.sha256).digest()).decode()
for entry in sigs.split(" "):
scheme, _, sig = entry.partition(",")
if scheme == "v1" and hmac.compare_digest(sig, expected):
return True
return FalseGo (crypto/hmac + crypto/sha256)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"strings"
)
func VerifyWebhook(secret string, headers map[string]string, body []byte) error {
id := headers["webhook-id"]
ts := headers["webhook-timestamp"]
sig := headers["webhook-signature"]
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return err
}
mac := hmac.New(sha256.New, raw)
mac.Write([]byte(id + "." + ts + "."))
mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, entry := range strings.Split(sig, " ") {
scheme, sigB64, ok := strings.Cut(entry, ",")
if !ok || scheme != "v1" {
continue
}
if hmac.Equal([]byte(sigB64), []byte(expected)) {
return nil
}
}
return errors.New("no matching v1 signature")
}Event catalog
Every event type produced by the platform lives in
contracts/schemas/webhook.v1.<type>.json
so you can generate types from it in your language of choice.
| Type | Fires when … |
|---|---|
appliance.status.changed | Appliance transitions between fleet states (running, offline, …). |
key.created | A tenant admin mints a new virtual key. |
key.revoked | A virtual key is revoked (manually or via TTL expiry). |
billing.threshold.reached | Tenant crosses a soft or hard spend limit on a billing dimension. |
incident.opened | Incident-service creates an incident (status=firing). |
incident.resolved | Incident-service transitions an incident to resolved. |
ota.rollout.completed | OTA plan finishes all waves (succeeded, failed, aborted). |
audit.anchor.published | Audit-aggregator publishes a daily Merkle root for your chain. |