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).
message.sentThe sender account sends an outgoing message: a manual reply on LinkedIn, a campaign step, or an API send. 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.
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.

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. 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).

{
  "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).

{
  "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."
}

campaign.finished

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

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

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. 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.connectedNone beyond account and status.
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).
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