Skip to content

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.

POST/v1/webhooks

Create a webhook

Registers an HTTPS endpoint and returns its signing secret. The secret is readable again from the list endpoint, unlike an API key.

API key or sessionFree

Body parameters

FieldTypeDescription
urlreqstringHTTPS endpoint. Must answer a POST within 10 seconds.
eventsreqstring[]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"
    ]
  }'
200 response
{
  "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"
}
GET/v1/webhooks

List webhooks

Registered endpoints, their subscriptions and whether delivery is currently active.

API key or sessionFree
curl "https://beamro.com/api/v1/webhooks" \
  -H "x-api-key: $SALVO_API_KEY"
200 response
{
  "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"
    }
  ]
}
DELETE/v1/webhooks/{webhook_id}

Delete a webhook

Stops delivery immediately. Queued attempts for that endpoint are dropped.

API key or sessionFree

Path parameters

FieldTypeDescription
webhook_idreqstringWebhook identifier.
curl -X DELETE "https://beamro.com/api/v1/webhooks/whk_4m1x7k0v2n9q6b3t8w" \
  -H "x-api-key: $SALVO_API_KEY"
200 response
{
  "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.

EventFires when
user.signed_upA user created a workspace.
project.createdA project was created and research was queued.
research.completedMarket research finished; the pitch is ready to use.
icp.selectedAn ideal-customer profile was accepted and can back a campaign.
campaign.createdA campaign was created in draft.
campaign.startedA campaign entered the scheduler and will begin sending.
campaign.pausedA campaign was paused, by you or by autopilot.
leads.importedLeads were added to a campaign, generated or imported.
message.queuedAn email was written and scheduled for a send window.
message.sentAn email was handed to the mail server. This is the billable moment.
message.bouncedAn email hard-bounced; the lead is suppressed automatically.
message.openedA tracked open was recorded.
reply.receivedAn inbound message landed on a thread, before classification.
reply.classifiedThe reply was classified — intent, sentiment and whether a human is needed.
meeting.bookedA meeting was agreed and written to the calendar.
lead.unsubscribedA lead opted out. Suppression is already written when this fires.
billing.credits_chargedCredits were deducted for a metered action.
billing.credits_refundedCredits a charge took were given back, under the same reference as that charge — the result it paid for was not delivered.
billing.topped_upThe sending wallet was funded.
billing.wallet_lowThe wallet will not cover the next day of scheduled sending.
domain.warmedA sending domain finished warm-up and reached full volume.
export.readyAn 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.

POST to your endpoint — reply.classified
{
  "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"
}
HeaderExampleMeaning
x-salvo-eventreply.classifiedEvent name, also present in the body.
x-salvo-deliverywhd_5k1v8n…Unique per attempt. Use it to make your handler idempotent.
x-salvo-timestamp1788091317Unix seconds when the signature was computed.
x-salvo-signaturev1=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 on x-salvo-delivery.