Webhooks

Subscribe to Swarmhit events, verify signed deliveries, and handle every event type with example payloads.

Webhooks push events to your server as they happen: replies, accepted connections, lead status changes, finished campaigns, and sender account lifecycle. Register an endpoint, pick the events it should receive, and verify the signature on every delivery.

Register an endpoint

Register webhook endpoints under Settings → API & Webhooks in the Swarmhit app, or with Create a webhook endpoint:

curl -X POST https://app.swarmhit.com/api/v1/webhooks \
  -H "Authorization: Bearer swh_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/swarmhit",
    "events": ["message.received", "connection.accepted"],
    "classifyReplies": true
  }'

The response includes the endpoint's secret: the HMAC signing key you use to verify deliveries. It is also returned by List webhook endpoints and Get a webhook endpoint, so you can re-read it later. There is no rotation endpoint: to change a secret, create a new endpoint and delete the old one.

Manage endpoints with Update a webhook endpoint (any subset of url, events, enabled, classifyReplies), Delete a webhook endpoint, and Send a test delivery.

Delivery

Every delivery is an HTTP POST with a JSON body. All payloads share one envelope:

{
  "event": "connection.accepted",
  "workspaceId": "wJ4kQz8XrN2mYpL5d",
  "createdAt": "2026-08-03T14:22:07.311Z",
  "data": { }
}

Each request carries these headers:

HeaderMeaning
X-Swarmhit-EventThe event type (same as event in the body).
X-Swarmhit-DeliveryUnique delivery id. Delivery is at-least-once, so deduplicate on it.
X-Swarmhit-SignatureHMAC-SHA256 hex of the raw request body, keyed by the endpoint's signing secret.

Respond with any 2xx status within 8 seconds to acknowledge. A non-2xx response or a timeout is retried with exponential backoff (4s, 16s, 64s, about 4 minutes, about 17 minutes) for up to 6 attempts, after which the delivery is marked failed. After 25 consecutive failed attempts the endpoint is auto-disabled and stops receiving new deliveries (disabledReason says why); any successful delivery resets the streak. Once you fix your endpoint, re-enable it with PATCH /webhooks/{id} and {"enabled": true}.

Verify signatures

Recompute the HMAC over the raw body (before any JSON parsing) and compare with crypto.timingSafeEqual:

const crypto = require('crypto');

app.post('/hooks/swarmhit', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('X-Swarmhit-Signature') || '';
  const expected = crypto
    .createHmac('sha256', process.env.SWARMHIT_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
  if (!valid) return res.status(401).end();

  const payload = JSON.parse(req.body);
  const deliveryId = req.get('X-Swarmhit-Delivery'); // dedupe on this

  res.status(200).end(); // ack fast, process async
});

Event catalog

These are the event names you can pass in events when creating or updating an endpoint:

EventFires when
message.receivedA lead replies to a LinkedIn message: on a campaign lead, or on a lead you actioned directly through the API. Email replies fire email.received.
message.sentA sender account sends an outgoing LinkedIn message: a manual reply, a campaign step, or an API send. Emails fire email.sent. Only fires when the chat's counterpart is a tracked lead.
connection.acceptedA lead accepts the connection request.
lead.status_changedA lead in a campaign reaches a resting status: replied, done, failed, paused or stopped.
lead.importedA new lead lands in the workspace via a LinkedIn search import. Fires once per new lead; refreshes of existing leads do not fire.
comment.pendingAn AI-drafted comment is waiting for manual approval before it posts.
voice_note.pendingA generated AI voice note is waiting for manual approval before it is sent.
campaign.finishedA campaign has finished outreach to every one of its leads.
account.connectedA LinkedIn sender account finishes connecting successfully.
account.connection_failedA sender connection attempt fails.
account.checkpoint_requiredLinkedIn asks a sender for a verification step before it will finish signing in.
account.status_changedA connected sender changes status (active, error, suspended or disconnected).
account.pausedA sender is paused, manually or automatically when its InMails could not be sent.
account.resumedA paused sender is resumed, manually or automatically when InMail credits return.
email.sentA mailbox sends an email: a campaign step or a reply from the inbox.
email.receivedA lead replies by email.
email.bouncedA campaign email bounces or is reported as spam.
email.openedA lead opens a tracked campaign email, once per email.
email.clickedA lead clicks a tracked link in a campaign email, once per email.
email.unsubscribedA lead unsubscribes through the link in a campaign email.

There is also a webhook.test event. It is not subscribable: it only fires when you call Send a test delivery, returns the result immediately, and does not retry or affect the endpoint's health counters.

message.received

A lead replied. source is campaign (the reply is on a campaign lead) or direct (a lead you actioned via the API); campaignLeadId and campaignId are null for direct-source events. Email replies come as email.received instead. Full payload:

