Skip to content

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" }
}
FieldMeaning
idThe event ID. The same event always has the same ID, including on retries.
type{product}.{resource}.{action}, in the past tense.
subjectThe ID of the resource the event is about.
data.objectThe resource as the API returns it.
data.previous_attributesThe old values of fields that changed.
requestThe 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.
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-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.

AttemptWait after the previous failure
1Immediate
21 minute
35 minutes
430 minutes
52 hours
65 hours
710 hours
8 onward12 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.

Vabu OS documentation