Appearance
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.
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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt 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:
- Read the raw request body. Do not parse and re-serialise it first.
- Split the header on
,and readtand eachv1. - Compute the HMAC-SHA256 of
t + "." + bodywith your secret, as hex. - Compare it with each
v1using a constant-time comparison. - Reject the request if
tis more than 5 minutes from your clock, so an old request cannot be replayed.
ts
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
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
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
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-Idof each event you handle and skip repeats. - No ordering. Events can arrive out of order. Use
created_atand 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/eventsandGET /v1/events/{id}list and fetch events for 30 days.POST /v1/webhook_endpoints/{id}/replayre-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}/testsends 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.