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.
{
"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
| Type | When |
|---|---|
| sender.connected | A 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_reconnect | LinkedIn rejected the session (session_expired), restricted the account (account_restricted) or the provider reported a broken account (account_status). |
| sender.paused | You 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.resumed | You resumed it (resumed_by_user), or its rest after a LinkedIn limit is over (hold_expired). |
| import.completed | An import finished, with found, imported and skipped. |
| import.failed | An import stopped, with its error. |
| campaign.launched | A campaign started, with how many leads were enrolled, skipped and waitingForPlan. |
| campaign.paused | A campaign was paused. |
| campaign.resumed | A paused campaign was resumed. |
| campaign.completed | A running campaign has no live enrollment left and nobody waiting to join. |
| campaign.archived | A campaign was archived; stopped counts the live enrollments it stopped (each also emits lead.stopped). |
| lead.enrolled | A lead joined a campaign and was assigned a sender. |
| lead.profile_visited | The sender visited the lead's profile. |
| lead.post_liked | The sender liked the lead's latest post (postId). |
| lead.invite_sent | An 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_accepted | The 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_withdrawn | One 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_sent | A campaign message was sent, with its text, conversationId, messageId and LinkedIn's providerMessageId. |
| lead.replied | The lead answered; their sequence stopped and the enrollment is replied (also after the last step, with sequenceFinished: true). Carries the message text. |
| lead.completed | The sequence ended without a reply; stopReason is not_accepted when the invitation was withdrawn unanswered. |
| lead.failed | The 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.stopped | Someone stopped the lead's sequence (stopped_by_user) or archived the campaign (campaign_archived). |
| message.received | Any 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.sent | A 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.connected | A 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.deleted | The 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.
{
"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
}
}
{
"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"
}
}
{
"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"
}
}
{
"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"
}
}
{
"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"
}
}
{
"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"
}
}
{
"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"
}
}
{
"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)"
}
}
{
"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
}
}
{
"id": "evt_01M61FGRDWSBH7D3YEVASJWB4Q",
"type": "campaign.paused",
"occurredAt": "2026-10-28T14:44:38.454Z",
"data": {
"campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
"campaignId": "cmp_FEiWSyyy"
}
}
{
"id": "evt_01M61FGREPXM7GZ7BKEDHMA79E",
"type": "campaign.resumed",
"occurredAt": "2026-10-28T14:44:38.472Z",
"data": {
"campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
"campaignId": "cmp_FEiWSyyy"
}
}
{
"id": "evt_01M61GG66EGPWV5PKGD5WW7MFF",
"type": "campaign.completed",
"occurredAt": "2026-10-28T15:01:48.357Z",
"data": {
"campaign": { "id": "cmp_FEiWSyyy", "name": "RevOps DE" },
"campaignId": "cmp_FEiWSyyy"
}
}
{
"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"
}
}
{
"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"
}
}
{
"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"
}
}
{
"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"
}
}
{
"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"
}
}
{
"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"
}
}
{
"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
}
}
{
"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"
}
}
{
"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
}
}
{
"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"
}
}
{
"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"
}
}
{
"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"
}
}
{
"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"
}
}
{
"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"
}
}
{
"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.
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"]
}'
{
"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.
{
"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.
/**
* 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;
}
// 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
2xxwithin 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 ofresponseBody,error,durationMsandnextAttemptAt. - After 50 failed attempts in a row the endpoint is disabled, with
disabledReason.PATCHit 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 eventid); sort byidif order matters. - Missed something? The feed keeps the same events:
GET /v1/events?after=<last id you processed>.