Klaviyo Marketing Module — Admin
HTTP surface for the Klaviyo marketing-automation integration — connect/disconnect the Klaviyo account, inspect each outbound sync surface, and find and merge duplicate profiles.
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, force resyncs/bootstraps, and find and merge duplicate Klaviyo profiles. 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, purge) |
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.
Public API key (settings, klaviyo.public_api_key)
Optional, and the only key in the group that leaves the admin surface: the Klaviyo public API key (site id) from Account → Settings → API keys. Server-side sync never reads it — it exists so the native apps and onsite JS can initialise the Klaviyo client SDK, which is why it is registered public and served anonymously from GET /store/settings/klaviyo. Edit it under Plugins → Klaviyo → Configuration. The private API key is set through the connect endpoint below and is never exposed on any public surface.
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> }.purgeendpoints queue a background job that deletes the surface's data inside Klaviyo and clears the local sync mirror, returning202with{ queued: true }. They report no counts — watch the surface's failed/state lists, or the worker log, for the outcome.
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.
Profiles are identified by email. Before each push — profile or event — the email is looked up in Klaviyo and the push targets the oldest profile holding it by id, so an account with duplicate profiles per email (see Duplicate profiles) keeps receiving data on the original rather than on whichever copy Klaviyo would pick. An email with no profile yet is sent without an id and Klaviyo creates one.
Name and phone come from the account; the default address — whoever the last order shipped to, often not the account holder — only fills them when the account has none. Phones are sent in E.164 (bare Indian numbers get +91; anything else unparseable is omitted), and absent values are omitted rather than sent as null, which would clear what the profile already holds. Location still comes from the default address.
Staff accounts (admin / superAdmin role) are left out of the account-wide bootstrap and duplicate scan, but not out of activity-driven sync: staff access is granted on existing customer accounts, so a staff member's own checkouts, orders and profile changes are sent like any customer's.
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 — Unsynced profile rows
Required permission: klaviyo: view. Same query/response, filtered to profiles not in Klaviyo: status = failed (Klaviyo rejected the push) or skipped (never pushed, e.g. no email could be resolved); lastError says which.
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 to Klaviyo via an outbox.
Every metric the legacy BeautyBarn integration sent keeps its name and property keys — its camelCase event data plus the PascalCase keys it added ($event_id, $value, OrderId, ItemNames, Items, …) — so flows and templates built against it keep working. Money properties are major units. Every metric also carries source (web / app).
| Metric | Sent when |
|---|---|
| Placed Order, plus one Ordered Product per line | Prepaid orders: when payment is captured (order.paid). COD orders: at placement (order.placed). |
| Started Checkout | Checkout is prepared (cart.checkout.prepared) |
| Cancelled Order | An order is cancelled — except a prepaid order cancelled before it was paid |
| Refunded Order | An order is fully refunded |
| Order Delivered, Order Completed | Every sub-order of the order is delivered |
| Customer Registered | An account is created |
| Paid Order, Partially Refunded Order, Fulfilled Order, Requested Return, Approved Return, Returned Order, Payment Failed, Cart Abandoned, Wishlist Added, Product Review Submitted | Their domain events; these have no legacy counterpart |
Viewed Product, Added to Cart, Applied Coupon, Removed Coupon, Removed from Cart, Increased / Decreased Cart Quantity, and profile identification are sent from the storefront through Klaviyo's onsite JS, which loads only when klaviyo.public_api_key is set.
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 — Undelivered event rows
Required permission: klaviyo: view. Paginated (shared page/limit), newest first. Lists every row that never reached Klaviyo: failed (Klaviyo rejected it) and dropped (not sent, e.g. no email could be resolved for the customer). POST /admin/klaviyo/events/resync/:eventId requeues either kind.
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 | No outbox row with that id. Any status is resyncable — operators re-deliver already-sent rows too — so existence is the only gate. |
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> }.
POST /admin/klaviyo/catalog/purge — Delete every Klaviyo catalog item
Required permission: klaviyo: manage. Response 202 — { "queued": true }.
Deletes every catalog item in the connected Klaviyo account — including items this store never created — and clears klaviyo_catalog_sync_state plus any unprocessed catalog outbox rows. Runs as a background job (klaviyo.catalog.purge); the response only confirms it was queued.
This exists for taking over a Klaviyo account that already holds another platform's catalog. Item ids are derived from our variant ids ($custom:::$default:::<variantId>), so they can never match the incumbent's — a bootstrap alone would add a complete second catalog rather than updating the first.
Clearing the local mirror is part of the same job, not an optional extra: with the mirror intact every variant is hash-identical to its last push, the drain answers noop, and a following bootstrap would re-push nothing and leave the account empty.
Full cutover sequence: purge the catalog, purge categories, bootstrap categories, bootstrap the catalog. Items carry relationships.categories, so they are deleted before the categories they point at and rebuilt after them — the mapper attaches category ids and Klaviyo rejects an item referencing a category that does not exist server-side. See POST /admin/klaviyo/categories/purge.
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> }.
POST /admin/klaviyo/categories/purge — Delete every Klaviyo category
Required permission: klaviyo: manage. Response 202 — { "queued": true }.
Category counterpart to POST /admin/klaviyo/catalog/purge — same contract, same reason for clearing the local mirror in the same job. Runs as klaviyo.category.purge.
Run this after the catalog purge and before the category bootstrap — catalog items reference categories, so they go first and come back last. The full sequence is on the catalog purge endpoint above.
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 }.
Duplicate profiles
Base path: /admin/klaviyo/duplicates. Finds customer emails the connected account holds more than one Klaviyo profile for — typically a profile the previous platform created (with its own external_id) plus a copy created later — and merges them. Until merged, a customer's server-side events and properties can sit on one copy while their history and onsite activity sit on another.
Shared item shape — KlaviyoDuplicateGroupItem (one per email):
type KlaviyoDuplicateStatus = "pending" | "queued" | "merged" | "failed";
type KlaviyoDuplicateGroupItem = {
id: string;
email: string;
customerId: string | null;
keepProfileId: string; // the oldest profile; merges fold into it
status: KlaviyoDuplicateStatus;
lastError: string | null;
scannedAt: string; // ISO
mergedAt: string | null; // ISO
profiles: Array<{ // oldest first
klaviyoProfileId: string;
externalId: string | null;
firstName: string | null;
lastName: string | null;
phoneNumber: string | null;
profileCreatedAt: string | null; // ISO
profileUpdatedAt: string | null; // ISO
}>;
};pending — found by a scan; queued — a merge was requested and the merge job has not reached it; merged — Klaviyo accepted the merge (or the duplicates were already gone); failed — Klaviyo rejected a merge, see lastError. Failed groups can be merged again.
GET /admin/klaviyo/duplicates — Duplicate groups
Required permission: klaviyo: view. Paginated with the shared offset query (limit / offset, searchValue matches the email), plus an optional status filter. Sorted by email. Response 200 — { data: KlaviyoDuplicateGroupItem[], metadata: { total, limit, offset, hasMore } }.
GET /admin/klaviyo/duplicates/summary — Last scan and counts
Required permission: klaviyo: view. Response 200 (also reports how many accounts the next scan would check):
{
uncheckedCustomers: number; // accounts no scan has checked yet
lastScan: {
id: string;
status: "running" | "completed" | "failed";
emailsScanned: number;
duplicatesFound: number;
lastError: string | null;
startedAt: string; // ISO
finishedAt: string | null; // ISO
} | null;
counts: { pending: number; queued: number; merged: number; failed: number };
}POST /admin/klaviyo/duplicates/scan — Start a scan
Required permission: klaviyo: manage. Response 202 — the new scan (same shape as lastScan). 409 while another scan is running (a scan still running after an hour is treated as dead and no longer blocks).
Runs as a background job (klaviyo.duplicates.scan). Scans are incremental: each account is checked once, and a scan covers only accounts no earlier scan has reached, so a rerun picks up new sign-ups and a scan that fails resumes where it stopped. Staff accounts are checked too (they shop like anyone else); guest placeholder accounts are not. Emails are looked up in Klaviyo 100 at a time, recording each one held by two or more profiles. Only our accounts' emails are checked: duplicates among profiles this store never syncs don't affect it. A scan never removes a group; a group whose duplicates were resolved elsewhere is settled as merged when it is merged, since the merge re-reads the profiles first.
POST /admin/klaviyo/duplicates/merge — Merge selected groups
Required permission: klaviyo: manage. Body { "ids": string[] } (1–500 group ids). Response 202 — { "queued": <count> }; only pending or failed groups are queued.
POST /admin/klaviyo/duplicates/merge-all — Merge every group
Required permission: klaviyo: manage. Queues every pending and failed group. Response 202 — { "queued": <count> }.
Both merge endpoints hand off to a background job (klaviyo.duplicates.merge). For each group it re-reads the email's profiles from Klaviyo, then merges every newer profile into the oldest through Klaviyo's Merge Profiles API. Klaviyo merges are irreversible: the newer profiles are deleted and their data moves to the oldest. After a merge the customer's profile mirror is pointed at the kept profile and a profile re-push is enqueued, so our properties land on it.
Related modules
admin-rbac— gates every endpoint viaklaviyo:*permissions (view/manage). Seeadmin-rbac.md.settings— theklaviyosettings group holds the public 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.
Invoice Module — Admin
HTTP surface for admin invoice access — async generation with SSE readiness notification, per-vendor GST tax invoices, lazily generated and cached; check status, enqueue, stream, download, or force regeneration for any order.
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…