{
  "event": "message.received",
  "workspaceId": "wJ4kQz8XrN2mYpL5d",
  "createdAt": "2026-08-03T14:22:07.311Z",
  "data": {
    "source": "campaign",
    "accountId": "sN4Xq7Lm2Rw9Zk5Yp",
    "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns",
    "campaignId": "gT5Rw2XqLm8Zk3vNd",
    "leadId": "eLd7Qw2XmRk9Zp4Yt",
    "chatId": "9Zp4YtLm2Rw8XqKd5",
    "message": {
      "text": "Thanks for reaching out, happy to hear more. Do you have time Thursday?",
      "at": "2026-08-03T14:22:05.000Z"
    },
    "from": {
      "firstName": "Maya",
      "lastName": "Lindqvist",
      "publicIdentifier": "maya-lindqvist",
      "providerId": "ACoAAB1x2y3z"
    },
    "classification": { "interest": "interested" }
  }
}

The examples below show only the data object; the envelope is always the same.

classification is the AI read of the reply and is opt-in per endpoint: set classifyReplies: true when creating or updating the webhook. It consumes workspace credits. An endpoint without the flag always sees classification: null, even when another endpoint in the workspace opted in. When enabled, classification.interest is one of: interested (open to a call, demo, pricing, or learning more), not_interested (a clear no, a rejection, or an unsubscribe request), or neutral (anything else: "not right now", a question with no intent either way, an off-topic reply). The field is always present but null whenever no classification was produced (flag off, out of credits, or the classifier failed). Treat null as unknown, never as neutral.

message.sent

The outbound mirror of message.received, so you can show the sender's side of the conversation. source is best-effort provenance inferred from the chat: campaign, direct, or manual (neither). Emails come as email.sent instead.

{
  "source": "manual",
  "accountId": "sN4Xq7Lm2Rw9Zk5Yp",
  "campaignLeadId": null,
  "campaignId": null,
  "leadId": "eLd7Qw2XmRk9Zp4Yt",
  "chatId": "9Zp4YtLm2Rw8XqKd5",
  "message": { "text": "Thursday 3pm works. Sending an invite now.", "at": "2026-08-03T14:31:40.000Z" },
  "to": {
    "firstName": "Maya",
    "lastName": "Lindqvist",
    "publicIdentifier": "maya-lindqvist",
    "providerId": "ACoAAB1x2y3z"
  }
}

connection.accepted

source is campaign (a campaign invite) or direct (a connection request sent via Send a connection request); campaign fields are null for direct-source events.

{
  "source": "campaign",
  "accountId": "sN4Xq7Lm2Rw9Zk5Yp",
  "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns",
  "campaignId": "gT5Rw2XqLm8Zk3vNd",
  "leadId": "eLd7Qw2XmRk9Zp4Yt",
  "acceptedAt": "2026-08-03T11:05:22.410Z",
  "lead": {
    "firstName": "Maya",
    "lastName": "Lindqvist",
    "publicIdentifier": "maya-lindqvist",
    "providerId": "ACoAAB1x2y3z"
  }
}

lead.status_changed

Fires when a lead in a campaign comes to rest, whether the executor moved it there or you did (for example Stop a lead or Pause a lead). lead.publicIdentifier is null for a lead added by email alone; read it by leadId.

{
  "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns",
  "campaignId": "gT5Rw2XqLm8Zk3vNd",
  "leadId": "eLd7Qw2XmRk9Zp4Yt",
  "oldStatus": "running",
  "newStatus": "replied",
  "lead": {
    "firstName": "Maya",
    "lastName": "Lindqvist",
    "publicIdentifier": "maya-lindqvist"
  }
}

lead.imported

One event per new lead created by a LinkedIn search import (see Create a lead import). lead is the full lead object, the same shape Get a lead returns.

{
  "importId": "iMp5Qw2XrLk8Zn4Yd",
  "source": "linkedin-search",
  "lead": {
    "id": "eLd7Qw2XmRk9Zp4Yt",
    "firstName": "Tomas",
    "lastName": "Ferreira",
    "headline": "VP Engineering at Nortada",
    "jobTitle": null,
    "company": null,
    "email": null,
    "location": "Porto, Portugal",
    "picture": "https://cdn.swarmhit.com/pictures/eLd7Qw2XmRk9Zp4Yt.jpg",
    "notes": null,
    "profileUrl": "https://www.linkedin.com/in/tomas-ferreira",
    "publicIdentifier": "tomas-ferreira",
    "openProfile": null,
    "tags": [],
    "autoTags": [],
    "listIds": ["lQz5Yp3NsRw2XqLm8"],
    "campaignIds": [],
    "customVariables": {},
    "source": "linkedin-search",
    "lastActivityAt": null,
    "enrichedAt": null,
    "createdAt": "2026-08-03T09:15:44.902Z",
    "updatedAt": "2026-08-03T09:15:44.902Z"
  }
}

