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 listen watches 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.

Deliveries are copied, never diverted

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

1

Install and sign in

npm install -g @avsbhq/cli, then avsb login. For scripts, set AVSB_TOKEN instead. See CLI Authentication.

2

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.

3

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.

Organization Settings, Integrations tab: the Webhooks section, before any webhook exists.
  1. Open the Integrations tab.
  2. Click Create your first webhook.

Step 1: listen

Shell
avsb listen --project PRJ-42 --forward http://localhost:3000/webhooks/avsb
Shell1 line
Plain text
Webhook 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)
Plain text6 lines

Flags:

FlagWhat 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-hintPrint 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:

Shell
avsb trigger experiment.launched --project PRJ-42
Shell1 line
Plain text
✓ 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 locally
Plain text5 lines

To see which events you can send:

Shell
avsb trigger --list --project PRJ-42
Shell1 line

The list comes from the platform on every run, so it is never out of date.

Flags:

FlagWhat it does
--project <id>Which project to send from.
--webhook <id>Send to one webhook instead of every enabled webhook in the project.
--listPrint the event names and send nothing.
--jsonPrint the event list, or the delivery results, as one JSON document.
--quietPrint 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.

A trigger reaches your real webhook URL

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:

PartValue
Method and bodyPOST with the original body bytes, unchanged
Content-Typeapplication/json
User-AgentAvsB-Webhooks/1.0, the same value production sends
X-AvsB-Delivery-IdThe delivery id, also the id field in the body
X-AvsB-EventThe event name
X-AvsB-Signaturesha256=<hex>, the original signature
X-AvsB-TimestampThe ISO-8601 instant that was signed
X-AvsB-Relayed-Byavsb-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:

TypeScript
import type {  WebhookHeaders,  VerifyWebhookOptions,  WebhookVerifyResult,} from '@avsbhq/node'function verifyWebhookSignature(  payload: string | Buffer,  headers: WebhookHeaders,  secret: string,  options?: VerifyWebhookOptions,): WebhookVerifyResult
TypeScript12 lines
TypeScript
/** * 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}
TypeScript25 lines

A Next.js route handler that accepts the forwarded delivery:

TypeScript
// 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 })}
TypeScript43 lines

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.

SituationWhat happens
You press Ctrl+CThe session summary prints and the command exits 0.
The platform closes the streamIt reconnects after a second. Long sessions do this routinely.
Reconnecting keeps failingIt 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 succeedsIt exits 1 right away with the reason, rather than retrying a setup problem.
Your token cannot reach the projectIt exits 1. Run avsb whoami to see which account and organization the token reaches.
Your local URL is downThe 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 listen shows every webhook in the project. If a project has several, use the destination URL on each line to tell them apart, or --webhook <id> on avsb trigger to 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.

Next steps

Was this helpful?