Webhooks: local development
A webhook is an automatic HTTP request A vs B sends to your server when something happens. Two CLI commands turn webhook development into a normal edit-and-refresh loop:
avsb listenwatches every webhook delivery your project makes and can POST a copy to a URL on your machine.avsb trigger <event>sends a sample payload for any event, so you do not have to launch a real experiment to see one.
Run them in two terminals and you have a full loop: trigger an event, watch it arrive, fix your handler, trigger again.
avsb listen shows you a copy of what your configured webhook URL was sent. The real delivery still goes out as normal, and nothing about it changes while you are listening.
Before you start
Install and sign in
npm install -g @avsbhq/cli, then avsb login. For scripts, set AVSB_TOKEN instead. See CLI Authentication.
Create a webhook for the project
In the dashboard, go to Organization Settings > Integrations > Webhooks and create one. Choose the project you are developing against and tick the events you want to see. Copy the signing secret shown at creation: it is only displayed once.
Know your project ID
Every command takes --project PRJ-42 (or the plain number). Inside a folder created by avsb project pull, the project is read from the local manifest and the flag is optional.
- Open the Integrations tab.
- Click Create your first webhook.
Step 1: listen
avsb listen --project PRJ-42 --forward http://localhost:3000/webhooks/avsbWebhook relay for PRJ-42 Forwarding to: http://localhost:3000/webhooks/avsb✓ Listening. Press Ctrl+C to stop.12:34:56 UTC experiment.launched del_9f2c1a {"id":"del_9f2c1a","event":"experiment.launched","timestamp":"2026-07-30T12:34:56.000Z",... forwarded to http://localhost:3000/webhooks/avsb (HTTP 204)Flags:
| Flag | What it does |
|---|---|
--project <id> | Which project to watch. PRJ-42 or 42. Defaults to the local project manifest. |
--forward <url> | POST a copy of each delivery here. Must start with http:// or https://. Leave it out to print only. |
--print-secret-hint | Print where to get a signing secret for local verification. It never prints the secret itself. |
Times are shown in UTC because that is the instant the delivery was signed. Long bodies are shortened on screen, but the copy that is forwarded is always complete.
Step 2: trigger
In a second terminal:
avsb trigger experiment.launched --project PRJ-42✓ Sent experiment.launched to 1 webhook Local dev https://example.com/hook HTTP 204 Run `avsb listen --forward <url>` in another terminal to receive these locallyTo see which events you can send:
avsb trigger --list --project PRJ-42The list comes from the platform on every run, so it is never out of date.
Flags:
| Flag | What it does |
|---|---|
--project <id> | Which project to send from. |
--webhook <id> | Send to one webhook instead of every enabled webhook in the project. |
--list | Print the event names and send nothing. |
--json | Print the event list, or the delivery results, as one JSON document. |
--quiet | Print only the result. Progress lines and notes are suppressed. |
Sample bodies carry "test": true. Their entity ids start with sample_, and their short ids are 0. This lets a handler tell a sample from the real thing, and stops a link built from a sample id from opening a real page. The project id, short id, and name are the real ones.
Each sample is modelled on the real event of that type, and uses the same field names. So a handler written against it will read a real delivery correctly. Two things a sample cannot promise: optional fields a particular emit happens to add, and the exact shape of flag.published. Its sample carries a richer flag block than the platform sends today.
avsb trigger delivers to the URL configured on the webhook, exactly like a real event. If that URL is a production endpoint, point a separate webhook at your development address first, then use --webhook <id> to target it.
What your local endpoint receives
The forwarded request is a faithful copy of the delivery:
| Part | Value |
|---|---|
| Method and body | POST with the original body bytes, unchanged |
Content-Type | application/json |
User-Agent | AvsB-Webhooks/1.0, the same value production sends |
X-AvsB-Delivery-Id | The delivery id, also the id field in the body |
X-AvsB-Event | The event name |
X-AvsB-Signature | sha256=<hex>, the original signature |
X-AvsB-Timestamp | The ISO-8601 instant that was signed |
X-AvsB-Relayed-By | avsb-cli. The only header production does not send, so your logs can tell the two apart. |
Because the body is passed through byte for byte and the signature headers are unchanged, signature verification behaves locally exactly as it does in production.
Verifying the signature locally
The signature covers deliveryId.timestamp.rawBody. @avsbhq/node ships the whole check, including the replay window and a constant-time comparison:
import type { WebhookHeaders, VerifyWebhookOptions, WebhookVerifyResult,} from '@avsbhq/node'function verifyWebhookSignature( payload: string | Buffer, headers: WebhookHeaders, secret: string, options?: VerifyWebhookOptions,): WebhookVerifyResult/** * Header bags this accepts, so it works with Node's `IncomingHttpHeaders`, a plain * object, or a `Headers` instance. */type WebhookHeaders = | Record<string, string | string[] | undefined> | { get(name: string): string | null }type WebhookVerifyResult = | { ok: true; deliveryId: string; timestamp: string } | { ok: false; reason: WebhookVerifyFailure; message: string }type WebhookVerifyFailure = | 'missing_headers' | 'malformed_signature' | 'malformed_timestamp' | 'timestamp_out_of_tolerance' | 'signature_mismatch'interface VerifyWebhookOptions { /** How old a delivery may be, in seconds. Defaults to 300. Set 0 to skip the age check. */ toleranceSeconds?: number /** Injectable clock in milliseconds since the epoch. Exists for tests. */ now?: () => number}A Next.js route handler that accepts the forwarded delivery:
// app/api/webhooks/avsb/route.tsimport { verifyWebhookSignature, type WebhookVerifyResult } from '@avsbhq/node'interface AvsbWebhookEnvelope { id: string event: string timestamp: string}function isAvsbWebhookEnvelope(value: unknown): value is AvsbWebhookEnvelope { if (typeof value !== 'object' || value === null) return false const candidate = value as Record<string, unknown> return ( typeof candidate.id === 'string' && typeof candidate.event === 'string' && typeof candidate.timestamp === 'string' )}export async function POST(request: Request): Promise<Response> { const secret: string | undefined = process.env.AVSB_WEBHOOK_SECRET if (secret === undefined) { return new Response('AVSB_WEBHOOK_SECRET is not set', { status: 500 }) } // Read the raw text, never a parsed and re-serialised object: re-serialising // changes key order and the digest would never match. const rawBody: string = await request.text() const result: WebhookVerifyResult = verifyWebhookSignature(rawBody, request.headers, secret) if (!result.ok) { return new Response(result.reason, { status: 401 }) } const parsed: unknown = JSON.parse(rawBody) if (!isAvsbWebhookEnvelope(parsed)) { return new Response('Unrecognised payload', { status: 400 }) } // Do your own work here, deduplicated on result.deliveryId: a retry of the // same delivery reuses that id. return Response.json({ received: parsed.event, deliveryId: result.deliveryId })}Getting the signing secret
A signing secret is shown twice in its life: when the webhook is created, and when the secret is rotated. No read ever returns it, so a read-only token cannot collect signing material. If you do not have the secret any more, rotate it:
- Dashboard: Organization Settings > Integrations > Webhooks, open the webhook, then Rotate next to the signing secret
- API:
POST /api/v1/projects/{projectId}/webhooks/{webhookId}/secret
Rotating takes effect immediately, so update every endpoint that verifies with the old secret. avsb listen --print-secret-hint prints this reminder next to the stream, and never prints a secret.
Reconnects and exit codes
The stream is long-lived, and the CLI reconnects on its own when it drops.
| Situation | What happens |
|---|---|
You press Ctrl+C | The session summary prints and the command exits 0. |
| The platform closes the stream | It reconnects after a second. Long sessions do this routinely. |
| Reconnecting keeps failing | It backs off 1s, 2s, 4s, 8s, 16s, then 30s between attempts, and after 10 failures in a row it gives up and exits 1. |
| The first connection never succeeds | It exits 1 right away with the reason, rather than retrying a setup problem. |
| Your token cannot reach the project | It exits 1. Run avsb whoami to see which account and organization the token reaches. |
| Your local URL is down | The delivery is still shown, the failure is printed, and listening continues. |
A forward that returns a non-2xx status counts as a failure in the summary, and prints with its status. So a handler that quietly returns 500 does not look like a success.
Things worth knowing
- The stream is live only. You see deliveries that happen while you are connected. Nothing is buffered for you, so a delivery made before you started listening, or during the second it takes to reconnect, is not replayed. The webhook's delivery log in the dashboard is the durable record.
- One project, one channel.
avsb listenshows every webhook in the project. If a project has several, use the destination URL on each line to tell them apart, or--webhook <id>onavsb triggerto fire at just one. - Several listeners are fine. Two developers can listen to the same project at once, and both receive every delivery.
- A trigger is a real delivery. It is signed, persisted, and appears in the webhook's delivery log alongside real events. One difference: a sample that fails is not retried in the background, so trigger it again once your endpoint is fixed.
- The relay needs no inbound access. Nothing listens on your machine: no tunnel, no port forwarding, and no public URL.