Skip to content

Webhooks

Rather than polling the API, get notified.

The four events

EventWhen it fires
model.traineda version has just been trained and serves predictions
model.staleenough outcomes have piled up, an integration would help
model.refreshedan integration produced a new version
credits.lowyour credit balance is running down

Registering an address

sh
curl -X POST "$SPAVIK_BASE_URL/v1/webhooks" \
  -H "X-API-Key: $SPAVIK_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/webhooks/spavik","events":["model.stale","credits.low"]}'

The secret appears once

The response to this creation contains a secret field. That is the only time it is shown. Put it in your secret manager immediately: there is no way to read it back.

The same address can only be registered once. A second attempt gets 409 WEBHOOK_URL_EXISTS, naming the webhook already holding the spot.

Verifying the signature

Every call is signed. Verify it before doing anything with the content: without that check, anyone who knows your URL can tell you anything.

python
from spavik.webhooks import verifier, SpavikSignatureError

@app.post("/webhooks/spavik")
async def receive(request):
    try:
        event = verifier(await request.body(), request.headers, SECRET)
    except SpavikSignatureError:
        return Response(status_code=400)
    handle(event)
    return Response(status_code=200)
js
app.post('/webhooks/spavik', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const event = await verifier(req.body, req.headers, process.env.SPAVIK_WEBHOOK_SECRET);
    handle(event);
    res.sendStatus(200);
  } catch {
    res.sendStatus(400);
  }
});
js
app.post('/webhooks/spavik', async (c) => {
  const body = await c.req.arrayBuffer();
  const event = await verifier(body, c.req.raw.headers, c.env.SPAVIK_WEBHOOK_SECRET);
  handle(event);
  return c.body(null, 200);
});

The bytes, not the object

The signature covers the bytes as received. A body parsed into JSON and re-serialised does not produce the same signature, even with exactly the same data. Hence the express.raw and the arrayBuffer above.

Verification also rejects a timestamp that is too old, which blocks replaying an intercepted call. The tolerance is five minutes by default.

Suspend rather than delete

sh
curl -X PATCH "$SPAVIK_BASE_URL/v1/webhooks/{id}" \
  -H "X-API-Key: $SPAVIK_API_KEY" \
  -H 'Content-Type: application/json' -d '{"active": false}'

A suspended webhook keeps its secret and its configuration. Deleting it would force you to create another one, and therefore to change secret.

Each webhook's last_status field tells you what your server answered on the last call: enough to spot a broken integration without digging through logs.

One point of separation

Webhooks are managed from a web session only. An API key that tries gets a 403. That is deliberate: a key deployed inside an application must not be able to redirect your notifications.

Do not confuse the two directions

This page describes outbound webhooks: Spavik calls your server. The route POST /v1/integrations/stripe/webhook is an inbound webhook, called by Stripe, and none of your concern.

Part of this documentation is generated from the OpenAPI contract.