comment.pending

An AI-drafted comment needs approval before it posts. Review it with the Approvals endpoints: approve, edit, reject or regenerate.

{
  "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns",
  "campaignId": "gT5Rw2XqLm8Zk3vNd",
  "leadId": "eLd7Qw2XmRk9Zp4Yt",
  "pendingCommentId": "pC8Zk3vNd5Rw2XqLm",
  "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:7228...",
  "comment": "Great point on onboarding friction. We saw the same pattern with self-serve trials."
}

voice_note.pending

A generated AI voice note needs approval before it is sent. Review it with the Approvals endpoints: approve, reject or re-record. Fetch the note to get its playable audioUrl.

{
  "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns",
  "campaignId": "gT5Rw2XqLm8Zk3vNd",
  "leadId": "eLd7Qw2XmRk9Zp4Yt",
  "pendingVoiceNoteId": "pV8Zk3vNd5Rw2XqLm",
  "script": "Hey Sacha, saw you're leading growth at Acme. Quick thought for you.",
  "durationMs": 7400
}

campaign.finished

Fires once, when every lead in the campaign has come to rest.

{
  "campaignId": "gT5Rw2XqLm8Zk3vNd",
  "name": "SaaS founders EU, July",
  "stats": { "total": 250 }
}

Email events

Email has its own event family so that message.* keeps meaning LinkedIn for every existing subscriber. The shapes mirror their LinkedIn counterparts with an email block on top, and accountId is the mailbox (see Mailboxes) rather than a LinkedIn sender.

email.sent

A mailbox sent an email: a campaign step (source: campaign) or a reply from the inbox (source: manual). email.messageId is the RFC 5322 Message-ID, quoted again by email.bounced; threaded is true when the email went out as a reply in an existing thread.

{
  "source": "campaign",
  "accountId": "mBx4Qw8RmZk2Yp5Ns",
  "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns",
  "campaignId": "gT5Rw2XqLm8Zk3vNd",
  "leadId": "eLd7Qw2XmRk9Zp4Yt",
  "chatId": "email:mBx4Qw8RmZk2Yp5Ns:<b7f1e2c4d9a0@example.com>",
  "message": {
    "text": "Hi Maya, quick question about your Q4 hiring plans...",
    "at": "2026-09-22T09:14:03.000Z"
  },
  "email": {
    "subject": "Quick question about your Q4 hiring",
    "messageId": "<b7f1e2c4d9a0@example.com>",
    "to": "maya@lindqvist.io",
    "threaded": false
  },
  "to": { "firstName": "Maya", "lastName": "Lindqvist", "email": "maya@lindqvist.io" }
}

email.received

A lead answered by email. message.text is the reply with the quoted history stripped, and classification works exactly as on message.received (opt in per endpoint with classifyReplies). Out-of-office and other auto-replies land in the inbox but do not fire this event.

{
  "source": "campaign",
  "accountId": "mBx4Qw8RmZk2Yp5Ns",
  "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns",
  "campaignId": "gT5Rw2XqLm8Zk3vNd",
  "leadId": "eLd7Qw2XmRk9Zp4Yt",
  "chatId": "email:mBx4Qw8RmZk2Yp5Ns:<b7f1e2c4d9a0@example.com>",
  "email": {
    "subject": "Re: Quick question about your Q4 hiring",
    "messageId": "<CAF3x9k2@mail.gmail.com>"
  },
  "message": {
    "text": "Happy to chat. Thursday works.",
    "at": "2026-09-22T11:02:19.000Z"
  },
  "from": {
    "firstName": "Maya",
    "lastName": "Lindqvist",
    "publicIdentifier": "maya-lindqvist",
    "email": "maya@lindqvist.io"
  },
  "classification": { "interest": "interested" }
}

email.bounced

A campaign email bounced (kind: bounce) or the recipient reported it as spam (kind: complaint). bounce.message is the reason in plain words and bounce.status the SMTP status code when the server gave one. deadAddress is true when the lead's address was marked unusable (emailStatus on the lead, so email steps skip it from now on); leadFailed when the lead was failed on the step that sent this email.

