Supercommerce API Docs
Admin API

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() in apps/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:

ActionGates
klaviyo: viewevery GET (status + all failed/list reads)
klaviyo: manageevery 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

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND (events resync, malformed id only)
500INTERNAL_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/:id endpoints 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.
  • bootstrap endpoints enqueue every row of the relevant kind and return { enqueued: <count> }.
  • purge endpoints queue a background job that deletes the surface's data inside Klaviyo and clears the local sync mirror, returning 202 with { 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" }
FieldTypeConstraints
apiKeystringTrimmed, 20..200 chars (Klaviyo private API keys are ≥ 20 chars)

Response 200

type KlaviyoConnectResponse = {
  connected: true;
  accountId: string;
  connectedAt: string;                 // ISO
};

Errors

StatusCodeWhen
400VALIDATION_ERRORKey fails zod (too short/long)
401/4xxupstreamKlaviyo 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)

NameTypeDefaultConstraints
pageint1>= 1
limitint501..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 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).

MetricSent when
Placed Order, plus one Ordered Product per linePrepaid orders: when payment is captured (order.paid). COD orders: at placement (order.placed).
Started CheckoutCheckout is prepared (cart.checkout.prepared)
Cancelled OrderAn order is cancelled — except a prepaid order cancelled before it was paid
Refunded OrderAn order is fully refunded
Order Delivered, Order CompletedEvery sub-order of the order is delivered
Customer RegisteredAn account is created
Paid Order, Partially Refunded Order, Fulfilled Order, Requested Return, Approved Return, Returned Order, Payment Failed, Cart Abandoned, Wishlist Added, Product Review SubmittedTheir 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 — 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 paramseventId: outbox row id.

Response 200{ "enqueued": true }.

Errors

StatusCodeWhen
404NOT_FOUNDNo 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 }.


  • admin-rbac — gates every endpoint via klaviyo:* permissions (view / manage). See admin-rbac.md.
  • settings — the klaviyo settings 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.

On this page