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: LinkedIn replies and messages, emails sent, received, bounced, opened and clicked, accepted connections, lead status changes, finished campaigns, sender and mailbox lifecycle, and the orders, payments and warmup of mailboxes bought through Swarmhit. Register an endpoint, pick the events it should receive, and verify the signature on every delivery.
Register an endpoint
Register webhook endpoints on the Webhooks page in Swarmhit, 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
}'url must be an absolute http:// or https:// URL (400 with code: invalid-url otherwise). events must be an array of names from the event catalog. Names Swarmhit does not know are dropped without an error, and the call fails with 400 (code: invalid-events) only when none is left, so check the events the response echoes. A workspace can have at most 25 endpoints (409, code: limit-reached). An endpoint receives only the events of the workspace it was created in; to cover the workspaces of an organization, see Webhooks per workspace.
The response includes the endpoint's secret (whsec_...): 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. Deleting an endpoint also deletes its queued deliveries and its delivery log.
Manage endpoints with Update a webhook endpoint (any subset of url, events, enabled, classifyReplies, with the same rules as on create), 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": { }
}createdAt is when Swarmhit emitted the event; it stays the same on every retry and replay. Each request carries these headers:
| Header | Meaning |
|---|---|
X-Swarmhit-Event | The event type (same as event in the body). |
X-Swarmhit-Delivery | Unique delivery id. Delivery is at-least-once, so deduplicate on it. |
X-Swarmhit-Signature | HMAC-SHA256 hex of the raw request body, keyed by the endpoint's signing secret. |
User-Agent | Swarmhit-Webhooks/1.0 |
Respond with any 2xx status within 8 seconds to acknowledge. A non-2xx response, a timeout or a network error is retried with exponential backoff, waiting at least 4s, 16s, 64s, about 4 minutes and about 17 minutes, for up to 6 attempts in all, after which the delivery is marked failed. Several deliveries go out at once and failed ones are retried later, so events can arrive out of order: use createdAt to order them.
After 25 consecutive failed attempts (counted across all of the endpoint's deliveries) the endpoint is auto-disabled: enabled turns false and disabledReason says why. Any successful delivery resets the streak (failureCount). A disabled endpoint receives nothing: events that happen while it is disabled are not queued for it, and deliveries still queued for it are marked failed. Once you fix your endpoint, re-enable it with PATCH /webhooks/{id} and {"enabled": true}, which also resets failureCount and clears disabledReason, then replay the failed deliveries you need.
Verify signatures
Recompute the HMAC over the raw body (before any JSON parsing), keyed by the whole secret string (whsec_ prefix included), and compare in constant time with crypto.timingSafeEqual. The signature covers the body only; no timestamp is signed.
const crypto = require('crypto');
app.post('/hooks/swarmhit', express.raw({ type: 'application/json' }), (req, res) => {
const received = Buffer.from(req.get('X-Swarmhit-Signature') || '');
const expected = Buffer.from(
crypto
.createHmac('sha256', process.env.SWARMHIT_WEBHOOK_SECRET) // the full whsec_... secret
.update(req.body)
.digest('hex')
);
const valid = received.length === expected.length && crypto.timingSafeEqual(received, expected);
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:
| Event | Fires when |
|---|---|
message.received | A 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.sent | A 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.accepted | A lead accepts a connection request: a campaign invite, or one sent through the API. |
lead.status_changed | A lead in a campaign reaches a resting status: replied, done, failed, paused or stopped. |
lead.imported | A new lead lands in the workspace via a LinkedIn search import. Fires once per new lead; refreshes of existing leads do not fire. |
comment.pending | An AI-drafted comment is waiting for manual approval before it posts. |
voice_note.pending | A generated AI voice note is waiting for manual approval before it is sent. |
campaign.finished | A campaign has finished outreach to every one of its leads. |
account.connected | A LinkedIn sender or a mailbox finishes connecting. |
account.connection_failed | A LinkedIn sender or mailbox connection attempt fails. |
account.checkpoint_required | LinkedIn asks a sender for a verification step before it will finish signing in. |
account.status_changed | A sender or mailbox changes status (active, error, suspended or disconnected). |
account.paused | A sender or mailbox is paused: manually, automatically when a sender's InMails could not be sent, or when a bought mailbox's warmup health turns critical. |
account.resumed | A paused sender or mailbox is resumed: manually, automatically when InMail credits return, or when warmup health recovers. |
email.sent | A mailbox sends an email: a campaign step or a reply from the inbox. |
email.received | A lead replies by email to a campaign that emailed it. |
email.bounced | A campaign email bounces or is reported as spam. |
email.opened | A lead opens a tracked campaign email, once per email. |
email.clicked | A lead clicks a tracked link in a campaign email, once per email. |
email.unsubscribed | A lead unsubscribes through the link in a campaign email. |
mailbox_order.completed | An order of bought mailboxes finishes: its mailboxes are connected, and warming when the order included warmup. |
mailbox_order.failed | A mailbox order stops: delayed until our team steps in (needs_attention), or cancelled. |
mailbox_billing.payment_failed | A payment for bought mailboxes fails: a purchase's monthly charge or a domain's yearly renewal. |
mailbox_billing.payment_recovered | A mailbox payment that had failed goes through. |
warmup.status_changed | A bought mailbox's warmup changes status. |
warmup.health_changed | A bought mailbox's warmup health changes. |
warmup.ready | A warming mailbox stops being held out of campaigns: campaigns can now email from it. |
There is also a webhook.test event. It is not subscribable: it only fires when you call Send a test delivery, and its data is { message, webhookId }. The call sends it right away, even to a disabled endpoint, and returns the result as { ok, status, error } (status is the HTTP status your endpoint answered, null on a timeout or network error). A test is not retried, carries no X-Swarmhit-Delivery header, is not recorded in the delivery log, and does not 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. chatId identifies the conversation on LinkedIn and is the same on message.sent for that conversation; it is not the inbox chat id (the conversation's inbox chats carry the same leadId and accountId). 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": "Qm7TzX2kLp9RwN4vYc8HdA",
"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.
On email.received, a reply is classified only when the workspace also has an enabled endpoint subscribed to message.received with classifyReplies: true, or when the campaign scores replies itself (aiInterestEnabled, unless the lead's interest was set by hand). Otherwise classification is null, even on an endpoint that opted in.
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": "Qm7TzX2kLp9RwN4vYc8HdA",
"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. It fires once per accepted invitation.
{
"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). Moving a lead back into progress (resume, retry) fires nothing. 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, except that campaignIds is always empty here.
{
"importId": "iMp5Qw2XrLk8Zn4Yd",
"source": "linkedin-search",
"lead": {
"id": "eLd7Qw2XmRk9Zp4Yt",
"firstName": "Tomas",
"lastName": "Ferreira",
"headline": "VP Engineering at Nortada",
"jobTitle": null,
"company": null,
"email": null,
"phone": null,
"emailStatus": null,
"emailOptOut": false,
"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 Swarmhit moves the last in-progress lead of an active campaign to a resting status; the campaign's status becomes finished. Pausing or stopping that last lead yourself (from the API, the app or an inbox reply) fires lead.status_changed but does not finish the campaign. stats is the campaign's stats object: only total, the number of leads in the campaign, is kept up to date, so count outcomes with List the leads in a campaign filtered by status.
{
"campaignId": "gT5Rw2XqLm8Zk3vNd",
"name": "SaaS founders EU, July",
"stats": { "total": 250, "done": 0, "failed": 0 }
}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, to and from carry the lead's email in place of providerId, and accountId is the mailbox (see Mailboxes) rather than a LinkedIn sender. chatId is the thread key, email:<mailboxId>:<first Message-ID>, shared by every email.sent and email.received of the thread; like on LinkedIn events, it is not the inbox chat id.
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, email.opened and email.clicked; 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. It fires only for a lead a campaign has emailed, so source is always campaign; mail from any other lead lands in the inbox without an event. message.text is the reply with the quoted history stripped, and classification works as on message.received (opt in per endpoint with classifyReplies; see the note above for the extra condition on email). 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 for good (kind: bounce) or the recipient reported it as spam (kind: complaint). It fires once per email; a delayed delivery the mail server is still retrying does not fire it. 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. An email the mailbox could not send at all fails the lead (lead.status_changed to failed) without an email.bounced.
{
"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 email 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, or from an automated client, 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, and a click on the unsubscribe link is never an email.clicked.
{
"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); a stop also fires lead.status_changed. campaignLeadId, campaignId and accountId are null when the lead has since been removed from the campaign. It fires once: opening the link alone, or unsubscribing again, 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 and mailbox events
Five of the six account.* events share one shape: data.account is the full account object, data.channel says which kind it is, and data.status is the account's connection status at emit time. With channel: "linkedin", account is the sender, the same shape Get a sender account returns (trimmed in the example below); with channel: "email", it is the mailbox (Get a mailbox). On either, account.hostedLinkId names the hosted connect link the account came through, if any. account.connection_failed has its own, leaner shape (see the table). Each event adds its own fields:
{
"account": { "id": "sN4Xq7Lm2Rw9Zk5Yp", "fullName": "Sofia Marin", "publicIdentifier": "sofia-marin", "status": "error" },
"channel": "linkedin",
"status": "error",
"previousStatus": "active",
"reason": "DISCONNECTED"
}| Event | Extra data fields |
|---|---|
account.connected | Plus hostedLinkId and externalId when the sender or mailbox came through a hosted connect link (externalId is null when the link was created without one). A mailbox fires it whenever it becomes active: when it first connects and again when it recovers from an error, alongside account.status_changed. |
account.connection_failed | account is always null and there is no status. username (for a mailbox, its address when known), reason (human-readable), and code (stable machine-readable reason, same vocabulary as API error codes; signin-* codes for a mailbox's Google or Microsoft sign-in). channel is "email" for a mailbox and absent for a LinkedIn sender. Plus hostedLinkId and externalId when the attempt came through a hosted connect link. For a mailbox it fires on a Google or Microsoft sign-in and on any connect through a hosted link. code: account-mismatch means a different LinkedIn account signed in on a reconnect link and was removed, possibly after its account.connected went out. |
account.checkpoint_required | LinkedIn senders only. checkpointType (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_changed | status is the new status: active, error, suspended, or disconnected when the account no longer exists (for example a mailbox you removed). previousStatus (which can also be pending) and reason (a short code or message) can be null or absent. |
account.paused | reason: user (manual), inmail_credits (a sender's InMails could not be sent) or warmup_health (a bought mailbox's warmup health turned critical; see Warmup events). A pause does not change status. |
account.resumed | by: user (manual), system (InMail credits returned) or warmup_health (warmup health recovered). |
Mailbox order events
mailbox_order.completed fires when an order of bought mailboxes finishes: every mailbox it could set up is connected (and warming when the order included warmup). Mailboxes that could not be set up are listed with step: failed (refused for a domain that could not be registered) and are refunded. mailbox_order.failed fires each time an order stops: status: needs_attention (delayed, our team is on it; the order can still resume and send mailbox_order.completed) or status: cancelled.
order is the order object, the same shape Get a mailbox order returns (trimmed below). hostedLinkId and externalId are added when the order was bought through a hosted link.
{
"order": {
"id": "oRd8Zk3vNd5Rw2XqL",
"status": "completed",
"statusLabel": "Ready",
"createdVia": "api",
"domains": [
{
"name": "getnortada.com",
"platform": "google",
"status": "ready",
"mailboxes": [
{
"email": "tomas@getnortada.com",
"step": "connected",
"accountId": "mBx4Qw8RmZk2Yp5Ns",
"connectedAt": "2026-09-30T10:12:44.000Z"
}
]
}
],
"completedAt": "2026-09-30T10:13:02.000Z"
},
"status": "completed",
"previousStatus": "authorizing"
}Mailbox billing events
mailbox_billing.payment_failed fires once when a payment for bought mailboxes fails:
kind: monthly: a purchase's monthly charge. Its warmup pauses after 1 day, and its mailboxes are cancelled oncancelOn(14 days after the first failure) unless it is paid.kind: renewal: a domain's yearly renewal. The domain and its mailboxes stop atexpiresAtunless it is paid.
Both carry orderId (the purchase, see Get a mailbox order), domains, amountCents, currency, and payUrl, a page where it can be paid online, or null. cancelOn and expiresAt are YYYY-MM-DD dates. Payment state is also on What you pay for bought mailboxes.
{
"kind": "monthly",
"orderId": "oRd8Zk3vNd5Rw2XqL",
"domains": ["getnortada.com"],
"amountCents": 4500,
"currency": "usd",
"payUrl": "https://invoice.stripe.com/i/...",
"cancelOn": "2026-10-21"
}mailbox_billing.payment_recovered fires when a payment that had failed goes through: nothing is cancelled and paused warmup resumes. It carries kind, orderId and domains; a recovered renewal also carries amountCents and currency.
Warmup events
The warmup of a mailbox bought through Swarmhit. All three events carry account, the mailbox with its warmup object (the same shape Get a mailbox returns, trimmed below), and channel: "email". Each adds its own fields:
| Event | Extra data fields |
|---|---|
warmup.status_changed | status and previousStatus (null for a warmup that just started): pending, active, paused, cancelled, past_due (payment due) or error. |
warmup.health_changed | health and previousHealth: excellent, good, warning, critical or unknown. When health turns critical after the hold, the mailbox is paused (account.paused with reason: warmup_health) so campaigns stop emailing from it; it is resumed (account.resumed with by: warmup_health) once health is back to good or excellent, unless it was paused by hand. |
warmup.ready | None. The mailbox is no longer held out of campaigns: the hold period ended with health not at warning or critical, you lifted it with Send before warmup is done, or warmup stopped running (paused, cancelled, past_due or error). Warmup carries on alongside campaigns when it is still running. |
{
"account": {
"id": "mBx4Qw8RmZk2Yp5Ns",
"email": "tomas@getnortada.com",
"status": "active",
"paused": true,
"pausedReason": "warmup_health",
"warmup": { "status": "active", "health": "critical", "holdUntil": "2026-09-30T00:00:00.000Z", "holdReason": "health" }
},
"channel": "email",
"health": "critical",
"previousHealth": "warning"
}Delivery log and debugging
Each delivery (one event for one endpoint) is recorded with the exact payload that was POSTed, and updated on every attempt: status, attempts, responseStatus, lastError (for example endpoint returned HTTP 500 or timed out after 8000ms) and nextAttemptAt.
- List the delivery log: all deliveries across the workspace, newest first, filterable by
event,status(pending,delivered,failed) andwebhookId;paging.totalcounts the matches. An unknowneventorstatusvalue is ignored rather than rejected. - List recent deliveries: one endpoint's deliveries, newest first, without filters.
- 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 (
404otherwise) and be enabled (400,code: disabled).
Delivery rows are dropped 7 days after creation. If you need a permanent record, poll the log or persist events on receipt.