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

EventWhen it fires
request.createdAfter the proxy receives an LLM response and inserts a row into requests
trace.completedWhen the last span in an agent trace closes
alert.triggeredWhen 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 records
http

All 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

FieldTypeRequiredDescription
namestringYesHuman-readable label (e.g. "Slack event pipe")
urlstringYesMust start with https://. Plain HTTP is rejected.
eventsstring[]YesEvents to subscribe to. An empty array means no events will be delivered.
is_activebooleanOptionalDefaults to true. Set false to pause delivery.
curl -X POST https://spanlens-server.vercel.app/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
  }'
bash

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"
}
json

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 the following JSON body as an HTTP POST to your endpoint when an event fires.

{
  "event": "request.created",
  "created_at": "2026-05-15T09:01:23Z",
  "data": {
    "id": "req_01j9xyz...",
    "project_id": "proj_01j9...",
    "model": "gpt-4o-mini-2024-07-18",
    "provider": "openai",
    "input_tokens": 512,
    "output_tokens": 128,
    "cost_usd": 0.000096,
    "duration_ms": 843
  }
}
json

The shape of the data field varies by event type. request.created contains a summary of the request row; trace.completed contains trace metadata; and alert.triggered contains the triggered rule and its current value.

Signature verification

Every delivery includes an X-Spanlens-Signature header. The value is an HMAC-SHA256 digest of the raw request body using 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')

  // Use timingSafeEqual to prevent timing attacks
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(signatureHeader, '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 })
})
typescript

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.

Delivery history

GET /api/v1/webhooks/:id/deliveries returns the last 10 delivery records. Each record includes the HTTP status code, the first 500 characters of the response body, and the delivery timestamp. If you see repeated 4xx or 5xx responses, check your server logs alongside the delivery history.

curl https://spanlens-server.vercel.app/api/v1/webhooks/wh_01j9abc.../deliveries \
  -H "Authorization: Bearer <JWT>"
bash
[
  {
    "id": "del_01j9...",
    "event": "request.created",
    "status_code": 200,
    "response_body": "{"ok":true}",
    "delivered_at": "2026-05-15T09:01:24Z"
  }
]
json

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://spanlens-server.vercel.app/api/v1/webhooks/wh_01j9abc.../test \
  -H "Authorization: Bearer <JWT>"
bash

Permissions

Actionadmineditorviewer
List / delivery history
Create / update / delete
Test delivery

Limitations

  • 20 webhooks per organization maximum.
  • No retries. If your server returns a non-2xx status or times out (10s), the delivery is recorded as failed and is not retried. Implement idempotency on your receiver side.
  • Only the last 10 delivery records are kept per webhook. Store delivery logs on your server if you need a complete audit trail.
  • HTTPS required. HTTP URLs are rejected at registration time.

Related: Alerts (threshold-based notifications), Audit logs (change history), Security (PII / prompt injection scanning).