Supercommerce API Docs
Webhooks

Webhook — Klaviyo (Marketing)

Public HTTP surface that Klaviyo's servers call to deliver webhook events. The handler verifies an HMAC signature over the raw body, dedups on Klaviyo's event id, and mirrors…

Public HTTP surface that Klaviyo's servers call to deliver webhook events. The handler verifies an HMAC signature over the raw body, dedups on Klaviyo's event id, and mirrors marketing-consent state (suppress/unsuppress) back into the local profile mirror. Every delivery is recorded in an inbox audit table; topics this module doesn't act on are acknowledged without local side-effects (Klaviyo remains the source of truth). The admin-side control panel for the same plugin is documented in ../admin/klaviyo.md.

Source: api-modules/marketing-klaviyo/src/controllers/marketing-klaviyo-webhook.controller.ts, internal/webhook-signature.ts, services/klaviyo-webhook.service.ts.

Optional plugin. When MarketingKlaviyoModule.forRoot() is removed from app.module, this route 404s. When the plugin is mounted but the webhook_ingest_enabled setting is off, the route returns 503 (so Klaviyo retries once re-enabled) rather than 404.


Authentication

PropertyValue
SchemeHMAC-SHA256 over the raw request body, hex-encoded
Signature headerx-klaviyo-request-signature
Timestamp headerx-klaviyo-request-timestamp (optional; enables the timestamped scheme)
Secret sourceklaviyo settings group — webhook_secret (via KlaviyoConfigService.getWebhookSecret()). Not an environment variable
ComparisonConstant-time (crypto.timingSafeEqual); length mismatch short-circuits to false

Two signature schemes are accepted, tried in order:

  1. RawHMAC-SHA256(secret, rawBody), hex.
  2. Timestamped (only attempted if the raw scheme fails and a timestamp header is present) — HMAC-SHA256(secret, "<timestamp>." + rawBody), hex. The timestamp is then range-checked against a 5-minute replay window (KLAVIYO_REPLAY_WINDOW_MS); both numeric (10-digit unix seconds / 13-digit ms) and ISO-8601 timestamps are accepted, and a delta over 5 minutes (or an unparseable value) rejects.

Verification runs against req.rawBody (captured by the global Fastify content-type parser). Fail-closed: a missing/empty secret or a missing signature header both make verification fail → 401.


Endpoints

POST /webhooks/marketing/klaviyo — Receive a Klaviyo webhook delivery

Public, HMAC-verified. Currently acts on profile.suppressed / profile.unsuppressed to mirror marketing-consent state into klaviyo_profile_state. Other topics are recorded in the inbox audit table and acknowledged without local side-effects.

Headers

NameRequiredNotes
x-klaviyo-request-signatureyesHex HMAC-SHA256 of the raw body
x-klaviyo-request-timestampnoEnables the timestamped signature scheme + replay window

Body — raw JSON, parsed leniently (not a strict DTO). The handler reads:

  • Event id (dedup key) from data.id, else metadata.event_id, else top-level id.
  • Topic from data.attributes.event_name, else data.type, else top-level topic, else "unknown".
  • Profile id (for consent topics) from top-level profile_id, else data.attributes.profile.data.id.
{
  "data": {
    "id": "01HXXXXEVENTID",
    "type": "event",
    "attributes": {
      "event_name": "profile.suppressed",
      "profile": { "data": { "id": "01HXXXXPROFILEID" } }
    }
  }
}

Response — always 200 on a verified, well-formed request (the body is a KlaviyoWebhookAck):

type KlaviyoWebhookAck = {
  accepted: true;
  topic: string;
  status: "processed" | "skipped";
};

Behavior & status mapping

Evaluated in this order:

ConditionHTTPNotes
webhook_ingest_enabled setting is off503Klaviyo retries when re-enabled
req.rawBody missing400Fastify raw-body parser misconfigured
Invalid / missing signature, or empty secret401Fail-closed
Body isn't valid JSON object200 skippedCan't act; tells Klaviyo to stop retrying
No event id in payload200 skippedCan't dedup
Duplicate event id (replay)200 skippedIdempotent — side effects don't re-run
profile.suppressed200 processedSets subscriptionStatus=suppressed, consentEmail=false on the matching profile
profile.unsuppressed200 processedSets subscriptionStatus=subscribed, consentEmail=true
Consent topic but no resolvable profile id200 skippedNothing to update
Any other topic (e.g. profile.created, list.profile_added)200 skippedRecorded in inbox audit, markSkipped("unhandled topic"); no local writes
Handler/dispatch throws after verification200 skippedmarkFailed(...) logged; still acks 200

Dedup / inbox

The Klaviyo event id is the primary key of klaviyo_webhook_inbox. tryInsert is a conditional insert; a duplicate id is logged as a replay and skipped without re-running side effects. Inbox row status enum: received | processed | failed | skipped (the HTTP ack only ever surfaces processed / skipped).


  • Klaviyo admin — connect/disconnect, sync triage, and the runtime kill switches (including webhook_ingest_enabled). See ../admin/klaviyo.md.
  • settings — the klaviyo group holds webhook_secret and webhook_ingest_enabled.
  • customer — consent mirroring updates the local profile state that maps customers to Klaviyo profiles.

On this page