Webhooks
Deliver Spanlens events to your own server as HTTP POST payloads in real time. Three event types are supported, request created, trace completed, and alert triggered, all signed with HMAC-SHA256 so you can verify authenticity. Use webhooks to build custom Slack bots, data pipelines, CI/CD triggers, or any other automation beyond the dashboard.
Supported events
| Event | When it fires |
|---|---|
request.created | After the proxy receives an LLM response and inserts a row into requests |
trace.completed | When the last span in an agent trace closes |
alert.triggered | When an Alert rule exceeds its threshold and sends a notification |
Endpoints
GET /api/v1/webhooks # List all webhooks in the organization
POST /api/v1/webhooks # Register a new webhook
PATCH /api/v1/webhooks/:id # Update name, URL, events, or active status
DELETE /api/v1/webhooks/:id # Delete a webhook
POST /api/v1/webhooks/:id/test # Send a test payload immediately
GET /api/v1/webhooks/:id/deliveries # Last 10 delivery recordsAll endpoints require Authorization: Bearer <supabase-jwt>. Creating, updating, and deleting webhooks requires admin or editor role. Viewers can only list webhooks and view delivery history.
Registering a webhook
Request schema
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Human-readable label (e.g. "Slack event pipe") |
url | string | Yes | Must start with https://. Plain HTTP is rejected. |
events | string[] | Yes | Events to subscribe to. An empty array means no events will be delivered. |
is_active | boolean | Optional | Defaults to true. Set false to pause delivery. |
curl -X POST https://api.spanlens.io/api/v1/webhooks \
-H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-d '{
"name": "My data pipeline",
"url": "https://my-server.example.com/hooks/spanlens",
"events": ["request.created", "alert.triggered"],
"is_active": true
}'Response example
{
"id": "wh_01j9abc...",
"name": "My data pipeline",
"url": "https://my-server.example.com/hooks/spanlens",
"secret": "a3f8c2d1e5b04f7a9c6e2d8b1a4f03c7",
"events": ["request.created", "alert.triggered"],
"is_active": true,
"created_at": "2026-05-15T09:00:00Z"
}The secret is a 32-character hex string returned only at registration time. Store it securely, it cannot be recovered if lost. Subsequent GET responses show only a masked value.
Payload structure
Spanlens sends a JSON body as an HTTP POST to your endpoint when an event fires. Every body carries event, webhook_id, and timestamp, which is when the event was first dispatched and stays the same on retries. Next to those sits one object that describes what happened.
{
"request": {
"id": "3f0c2a8e-...",
"provider": "openai",
"model": "gpt-4o-mini-2024-07-18",
"prompt_tokens": 512,
"completion_tokens": 128,
"total_tokens": 640,
"cost_usd": 0.000154,
"latency_ms": 843,
"status_code": 200,
"trace_id": null,
"created_at": "2026-05-15T09:01:23.000Z"
},
"event": "request.created",
"timestamp": "2026-05-15T09:01:23.512Z",
"webhook_id": "8d0c41f2-..."
}trace.completed sends a trace object with id, status, ended_at, and duration_ms. alert.triggered sends an alert object with id, name, type, threshold, current_value, and window_minutes, plus an organization object with its name. A test delivery has event set to test and no event object.
Delivery headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-Spanlens-Signature | sha256= followed by the hex HMAC-SHA256 of the raw body. See signature verification. |
X-Spanlens-Delivery-Id | A UUID for the delivery. Every retry of the same event sends the same value, so use it to drop duplicates. See retries and duplicates. |
Signature verification
Every delivery includes an X-Spanlens-Signature header. Its value is sha256= followed by the hex HMAC-SHA256 digest of the raw request body, keyed with the secret issued at registration. Always verify the signature to reject forged requests.
Node.js verification example
import crypto from 'node:crypto'
export function verifySpanlensSignature(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex')
// The header looks like "sha256=<hex digest>"
const prefix = 'sha256='
if (!signatureHeader.startsWith(prefix)) return false
const received = signatureHeader.slice(prefix.length)
// Use timingSafeEqual to prevent timing attacks
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(received, 'hex')
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
// Express example
app.post('/hooks/spanlens', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-spanlens-signature'] as string
if (!verifySpanlensSignature(req.body.toString(), sig, process.env.WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'Invalid signature' })
}
const event = JSON.parse(req.body.toString())
// handle event
res.json({ ok: true })
})Important: read req.body as raw bytes. Re-serializing the parsed JSON can change whitespace or key order, causing a signature mismatch. Use express.raw() or an equivalent middleware.
Retries and duplicates
An attempt succeeds when your endpoint answers with a 2xx status within 10 seconds. Any other status, a timeout, a connection error, or a redirect that Spanlens will not follow counts as a failed attempt.
Spanlens makes up to 5 attempts per event: the original delivery and 4 retries. Each retry waits at least 1, 2, 4, and 8 minutes after the attempt before it. Retries are sent by a job that runs every 5 minutes, so a retry can arrive a few minutes after it becomes due, and the whole sequence takes roughly 20 to 30 minutes. If the fifth attempt also fails, the delivery is dead-lettered and not tried again. Pending retries also stop when you disable or delete the webhook.
Retries have a 24 hour limit, counted from the first attempt. If retries are held up on the Spanlens side and a delivery is still waiting 24 hours after its event, it is dead-lettered with the reason expired rather than sent a day late.
Delivery is at least once. A retry can reach you even after an earlier attempt was processed, for example when your endpoint did the work but answered too slowly. Every attempt for the same event carries the same X-Spanlens-Delivery-Id, so record the IDs you have handled and skip repeats. Events are not guaranteed to arrive in order.
Redirects
Spanlens follows up to 3 redirects and sends the same signed POST, body and headers included, to each new location. Every location has to pass the same checks as the URL you registered: it must use HTTPS and must not resolve to a private, loopback, link-local, or cloud metadata address. The address is checked again at the moment Spanlens connects, so a DNS record that changes after the first check cannot get around it. A redirect that fails these checks, or a fourth redirect in a row, fails the attempt. Registering the final URL saves the extra round trips.
Delivery history
GET /api/v1/webhooks/:id/deliveries returns the 10 most recent delivery records, newest first. Each record describes one event and the result of its latest attempt: status, http_status, error_message, and duration_ms, plus attempt_count, next_retry_at while a retry is pending, and dlq_at with dlq_reason once the delivery has been dead-lettered. The record id is the value sent as X-Spanlens-Delivery-Id. The response body from your endpoint is not stored, so check your server logs alongside the delivery history when you see repeated 4xx or 5xx responses.
curl https://api.spanlens.io/api/v1/webhooks/<webhook-id>/deliveries \
-H "Authorization: Bearer <JWT>"{
"success": true,
"data": [
{
"id": "6f1c3a9e-...",
"webhook_id": "8d0c41f2-...",
"event_type": "request.created",
"status": "failed",
"http_status": 503,
"error_message": "HTTP 503",
"duration_ms": 412,
"attempt_count": 2,
"next_retry_at": "2026-05-15T09:04:24.000Z",
"dlq_at": null,
"dlq_reason": null,
"delivered_at": "2026-05-15T09:01:24.000Z"
}
]
}Abridged. Records also carry the stored payload and internal bookkeeping fields.
Test delivery
Call POST /api/v1/webhooks/:id/test to immediately send a dummy payload. Use this to verify your endpoint URL and signature verification logic without waiting for a real event.
curl -X POST https://api.spanlens.io/api/v1/webhooks/wh_01j9abc.../test \
-H "Authorization: Bearer <JWT>"Permissions
| Action | admin | editor | viewer |
|---|---|---|---|
| List / delivery history | ✓ | ✓ | ✓ |
| Create / update / delete | ✓ | ✓ | , |
| Test delivery | ✓ | ✓ | , |
Limitations
- 20 webhooks per organization maximum.
- Up to 5 attempts per event. A delivery that still fails after 4 retries, roughly 20 to 30 minutes after the first attempt, is dead-lettered and not sent again. Deliveries are at least once, so deduplicate on
X-Spanlens-Delivery-Id. See retries and duplicates. - The deliveries endpoint returns the 10 most recent records per webhook. Store delivery logs on your server if you need a complete audit trail.
- HTTPS required. HTTP URLs are rejected at registration time, and redirects to HTTP URLs are not followed.
Related: Alerts (threshold-based notifications), Audit logs (change history), Security (PII / prompt injection scanning).