Skip to content

Webhooks

Webhooks stream Vaam outreach activity to an HTTPS endpoint you control, as signed HTTP events. Use them to sync activity into your own systems — a CRM, a data warehouse, an internal alerting service — without polling the Vaam API.

Each event is a JSON payload identifying what happened, the prospect it happened to, and which sequence they’re in. Every request is signed so you can verify it came from Vaam, and every delivery’s outcome is logged so you can inspect or replay it.

Webhooks vs. the webhook step: the webhook step fires at one chosen point in a single sequence and sends data about the prospect reaching that step — it’s a sequence building block. Webhooks are account-wide: once configured, an endpoint receives every subscribed event across all of your sequences, is HMAC-signed, and comes with a delivery log and retries. If you need “call my endpoint when a prospect reaches this step,” use the webhook step. If you need “stream everything happening across my account to my system,” use webhooks.

Setting up a webhook endpoint requires an admin or owner role on the team.

  1. Go to Settings → Webhooks.
  2. Click Add endpoint and enter your endpoint’s HTTPS URL and a description.
  3. Copy the signing secret (whsec_...) shown on creation — it’s only displayed once. Store it securely; you’ll need it to verify signatures.
  4. Choose which events to subscribe to: All events, or a specific list of event types.
  5. Optionally enable Include message bodies if you want email and LinkedIn message text, and form answer summaries, included in payloads (off by default — see Events).
  6. Click Send test event to deliver a webhook.test event through the real delivery pipeline and confirm your endpoint is reachable and your signature verification works.

Every delivery includes these headers:

HeaderDescription
X-Vaam-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
X-Vaam-EventThe event type, e.g. linkedin.message.received
X-Vaam-Delivery<id>:<endpoint id>:<attempt number>

To verify a delivery, compute an HMAC-SHA256 of ${t}.${rawBody} using your signing secret and compare it to v1 with a timing-safe comparison. Reject requests where the timestamp is more than 5 minutes old or in the future — this guards against replay.

const crypto = require("crypto");
function verifyVaamSignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=")),
);
const t = parts.t;
const v1 = parts.v1;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
const receivedBuf = Buffer.from(v1, "hex");
if (
expectedBuf.length !== receivedBuf.length ||
!crypto.timingSafeEqual(expectedBuf, receivedBuf)
) {
throw new Error("Invalid signature");
}
const ageSeconds = Math.abs(Date.now() / 1000 - Number(t));
if (ageSeconds > 5 * 60) {
throw new Error("Timestamp outside tolerance");
}
}

Use the raw request body — not a re-serialized version of parsed JSON — since re-serializing can change byte-for-byte formatting and break the signature check.

Secret rotation: rotating an endpoint’s secret takes effect immediately. Every delivery sent after rotation is signed with the new secret, including retries of deliveries that were already queued, and the old secret stops working at once. Update your receiver with the new secret right away; until you do, it rejects Vaam’s deliveries, and any delivery that still fails after its retries is lost unless you redeliver it from the delivery log.

Vaam supports 17 subscribable event types, plus a synthetic test event. Fields marked † are only included in data when Include message bodies is enabled on the endpoint; otherwise they’re omitted. Message bodies and text content — email body, LinkedIn message text, and form answer summaries — are truncated at 10,000 characters, with "truncated": true added to data when that happens. Other fields are not truncated. Form answer content additionally respects the form’s own answer-sharing setting: if the form isn’t configured to share full answers, answerSummary lists the answered fields with their values redacted, even on endpoints with Include message bodies enabled.

EventFires whendata fields
sequence.startedA prospect starts a sequence—
sequence.completedA prospect finishes every step of a sequence—
sequence.canceledA prospect’s sequence is stopped before completionreason (when available)
EventFires whendata fields
email.sentVaam sends an email stepsubject†, body†
email.receivedA prospect replies by email—
EventFires whendata fields
linkedin.message.sentVaam sends a LinkedIn message stepbody†
linkedin.message.receivedA prospect replies on LinkedInmessageText†, threadLink
linkedin.profile.visitedVaam visits a prospect’s LinkedIn profile as a step—
linkedin.connection.sentA LinkedIn connection request is sent—
linkedin.connection.acceptedA prospect accepts the connection request—
linkedin.connection.expiredA sent connection request expires unaccepted—

linkedin.connection.accepted also fires when the prospect was already connected to you, or when you had already accepted their invitation. Vaam cannot tell these cases apart from a request that the prospect accepted.

Vaam sends one reply event (email.received or linkedin.message.received) each time a reply moves a prospect into the replied state. While a prospect stays in that state, further replies from them do not send another event. Resume the prospect in the sequence to receive events for later replies.

