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
webhookroute channel (seeservicenow.md) deliversneeds_reviewdispatches and signs the body asX-Planisphere-Signature: sha256=<hmac(body)>. Event webhooks are signed differently (t=<ts>,v1=<hmac>, below) and carryX-Planisphere-Id+X-Planisphere-Timestamp. Tell them apart by the signature prefix. Both use the same HMAC key for a given integration: with a mintedwhsec_…secret that key is the derived key (below); with asecret_refit 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 firesmeter.thresholdexactly 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 assigning_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
2xxmarks 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.thresholdare each enqueued at most once per underlying event). verify.failedis 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.