Skip to content

Events and webhooks

Everything that happens in a workspace is an event with a stable shape. Read them from the cursor feed (GET /v1/events) or receive them on signed webhooks. Both carry the same JSON.

Event shape

Every event is { id, type, occurredAt, data }. The id (evt_…) sorts by time and is also the feed cursor. data carries ids plus enough context to act without a second call: the lead's name, company and profile URL, the campaign and sender names, the message text. Lead events carry campaignId, leadId, senderId and enrollmentId, plus campaign, lead and sender objects with names; campaign events carry campaignId and campaign; sender events are flat: senderId, name, kind, status, and when they apply profileUrl, statusDetail (the status in words), reason (a code to branch on: paused_by_user, resumed_by_user, plan_limit, linkedin_limit, hold_expired, session_expired, account_restricted, account_status, connected, sandbox_created) and until. Each type has exactly one shape, published as a schema under webhooks in the OpenAPI document; types and fields are only ever added. Key order in data is not meaningful.

lead.replied
{
  "id": "evt_01M61FSRCJSJGHNWTHTF50RVHC",
  "type": "lead.replied",
  "occurredAt": "2026-10-28T14:49:28.360Z",
  "data": {
    "lead": { "id": "lead_z7P9HPjr", "name": "Laura Murphy", "company": "Rekall Software", "headline": "Head of RevOps at Rekall Software", "profileUrl": "https://www.linkedin.com/in/sbx-laura-murphy-eh00", "publicIdentifier": "sbx-laura-murphy-eh00", "providerId": "ACoSBX0f3k2Lm" },
    "text": "Thanks! I'll take a look and get back to you.",
    "leadId": "lead_z7P9HPjr",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "senderId": "snd_BmvhDF6L",
    "messageId": "msg_Fq8CEObh",
    "campaignId": "cmp_FEiWSyyy",
    "enrollmentId": "enr_73pKwls5",
    "conversationId": "cnv_EcBrECct",
    "providerMessageId": "sbxmsg_V1StGXR8",
    "sequenceFinished": true
  }
}

Event types

