Skip to content

Event Webhooks

Planisphere delivers signed, replay-proof webhooks to a URL you control when things happen in your tenant — an evidence receipt is sealed, a roll-up anchors your chain, a verification fails, or metered usage crosses a threshold. Delivery is self-serve: register the integration and events flow. No operator step.

Not the same as the review-routing webhook. The webhook route channel (see servicenow.md) delivers needs_review dispatches and signs the body as X-Planisphere-Signature: sha256=<hmac(body)>. Event webhooks are signed differently (t=<ts>,v1=<hmac>, below) and carry X-Planisphere-Id + X-Planisphere-Timestamp. Tell them apart by the signature prefix. Both use the same HMAC key for a given integration: with a minted whsec_… secret that key is the derived key (below); with a secret_ref it is the resolved secret value.

Event catalog

Event Fires when Key payload fields
record.sealed A gate decision is recorded and its evidence packet sealed. action_key, decision, attestation_root, stamp_id, href
receipt.anchored A canonical roll-up epoch anchors your tenant chain root. epoch_seq, canonical_root, epoch_digest, tenant_chain_root, tenant_seq, leaf_index
verify.failed An authenticated verify call does not pass. action_key, href, vertical, failed_checks
meter.threshold Cumulative metered usage first crosses a threshold you configured. threshold, meter_total, usage_unit

Every payload also carries event, tenant, and created_at. Example record.sealed body:

{
  "event": "record.sealed",
  "tenant": "tenant-law",
  "action_key": "law:check-citations:9f2c…",
  "decision": "allow",
  "attestation_root": "ab34…",
  "stamp_id": "stamp-… ",
  "href": "https://api.planisphere.ooo/law/evidence-packets/law:check-citations:9f2c…",
  "created_at": "2026-07-22T01:39:30+00:00"
}

Register

A webhook integration is a tenant integration on the webhook channel. Set the delivery URL, the list of events you want, and (for meter.threshold) the cumulative thresholds. Omit secret_ref and Planisphere mints your signing secret at registration — fully self-serve, no operator or server-side configuration:

PUT /tenant/integrations/webhook/primary
X-Planisphere-Key: ps_live_…
{
  "enabled": true,
  "config": {
    "url": "https://app.example.com/hooks/planisphere",
    "events": ["record.sealed", "verify.failed", "receipt.anchored", "meter.threshold"],
    "meter_thresholds": [10000, 50000]
  }
}

The response includes signing_secret (whsec_…) exactly once — store it now. Only a derived key is kept server-side; no endpoint returns the secret again. Unlike an API-key hash, that derived key is the HMAC signing key itself (see Security for what that means). To replace it, send the same PUT with "rotate_signing_secret": true — a fresh secret is minted and shown once.

  • events — only subscribed events are delivered. Omit an event to opt out.
  • meter_thresholds — cumulative meter-unit integers. Each threshold fires meter.threshold exactly once, the moment your running total first reaches it. Omit the key to receive no threshold events even if you subscribe to the event.
  • secret_ref (optional, server-managed alternative) — env://NAME, secret://…, or a cloud ref (aws-sm://…, gcp-sm://…, azure-kv://…). When supplied, it is used for signing instead of a minted secret. Raw secret material is rejected.

Check your deliveries

Per-delivery status is visible on the integration — no guessing whether events arrived:

GET /tenant/integrations/webhook/primary/deliveries?status=failed&limit=50
X-Planisphere-Key: ps_live_…

Each item carries status (pending, delivered, failed), attempt_count, last_error, and the delivery payload, newest first, with limit/offset pagination. delivered means your endpoint returned a 2xx.

Delivery headers

Content-Type:            application/json
X-Planisphere-Event:     record.sealed
X-Planisphere-Id:        evt_5c1d…                 # unique per delivery
X-Planisphere-Timestamp: 1784684369
X-Planisphere-Signature: t=1784684369,v1=9a0f…    # HMAC-SHA256 hex

Verify the signature

The signature covers "{timestamp}.{id}.{raw_body}" — recompute the HMAC over the exact bytes you received, do not re-serialize the JSON. Reject if the signature does not match, or if the timestamp is too old for your tolerance (the timestamp is bound into the signature, so a captured payload cannot be replayed or re-stamped).

The HMAC key depends on how you registered:

  • Minted whsec_… secret (the self-serve default): derive the key once — hmac_key = sha256_hex("planisphere-webhook-signing:" + signing_secret). Planisphere stores only this derived key (never the raw secret) and signs with it; you recompute it from the secret you were shown once. The exact recipe also travels on the registration response as signing_key_derivation.
  • secret_ref: the resolved secret value is the HMAC key directly.

Python:

import hashlib, hmac, time

def hmac_key(signing_secret: str) -> str:
    """For minted whsec_ secrets. For secret_ref, use the secret itself."""
    return hashlib.sha256(
        ("planisphere-webhook-signing:" + signing_secret).encode()
    ).hexdigest()

def verify(key: str, headers, raw_body: bytes, tolerance_s: int = 300) -> bool:
    ts = headers["X-Planisphere-Timestamp"]
    event_id = headers["X-Planisphere-Id"]
    sig = headers["X-Planisphere-Signature"].split("v1=", 1)[1]
    if abs(time.time() - int(ts)) > tolerance_s:
        return False
    message = f"{ts}.{event_id}.{raw_body.decode()}".encode()
    expected = hmac.new(key.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

Node:

import { createHash, createHmac, timingSafeEqual } from "node:crypto";

// For minted whsec_ secrets. For secret_ref, use the secret itself.
export function hmacKey(signingSecret) {
  return createHash("sha256")
    .update(`planisphere-webhook-signing:${signingSecret}`)
    .digest("hex");
}

export function verify(key, headers, rawBody, toleranceS = 300) {
  const ts = headers["x-planisphere-timestamp"];
  const id = headers["x-planisphere-id"];
  const sig = headers["x-planisphere-signature"].split("v1=")[1];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceS) return false;
  const expected = createHmac("sha256", key)
    .update(`${ts}.${id}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(sig);
  return a.length === b.length && timingSafeEqual(a, b);
}

Delivery, retries, idempotency

  • Deliveries are enqueued transactionally with the event and drained by a background worker; a 2xx marks success, anything else fails and retries.
  • Retries use exponential backoff and dead-letter after several attempts. Each delivery has a stable X-Planisphere-Id — dedupe on it, and treat handlers as idempotent (record.sealed/receipt.anchored/meter.threshold are each enqueued at most once per underlying event).
  • verify.failed is emitted only when the caller authenticated with a tenant key; anonymous/offline verification never triggers a webhook.

Security

For minted secrets, Planisphere stores only the derived signing key — the raw whsec_… value is shown once at mint time and never stored or returned by any endpoint. Be precise about what that buys you: the derived key is the symmetric HMAC key that signs your deliveries, so it is usable signing material, not a one-way hash like an API-key digest (which cannot authenticate on its own). Anyone who obtained the stored derived key could sign forged deliveries to your endpoint — so treat webhook signatures as transport authentication, and rely on the sealed evidence receipts themselves (which are asymmetrically signed and independently verifiable) for anything evidentiary. If you want signing material kept out of the Planisphere database entirely, register a secret_ref instead: Planisphere then stores only the reference, never the secret, and resolves it from your secret manager at send time to sign the delivery.