Webhooks
Get a signed HTTP call when a case changes. Make an endpoint, verify the signature in TypeScript, Python, PHP or curl, handle retries and test locally.
A webhook is a call we make to your server when something happens: a case gets a new next date, an order is published, a report is ready. You do not poll. Webhooks are free. They need the scope webhooks:write.
#Make an endpoint
POST /webhook-endpoints registers a URL and the events it wants. The response carries the signing secret, whsec_…. It is shown once; store it like a password.
| Field | Meaning |
|---|---|
url |
Where to post. Required, https, at most 500 characters. |
events |
"*" for every event (the default), or a list of event types. An unknown type gives 400 invalid_events. |
description |
Optional, at most 200 characters. |
curl -X POST https://adv.heliosynctech.com/api/v1/webhook-endpoints \
-H "Authorization: Bearer $AOS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c0f0e-5b0e-4b7e-9c1d-2f6f6c1a9a10" \
-d '{ "url": "https://example.com/hooks/aos", "events": ["case.next_date_changed", "order.published"] }'
{
"object": "webhook_endpoint",
"id": "whe_3",
"url": "https://example.com/hooks/aos",
"events": ["case.next_date_changed", "order.published"],
"description": null,
"status": "active",
"livemode": false,
"failures": 0,
"disabled_at": null,
"created_at": "2026-12-01T10:20:00+05:30",
"secret": "whsec_4f9c1e7a0b3d58a2c6e1f4b9d07a3c52"
}
An account may have 20 endpoints in each mode (409 too_many_endpoints). A test key sees and makes test endpoints; a live key, live ones. They never mix. Other calls, all free:
| Call | Does |
|---|---|
GET /webhook-endpoints |
List your endpoints in this mode. |
GET /webhook-endpoints/{id} |
One endpoint, with its ten most recent deliveries. |
DELETE /webhook-endpoints/{id} |
Remove an endpoint and its pending deliveries. |
POST /webhook-endpoints/{id}/test |
Send a signed webhook.test event to the endpoint now. |
Where an endpoint may point. The URL must be https and must resolve to a public address, for test and live endpoints alike. Private, loopback and link-local addresses are refused with invalid_webhook_url, when you make the endpoint and again at every delivery. Our servers cannot reach your laptop, so for local work use the CLI: aos listen forwards events to localhost.
#What we send
A POST with a JSON body and these headers:
Content-Type: application/json
User-Agent: AdvocateOS-Webhooks/1.0
AOS-Event-Id: evt_5nQ7xLp2Vt9RkB4mCe8Hy1Ws
AOS-Signature: t=1796096400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_5nQ7xLp2Vt9RkB4mCe8Hy1Ws",
"object": "event",
"type": "case.next_date_changed",
"created_at": "2026-12-01T10:20:00+05:30",
"data": {
"watch_id": "wch_41",
"cnr": "SBXX010000012026",
"changes": [ { "field": "next_hearing_date", "from": "2026-12-08", "to": "2026-12-22" } ],
"case": { "object": "case", "cnr": "SBXX010000012026", "status": "pending", "next_hearing_date": "2026-12-22", "protected": false },
"sandbox": true
},
"mode": "test"
}
AOS-Event-Id is the same as the body's id; use either as your dedupe key. The case object above is shortened. The event types and their data are in events. We do not follow redirects, and wait 10 seconds for your answer.
#Verify the signature
Check every call. Anyone can post to your URL; only we hold the secret.
The AOS-Signature header is t={unix time},v1={signature}. The signature is the hex HMAC-SHA256, with your whsec_… secret as the key, of the string {t}.{raw body}: the time, a full stop, then the body exactly as it arrived.
- Read the raw body before any JSON parser touches it. Re-serialised JSON will not match.
- Compute the HMAC and compare it with
v1using a constant-time comparison. - Reject the call if
tis more than 5 minutes from your clock, so a captured call cannot be replayed later. Every attempt, including a retry, is signed afresh, sotis the time of that attempt. - Reply
2xxquickly. Do slow work after you reply.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, secret: string, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=") as [string, string]));
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
const want = Buffer.from(expected, "hex");
if (given.length !== want.length || !timingSafeEqual(given, want)) throw new Error("bad signature");
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) throw new Error("stale timestamp");
return JSON.parse(rawBody);
}
// Express: use express.raw({ type: "application/json" }) so req.body is the raw bytes.
// app.post("/hooks/aos", express.raw({ type: "application/json" }), (req, res) => {
// const event = verify(req.body.toString("utf8"), req.header("AOS-Signature") ?? "", process.env.AOS_WEBHOOK_SECRET!);
// res.sendStatus(200);
// });
import hashlib, hmac, json, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> dict:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
raise ValueError("bad signature")
if abs(time.time() - int(parts["t"])) > tolerance:
raise ValueError("stale timestamp")
return json.loads(raw_body)
# Flask: verify(request.get_data(), request.headers["AOS-Signature"], SECRET)
<?php
function verify(string $rawBody, string $header, string $secret, int $tolerance = 300): array
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
$parts[$k] = $v;
}
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $rawBody, $secret);
if (!hash_equals($expected, $parts['v1'] ?? '')) {
throw new RuntimeException('bad signature');
}
if (abs(time() - (int) $parts['t']) > $tolerance) {
throw new RuntimeException('stale timestamp');
}
return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
}
// Plain PHP: verify(file_get_contents('php://input'), $_SERVER['HTTP_AOS_SIGNATURE'] ?? '', getenv('AOS_WEBHOOK_SECRET'));
// Laravel: $request->getContent() is the raw body; exempt the route from CSRF.
# Check a captured call by hand. T and BODY come from the request you saved.
T=1796096400
BODY='{"id":"evt_5nQ7xLp2Vt9RkB4mCe8Hy1Ws","object":"event","type":"case.next_date_changed"}'
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$WHSEC" -hex
If your check fails, the usual causes are: you used the parsed and re-serialised body, a proxy changed the body (compression and trailing newlines count), you used the wrong endpoint's secret, or your server's clock is wrong.
#Retries
We treat any 2xx as delivered. Anything else, or no answer in 10 seconds, is a failure. The first attempt is at once; we then retry after:
1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 24 hours.
That is seven attempts in about 32 hours. A delivery that fails them all is marked failed. An endpoint that has had only failures for 3 days is disabled, and no new events are queued for it. The missed events are still in the event log, so you can fetch them with GET /events.
To switch a disabled endpoint back on, fix it, then call POST /webhook-endpoints/{id}/test. If your server answers the test with a 2xx, the endpoint is enabled again and its failure count resets. GET /webhook-endpoints/{id} shows status (active or disabled), failures, disabled_at and the recent deliveries, each with status (pending, ok or failed), attempts, http_status and next_attempt_at.
Because we retry, and because a network can drop your reply after you did the work:
- The same event can arrive more than once. Store the event
idand skip one you have seen. - Events can arrive out of order. Use
created_atand the fields indata, not arrival order. - Reply
2xxfor events you do not care about. A4xxmakes us retry.
#Test it
POST /webhook-endpoints/{id}/test sends one webhook.test event, signed like a real one, and tells you what your server said. It answers 200 whether or not your server did.
curl -X POST https://adv.heliosynctech.com/api/v1/webhook-endpoints/whe_3/test -H "Authorization: Bearer $AOS_KEY"
{
"object": "webhook_test",
"endpoint_id": "whe_3",
"event_id": "evt_9mTz4WqL2xKc7BnR5vHd3PsY",
"delivered": true,
"http_status": 200,
"response": "ok"
}
The event it sends:
{
"id": "evt_9mTz4WqL2xKc7BnR5vHd3PsY",
"object": "event",
"type": "webhook.test",
"created_at": "2026-12-01T10:25:00+05:30",
"data": { "message": "A test event from Advocate OS." },
"mode": "test"
}
More ways to try your handler:
POST /sandbox/eventssends any event type you choose to your test endpoints. See sandbox.aos listenforwards test events tolocalhost; see the CLI.- Watch
SBXX010000032026, which gets a new order every sandbox day. See monitoring and case watch with webhooks.