Examples use fictional data. Every company, person, address and count in them is invented, and their domains use the reserved .example TLD.
Platform
Webhooks
22 events, one signed envelope, and a verification snippet you can paste. Subscribe once and stop polling for the things that matter.
/v1/webhooksCreate a webhook
Registers an HTTPS endpoint and returns its signing secret. The secret is readable again from the list endpoint, unlike an API key.
Body parameters
| Field | Type | Description |
|---|---|---|
| urlreq | string | HTTPS endpoint. Must answer a POST within 10 seconds. |
| eventsreq | string[] | Event names to subscribe to. Use ["*"] for everything. |
curl -X POST "https://beamro.com/api/v1/webhooks" \
-H "x-api-key: $SALVO_API_KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://hooks.harlowmeadow.example/salvo",
"events": [
"reply.classified",
"meeting.booked",
"billing.wallet_low"
]
}'{
"id": "whk_4m1x7k0v2n9q6b3t8w",
"org_id": "org_4k1p8w2ne6ts0ybc7j",
"url": "https://hooks.harlowmeadow.example/salvo",
"secret": "whsec_2b7n4k9x1v6m0q3t8w5j",
"events": [
"reply.classified",
"meeting.booked",
"billing.wallet_low"
],
"active": true,
"created_at": "2026-08-31T13:10:22.000Z"
}/v1/webhooksList webhooks
Registered endpoints, their subscriptions and whether delivery is currently active.
curl "https://beamro.com/api/v1/webhooks" \
-H "x-api-key: $SALVO_API_KEY"{
"rows": [
{
"id": "whk_4m1x7k0v2n9q6b3t8w",
"org_id": "org_4k1p8w2ne6ts0ybc7j",
"url": "https://hooks.harlowmeadow.example/salvo",
"secret": "whsec_2b7n4k9x1v6m0q3t8w5j",
"events": [
"reply.classified",
"meeting.booked",
"billing.wallet_low"
],
"active": true,
"created_at": "2026-08-31T13:10:22.000Z"
}
]
}/v1/webhooks/{webhook_id}Delete a webhook
Stops delivery immediately. Queued attempts for that endpoint are dropped.
Path parameters
| Field | Type | Description |
|---|---|---|
| webhook_idreq | string | Webhook identifier. |
curl -X DELETE "https://beamro.com/api/v1/webhooks/whk_4m1x7k0v2n9q6b3t8w" \
-H "x-api-key: $SALVO_API_KEY"{
"ok": true
}Event catalogue
Names are stable and will not be reworded — they are as much a part of the API as the paths. New events may be added, so treat an unknown name as something to ignore rather than something to crash on.
| Event | Fires when |
|---|---|
user.signed_up | A user created a workspace. |
project.created | A project was created and research was queued. |
research.completed | Market research finished; the pitch is ready to use. |
icp.selected | An ideal-customer profile was accepted and can back a campaign. |
campaign.created | A campaign was created in draft. |
campaign.started | A campaign entered the scheduler and will begin sending. |
campaign.paused | A campaign was paused, by you or by autopilot. |
leads.imported | Leads were added to a campaign, generated or imported. |
message.queued | An email was written and scheduled for a send window. |
message.sent | An email was handed to the mail server. This is the billable moment. |
message.bounced | An email hard-bounced; the lead is suppressed automatically. |
message.opened | A tracked open was recorded. |
reply.received | An inbound message landed on a thread, before classification. |
reply.classified | The reply was classified — intent, sentiment and whether a human is needed. |
meeting.booked | A meeting was agreed and written to the calendar. |
lead.unsubscribed | A lead opted out. Suppression is already written when this fires. |
billing.credits_charged | Credits were deducted for a metered action. |
billing.credits_refunded | Credits a charge took were given back, under the same reference as that charge — the result it paid for was not delivered. |
billing.topped_up | The sending wallet was funded. |
billing.wallet_low | The wallet will not cover the next day of scheduled sending. |
domain.warmed | A sending domain finished warm-up and reached full volume. |
export.ready | An export finished; the payload carries the signed download link. |
Delivery format
Every delivery is a POST with a JSON body in this envelope. payload varies by event; the four fields around it never do, so a single handler can route on name and pass the rest along.
{
"id": "evt_9k2v7m0x4n1q6b3t8w",
"name": "reply.classified",
"org_id": "org_4k1p8w2ne6ts0ybc7j",
"user_id": null,
"entity_type": "thread",
"entity_id": "thr_2b7n4k9x1v6m0q3t8w",
"payload": {
"thread_id": "thr_2b7n4k9x1v6m0q3t8w",
"lead_id": "led_9f21c7ab4d0e",
"campaign_id": "cmp_1t7k4v9m2q0b6x3n8w",
"intent": "interested",
"sentiment": 0.72,
"needs_human": false,
"preview": "Send the sheet — we are locking November now."
},
"occurred_at": "2026-08-28T08:41:57.000Z"
}| Header | Example | Meaning |
|---|---|---|
x-salvo-event | reply.classified | Event name, also present in the body. |
x-salvo-delivery | whd_5k1v8n… | Unique per attempt. Use it to make your handler idempotent. |
x-salvo-timestamp | 1788091317 | Unix seconds when the signature was computed. |
x-salvo-signature | v1=8f3c… | Versioned HMAC. See below. |
Verifying the signature
Salvo signs the timestamp and the raw body together with your endpoint secret. Build the signed string as timestamp + "." + rawBody, HMAC it with SHA-256, hex-encode, and compare against the v1= value in constant time. Two rules matter: verify against the raw bytes before any JSON parsing, and reject anything older than five minutes so a captured delivery cannot be replayed.
import crypto from 'node:crypto';
export function verify(rawBody, headers, secret) {
const ts = headers['x-salvo-timestamp'];
const sig = String(headers['x-salvo-signature'] ?? '').replace(/^v1=/, '');
if (!ts || !sig) return false;
// Reject replays older than five minutes.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(sig);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Answer fast, work later
Return a 2xx within ten seconds and do the real work afterwards. Non-2xx responses are retried eight times over 24 hours with exponential backoff; an endpoint that fails for three days straight is marked inactive and you get an email. Deliveries are at-least-once, so key your handler onx-salvo-delivery.