Skip to content
Webhooks SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

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, on contact.created only.
  • payload.email — { id, emailMessageId, subject } on every send and engagement event. The campaign, workflow, or transactional email it belongs to is in campaignId, loopId, or transactionalId beside it; the email.* events also carry sourceType.
  • payload.mailingList on the contact.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.hardBounced is followed by contact.unsubscribed.
  • An unsubscribe link sends email.unsubscribed plus either contact.unsubscribed or contact.mailingList.unsubscribed, depending on whether the email went to a mailing list.
  • Deleting a contact sends contact.deleted and contact.unsubscribed.
  • Campaign and workflow sends fire once per recipient — a campaign to 1,000 contacts is 1,000 campaign.email.sent events, 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 — the webhook-id header, the spec’s canonical idempotency key. The body has no id of its own.
  • event.type — the body’s eventName.
  • event.timestamp — the signed webhook-timestamp header. The body’s eventTime is 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. webhookSchemaVersion is 1.0.0 for 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.

Last updated on September 1, 2026

Was this page helpful?