Aller au contenu principal
Operayde
Réserver un briefing dirigeants
/
API reference

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:

HeaderValue
webhook-idmsg_<uuid> — mirrors the payload's id field
webhook-timestampUnix seconds when the delivery was signed
webhook-signaturev1,<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 False

Go (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.

TypeFires when …
appliance.status.changedAppliance transitions between fleet states (running, offline, …).
key.createdA tenant admin mints a new virtual key.
key.revokedA virtual key is revoked (manually or via TTL expiry).
billing.threshold.reachedTenant crosses a soft or hard spend limit on a billing dimension.
incident.openedIncident-service creates an incident (status=firing).
incident.resolvedIncident-service transitions an incident to resolved.
ota.rollout.completedOTA plan finishes all waves (succeeded, failed, aborted).
audit.anchor.publishedAudit-aggregator publishes a daily Merkle root for your chain.