EventFires whendata fields
video.openedA prospect opens a video for the first timecaptureId
video.playingA prospect starts playing a video for the first timecaptureId
video.watched75A prospect watches at least 75% of a videocaptureId, completionPercentage
video.conversionA prospect completes a video’s call-to-action. Vaam sends the first conversion of each type for each video.captureId, conversionType
EventFires whendata fields
link.clickedA prospect clicks a tracked linklinkUrl, linkId, stepId, clickedAt (click time as epoch milliseconds)
form.submittedA prospect submits a formformTitle, answerSummary†
EventFires whendata fields
webhook.testYou click Send test event in settingsmessage

Every event shares the same envelope. Here’s an example linkedin.message.received delivery:

{
"id": "5f1c6b1e-8a52-5c7e-9a3d-1b2c3d4e5f60",
"event": "linkedin.message.received",
"version": 1,
"occurredAt": "2026-08-08T09:14:03.000Z",
"prospect": {
"id": "abc123",
"firstname": "Jane",
"lastname": "Doe",
"email": "jane@example.com",
"company": "Acme Inc",
"jobtitle": "VP Sales",
"phone": "+1234567890",
"linkedinProfileUrl": "https://linkedin.com/in/janedoe",
"tags": ["enterprise", "inbound"],
"custom": "...",
"custom2": "...",
"custom3": "..."
},
"enrollment": {
"id": "enr456",
"sequenceId": "seq789",
"sequenceName": "Enterprise Outreach Q1"
},
"data": {
"threadLink": "https://www.linkedin.com/messaging/thread/...",
"messageText": "Sounds interesting — can you send more info?"
}
}
  • id is the event’s deduplication key — use it to detect and discard duplicate deliveries.
  • prospect reflects prospect data as of when the event was prepared for delivery, not as of delivery time — the payload is built once and reused as-is for every retry or redelivery, so those all resend the exact same bytes rather than a freshly looked-up copy. If the prospect had been deleted by the time the payload was built, prospect degrades to { "id": "..." }.
  • enrollment carries the sequence run this event belongs to: its id, the sequence’s id, and the sequence’s name — this is the prospect’s progress in a sequence, not a separate concept to configure.
  • Delivery is at-least-once. Use id to deduplicate on your end — the same event can arrive more than once.
  • There’s no ordering guarantee across events. Order events using occurredAt, and expect near-simultaneous events for the same prospect activity.
  • A 2xx response within 10 seconds counts as delivered. Anything else — a timeout, a non-2xx status, a connection error — is retried.
  • Redirects are not followed — a 3xx response counts as a failed attempt. Point the endpoint at its final URL.
  • Failed deliveries are retried up to 6 times over roughly 31 minutes (7 attempts total, including the first), with exponential backoff: 30s, 1m, 2m, 4m, 8m, 16m after the first attempt.
  • Endpoints subscribed to All events automatically receive any new event types Vaam adds later. Endpoints with an explicit event list do not — you need to add new types yourself.

The endpoint’s delivery log in settings records the final outcome of each delivery — delivered, or failed after all retries — not every intermediate attempt. Delivered rows show the status code and timing; failed rows show the error and, when available, the HTTP status. Delivered entries are kept for 7 days and failed entries for 30 days, and the settings page shows the 50 most recent. Failed deliveries can be redelivered from the log — a redelivery resends the exact original payload, not a freshly generated one.

Sustained failures escalate in two stages:

  • Warning email — sent to team admins and owners once an endpoint has roughly 10 consecutive failures with no successful delivery in 6 hours. At most one warning email is sent per 24 hours.
  • Automatic disable — an endpoint is disabled once it reaches roughly 100 consecutive failures with no successful delivery in 3 days. You’ll get an email when this happens. Re-enable the endpoint with a single toggle in settings after fixing the receiving end — this resets the failure counters.

Transient failures alone never disable an endpoint or change its configuration.

  • Must use HTTPS — HTTP URLs are not accepted. This, along with the hostname format and the check against Vaam domains, is validated when you save the endpoint.
  • Must resolve to a public address. This is enforced at delivery time, not when you save the endpoint — if the hostname resolves to a private or internal network address when a delivery is attempted, that delivery fails rather than the endpoint being rejected at setup.
  • Cannot point at a Vaam domain.
  • Sync activity to an internal CRM or data warehouse — keep your own system up to date with outreach activity without polling.
  • Notify Slack through your own service that consumes the webhook and posts to a channel.
  • Trigger custom automations — kick off internal workflows when a prospect replies, converts on a video, or submits a form.