Klaviyo Marketing Module — Admin
HTTP surface for the Klaviyo marketing-automation integration — connect/disconnect the Klaviyo account, inspect each outbound sync surface (profiles, events, product catalog,…
HTTP surface for the Klaviyo marketing-automation integration — connect/disconnect the Klaviyo account, inspect each outbound sync surface (profiles, events, product catalog, categories, coupons), triage failed rows, and force resyncs/bootstraps. Outbound sync runs asynchronously via BullMQ; these endpoints are the operator's control panel over that pipeline. Inbound Klaviyo webhooks are documented separately (see ../webhooks/klaviyo.md).
Source:
api-modules/marketing-klaviyo/src/controllers/(admin-klaviyo*.controller.ts).Optional plugin. Registered via
MarketingKlaviyoModule.forRoot()inapps/api/src/app.module.ts. Removing that line disables the plugin entirely — listeners stop firing, BullMQ processors never register, these admin endpoints disappear from the OpenAPI spec, and the inbound webhook route 404s. No core table references this module's data. There are additional runtime kill switches (settings, see below) layered under the module-level switch.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching klaviyo:* permission. There are exactly two actions:
| Action | Gates |
|---|---|
klaviyo: view | every GET (status + all failed/list reads) |
klaviyo: manage | every mutating POST/DELETE (connect, disconnect, resync, bootstrap) |
HTTP status codes
Every mutating POST overrides the NestJS default 201 to 200 (@HttpCode(200)). DELETE returns 200.
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* on list endpoints */ }
}Paginated lists carry metadata: { total, limit, offset, hasMore } (the platform-standard ApiWrappedPaginatedResponse shape).
Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND (events resync, malformed id only) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Runtime kill switches (settings, klaviyo group)
Independent of the module-level on/off. Master sync_enabled plus per-surface profile_sync_enabled, event_sync_enabled, catalog_sync_enabled, coupon_sync_enabled, and webhook_ingest_enabled. The connection status endpoint reports the resolved per-surface state.
Async semantics
resync/:idendpoints are fire-and-forget enqueues — they return{ enqueued: true }once the row is in the outbox; a drain processor picks it up on the next tick. They do not wait for the push to Klaviyo.bootstrapendpoints enqueue every row of the relevant kind and return{ enqueued: <count> }.
Connection lifecycle
Base path: /admin/klaviyo.
GET /admin/klaviyo/status — Connection + configuration status
Required permission: klaviyo: view. Masked summary only — the raw API key is never returned.
Response 200
type KlaviyoConnectionStatusResponse = {
connected: boolean;
accountId: string | null;
connectedAt: string | null; // ISO
apiKeyMasked: string | null;
syncEnabled: boolean; // master sync switch
perSurfaceEnabled: {
profile: boolean;
event: boolean;
catalog: boolean;
coupon: boolean;
webhookIngest: boolean;
};
configuration: {
feed: "configured" | "missing";
missingKeys: string[];
revision: string; // Klaviyo API revision (e.g. "2024-10-15")
};
};POST /admin/klaviyo/connect — Connect (verify + persist API key)
Required permission: klaviyo: manage. Live-probes the key against Klaviyo's /accounts endpoint and persists it on success. The key is never echoed back.
Body
{ "apiKey": "pk_live_xxxxxxxxxxxxxxxxxxxx" }| Field | Type | Constraints |
|---|---|---|
apiKey | string | Trimmed, 20..200 chars (Klaviyo private API keys are ≥ 20 chars) |
Response 200
type KlaviyoConnectResponse = {
connected: true;
accountId: string;
connectedAt: string; // ISO
};Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Key fails zod (too short/long) |
| 401/4xx | upstream | Klaviyo rejects the key during the live /accounts probe |
DELETE /admin/klaviyo/connect — Disconnect
Required permission: klaviyo: manage. Drops the persisted credential row and busts the in-memory SDK session.
Response 200 — { "disconnected": true }.
Profile sync
Base path: /admin/klaviyo/profiles. Mirrors customers into Klaviyo profiles.
Shared item shape — KlaviyoProfileStateItem:
type KlaviyoProfileSyncStatus = "pending" | "synced" | "failed" | "skipped";
type KlaviyoSubscriptionStatus = "subscribed" | "unsubscribed" | "suppressed" | "never_subscribed";
type KlaviyoProfileStateItem = {
id: string;
customerId: string | null;
email: string | null;
klaviyoProfileId: string | null;
status: KlaviyoProfileSyncStatus;
subscriptionStatus: KlaviyoSubscriptionStatus;
lastError: string | null;
attempts: number;
lastSyncedAt: string | null; // ISO
updatedAt: string; // ISO
};GET /admin/klaviyo/profiles — All profile mirror rows
Required permission: klaviyo: view. Lists all profile rows (not just failed).
Query (shared by every list endpoint in this module)
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 50 | 1..200 |
Response 200 — paginated KlaviyoProfileStateItem[].
GET /admin/klaviyo/profiles/failed — Failed profile rows
Required permission: klaviyo: view. Same query/response, filtered to status = failed.
POST /admin/klaviyo/profiles/resync/:customerId — Re-push one customer
Required permission: klaviyo: manage. Enqueues an upsert for customerId. Response 200 — { "enqueued": true }.
POST /admin/klaviyo/profiles/bootstrap — Re-push every customer
Required permission: klaviyo: manage. Use after first connect. Response 200 — { "enqueued": <count> }.
Event sync
Base path: /admin/klaviyo/events. Streams ecommerce metrics (Placed Order, Started Checkout, Added to Cart, etc.) to Klaviyo via an outbox.
Item shape — KlaviyoEventOutboxItem:
type KlaviyoEventSyncStatus = "pending" | "sent" | "failed" | "dropped";
type KlaviyoEventOutboxItem = {
id: string;
metricName: string; // e.g. "Placed Order"
idempotencyKey: string;
customerId: string | null;
email: string | null;
status: KlaviyoEventSyncStatus;
occurredAt: string; // ISO
enqueuedAt: string; // ISO
processedAt: string | null; // ISO
attempts: number;
lastError: string | null;
valueCents: number | null; // integer subunits
valueCurrency: string | null;
};GET /admin/klaviyo/events/failed — Failed event rows
Required permission: klaviyo: view. Paginated (shared page/limit), newest first.
POST /admin/klaviyo/events/resync/:eventId — Requeue a failed event
Required permission: klaviyo: manage. Event payloads are immutable once enqueued, so this just resets the row to status=pending, attempts=0, and clears processedAt; the drain re-sends on the next tick.
Path params — eventId: outbox row id.
Response 200 — { "enqueued": true }.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Only when eventId is malformed (fails the ^[0-9a-f-]{8,}$ id format check). A well-formed id that isn't currently failed is accepted — operators sometimes re-deliver already-sent rows. |
Catalog sync
Base path: /admin/klaviyo/catalog. Pushes product variants to the Klaviyo catalog.
Item shape — KlaviyoCatalogStateItem:
type KlaviyoCatalogSyncStatus = "pending" | "submitted" | "synced" | "failed" | "deleted" | "skipped";
type KlaviyoCatalogStateItem = {
variantId: string;
klaviyoCatalogItemId: string | null;
status: KlaviyoCatalogSyncStatus;
lastError: string | null;
attempts: number;
lastPushedAt: string | null; // ISO
updatedAt: string; // ISO
};GET /admin/klaviyo/catalog/failed — Failed variant syncs
Required permission: klaviyo: view. Paginated (shared page/limit).
POST /admin/klaviyo/catalog/resync/:variantId — Re-push one variant
Required permission: klaviyo: manage. Response 200 — { "enqueued": true }.
POST /admin/klaviyo/catalog/bootstrap — Re-push every variant
Required permission: klaviyo: manage. Use after first connect or after re-mapping rules change. Response 200 — { "enqueued": <count> }.
Category sync
Base path: /admin/klaviyo/categories. Pushes categories to Klaviyo.
Item shape — KlaviyoCategoryStateItem:
type KlaviyoCategorySyncStatus = "pending" | "synced" | "failed" | "deleted" | "skipped";
type KlaviyoCategoryStateItem = {
categoryId: string;
klaviyoCategoryId: string | null;
status: KlaviyoCategorySyncStatus;
lastError: string | null;
attempts: number;
lastPushedAt: string | null; // ISO
updatedAt: string; // ISO
};GET /admin/klaviyo/categories/failed — Failed category syncs
Required permission: klaviyo: view. Paginated (shared page/limit), newest first.
POST /admin/klaviyo/categories/resync/:categoryId — Re-push one category
Required permission: klaviyo: manage. Response 200 — { "enqueued": true }.
POST /admin/klaviyo/categories/bootstrap — Re-push every category
Required permission: klaviyo: manage. Response 200 — { "enqueued": <count> }.
Coupon sync
Base path: /admin/klaviyo/coupons. Pushes discounts to Klaviyo as coupons. No bootstrap — operators typically have only tens of discounts, so per-discount resync suffices.
Item shape — KlaviyoCouponStateItem:
type KlaviyoCouponSyncStatus = "pending" | "synced" | "failed" | "deleted" | "skipped";
type KlaviyoCouponStateItem = {
discountId: string;
klaviyoCouponId: string | null;
klaviyoCouponCodeId: string | null;
lastPushedCode: string | null;
status: KlaviyoCouponSyncStatus;
lastError: string | null;
attempts: number;
lastPushedAt: string | null; // ISO
updatedAt: string; // ISO
};GET /admin/klaviyo/coupons/failed — Failed coupon syncs
Required permission: klaviyo: view. Paginated (shared page/limit), newest first.
POST /admin/klaviyo/coupons/resync/:discountId — Re-push one discount
Required permission: klaviyo: manage. Response 200 — { "enqueued": true }.
Related modules
admin-rbac— gates every endpoint viaklaviyo:*permissions (view/manage). Seeadmin-rbac.md.settings— theklaviyosettings group holds the API key, webhook secret, revision, and the master + per-surface kill switches.integration— the plugin registers a no-op token refresher (getFreshAccessToken("klaviyo")); Klaviyo private API keys never expire.customer/catalog/discount/order/cart— domain events from these modules drive the profile, catalog, category, coupon, and event sync surfaces.- Inbound webhooks — Klaviyo → us, mirroring consent state. See
../webhooks/klaviyo.md.
Google Merchant Center Module — Admin
HTTP surface for syncing eligible product variants to a Google Merchant Center account via the Merchant API v1 (the canonical successor to the deprecated Content API for…
Meta Catalog Module — Admin
HTTP surface for syncing eligible product variants to a Meta Commerce Catalog (Facebook + Instagram Shops) via the Catalog Batch API (POST /{catalog_id}/items_batch) and the…