Webhooks

Instead of polling, register an HTTPS endpoint and we deliver events to it as they happen. Every delivery is signed, so you can prove it came from us.

Registering an endpoint

POST /api/v1/client/webhooks registers a subscription and returns a secret.

curl -X POST "https://<external-api-host>/api/v1/client/webhooks" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://your-app.example.com/hooks/staffngo", … }'

The secret is returned once, at registration. It is stored encrypted on our side and cannot be read back. Capture it immediately; losing it means registering again.

Registering the same URL twice does not create a duplicate subscription — the same registration is returned rather than a second one. Your endpoint must be publicly reachable over HTTPS: private, loopback and unresolvable hosts are refused outright.

The id you get back is an opaque string — store and send it back verbatim. Do not parse it as a number, derive one from another, or build one yourself: the values are deliberately not sequential, and a value you did not receive from us will not resolve.

What a delivery looks like

Header Contents
X-Webhook-Id Unique event identifier — use it to deduplicate.
X-Webhook-Event Event type.
X-Webhook-Timestamp Unix time (seconds) at signing.
X-Webhook-Signature sha256=<lowercase hex HMAC>

Verifying the signature

The signature is an HMAC-SHA256, keyed by your secret, over the canonical string {timestamp}.{raw body} — the body exactly as received, before any JSON parsing or re-serialization.

import hmac, hashlib, time

def verify(secret: str, timestamp: str, raw_body: bytes, header: str) -> bool:
    expected = hmac.new(
        secret.encode("utf-8"),
        f"{timestamp}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()
    # Constant-time comparison — a plain == leaks the signature one byte at a time.
    if not hmac.compare_digest(f"sha256={expected}", header):
        return False
    # Reject stale timestamps so a captured delivery cannot be replayed later.
    return abs(time.time() - int(timestamp)) < 300

Two rules that matter more than they look:

  1. Compare in constant time. A naive string comparison is a timing oracle.
  2. Check the timestamp. The signature alone does not expire; without a freshness window, anyone who captured a delivery can replay it indefinitely.

Responding

Answer 2xx quickly. Do the actual work asynchronously — a slow handler eats into the delivery timeout and turns a successful event into a retry.

Your response What we do
2xx Delivered. Done.
408, 429, 5xx Treated as transient — retried with backoff.
Any other 4xx Treated as permanent — not retried, and repeated failures disable the subscription.
Timeout, DNS, TLS or connection failure Transient — retried with backoff.

Retries mean your handler must be idempotent: deduplicate on X-Webhook-Id. A delivery already processed can legitimately arrive again after a network failure on our side.

Testing before you go live

POST /api/v1/client/webhooks/test sends a ping to your registered endpoint, so you can validate reachability and your signature check without waiting for a real event. Use it whenever you change the receiving side.