TypeWhen
sender.connectedA sender became active: a sandbox sender on creation (reason sandbox_created), a LinkedIn account when its hosted page finishes (connected); reconnected: true when an existing sender came back.
sender.needs_reconnectLinkedIn rejected the session (session_expired), restricted the account (account_restricted) or the provider reported a broken account (account_status).
sender.pausedYou paused it (paused_by_user), a plan change paused it (plan_limit), or LinkedIn answered with a limit (linkedin_limit: still active, resting until until).
sender.resumedYou resumed it (resumed_by_user), or its rest after a LinkedIn limit is over (hold_expired).
import.completedAn import finished, with found, imported and skipped.
import.failedAn import stopped, with its error.
campaign.launchedA campaign started, with how many leads were enrolled, skipped and waitingForPlan.
campaign.pausedA campaign was paused.
campaign.resumedA paused campaign was resumed.
campaign.completedA running campaign has no live enrollment left and nobody waiting to join.
campaign.archivedA campaign was archived; stopped counts the live enrollments it stopped (each also emits lead.stopped).
lead.enrolledA lead joined a campaign and was assigned a sender.
lead.profile_visitedThe sender visited the lead's profile.
lead.post_likedThe sender liked the lead's latest post (postId).
lead.invite_sentAn invitation was sent, with the rendered note when the step has one. senderId is the inviting sender; secondChance: true marks an extra sender's invitation (settings.secondChance) while the first one was unanswered.
lead.invite_acceptedThe lead accepted (invitedAt, acceptedAt); the next step is planned from now. When they accepted a second chance's invitation, previousSenderId names the sender the enrollment moved from: senderId sends the next steps and owns the conversation.
lead.invite_withdrawnOne sender's invitation was taken back: reason window_ended (nobody accepted within acceptanceWindowDays; the lead completes with not_accepted) or connected_elsewhere (they connected with another sender of the enrollment).
lead.message_sentA campaign message was sent, with its text, conversationId, messageId and LinkedIn's providerMessageId.
lead.repliedThe lead answered; their sequence stopped and the enrollment is replied (also after the last step, with sequenceFinished: true). Carries the message text.
lead.completedThe sequence ended without a reply; stopReason is not_accepted when the invitation was withdrawn unanswered.
lead.failedThe lead can't continue: stopReason (not_found, cannot_invite, not_connected, linkedin_error, blocklisted, missing_variable, note_too_long, message_too_long) and detail in words.
lead.stoppedSomeone stopped the lead's sequence (stopped_by_user) or archived the campaign (campaign_archived).
message.receivedAny inbound message from a lead, in or out of a campaign, with providerMessageId to deduplicate. Messages with people who are not leads are never stored or announced.
message.sentA message to a lead that no sequence step sent: source inbox (sent through Outvoy) or manual (written by the account's owner on LinkedIn).
lead.connectedA lead and a sender are connected and no lead.invite_accepted said so: found by a profile lookup (alreadyConnected: true), made outside Outvoy, or an invitation accepted after its enrollment ended (enrollmentId and campaign say which). Once per lead and sender. With previousSenderId, this sender took the enrollment over.
lead.deletedThe lead was erased (DELETE /v1/leads/{leadId}): data is just leadId. Delete what you keep about them. Their earlier events stay with the same ids, but the person's details are blanked (lead.redacted: true, empty profileUrl and texts).

Types are only ever added, never renamed or removed. Filter the feed with ?types=lead.replied,lead.invite_accepted, or give an endpoint eventTypes.

Payload examples

One example per type (ids shortened), including a LinkedIn-limit pause and its end, and a sequence ended by blocking the lead after launch. lead.replied also arrives after a sequence has finished, with sequenceFinished: true.

sender.connected · reason sandbox_created
{
  "id": "evt_01M61FG52HNJ0G40H34Q3GBQC5",
  "type": "sender.connected",
  "occurredAt": "2026-10-28T14:44:18.640Z",
  "data": {
    "kind": "sandbox",
    "name": "Lena Hoffmann",
    "reason": "sandbox_created",
    "status": "active",
    "senderId": "snd_BmvhDF6L",
    "profileUrl": "https://www.linkedin.com/in/sbx-sender-lena-hoffmann-r8sy",
    "reconnected": false
  }
}
sender.paused · reason paused_by_user
{
  "id": "evt_01M61FGRCVJAT2ZVDE44F8H9JP",
  "type": "sender.paused",
  "occurredAt": "2026-10-28T14:44:38.427Z",
  "data": {
    "kind": "sandbox",
    "name": "Lena Hoffmann",
    "reason": "paused_by_user",
    "status": "paused",
    "senderId": "snd_BmvhDF6L",
    "profileUrl": "https://www.linkedin.com/in/sbx-sender-lena-hoffmann-r8sy",
    "statusDetail": "Paused by you"
  }
}
sender.paused · reason linkedin_limit
{
  "id": "evt_01M61H2XQ9V4D5K8N3T6Y7R2PA",
  "type": "sender.paused",
  "occurredAt": "2026-10-28T15:10:42.118Z",
  "data": {
    "senderId": "snd_9Hk2LmQp",
    "name": "Marc Weber",
    "kind": "linkedin",
    "status": "active",
    "profileUrl": "https://www.linkedin.com/in/marc-weber",
    "statusDetail": "LinkedIn limit reached; resumes Thu 00:00 Europe/Berlin",
    "reason": "linkedin_limit",
    "until": "2026-10-28T23:00:00.000Z"
  }
}
sender.resumed · reason resumed_by_user
{
  "id": "evt_01M61FGRD8PN098CMWWP5E3A6Y",
  "type": "sender.resumed",
  "occurredAt": "2026-10-28T14:44:38.440Z",
  "data": {
    "kind": "sandbox",
    "name": "Lena Hoffmann",
    "reason": "resumed_by_user",
    "status": "active",
    "senderId": "snd_BmvhDF6L",
    "profileUrl": "https://www.linkedin.com/in/sbx-sender-lena-hoffmann-r8sy"
  }
}
sender.resumed · reason hold_expired
{
  "id": "evt_01M61V8C2M7Q1W3E5R9T0Y4U6I",
  "type": "sender.resumed",
  "occurredAt": "2026-10-28T23:00:05.204Z",
  "data": {
    "senderId": "snd_9Hk2LmQp",
    "name": "Marc Weber",
    "kind": "linkedin",
    "status": "active",
    "profileUrl": "https://www.linkedin.com/in/marc-weber",
    "reason": "hold_expired"
  }
}
sender.needs_reconnect · reason session_expired
{
  "id": "evt_01M61J0B6Z3X8C1V5N7M2K4L9P",
  "type": "sender.needs_reconnect",
  "occurredAt": "2026-10-29T08:14:09.551Z",
  "data": {
    "senderId": "snd_9Hk2LmQp",
    "name": "Marc Weber",
    "kind": "linkedin",
    "status": "needs_reconnect",
    "profileUrl": "https://www.linkedin.com/in/marc-weber",
    "statusDetail": "The LinkedIn session expired: reconnect the account",
    "reason": "session_expired"
  }
}
import.completed
{
  "id": "evt_01M61FG9NC7T8BZ5XVD48SCY2M",
  "type": "import.completed",
  "occurredAt": "2026-10-28T14:44:23.340Z",
  "data": {
    "found": 20,
    "listId": "list_jRwohnd8",
    "source": "search",
    "skipped": 0,
    "importId": "imp_K0FnnFDv",
    "imported": 20,
    "listName": "RevOps DE",
    "senderId": "snd_BmvhDF6L"
  }
}
import.failed
{
  "id": "evt_01M61J3F8A2S4D6F8G0H1J3K5L",
  "type": "import.failed",
  "occurredAt": "2026-10-29T08:20:31.007Z",
  "data": {
    "importId": "imp_Xq7Lm2Pa",
    "listId": "list_B2k9Wq4R",
    "listName": "Webinar engagers",
    "senderId": "snd_9Hk2LmQp",
    "source": "post_engagers",
    "found": 0,
    "imported": 0,
    "skipped": 0,
    "error": "LinkedIn says this person or post doesn't exist (any more)"
  }
}
campaign.launched
{
  "id": "evt_01M61FGR949FV24WW9FA6KV9RM",
  "type": "campaign.launched",
  "occurredAt": "2026-10-28T14:44:38.274Z",
  "data": {
    "skipped": 0,
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "enrolled": 20,
    "campaignId": "cmp_FEiWSyyy",
    "waitingForPlan": 0
  }
}
campaign.paused
{
  "id": "evt_01M61FGRDWSBH7D3YEVASJWB4Q",
  "type": "campaign.paused",
  "occurredAt": "2026-10-28T14:44:38.454Z",
  "data": {
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "campaignId": "cmp_FEiWSyyy"
  }
}
campaign.resumed
{
  "id": "evt_01M61FGREPXM7GZ7BKEDHMA79E",
  "type": "campaign.resumed",
  "occurredAt": "2026-10-28T14:44:38.472Z",
  "data": {
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "campaignId": "cmp_FEiWSyyy"
  }
}
campaign.completed
{
  "id": "evt_01M61GG66EGPWV5PKGD5WW7MFF",
  "type": "campaign.completed",
  "occurredAt": "2026-10-28T15:01:48.357Z",
  "data": {
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "campaignId": "cmp_FEiWSyyy"
  }
}
campaign.archived
{
  "id": "evt_01M61FMBYFC5VHNJRWBNSWDGM7",
  "type": "campaign.archived",
  "occurredAt": "2026-10-28T14:46:36.724Z",
  "data": {
    "stopped": 8,
    "campaign": { "id": "cmp_XR2jPlNV", "name": "Webinar follow-up" },
    "campaignId": "cmp_XR2jPlNV"
  }
}
lead.enrolled
{
  "id": "evt_01M61FGR8H3ZV5SCGBHAP3KXSY",
  "type": "lead.enrolled",
  "occurredAt": "2026-10-28T14:44:38.274Z",
  "data": {
    "lead": { "id": "lead_1C38sQ4W", "name": "Daniel Martin", "company": "Northwind Cloud", "headline": "Head of RevOps at Northwind Cloud", "profileUrl": "https://www.linkedin.com/in/sbx-daniel-martin-icxh" },
    "leadId": "lead_1C38sQ4W",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "senderId": "snd_BmvhDF6L",
    "campaignId": "cmp_FEiWSyyy",
    "enrollmentId": "enr_0Gu5y6g6"
  }
}
lead.profile_visited
{
  "id": "evt_01M61FGRAK24NAD7CGK80SSNHH",
  "type": "lead.profile_visited",
  "occurredAt": "2026-10-28T14:44:38.350Z",
  "data": {
    "lead": { "id": "lead_GjZVBF0x", "name": "Sophia Wright", "company": "Atlas CRM", "headline": "Head of RevOps at Atlas CRM", "profileUrl": "https://www.linkedin.com/in/sbx-sophia-wright-4y6l" },
    "leadId": "lead_GjZVBF0x",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "senderId": "snd_BmvhDF6L",
    "stepIndex": 0,
    "campaignId": "cmp_FEiWSyyy",
    "enrollmentId": "enr_SvKhQgT0"
  }
}
lead.post_liked
{
  "id": "evt_01M61FJ94GX1RWZJ5W3NX0S9DH",
  "type": "lead.post_liked",
  "occurredAt": "2026-10-28T14:45:28.328Z",
  "data": {
    "lead": { "id": "lead_phxfV6mH", "name": "Max Taylor", "company": "Acme Analytics", "headline": "CTO at Acme Analytics", "profileUrl": "https://www.linkedin.com/in/sbx-max-taylor-2ek3" },
    "leadId": "lead_phxfV6mH",
    "postId": "sbx-post-ACoSBX002au2ek3-1",
    "sender": { "id": "snd_utrP6jsp", "name": "David Williams" },
    "campaign": { "id": "cmp_XR2jPlNV", "name": "Webinar follow-up" },
    "senderId": "snd_utrP6jsp",
    "stepIndex": 0,
    "campaignId": "cmp_XR2jPlNV",
    "enrollmentId": "enr_xqUF8AJp"
  }
}
lead.invite_sent
{
  "id": "evt_01M61FKT01XDCTVGZQQJGV1T7F",
  "type": "lead.invite_sent",
  "occurredAt": "2026-10-28T14:46:18.360Z",
  "data": {
    "lead": { "id": "lead_GjZVBF0x", "name": "Sophia Wright", "company": "Atlas CRM", "headline": "Head of RevOps at Atlas CRM", "profileUrl": "https://www.linkedin.com/in/sbx-sophia-wright-4y6l" },
    "note": "Hi Sophia, I run pipeline audits for SaaS teams like Atlas CRM. Open to connecting?",
    "leadId": "lead_GjZVBF0x",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "senderId": "snd_BmvhDF6L",
    "stepIndex": 1,
    "campaignId": "cmp_FEiWSyyy",
    "enrollmentId": "enr_SvKhQgT0"
  }
}
lead.invite_accepted
{
  "id": "evt_01M61FQ0HMKY9631HEB309SXVD",
  "type": "lead.invite_accepted",
  "occurredAt": "2026-10-28T14:48:00.859Z",
  "data": {
    "lead": { "id": "lead_z7P9HPjr", "name": "Laura Murphy", "company": "Rekall Software", "headline": "Head of RevOps at Rekall Software", "profileUrl": "https://www.linkedin.com/in/sbx-laura-murphy-eh00" },
    "leadId": "lead_z7P9HPjr",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "senderId": "snd_BmvhDF6L",
    "invitedAt": "2026-10-28T14:47:38.354Z",
    "acceptedAt": "2026-10-28T14:48:00.859Z",
    "campaignId": "cmp_FEiWSyyy",
    "enrollmentId": "enr_73pKwls5"
  }
}
lead.invite_withdrawn
{
  "id": "evt_01M61GDEBQZRPQGWT3SR5GCE8E",
  "type": "lead.invite_withdrawn",
  "occurredAt": "2026-10-28T15:00:18.412Z",
  "data": {
    "lead": { "id": "lead_GjZVBF0x", "name": "Sophia Wright", "company": "Atlas CRM", "headline": "Head of RevOps at Atlas CRM", "profileUrl": "https://www.linkedin.com/in/sbx-sophia-wright-4y6l" },
    "leadId": "lead_GjZVBF0x",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "senderId": "snd_BmvhDF6L",
    "invitedAt": "2026-10-28T14:46:18.360Z",
    "campaignId": "cmp_FEiWSyyy",
    "enrollmentId": "enr_SvKhQgT0",
    "acceptanceWindowDays": 14
  }
}
lead.message_sent
{
  "id": "evt_01M61FRV48FPPQXF76ZD60XKR3",
  "type": "lead.message_sent",
  "occurredAt": "2026-10-28T14:49:03.348Z",
  "data": {
    "lead": { "id": "lead_z7P9HPjr", "name": "Laura Murphy", "company": "Rekall Software", "headline": "Head of RevOps at Rekall Software", "profileUrl": "https://www.linkedin.com/in/sbx-laura-murphy-eh00" },
    "text": "Thanks for connecting, Laura. Happy to compare notes on RevOps.",
    "leadId": "lead_z7P9HPjr",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "senderId": "snd_BmvhDF6L",
    "stepIndex": 2,
    "campaignId": "cmp_FEiWSyyy",
    "enrollmentId": "enr_73pKwls5",
    "conversationId": "cnv_EcBrECct"
  }
}
lead.replied
{
  "id": "evt_01M61FSRCJSJGHNWTHTF50RVHC",
  "type": "lead.replied",
  "occurredAt": "2026-10-28T14:49:28.360Z",
  "data": {
    "lead": { "id": "lead_z7P9HPjr", "name": "Laura Murphy", "company": "Rekall Software", "headline": "Head of RevOps at Rekall Software", "profileUrl": "https://www.linkedin.com/in/sbx-laura-murphy-eh00", "publicIdentifier": "sbx-laura-murphy-eh00", "providerId": "ACoSBX0f3k2Lm" },
    "text": "Thanks! I'll take a look and get back to you.",
    "leadId": "lead_z7P9HPjr",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "senderId": "snd_BmvhDF6L",
    "messageId": "msg_Fq8CEObh",
    "campaignId": "cmp_FEiWSyyy",
    "enrollmentId": "enr_73pKwls5",
    "conversationId": "cnv_EcBrECct",
    "providerMessageId": "sbxmsg_V1StGXR8",
    "sequenceFinished": true
  }
}
lead.connected · a second-chance sender was connected already
{
  "id": "evt_01M61G7K2XQ8R4V9M3N6B2C1DT",
  "type": "lead.connected",
  "occurredAt": "2026-10-31T09:12:40.118Z",
  "data": {
    "lead": { "id": "lead_1C38sQ4W", "name": "Daniel Martin", "company": "Northwind Cloud", "profileUrl": "https://www.linkedin.com/in/sbx-daniel-martin-icxh", "publicIdentifier": "sbx-daniel-martin-icxh", "providerId": "ACoSBX9a1b2Cd" },
    "leadId": "lead_1C38sQ4W",
    "sender": { "id": "snd_Q2wErTy8", "name": "Marco Rossi" },
    "senderId": "snd_Q2wErTy8",
    "connectedAt": "2026-10-31T09:12:40.118Z",
    "alreadyConnected": true,
    "enrollmentId": "enr_0Gu5y6g6",
    "campaignId": "cmp_FEiWSyyy",
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "previousSenderId": "snd_BmvhDF6L"
  }
}
message.sent · written on LinkedIn
{
  "id": "evt_01M61H0P4S7D2F5G8H1J3K6L9Q",
  "type": "message.sent",
  "occurredAt": "2026-10-31T10:02:11.000Z",
  "data": {
    "text": "Great talking earlier, here's the deck.",
    "source": "manual",
    "sentAt": "2026-10-31T10:02:11.000Z",
    "lead": { "id": "lead_z7P9HPjr", "name": "Laura Murphy", "profileUrl": "https://www.linkedin.com/in/sbx-laura-murphy-eh00", "publicIdentifier": "sbx-laura-murphy-eh00" },
    "leadId": "lead_z7P9HPjr",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "senderId": "snd_BmvhDF6L",
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "messageId": "msg_K2l9QpZs",
    "conversationId": "cnv_EcBrECct",
    "providerMessageId": "lnk_7Gq2Xw"
  }
}
lead.completed
{
  "id": "evt_01M61GDEBS31KWT8E7YN7XDZ02",
  "type": "lead.completed",
  "occurredAt": "2026-10-28T15:00:18.412Z",
  "data": {
    "lead": { "id": "lead_GjZVBF0x", "name": "Sophia Wright", "company": "Atlas CRM", "headline": "Head of RevOps at Atlas CRM", "profileUrl": "https://www.linkedin.com/in/sbx-sophia-wright-4y6l" },
    "leadId": "lead_GjZVBF0x",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "senderId": "snd_BmvhDF6L",
    "campaignId": "cmp_FEiWSyyy",
    "stopReason": "not_accepted",
    "enrollmentId": "enr_SvKhQgT0"
  }
}
lead.failed
{
  "id": "evt_01M61FMQA0H6W56AMHRA9YBKJE",
  "type": "lead.failed",
  "occurredAt": "2026-10-28T14:46:48.371Z",
  "data": {
    "lead": { "id": "lead_g5X67CXZ", "name": "Paul Murphy", "company": "Quanta Billing", "headline": "Head of RevOps at Quanta Billing", "profileUrl": "https://www.linkedin.com/in/sbx-paul-murphy-9x8j" },
    "detail": "On the workspace blocklist (by profile or by company)",
    "leadId": "lead_g5X67CXZ",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "senderId": "snd_BmvhDF6L",
    "campaignId": "cmp_FEiWSyyy",
    "stopReason": "blocklisted",
    "enrollmentId": "enr_TynWWee5"
  }
}
lead.stopped
{
  "id": "evt_01M61FKKXBREVCWGKMDP3K4PEB",
  "type": "lead.stopped",
  "occurredAt": "2026-10-28T14:46:12.137Z",
  "data": {
    "lead": { "id": "lead_sMFBr5Df", "name": "John Turner", "company": "Monarch Talent", "headline": "Head of RevOps at Monarch Talent", "profileUrl": "https://www.linkedin.com/in/sbx-john-turner-gayu" },
    "leadId": "lead_sMFBr5Df",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "senderId": "snd_BmvhDF6L",
    "campaignId": "cmp_FEiWSyyy",
    "stopReason": "stopped_by_user",
    "enrollmentId": "enr_7O9qdsXB"
  }
}
message.received
{
  "id": "evt_01M61FSRCJSJGHNWTHTF50RVHD",
  "type": "message.received",
  "occurredAt": "2026-10-28T14:49:28.360Z",
  "data": {
    "lead": { "id": "lead_z7P9HPjr", "name": "Laura Murphy", "company": "Rekall Software", "headline": "Head of RevOps at Rekall Software", "profileUrl": "https://www.linkedin.com/in/sbx-laura-murphy-eh00", "publicIdentifier": "sbx-laura-murphy-eh00", "providerId": "ACoSBX0f3k2Lm" },
    "text": "Thanks! I'll take a look and get back to you.",
    "leadId": "lead_z7P9HPjr",
    "sender": { "id": "snd_BmvhDF6L", "name": "Lena Hoffmann" },
    "sentAt": "2026-10-28T14:49:28.360Z",
    "campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
    "senderId": "snd_BmvhDF6L",
    "messageId": "msg_Fq8CEObh",
    "conversationId": "cnv_EcBrECct"
  }
}

Creating an endpoint

Create endpoints in the console or with POST /v1/webhooks (up to 20 per workspace). eventTypes empty or omitted means every type. The URL must be HTTPS and resolve to a public address. The response carries the signing secret (whsec_…) once; POST /v1/webhooks/{webhookEndpointId}/rotate-secret issues a new one, and the old one stops at once.

Request
curl https://api.getoutvoy.com/v1/webhooks \
  -H "Authorization: Bearer $OUTVOY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/outvoy",
    "description": "Replies to the CRM",
    "eventTypes": ["lead.replied", "sender.needs_reconnect"]
  }'
