Loops
Verify and handle Loops webhooks — contact, mailing list, email send, and engagement events.
Add your endpoint under Settings → Webhooks in Loops, pass the signing secret it shows you, and handle events by name:
import { createWebhookHandler } from 'webhooks-sdk'
import { loops } from 'webhooks-sdk/loops'
const handler = createWebhookHandler({
provider: loops({ secret: process.env.LOOPS_SIGNING_SECRET! }),
on: {
'contact.created': async (event) => {
await syncContact(event.payload.contactIdentity.email)
},
'email.hardBounced': async (event) => {
await suppress(event.payload.contactIdentity.email)
},
},
})
export const POST = handler.fetch
The secret is the whsec_… value shown next to the endpoint URL. Loops signs
with Standard Webhooks over the spec’s
webhook-* headers — the id and timestamp are inside the signed material, so
a replayed request is rejected on its own.
Options
| Option | Type | Default | |
|---|---|---|---|
secret |
string | string[] |
— | The whsec_… signing secret(s). Pass an array during rotation. |
tolerance |
number |
300 |
Replay window in seconds. |
Events
contact.created · contact.unsubscribed · contact.deleted ·
contact.mailingList.subscribed · contact.mailingList.unsubscribed ·
campaign.email.sent · loop.email.sent · transactional.email.sent ·
email.delivered · email.softBounced · email.hardBounced ·
email.opened · email.clicked · email.unsubscribed ·
email.resubscribed · email.spamReported · testing.testEvent
These autocomplete in the on map; any other string still routes, so an
event type Loops adds later is handled without an SDK update.
Mind the casing: multi-word segments are camelCase on the wire —
email.softBounced, email.spamReported, contact.mailingList.subscribed.
Those are Loops’ own strings, kept verbatim because they must match the wire.
The event name is eventName, and there is no data
Loops’ body is flat. The event name lives in eventName rather than type —
the wrapper reads it from there, which is the one thing it does beyond the
generic provider — and the context objects sit at the top level instead of
under a data key:
payload.contactIdentity—{ id, email, userId }, on every event except the test event. Fetch the full contact through the API when you need more.payload.contact— the full contact including custom properties, oncontact.createdonly.payload.email—{ id, emailMessageId, subject }on every send and engagement event. The campaign, workflow, or transactional email it belongs to is incampaignId,loopId, ortransactionalIdbeside it; theemail.*events also carrysourceType.payload.mailingListon thecontact.mailingList.*events;payload.mailingLists(an array) on campaign and workflow sends addressed to lists.
Loops renamed loops to workflows in May 2026 but kept the payload strings for
compatibility: the send event is still loop.email.sent, the ids are
loopId and loopName, and a workflow email’s sourceType is "loop".
One action, several events
Loops fans some actions out into more than one event, each with its own
webhook-id, so idempotency won’t collapse them
— pick the one you act on:
- A hard bounce unsubscribes the contact, so
email.hardBouncedis followed bycontact.unsubscribed. - An unsubscribe link sends
email.unsubscribedplus eithercontact.unsubscribedorcontact.mailingList.unsubscribed, depending on whether the email went to a mailing list. - Deleting a contact sends
contact.deletedandcontact.unsubscribed. - Campaign and workflow sends fire once per recipient — a campaign to
1,000 contacts is 1,000
campaign.email.sentevents, delivered at most 10 per second.
email.opened, email.clicked, email.unsubscribed, and email.resubscribed
never fire for transactional emails, which Loops doesn’t track.
Rotating the secret
Rolling the secret in the dashboard keeps the previous one valid for 24 hours, and during that window Loops sends a signature for each secret in the header. Either secret verifies on its own, so swap the env var whenever it suits you — or pass both as an array and not think about it. Loops allows one webhook endpoint per account.
The envelope
event.id— thewebhook-idheader, the spec’s canonical idempotency key. The body has no id of its own.event.type— the body’seventName.event.timestamp— the signedwebhook-timestampheader. The body’seventTimeis also seconds since the epoch, but marks when the event occurred in Loops, which can be earlier than the delivery.event.payload— the flat body:{ eventName, eventTime, webhookSchemaVersion }plus the context objects above.webhookSchemaVersionis1.0.0for every event.
Standalone & testing
The wrapper is the Standard Webhooks
provider with id: 'loops' and
eventType: 'eventName', so the standalone triple lives there:
import {
verifyStandardWebhook, // (raw, { secret }) — throws on failure
parseStandardWebhook, // (raw, { eventType: 'eventName' }) — the envelope
signStandardWebhook, // (body, secret) — all three webhook-* headers, for tests
} from 'webhooks-sdk/standard-webhooks'
signStandardWebhook’s default webhook-* header names are exactly what
Loops sends. The Webhooks settings page can also send a real, signed
testing.testEvent to your endpoint — handle it or let it route as
unhandled. See Testing.