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
idyou 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:
- Compare in constant time. A naive string comparison is a timing oracle.
- 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.