200 OK
{
  "data": {
    "id": "whk_5N7mkM4H",
    "url": "https://example.com/hooks/outvoy",
    "description": "Replies to the CRM",
    "eventTypes": ["lead.replied", "sender.needs_reconnect"],
    "enabled": true,
    "consecutiveFailures": 0,
    "createdAt": "2026-10-28T12:50:34.850Z",
    "updatedAt": "2026-10-28T12:50:34.850Z",
    "secret": "whsec_n6uRBLP0..."
  }
}

POST /v1/webhooks/{webhookEndpointId}/test sends a signed ping now and answers with the attempt. Its type is webhook.test and its id an ordinary evt_…, but it never appears in the feed, it isn't retried and it doesn't count towards the failure streak.

webhook.test body
{
  "id": "evt_01M61FG54P7D636FEM34D6CD2Y",
  "type": "webhook.test",
  "occurredAt": "2026-10-28T14:44:18.710Z",
  "data": { "webhookEndpointId": "whk_CWnqFw9N", "message": "This is a test event from Outvoy." }
}

Verifying the signature

Each delivery is a POST with the event as its JSON body and these headers:

Content-Type: application/json
User-Agent: Outvoy-Webhooks/1.0
Outvoy-Event-Id: evt_01M61A0DX3XXSWY07ZAN39MAWC
Outvoy-Signature: t=1793191871,v1=ee87b26411a80b93a418a9216d841eea64b00ac9c1d804e6bfd5df1ad1089a0c

