DOCS · Webhook delivery

The events Plumbline AI firesat your endpoint, signed.

Every dispatch is an HMAC-SHA256-signed POST to a URL you opt into — the same audit ledger that the dashboard replays, signed so your receiver can verify it actually came from Plumbline AI. Read end to end for the event catalog, the signature scheme, the retry / backoff policy, and the intake form that opens webhook access on a buyer account.

Event catalog

Five signed events. Closed enum.

These are the only event types Plumbline AI emits today. The set is closed — a new type means a contract bump, not a silent addition — so a subscriber never has to guess what an unknown type string means.

EventEmitted whenPayload summaryStatus
Eval run createdeval.run.created
On every POST /api/eval-records against a model you own.{ type, model_id, run_id, version, hash (sha256), created_at }
live today
Run signed offrun.signed
When an operator signs off an eval run from /dashboard/eval-runs/[id].{ type, model_id, run_id, signed_by_user_id, reviewer_name, signed_at }
live today
Drift detecteddrift.detected
When a streaming drift window crosses the per-model drift threshold.{ type, model_id, drift_score, window_size, hash (sha256), detected_at }upcoming
Alert firedalert.fired
On the same tick as an alert row landing in the buyer inbox.{ type, alert_id, rule_code, severity, model_id, fired_at }upcoming
Alert resolvedalert.resolved
When an operator resolves an alert in /dashboard/alerts.{ type, alert_id, rule_code, severity, resolved_at }
live today
What every dispatch carries

The body is the canonicalised JSON payload (sorted keys, ISO-8601 timestamps) — the bytes Plumbline AI signed are the bytes your receiver will receive. Every POST also carries three headers:

  • Content-Type: application/json
  • X-Polsia-Event-Type: eval.run.created (one of the four values above)
  • X-Polsia-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
  • X-Polsia-Delivery-Id: 7a3f1c4e9b2d5a8f (16-hex, idempotency key)

Signature scheme

HMAC-SHA256, one header.

Every dispatch is a single HMAC-SHA256 call keyed by the plaintext signing secret the dashboard hands back at subscription creation. The receiver verifies by recomputing the HMAC over the raw POST bytes and comparing to the sha256= value carried in X-Polsia-Signature. The dispatcher canonicalises the body (sorted keys, ISO dates) before it signs; the receiver does NOT re-canonicalise — verify over the bytes you received.

Verify on your side

Drop-in Node verifier. Reads the raw POST body, recomputes HMAC-SHA256 with the plaintext secret the dashboard returned at create time, and uses timingSafeEqual so the comparison does not leak the signature length.

Node · HMAC-SHA256 verifier

Node · X-Polsia-Signature
paste-run
// verify_polsia_webhook.js
//
// Drop-in for a Node receiver. Reads the RAW POST body, recomputes
// HMAC-SHA256 with the same plaintext secret the dashboard minted,
// and compares against the 'sha256=' half of X-Polsia-Signature.

import crypto from 'node:crypto';

export function verify(req, rawBody, secret) {
  const header = req.headers['x-polsia-signature'] ?? '';
  if (!header.startsWith('sha256=')) {
    throw new Error('unsupported signature scheme');
  }
  const presented = header.slice('sha256='.length);

  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)        // RAW POST bytes — dispatcher canonicalises
    .digest('hex');         //   before signing, so do NOT re-canonicalise here

  // timingSafeEqual avoids leaking the signature length to attackers.
  const presentedBuf = Buffer.from(presented, 'hex');
  const expectedBuf  = Buffer.from(expected,  'hex');
  if (presentedBuf.length !== expectedBuf.length) return false;
  return crypto.timingSafeEqual(presentedBuf, expectedBuf);
}

// Every dispatch also carries an idempotency key — X-Polsia-Delivery-Id —
// and the SHA-256 of the body. Use either to dedupe on your side:
const deliveryId = req.headers['x-polsia-delivery-id'];
const eventType  = req.headers['x-polsia-event-type'];
AlgorithmHMAC-SHA256
Encodinglowercase hex (64 chars)
HeaderX-Polsia-Signature: sha256=<hex>
Inputraw POST bytes (canonicalised by dispatcher)

Retry & backoff

Six attempts, then the row sits.

A dispatch is "successful" when your receiver returns a 2xx within 10 seconds. Anything else (non-2xx, timeout, DNS, TLS failure) is treated as a failure and retried on the schedule below. After the final attempt the row sits in /dashboard/webhooks for an operator to replay by hand.

Schedule

Up to 6attempts per delivery. Each attempt's response (or absence of one) is persisted as a WebhookDelivery row — the audit ledger and the dispatch log are the same table.

  • Attempt 2+30 seconds
  • Attempt 3+120 seconds
  • Attempt 4+600 seconds
  • Attempt 5+1,800 seconds
  • Attempt 6+3,600 seconds

Dead-letter / replay

After the final attempt, the delivery is marked failed and remains visible on the dashboard's webhooks page. From there, an operator can POST /api/webhooks/deliveries/<id>/replay to re-fire the same signed payload with a fresh signing nonce — the receiver gets the same body bytes again, so its idempotency store handles the dedupe.

Idempotency on your side

Every dispatch carries X-Polsia-Delivery-Id (16-hex) — replay-safe and identical across retries of the same logical event. Use it (or the SHA-256 body hash carried in the hash field) as the dedupe key; retries of the same payload will repeat the same Delivery-Id and signature.

Subscribe

Request webhook access for your buyer account.

Webhook subscriptions are provisioned per buyer account. Drop the team a note with your receiver URL, the model ids you want to subscribe, and the events you care about — we'll generate an endpoint, mint a per-target signing secret, and hand it back so your receiver can ship.

Or reach the team directly at plumbline-ai-9@polsia.app.

Next · Pick a direction

Signed. Ready to wire.

Mint the pl_… key the SDK expects, or hand the receiver contract directly to your integrator.