Skip to main content
Webhooks let your app react to run lifecycle events without polling. When an event fires, Spine POSTs a signed JSON envelope to a URL you configured, and you verify it with a shared secret. Pair webhooks with polling if you want both — the delivery tells you when a run is ready, and GET /v1/run/{run_id} gives you the full shape on demand.
Webhook endpoints are configured per workspace in the Spine developer portal. A single endpoint can subscribe to one or more event types, or to * for everything.

Event types

EventFires when
run.startedImmediately after POST /v1/run creates the run.
run.completedThe run reaches terminal status completed. Includes final_output and artifacts.
run.failedThe run reaches terminal status failed. Includes the error message.
webhook.pingFired by Send test event in the portal — use it to exercise your verifier.

Event envelope

Every delivery has the same top-level shape.
{
  "id": "evt_78ffe7c625d94d2192b2f5a8d63933a6",
  "type": "run.completed",
  "created": 1745251200,
  "livemode": true,
  "api_version": "2026-04-01",
  "data": {
    "object": {
      "run_id": "06bb6cee-7982-48af-acd4-05d21ce39ed5",
      "canvas_id": "3c86545a-2e06-4c29-aac5-557f897c8ada",
      "status": "completed",
      "duration_ms": 10736,
      "artifacts": [],
      "final_output": "2 + 2 equals 4."
    }
  }
}
FieldTypeNotes
idstringevt_<uuid>. Unique per delivery — use for idempotency.
typestringOne of the event types above.
createdintUnix seconds when the event was generated.
livemodebooltrue in production, false in development / staging.
api_versionstringEnvelope schema version. Bumped on breaking changes.
data.objectobjectThe payload. For run.* events, mirrors GET /v1/run/{run_id}.

Request headers

Content-Type: application/json
User-Agent: Spine-Webhooks/1.0
Spine-Signature: t=1745251200,v1=5fe3f78dbf86c53084d1bd1222005d3c064567937e5ceafb904e30551fdb89ad
Spine-Event-Id: 37070895-029a-4802-a5da-4052e7964cc3
Spine-Event-Type: run.completed
Spine-Signature is t={unix_ts},v1={hex} — timestamp and HMAC-SHA256 over {t}.{raw_body}. Spine-Event-Id equals event.id in the body; persist it to dedupe. Spine-Event-Type is redundant with event.type but handy for routing before JSON parse.

Verify the signature

Always verify the signature before processing a delivery. Anyone who learns your webhook URL can send unsigned POSTs; the signature proves the body came from Spine.
Given the whsec_... secret you stored when you created the endpoint, the raw request body, and the Spine-Signature header, verification is five steps:
  1. Parse the header. Split on , to extract t (unix seconds) and every v1= value.
  2. Check freshness. Reject if abs(now - t) > 300 — blocks replayed deliveries.
  3. Rebuild the signed payload. Literally {t}.{raw_body}: the header’s t value, a single ., then the raw request body bytes. Not the parsed JSON — frameworks may reorder keys and invalidate the signature.
  4. Recompute the HMAC. HMAC-SHA256(secret, signed_payload) as a lowercase hex digest.
  5. Constant-time compare against any v1 value from the header. Use hmac.compare_digest (Python) or crypto.timingSafeEqual (Node) — never ==.
import hmac, hashlib, time

TOLERANCE_SECONDS = 300

def verify_spine_webhook(secret: str, raw_body: bytes, header: str) -> bool:
    pairs = [kv.split("=", 1) for kv in header.split(",") if "=" in kv]
    t = next((v for k, v in pairs if k == "t"), None)
    v1s = [v for k, v in pairs if k == "v1"]
    if not t or not v1s:
        return False
    if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return any(hmac.compare_digest(expected, v) for v in v1s)
Express parses JSON by default and discards the raw bytes needed for verification — capture them first.
app.post(
  "/webhooks/spine",
  express.raw({ type: "application/json" }),
  (req, res) => {
    if (!verifySpineWebhook(SECRET, req.body, req.header("Spine-Signature") ?? "")) {
      return res.status(400).json({ error: "invalid signature" });
    }
    const event = JSON.parse(req.body.toString("utf8"));
    // dedupe on event.id, then handle event.type
    res.status(200).json({ ok: true });
  },
);

Test your setup

Before wiring real events to your verifier, send a ping and confirm it parses cleanly.
  1. In the Spine developer portal, open your webhook endpoint and click Send test event. This fires a webhook.ping delivery through the real pipeline — signed, headered, same shape as production events.
  2. Confirm your endpoint returned 2xx in the portal’s delivery log. A non-2xx means your verifier rejected a legitimate Spine signature; inspect the delivery’s response body for your error message.
  3. Click Resend on the delivery to re-fire it as you iterate.
The ping envelope is intentionally minimal so you can assert on it:
{
  "id": "evt_...",
  "type": "webhook.ping",
  "created": 1745251200,
  "livemode": false,
  "api_version": "2026-04-01",
  "data": { "object": { "ok": true } }
}
Common causes of a failed verifier:
  • Body was re-parsed before hashing. Hash the bytes you received, not the re-serialized JSON. Express needs express.raw(); FastAPI needs await request.body() on the Request object.
  • Wrong secret. After rotation, the previous secret stops signing immediately — update the value in your app before the next delivery.
  • Clock skew. If your server drifts more than 5 minutes from Spine’s clock, every delivery will fail the freshness check. Run NTP.
  • Non-constant-time compare. Fine functionally, but leaks timing information to attackers. Use the platform primitive.

Responding

Return any 2xx status within 10 seconds to mark the delivery as succeeded. Anything else — non-2xx, timeout, network error — is recorded as failed in the delivery log. Do heavy work asynchronously: acknowledge fast, process later.

Idempotency

Two situations can look like duplicates:
  • Resend. Clicking Resend in the portal clones a past delivery and fires a fresh one with a new Spine-Event-Id.
  • Repeat trigger. If a run transitions through a terminal state more than once (rare), each transition issues a new delivery with a new Spine-Event-Id.
Either way: persist the Spine-Event-Id alongside the downstream effect, and skip if you’ve seen it.

Secret management

The full whsec_... secret is shown exactly once when you create or rotate an endpoint. Store it in a secret manager or your .env — after the reveal, the portal shows only a masked form like whsec_••••abcd. Rotating invalidates the old secret immediately. If multiple consumers share a secret, update your verifiers before rotating.

Notes

  • Deliveries are single-attempt in v1. Transient failures are recorded and can be re-fired with Resend in the portal. Automatic retries with exponential backoff are planned.
  • Use * in the events list to subscribe an endpoint to every event type, including ones added later.
  • HTTPS is enforced in production.