t is the Unix time of the attempt in seconds. v1 is the hex HMAC-SHA256 of the string <t>.<raw body> keyed with the endpoint's secret. Compute it over the exact bytes you received, compare in constant time, and reject timestamps more than 5 minutes from now. Accept the request when any v1 entry matches.

verify-outvoy-signature.ts
/**
 * Verifies an Outvoy webhook request. Pass the raw body exactly as received:
 * parsing and re-serializing the JSON changes the bytes and breaks the signature.
 * Web Crypto only: Node 18+, Bun, Deno, edge runtimes and browsers.
 */
export async function verifyOutvoySignature(
  secret: string,
  header: string | null,
  body: string,
  toleranceSeconds = 300,
): Promise<boolean> {
  if (!header) return false;
  let timestamp: number | undefined;
  const signatures: string[] = [];
  for (const part of header.split(",")) {
    const separator = part.indexOf("=");
    if (separator < 0) continue;
    const key = part.slice(0, separator).trim();
    const value = part.slice(separator + 1).trim();
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    if (key === "v1" && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
  }
  if (timestamp === undefined || signatures.length === 0) return false;

  // Reject old (or future) requests so a captured one can't be replayed.
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;

  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(`${timestamp}.${body}`));
  const expected = Array.from(new Uint8Array(mac), (b) => b.toString(16).padStart(2, "0")).join("");

  // Several v1 entries may be present; any one matching is enough.
  return signatures.some((signature) => constantTimeEqual(signature, expected));
}

function constantTimeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}
Handler
// Any framework works; this is a plain Fetch API handler (Bun, Deno, Hono, Next.js route).
export async function POST(request: Request): Promise<Response> {
  const body = await request.text();
  const ok = await verifyOutvoySignature(
    process.env.OUTVOY_WEBHOOK_SECRET ?? "",
    request.headers.get("Outvoy-Signature"),
    body,
  );
  if (!ok) return new Response("invalid signature", { status: 401 });

  const event = JSON.parse(body); // { id, type, occurredAt, data }
  if (event.type === "webhook.test") return new Response(null, { status: 204 });

  // Delivery is at least once: Outvoy-Event-Id (= event.id) dedupes repeats.
  await enqueueOnce(request.headers.get("Outvoy-Event-Id") ?? event.id, event); // your own job queue
  return new Response(null, { status: 204 }); // any 2xx within 10 seconds
}

Retries and delivery

  • Answer with any 2xx within 10 seconds to acknowledge a delivery. Do the work asynchronously and answer quickly. Redirects are not followed.
  • Anything else (another status, a timeout, a network error) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours: 8 attempts over about 45 hours.
  • Every attempt is a row in the delivery log (GET /v1/webhooks/{webhookEndpointId}/deliveries, newest first): attempt, status (pending, succeeded, failed), responseStatus, the first 2 KB of responseBody, error, durationMs and nextAttemptAt.
  • After 50 failed attempts in a row the endpoint is disabled, with disabledReason. PATCH it with { "enabled": true } to re-enable it: the streak resets and pending retries resume.
  • Delivery is at least once, and order isn't guaranteed. Deduplicate on Outvoy-Event-Id (the event id); sort by id if order matters.
  • Missed something? The feed keeps the same events: GET /v1/events?after=<last id you processed>.