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 fromapp.module, this route 404s. When the plugin is mounted but thewebhook_ingest_enabledsetting is off, the route returns 503 (so Klaviyo retries once re-enabled) rather than 404.
Authentication
| Property | Value |
|---|---|
| Scheme | HMAC-SHA256 over the raw request body, hex-encoded |
| Signature header | x-klaviyo-request-signature |
| Timestamp header | x-klaviyo-request-timestamp (optional; enables the timestamped scheme) |
| Secret source | klaviyo settings group — webhook_secret (via KlaviyoConfigService.getWebhookSecret()). Not an environment variable |
| Comparison | Constant-time (crypto.timingSafeEqual); length mismatch short-circuits to false |
Two signature schemes are accepted, tried in order:
- Raw —
HMAC-SHA256(secret, rawBody), hex. - 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
| Name | Required | Notes |
|---|---|---|
x-klaviyo-request-signature | yes | Hex HMAC-SHA256 of the raw body |
x-klaviyo-request-timestamp | no | Enables 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, elsemetadata.event_id, else top-levelid. - Topic from
data.attributes.event_name, elsedata.type, else top-leveltopic, else"unknown". - Profile id (for consent topics) from top-level
profile_id, elsedata.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:
| Condition | HTTP | Notes |
|---|---|---|
webhook_ingest_enabled setting is off | 503 | Klaviyo retries when re-enabled |
req.rawBody missing | 400 | Fastify raw-body parser misconfigured |
| Invalid / missing signature, or empty secret | 401 | Fail-closed |
| Body isn't valid JSON object | 200 skipped | Can't act; tells Klaviyo to stop retrying |
| No event id in payload | 200 skipped | Can't dedup |
| Duplicate event id (replay) | 200 skipped | Idempotent — side effects don't re-run |
profile.suppressed | 200 processed | Sets subscriptionStatus=suppressed, consentEmail=false on the matching profile |
profile.unsuppressed | 200 processed | Sets subscriptionStatus=subscribed, consentEmail=true |
| Consent topic but no resolvable profile id | 200 skipped | Nothing to update |
Any other topic (e.g. profile.created, list.profile_added) | 200 skipped | Recorded in inbox audit, markSkipped("unhandled topic"); no local writes |
| Handler/dispatch throws after verification | 200 skipped | markFailed(...) 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).
Related modules
- Klaviyo admin — connect/disconnect, sync triage, and the runtime kill switches (including
webhook_ingest_enabled). See../admin/klaviyo.md. settings— theklaviyogroup holdswebhook_secretandwebhook_ingest_enabled.customer— consent mirroring updates the local profile state that maps customers to Klaviyo profiles.
Vendor Module — Vendor-self-service surface
HTTP endpoints for a user registering to become a vendor via the step-wise onboarding wizard and inspecting their own application history. The single-submit registration endpoint creates a pending vendor application for admin review.
Webhook — PhonePe (Payments)
Public endpoint PhonePe calls to deliver payment and refund lifecycle events. Verified with SHA256(username:password), deduplicated on a composed event id, and never trusted for money.