{
  "accountId": "mBx4Qw8RmZk2Yp5Ns",
  "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns",
  "campaignId": "gT5Rw2XqLm8Zk3vNd",
  "leadId": "eLd7Qw2XmRk9Zp4Yt",
  "kind": "bounce",
  "email": { "messageId": "<b7f1e2c4d9a0@example.com>", "recipient": "maya@lindqvist.io" },
  "bounce": { "action": "failed", "status": "5.1.1", "message": "The address does not exist." },
  "deadAddress": true,
  "leadFailed": true,
  "at": "2026-09-22T09:14:41.000Z"
}

email.opened

A lead opened a tracked campaign email. It fires once per email, on the first open, never for a repeat view. Opens are approximate: some mail apps load images on their own, others block them, and an open in the first two minutes after the send is ignored because mail security scanners make them. stepId is the email step that sent it; email.messageId is the same Message-ID email.sent carried.

{
  "accountId": "mBx4Qw8RmZk2Yp5Ns",
  "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns",
  "campaignId": "gT5Rw2XqLm8Zk3vNd",
  "leadId": "eLd7Qw2XmRk9Zp4Yt",
  "stepId": "s1",
  "email": { "messageId": "<b7f1e2c4d9a0@example.com>", "subject": "Quick question, Maya" },
  "at": "2026-09-22T11:02:17.000Z"
}

email.clicked

A lead clicked a tracked link in a campaign email. Same shape as email.opened, plus url, the link that was clicked. It fires once per email, on the first click; clicks in the first two minutes after the send are ignored.

{
  "accountId": "mBx4Qw8RmZk2Yp5Ns",
  "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns",
  "campaignId": "gT5Rw2XqLm8Zk3vNd",
  "leadId": "eLd7Qw2XmRk9Zp4Yt",
  "stepId": "s1",
  "email": { "messageId": "<b7f1e2c4d9a0@example.com>", "subject": "Quick question, Maya" },
  "url": "https://calendly.com/maya-lindqvist/intro",
  "at": "2026-09-22T11:04:52.000Z"
}

email.unsubscribed

A lead unsubscribed through the unsubscribe link in a campaign email: from the page it opens (source: page) or with their mail app's one-click unsubscribe (source: one-click). Every lead of the workspace with that address now has emailOptOut: true, and leadStopped says whether the lead was stopped in the campaign that emailed it (it is not when it had already finished). campaignLeadId is null when the lead has since been removed from the campaign. Opening the link alone fires nothing.

{
  "accountId": "mBx4Qw8RmZk2Yp5Ns",
  "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns",
  "campaignId": "gT5Rw2XqLm8Zk3vNd",
  "leadId": "eLd7Qw2XmRk9Zp4Yt",
  "email": { "address": "maya@lindqvist.io" },
  "source": "one-click",
  "leadStopped": true,
  "at": "2026-09-22T12:31:08.000Z"
}

Sender account events

All six account.* events share one shape: data.account is the full sender object, the same shape Get a sender account returns (trimmed in the examples below), and data.status is the sender's connection status at emit time. For a mailbox, data.channel is "email" and data.account is the mailbox object (Get a mailbox); for a sender it is "linkedin". Each event adds its own fields:

{
  "account": { "id": "sN4Xq7Lm2Rw9Zk5Yp", "fullName": "Sofia Marin", "publicIdentifier": "sofia-marin", "status": "disconnected" },
  "status": "disconnected",
  "previousStatus": "active",
  "reason": null
}
EventExtra data fields
account.connectedaccount and status; plus hostedLinkId and externalId when the sender came through a hosted connect link.
account.connection_failedaccount is null when no account record was created; plus username, reason (human-readable), and code (stable machine-readable reason, same vocabulary as API error codes); plus hostedLinkId and externalId when the attempt came through a hosted connect link.
account.checkpoint_requiredcheckpointType (for example 2FA, OTP, IN_APP_VALIDATION, PHONE_REGISTER, CAPTCHA). The full challenge is on account.checkpoint. Answer within five minutes via Solve a checkpoint; see the Sender accounts guide.
account.status_changedpreviousStatus and reason (both nullable). status is one of active, error, suspended, disconnected, pending.
account.pausedreason: user (manual) or inmail_credits (its InMails could not be sent). A pause does not change status.
account.resumedby: user (manual) or system (InMail credits returned).

Delivery log and debugging

Every delivery attempt is recorded, with the exact payload that was POSTed:

  • List the delivery log: all deliveries across the workspace, newest first, filterable by event, status (pending, delivered, failed) and webhookId.
  • List recent deliveries: the same, scoped to one endpoint.
  • Replay a delivery: re-queues a past delivery to the endpoint's current URL with the stored payload unchanged. It gets a new delivery id, so a consumer that dedupes will re-process it. The endpoint must still exist and be enabled.

Delivery rows are dropped 7 days after creation. If you need a permanent record, poll the log or persist events on receipt.

On this page