# Webhooks

Webhooks tell your server when something changes, such as an order being paid, so you do not need to poll. Vabu OS sends an HTTPS `POST` with a signed JSON body to each endpoint you register.

You manage endpoints at `/v1/webhook_endpoints`. Each endpoint has a URL, the event types it wants (wildcards such as `events.*` work), an API version and a signing secret that starts with `whsec_`. In live mode the URL must be HTTPS and must resolve to a public address. Private, loopback and link-local addresses are blocked.

## Event envelope

Every delivery has the same envelope. The event types and their payloads are in the [event catalogue](https://docs.vabu.live/events/index.md).

```json
{
  "id": "evt_01JB4...",
  "object": "event",
  "type": "events.order.paid",
  "api_version": "2026-11-01",
  "created_at": "2026-11-02T18:30:05Z",
  "livemode": true,
  "tenant": "ten_01J...",
  "subject": "ord_01J...",
  "data": { "object": { }, "previous_attributes": { } },
  "request": { "id": "req_01J...", "idempotency_key": "order-8842" }
}
```

| Field | Meaning |
|---|---|
| `id` | The event ID. The same event always has the same ID, including on retries. |
| `type` | `{product}.{resource}.{action}`, in the past tense. |
| `subject` | The ID of the resource the event is about. |
| `data.object` | The resource as the API returns it. |
| `data.previous_attributes` | The old values of fields that changed. |
| `request` | The API request that caused the event, when there was one. |

Each delivery also has the headers `Vabu-Event-Id`, `Vabu-Event-Type` and `Vabu-Signature`.

## Verify the signature

Check every delivery before you trust it. The `Vabu-Signature` header looks like this:

```text
Vabu-Signature: t=1762108205,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`t` is the Unix time of the delivery. `v1` is the hex HMAC-SHA256 of the string `t + "." + body`, keyed with your endpoint secret. The header can hold several `v1` values while you are rolling a secret: accept the request if any one of them matches.

To verify:

1. Read the **raw** request body. Do not parse and re-serialise it first.
2. Split the header on `,` and read `t` and each `v1`.
3. Compute the HMAC-SHA256 of `t + "." + body` with your secret, as hex.
4. Compare it with each `v1` using a constant-time comparison.
5. Reject the request if `t` is more than 5 minutes from your clock, so an old request cannot be replayed.

::: code-group

```ts [TypeScript]
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verifyVabuSignature(rawBody: string, header: string, secret: string, toleranceSeconds = 300): boolean {
  const parts = header.split(',').map((p) => p.trim().split('=') as [string, string])
  const t = parts.find(([k]) => k === 't')?.[1]
  const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v)
  if (!t || signatures.length === 0) return false
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false

  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
  return signatures.some((sig) => {
    const given = Buffer.from(sig, 'hex')
    return given.length === expected.length && timingSafeEqual(given, expected)
  })
}
```

```php [PHP]
<?php

function verifyVabuSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
    $t = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($key === 't') {
            $t = $value;
        } elseif ($key === 'v1') {
            $signatures[] = $value;
        }
    }

    if ($t === null || $signatures === [] || abs(time() - (int) $t) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);

    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

// In your controller: verifyVabuSignature(file_get_contents('php://input'), $_SERVER['HTTP_VABU_SIGNATURE'] ?? '', $secret)
```

```python [Python]
import hashlib
import hmac
import time


def verify_vabu_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    t = None
    signatures = []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            t = value
        elif key == "v1":
            signatures.append(value)

    if t is None or not signatures or abs(time.time() - int(t)) > tolerance:
        return False

    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in signatures)
```

```go [Go]
package vabu

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"math"
	"strconv"
	"strings"
	"time"
)

// VerifySignature checks a Vabu-Signature header against the raw request body.
func VerifySignature(rawBody []byte, header, secret string, tolerance time.Duration) bool {
	var t string
	var signatures []string
	for _, part := range strings.Split(header, ",") {
		key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch key {
		case "t":
			t = value
		case "v1":
			signatures = append(signatures, value)
		}
	}

	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || len(signatures) == 0 {
		return false
	}
	if math.Abs(float64(time.Now().Unix()-ts)) > tolerance.Seconds() {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)

	for _, sig := range signatures {
		given, err := hex.DecodeString(sig)
		if err == nil && hmac.Equal(given, expected) {
			return true
		}
	}
	return false
}
```

:::

Return a `2xx` status quickly, and do slow work after you respond. Any other status, a timeout, or a redirect counts as a failure.

## Delivery guarantees

- **At least once.** An event can arrive more than once. Store the `Vabu-Event-Id` of each event you handle and skip repeats.
- **No ordering.** Events can arrive out of order. Use `created_at` and fetch the current object from the API when order matters.
- **Timeout.** Your endpoint has 10 seconds to answer with a `2xx`.
- **Redirects** are not followed.

## Retries

When a delivery fails, Vabu OS retries with exponential backoff and jitter for up to 3 days.

| Attempt | Wait after the previous failure |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 onward | 12 hours, until 72 hours have passed |

If an endpoint fails every delivery for 3 days, Vabu OS disables it, emails your tenant owners and emits `platform.webhook_endpoint.disabled`. Each endpoint has a concurrency limit, 10 by default, so one slow endpoint does not slow others.

## Replay and test

- `GET /v1/events` and `GET /v1/events/{id}` list and fetch events for 30 days.
- `POST /v1/webhook_endpoints/{id}/replay` re-sends events to an endpoint. Pass a time range or a list of event IDs. Use it after an outage on your side.
- `POST /v1/webhook_endpoints/{id}/test` sends a test event.
- Every delivery attempt is logged with its status, latency and the first 4 KB of your response, so you can see why one failed.

## Roll a secret

Rolling a secret keeps the old one valid for a while. During that time each delivery carries two `v1` values, one for each secret. Because the verification code accepts any matching `v1`, you can deploy the new secret first, and nothing is dropped.
