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
| Event | Fires when |
|---|
run.started | Immediately after POST /v1/run creates the run. |
run.completed | The run reaches terminal status completed. Includes final_output and artifacts. |
run.failed | The run reaches terminal status failed. Includes the error message. |
webhook.ping | Fired 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."
}
}
}
| Field | Type | Notes |
|---|
id | string | evt_<uuid>. Unique per delivery — use for idempotency. |
type | string | One of the event types above. |
created | int | Unix seconds when the event was generated. |
livemode | bool | true in production, false in development / staging. |
api_version | string | Envelope schema version. Bumped on breaking changes. |
data.object | object | The payload. For run.* events, mirrors GET /v1/run/{run_id}. |
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:
- Parse the header. Split on
, to extract t (unix seconds) and every v1= value.
- Check freshness. Reject if
abs(now - t) > 300 — blocks replayed deliveries.
- 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.
- Recompute the HMAC.
HMAC-SHA256(secret, signed_payload) as a lowercase hex digest.
- 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.
- 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.
- 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.
- 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.