DevelopersDocsConcepts

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.

joined tohashedsent aschecked byt over 5 minutes offtunix time of this attempt{t}.{raw body}the body exactly as sentHMAC-SHA256keyed with your whsec_ secretAOS-Signaturet=…,v1=…Your serverrecompute, compare, reply 2xxRejecta replayed call
How a webhook is signed

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.

  1. Read the raw body before any JSON parser touches it. Re-serialised JSON will not match.
  2. Compute the HMAC and compare it with v1 using a constant-time comparison.
  3. Reject the call if t is more than 5 minutes from your clock, so a captured call cannot be replayed later. Every attempt, including a retry, is signed afresh, so t is the time of that attempt.
  4. Reply 2xx quickly. 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 id and skip one you have seen.
  • Events can arrive out of order. Use created_at and the fields in data, not arrival order.
  • Reply 2xx for events you do not care about. A 4xx makes 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/events sends any event type you choose to your test endpoints. See sandbox.
  • aos listen forwards test events to localhost; see the CLI.
  • Watch SBXX010000032026, which gets a new order every sandbox day. See monitoring and case watch with webhooks.