API Reference

Complete REST API reference. Manage contacts, campaigns, flows, and more programmatically.

https://cdp-api.sidcorp.co/api/v1Get API Key

Overview

Looking for a specific endpoint?

Every endpoint, with its parameters, schemas and a panel that sends real requests, is in the API Reference. This page is the guides: how the pieces fit together, and the things that bite.

Authentication

All API requests require an API key passed in the X-API-Key header. Create keys in Settings → API Keys.

curl https://cdp-api.sidcorp.co/api/v1/contacts \
  -H "X-API-Key: emk_your_api_key_here"

This X-API-Key is for the management REST API. To create & identify contacts from your website and track events, use the Contact Sync (Collect) API — authenticated by your site_id, also see the JavaScript SDK.

Response Format

Success responses return JSON with a data key. Errors return an error string.

Success (200)

{
  "data": [ ... ]
}

Error (4xx)

{
  "error": "invalid request"
}

Pagination

List endpoints accept page and perPage query parameters.

curl "https://cdp-api.sidcorp.co/api/v1/contacts?page=2&perPage=25" \
  -H "X-API-Key: emk_your_key"

Rate Limits

Public endpoints are rate limited to keep the platform healthy. Limits are counted over a rolling one-minute window. When a limit is exceeded the endpoint responds with 429 Too Many Requests and the body below — wait out the current minute and retry with exponential backoff.

ParameterTypeRequiredDescription
E-commerce webhook/ecom/{platform}/{integrationId}Required300 req/min per integration + 1,200 req/min per source IP (defaults; configurable per deployment).
Contact Sync / Collect/collect/*Required1,000 req/min per IP · 5,000 req/min per site_id · 200 req/min per IP + site_id.
Auth/api/v1/auth/*Required10 req/min per IP (register, login, refresh).
Consent pages/cp/*Required30 req/min per IP.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json

{
  "error": "rate limit exceeded"
}

The management REST API (X-API-Key) has no fixed per-minute limit today, but build your integration to handle 429 responses gracefully in case limits are introduced. The “per integration” and “per site_id” dimensions mean each store/site has its own independent budget — one busy store cannot exhaust another's.

Contacts

Idempotency & retries

Contacts dedupe by email, so contact writes are safe to retry — re-sending updates rather than duplicates. Always retry on 429 (rate limit) and 503 (server draining for a deploy), honoring the Retry-After header with exponential backoff. Note: subscription_status of an existing contact is not changed by reconcile — manage consent via the update endpoints.

Contact Sync

There are two ways to sync contacts. The recommended path for websites is the Collect API below — the same endpoints the JavaScript SDK (emk.js) calls. Identify a visitor by email (the contact is created if it's new) and track events. Authenticated by your site_id — no API key — and callable from the browser or your server. The second is a per-integration backend webhook (see the note at the end of this section).

POST/collect/identify

Create or identify a contact by email. Subsequent events are linked to this contact.

ParameterTypeRequiredDescription
site_idstringRequiredYour workspace / site ID
emailstringRequiredContact email — the contact is created if it doesn't exist
propertiesobjectOptionalfirst_name, last_name, phone, … (set on create or when empty)
anonymous_idstringOptionalAnonymous visitor id to merge into this contact
curl -X POST https://cdp-api.sidcorp.co/collect/identify \
  -H "Content-Type: application/json" \
  -d '{
    "site_id": "YOUR_SITE_ID",
    "email": "[email protected]",
    "properties": { "first_name": "Jane", "last_name": "Doe" }
  }'

Response

{
  "contact_id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "[email protected]"
}
POST/collect/event

Track an event for a contact. Use a standard event name (see the JavaScript SDK) or any custom name, e.g. signed_up.

ParameterTypeRequiredDescription
site_idstringRequiredYour workspace / site ID
event_namestringRequiredStandard (viewed_product, …) or custom (signed_up)
emailstringOptionalContact email to attach the event to (or use contact_id / anonymous_id)
contact_idstringOptionalContact ID to attach the event to
propertiesobjectOptionalEvent properties as JSON
curl -X POST https://cdp-api.sidcorp.co/collect/event \
  -H "Content-Type: application/json" \
  -d '{
    "site_id": "YOUR_SITE_ID",
    "email": "[email protected]",
    "event_name": "signed_up"
  }'

Response

{ "success": true }

Backend push alternative (webhook)

E-commerce / backend integrations can also push contacts via a per-integration webhook — POST /ecom/website/{integrationId} with Authorization: Bearer <integration key> and a body like { "event_type": "customer_created", "external_id": "123", "email": "…" }. Send external_id to stay idempotent. Find the URL & key on the integration's Contacts → Sync contacts tab.

Events

POST/api/v1/events

Track a custom event for a contact. Use a standard event name (see the JavaScript SDK's Standard Events, e.g. viewed_product) or any custom name.

ParameterTypeRequiredDescription
contact_idstringRequiredContact ID the event belongs to
event_namestringRequiredEvent name — a standard name (viewed_product, added_to_cart, …) or your own
propertiesobjectOptionalEvent properties as JSON
sourcestringOptionalOrigin of the event (e.g. website, shopify)
curl -X POST https://cdp-api.sidcorp.co/api/v1/events \
  -H "X-API-Key: emk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_id": "CONTACT_ID",
    "event_name": "viewed_product",
    "properties": { "product_id": "SKU-123", "price": 29.99 }
  }'

Response

{
  "id": "9f1c2b7e-...",
  "contact_id": "CONTACT_ID",
  "event_name": "viewed_product",
  "properties": { "product_id": "SKU-123", "price": 29.99 },
  "source": "website",
  "created_at": "2026-03-29T10:00:00Z"
}
POST/api/v1/events/batch

Track up to 1000 custom events in one call, keyed to contacts by email (built for syncing events from an external system). Partial success: rows with an unknown email or a missing field are returned in the errors array without failing the rest. The contact must already exist — reconcile contacts first; this endpoint does NOT create contacts. Each written event fires its flow trigger and re-evaluates the contact's segment membership, exactly like the single-event endpoint.

ParameterTypeRequiredDescription
eventsarrayRequiredArray of event objects (max 1000)
events[].emailstringRequiredEmail of the contact the event belongs to (case-insensitive)
events[].event_namestringRequiredEvent name — standard (viewed_product, …) or your own
events[].propertiesobjectOptionalEvent properties as JSON
events[].sourcestringOptionalOrigin of the event (e.g. crm, website)
events[].timestampstringOptionalRFC3339 time the event occurred — for backfilling history; defaults to now
curl -X POST https://cdp-api.sidcorp.co/api/v1/events/batch \
  -H "X-API-Key: emk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      { "email": "[email protected]", "event_name": "viewed_product",
        "properties": { "product_id": "SKU-123", "price": 29.99 }, "source": "website" },
      { "email": "[email protected]", "event_name": "signed_up",
        "timestamp": "2026-03-29T10:00:00Z" }
    ]
  }'

Response

{
  "processed": 1,
  "skipped": 1,
  "errors": [
    { "index": 1, "email": "[email protected]", "error": "contact not found (reconcile the contact first)" }
  ]
}
PUT/api/v1/events/batch

Correct events you already synced (up to 1000), matched by email + event_name + dedupe_key. Use it to fix a wrong event time (e.g. events sent without a timestamp were stamped at ingest instead of when they actually happened) or a missing source. Each of timestamp / source / properties is applied only when provided; timestamp updates the event's canonical time. Does NOT re-fire flow triggers (a correction won't re-enroll contacts). Set upsert=true to insert rows that have no existing match instead of reporting them as errors.

ParameterTypeRequiredDescription
eventsarrayRequiredArray of event corrections (max 1000)
events[].emailstringRequiredEmail of the contact the event belongs to
events[].event_namestringRequiredEvent name (must match the stored event)
events[].dedupe_keystringRequiredMatches the event's properties.dedupe_key — identifies which event to update
events[].timestampstringOptionalRFC3339 corrected event time → sets created_at
events[].sourcestringOptionalCorrected source
events[].propertiesobjectOptionalReplacement properties (omit to keep existing)
upsertbooleanOptionalInsert a row when no matching dedupe_key is found (default false)
curl -X PUT https://cdp-api.sidcorp.co/api/v1/events/batch \
  -H "X-API-Key: emk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "upsert": true,
    "events": [
      { "email": "[email protected]", "event_name": "subscription_created",
        "dedupe_key": "sub_123", "timestamp": "2026-04-03T13:54:13Z", "source": "myapp" }
    ]
  }'

Response

{
  "updated": 1,
  "inserted": 0,
  "skipped": 0,
  "errors": []
}
GET/api/v1/events

List custom events.

curl "https://cdp-api.sidcorp.co/api/v1/events?contact_id=CONTACT_ID&page=1" \
  -H "X-API-Key: emk_your_key"

Shipment & order tracking

Push shipment and order-status updates from your fulfillment / carrier service so each order's Shipping Status (shown per-order in a contact's Orders tab) advances automatically and the shipping_information / order_updated flow triggers fire.

Two ways to send a shipment update

  1. Dedicated endpoint POST /api/v1/events/shipment (below) — for a full carrier payload; the order is matched by order.origin_order_id and the status comes from shipment_status.slug.
  2. Generic events API — a plain POST /api/v1/events (or /events/batch) with event_name: "shipping_information" plus properties.order_number and properties.tracking_status. This also advances the order's Shipping Status and fires the registered shipping_information trigger — not the generic custom-event trigger. The order is matched by order_number / origin_order_id, zero-pad-insensitive (6775 matches 000006775):
    { "contact_id": "CONTACT_ID", "event_name": "shipping_information",
      "source": "your_store",
      "properties": { "order_number": "000006775", "tracking_status": "In Transit" } }

tracking_status is format-insensitive. The human label and the canonical value are interchangeable — "Info Received", "InfoReceived" and "info_received" all match a flow configured for info_received (same for in_transit, delivered, …).

Gotcha — the flow's Source filter. A shipping_information flow only enrolls when the event's source equals the flow's Source filter (exact match). If you tag shipment events with a source that is not your storefront integration (e.g. a generic "hub"), either send the source that matches the flow or set the flow's trigger Source to All sources — otherwise it never fires, even though the order's Shipping Status still updates. The MCP tool update_order_shipping_status does the same as (1) for a single order.

POST/api/v1/events/shipment

Report a shipment status change. Advances the matched order's Shipping Status and fires the shipping_information flow trigger. shipment_status.slug is normalised to a canonical value: Pending→pending, InfoReceived→info_received, InTransit→in_transit, OutForDelivery→out_for_delivery, Delivered→delivered, Exception→exception, AttemptFail/FailedAttempt→failed_attempt, Expired→expired.

ParameterTypeRequiredDescription
tracking_numberstringOptionalCarrier tracking number
shipment_status.slugstringRequiredInfoReceived | InTransit | OutForDelivery | Delivered | Exception | FailedAttempt | Expired | Pending
order.origin_order_idstringRequiredYour merchant order id / number — used to match and update the order's Shipping Status
order.site.platform.slugstringOptionalPlatform slug (e.g. magento2, shopify)
customer.emailstringOptionalCustomer email — resolves/creates the contact for the flow trigger
carrier.namestringOptionalCarrier display name (e.g. FedEx)
curl -X POST https://cdp-api.sidcorp.co/api/v1/events/shipment \
  -H "X-API-Key: emk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "tracking_number": "1Z999AA10123456784",
    "shipment_status": { "slug": "InTransit" },
    "carrier": { "name": "FedEx" },
    "order": {
      "origin_order_id": "10456",
      "site": { "platform": { "slug": "magento2" } }
    },
    "customer": { "email": "[email protected]" }
  }'

Response

{
  "status": "processed",
  "event_id": "shipment_123_in_transit",
  "event_type": "shipping_information",
  "has_contact": true
}
POST/api/v1/events/order-update

Report an order-status change. Fires the order_updated flow trigger and, when the status is unambiguous (delivered/complete → Delivered, cancelled → Canceled), updates the order's Shipping Status.

ParameterTypeRequiredDescription
origin_order_idstringRequiredYour merchant order id / number
order_status.slugstringRequiredOrder status slug (e.g. delivered, cancelled, processing)
order_status.namestringOptionalHuman label for the status
customer.emailstringOptionalCustomer email — resolves the contact for the flow trigger
curl -X POST https://cdp-api.sidcorp.co/api/v1/events/order-update \
  -H "X-API-Key: emk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "origin_order_id": "10456",
    "order_status": { "slug": "delivered", "name": "Delivered" },
    "customer": { "email": "[email protected]" }
  }'

E-commerce

Orders are what every revenue number reads from: a contact's lifetime value, the revenue column on a campaign, the return on a flow. There are two ways to send them in, and they are not equivalent — only the website webhook credits revenue back to the campaign or flow that earned it. Use it unless you already hold our contact IDs.

 Website webhook (recommended)Create order (REST)
EndpointPOST /ecom/website/{integrationId}POST /api/v1/ecommerce/orders
Identifies the customer byemail — a new address creates the contact for youcontact_id — our UUID, which you have to look up first
Safe to send twiceYes — a repeat of the same external_id is ignoredNo — every call inserts another order and adds its amount again
Campaign / flow revenueYes — the order is attributed on arrivalNo — the order is stored with no campaign, so those reports stay at $0
Contact lifetime revenueYesYes
Starts order_placed flowsYesYes
AuthThe integration's own keyYour workspace API key

Website integration webhook

Push orders and carts from your own site through the same pipe the Shopify and WooCommerce integrations use, so revenue reporting, abandoned-cart flows and attribution behave identically. Three steps to get running:

  1. Create a Website integration in Settings → Integrations, then copy its integrationId and api_key.
  2. POST an order_placed event from your backend whenever an order is paid — from the server, not the browser, so the key stays private.
  3. Confirm it landed: the order shows on the contact's timeline, and revenue appears under Analytics → Revenue.
POST/ecom/website/{integrationId}

Send one e-commerce event from your website. Authenticated by the integration's own API key — your workspace API key is not used here. This endpoint never rejects a payload over its shape, so Required below means the event will not do what you want without the field, not that you get a 400.

ParameterTypeRequiredDescription
AuthorizationheaderRequiredBearer <integration api_key> — the key shown on the Website integration, NOT your emk_ workspace key.
event_typestringRequiredorder_placed | fulfilled_order | cancelled_order | refunded_order | added_to_cart | started_checkout | customer_created | customer_updated | product_created | product_updated | product_deleted
emailstringRequiredThe customer. An address we have not seen creates a new contact (see below). Not needed for product_* events, which describe the store rather than a person.
external_idstring | numberOptionalRecommended. Unique id for THIS event — the retry key. "id" works too. Order and cart events fall back to order.external_id / cart.external_id. Without any id the event is still accepted, but a retry is applied twice. See "Give every event its own id".
orderobjectOptionalRequired for order_placed, fulfilled_order, cancelled_order and refunded_order. Omit it and we store nothing while still answering 200.
order.external_idstring | numberRequiredYour own order id. Without it no order row is written at all — this is the field to check first when revenue does not show up.
order.total_amountnumberRequiredOrder total in order.currency. This is the number revenue reports sum.
order.currencystringRequiredISO code, e.g. USD. Omitting it stores an empty currency rather than a default, which leaves revenue totals ambiguous.
order.statusstringOptionalYour own status string, e.g. paid. Stored as sent; empty if omitted.
order.ordered_atstringOptionalRFC 3339 (2026-01-01T10:00:00Z) or "YYYY-MM-DD HH:MM:SS". Defaults to now — send the real time when backfilling, since attribution windows are measured from it.
order.order_numberstringOptionalHuman-facing number shown in emails via {{ order.id }}.
order.itemsarrayOptionalLine items: external_id, title, quantity, price. items[].external_id must be the same product id you send to the catalog.
cartobjectOptionalRequired for added_to_cart and started_checkout: external_id, total_amount, currency, checkout_url, items.
identity_tokenstringOptionalCross-device attribution — forward the _ir_id cookie. Also accepted as _ir_id or _emk.
propertiesobjectOptionalOn customer_created / customer_updated only: arbitrary fields merged into the contact's properties, e.g. {"plan":"PRO"}. This is how you sync non-built-in attributes.
# Order placed — records revenue and attributes it
curl -X POST https://cdp-api.sidcorp.co/ecom/website/INTEGRATION_ID \
  -H "Authorization: Bearer <integration api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "order_placed",
    "email": "[email protected]",
    "external_id": "ORD-1234:placed",
    "identity_token": "<value of the _ir_id cookie, if you have one>",
    "order": {
      "external_id": "ORD-1234",
      "order_number": "#1234",
      "total_amount": 99.99,
      "currency": "USD",
      "status": "paid",
      "ordered_at": "2026-01-01T10:00:00Z",
      "items": [
        {
          "external_id": "prod_001",
          "title": "T-Shirt",
          "quantity": 1,
          "price": 99.99
        }
      ]
    }
  }'

# Cart event — feeds abandoned-cart flows
curl -X POST https://cdp-api.sidcorp.co/ecom/website/INTEGRATION_ID \
  -H "Authorization: Bearer <integration api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "added_to_cart",
    "email": "[email protected]",
    "cart": {
      "external_id": "cart_abc",
      "total_amount": 49.99,
      "currency": "USD",
      "checkout_url": "https://yoursite.com/checkout/cart_abc",
      "items": [
        { "external_id": "prod_001", "title": "Hoodie", "quantity": 1, "price": 49.99 }
      ]
    }
  }'

Response

// Accepted and applied
{ "status": "processed", "event_type": "order_placed" }

// This event_type + external_id was already seen — nothing was applied a second time
{ "status": "duplicate" }

// event_type is missing, or contains a dot — use signed_up, not user.signed_up
{ "status": "skipped", "event_type": "order.created" }

// Body could not be parsed as JSON
{ "status": "skipped", "reason": "normalize_error" }

// 401 wrong or missing key   404 unknown / inactive integration   429 rate limited
{ "error": "invalid api key" }

A 200 does not mean the order was saved

Every accepted request answers 200 with a status, and only processed means we applied it. Two ways to get processed while nothing is stored: no order object on an order event, or an order.external_id that is empty. Both are silent by design — the webhook never fails a delivery over payload shape. Read the status field, and when revenue is missing check order.external_id before anything else.

Give every event its own id (recommended)

Duplicate protection is keyed on (workspace, event_type, external_id), so a retry of the same event is ignored while order_placed and a later fulfilled_order for one order are both applied. Order and cart events may leave out the top-level external_id and we use order.external_id / cart.external_id.

The id is optional. An event sent without one is still accepted and processed — but as a new event every time, so if your sender retries, the retry is applied twice. If you retry, send an external_id (or id) that is unique per event; a timestamp or a hash of the payload works well.

Your own event names

Any event_type without a dot is accepted, e.g. subscription_renewed. It is stored on the contact's timeline with the whole body as its properties (put the fields you want to filter on at the top level), can be used in segments, and starts any flow triggered by that event. For behaviour events only, POST /api/v1/events/batch does the same in bulk.

An unknown email creates a subscribed contact

If email does not match an existing contact we create one, with subscription_status: subscribed and a source of website:<your domain>, so the order is never orphaned. That contact is marketable immediately — only send addresses you have a lawful basis to email, and suppress the rest through POST /api/v1/suppressions.

The flip side: an order event we cannot tie to a contact is not stored at all. Send email (or an identity_token) on every order event — a guest checkout with neither is answered 200 processed and lost. The same happens when your workspace has hit its contact limit and the new contact cannot be created, so watch that ceiling if orders stop appearing. There are no contact-less orders in this system.

Rate limits

Up to 300 requests per minute per integration (its own independent budget), plus a looser 1,200 req/min ceiling per source IP shared by integrations sending from the same host. Exceeding either returns 429 Too Many Requests. Queue and retry with backoff so a flash sale is not dropped — retries are safe as long as each event keeps the same external_id. Both defaults are configurable per deployment.

How revenue gets attributed

An order is credited to a campaign or flow when the contact clicked or opened its email before the order was placed. Hierarchical last-touch: a click inside the click window (default 5 days) always wins; otherwise the most recent open inside the open window (default 3 days). Both windows are editable per workspace under Settings → Analytics. Cancelled orders are reversed and partial refunds subtracted, so reports show net revenue.

Attribution runs once, when the order first arrives, and uses order.ordered_at — not the time of your request. An order that arrives before the engagement it should be credited to will not be re-attributed later.

Cross-device identity (cookie forwarding)

Every link in a sent email carries a unique _ir_id parameter, and our tracking snippet (emk.js) stores it in a 14-day cookie when the visitor lands on your site. Read that cookie at checkout and forward it as identity_token — the order is then credited to the contact who clicked, even if they checked out with a different email.

If the checkout email belongs to a different existing customer who has already ordered, that customer wins: we never move one paying customer's order onto another.

Backfilling and gap recovery

To recover orders a webhook missed (network blip, downtime), re-push the day's orders to POST /api/v1/ecommerce/sync-historical-orders from a nightly cron (workspace API-key auth). It upserts by external_id, so re-running never double-counts, and it attributes from each order's real ordered_at rather than the time you ran it.

Create an order directly

For a system that already stores our contact IDs — a migration script, or your own admin tooling. Read the caveat first: this is a plain insert, not the ingest pipeline above.

What this endpoint does not do

It has no duplicate protection — sending the same order twice creates two rows and counts the revenue twice, even with an identical external_id — and it does not attribute revenue, so the order lands with no campaign or flow attached and stays invisible to campaign and flow revenue reports. It does update the contact's lifetime revenue and does start order_placed flows. If either gap matters to you, use the website webhook instead.

POST/api/v1/ecommerce/orders

Insert one order against a known contact ID. Every parameter below marked Required must be present and non-empty — a missing field, or total_amount: 0, is rejected with 400.

ParameterTypeRequiredDescription
contact_idstringRequiredOur contact UUID. Look it up with GET /api/v1/contacts?email=… — this endpoint does not accept an email address.
platformstringRequiredFree-form label for where the order came from, e.g. website.
order_numberstringRequiredHuman-facing order number.
total_amountnumberRequiredOrder total. Must be greater than zero — a 0 is treated as a missing field.
currencystringRequiredISO code, e.g. USD.
statusstringRequiredYour own status string, e.g. paid.
ordered_atstringRequiredRFC 3339 timestamp.
external_idstringOptionalYour order id. Stored for reference — it is NOT a duplicate key on this endpoint.
itemsarrayOptionalLine items: external_id, title, quantity, price.
curl -X POST https://cdp-api.sidcorp.co/api/v1/ecommerce/orders \
  -H "X-API-Key: emk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_id": "CONTACT_UUID",
    "platform": "website",
    "order_number": "#1234",
    "external_id": "ORD-1234",
    "total_amount": 99.99,
    "currency": "USD",
    "status": "paid",
    "ordered_at": "2026-01-01T10:00:00Z",
    "items": [
      { "external_id": "prod_001", "title": "T-Shirt", "quantity": 1, "price": 99.99 }
    ]
  }'

Response

// 201 Created (trimmed)
{
  "id": "9f1c…",
  "contact_id": "CONTACT_UUID",
  "order_number": "#1234",
  "total_amount": 99.99,
  "currency": "USD",
  "status": "paid",
  "attributed_campaign_id": null,
  "attributed_flow_id": null,
  "ordered_at": "2026-01-01T10:00:00Z"
}
GET/api/v1/ecommerce/products

List products.

curl https://cdp-api.sidcorp.co/api/v1/ecommerce/products -H "X-API-Key: emk_your_key"

Product Catalog

Product blocks in emails, product feeds and every product recommendation read from one catalog. This is the contract an integration writes into — the same field names on the wire and in the API response, whichever way you send them.

One product id, everywhere

Whatever you send as external_id here must be the same string you send as properties.product_id on view and cart events, and as items[].external_id on order and cart lines. That single value is what joins customer behaviour to the catalog — SKU and title are never used as the join key. Mismatched ids raise no error anywhere: recommendations simply return nothing, forever. Get this right before sending anything else.

Product fields

FieldTypeSend it?What it is for
external_idstringRequiredThe product's id in YOUR store, and the join key for everything. View events, cart items and order items are matched to the catalog by external_id — never by SKU or title. Unique per store.
titlestringRequiredDisplay name in product blocks.
pricenumberRequiredUnit price in the currency below, after any sale price. Powers price filters and the rendered price. A later sync reporting 0 will not overwrite a known price.
currencystring (ISO 4217)RecommendedCurrency of price and compare_at_price. Defaults to USD — which is the wrong money in an email if your store is not USD.
urlstringRecommendedProduct page link. Without it a product card renders with nothing to click.
image_urlstringRecommendedMain image, absolute and publicly reachable — email clients fetch it from the recipient's device, so a URL needing your session or a private CDN renders broken.
in_stockbooleanRecommendedWhether the product can be BOUGHT. Must reflect inventory, not whether it is published or visible: sending a publish flag here makes the whole catalog read in stock and the "in stock only" feed filter do nothing.
stock_qtyinteger or nullOptionalUnits available. Leave it out (null) when your platform does not report inventory — null means unknown and stock-level filters exclude the product, whereas sending 0 marks it sold out. Sum across variants, and stay null when tracking is off.
categoriesstring[]RecommendedCategory / collection names. Powers the feed's category filters (any-of and all-of) and the top-up half of the may-also-like ranking. Shown to merchants exactly as sent.
compare_at_pricenumber or nullOptionalOriginal price when on sale; rendered as the struck-through "was" price.
descriptionstringOptionalLong description, available to product blocks as {{ item.description }}.
product_typestringOptionalYour platform's own type/kind for the product. Independent of categories.
weightnumber or nullOptionalShipping weight in your own unit. Stored, not interpreted.
variantsjsonOptionalThe product's variants: external_id (the platform's variant id, never the product's), sku, title, options (e.g. size: XL), price, compare_at_price, in_stock, stock_qty, image_url, url, position. When present they become the variant rows and set the parent's price and stock; omit the key to leave stored variants alone, send [] to clear them.
metadataobject<string,string>OptionalAny extra attributes (manufacturer, colour, short_description…). This is the place for fields the contract has no column for.
platformstringSet by BozuroTaken from the integration the write arrived on (shopify | woocommerce | bigcommerce | magento2 | website | manual), and part of the row's identity — the same external_id on two platforms is two products.
sourcestringSet by Bozuro"platform:shop_domain" of the integration that wrote the row, so a multi-store workspace can filter one store's catalog.
synced_attimestampSet by BozuroWhen we last wrote the row; bumped on every upsert.

Three ways products get in

They do not all carry the same fields — pick the one that matches how your store works.

1. Platform sync (Shopify, WooCommerce, Magento)

Connect the store in Settings → Integrations and the catalog syncs on a schedule and on demand. Carries the whole contract, inventory included. Nothing to build.

2. Product webhook (custom / headless store)

Push product_created / product_updated / product_deleted to your Website integration's webhook (below). Carries the whole contract too, and is how you keep the catalog fresh as products change. Retries are safe — an identical redelivery is ignored, a changed payload is applied.

3. Manual / CSV import in the app

For a catalog with no system to sync from. These products get a generated external_id, so they render in feeds and manual picks but can never be ranked by customer behaviour — there is no store id for events to match.

POST/ecom/website/{integrationId}

Create or update a catalog product. Upserted by external_id — send the whole product every time, because fields you omit are cleared. Catalog events carry no email: they describe the store, not a person.

ParameterTypeRequiredDescription
AuthorizationheaderRequiredBearer <integration api_key>
event_typestringRequiredproduct_created | product_updated | product_deleted
productobjectRequiredThe product, using the field names in the table above. For product_deleted only product.external_id is needed.
# Create or update a product
curl -X POST https://cdp-api.sidcorp.co/ecom/website/INTEGRATION_ID \
  -H "Authorization: Bearer emk_your_integration_key" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "product_updated",
    "product": {
      "external_id": "SKU-123",
      "title": "Cotton T-Shirt",
      "price": 29.99,
      "compare_at_price": 39.99,
      "currency": "USD",
      "url": "https://yoursite.com/p/cotton-tshirt",
      "image_url": "https://cdn.yoursite.com/tshirt.jpg",
      "in_stock": true,
      "stock_qty": 12,
      "product_type": "apparel",
      "categories": ["Tops", "Summer Sale"],
      "metadata": { "manufacturer": "ACME" }
    }
  }'

# Remove a product from the catalog
curl -X POST https://cdp-api.sidcorp.co/ecom/website/INTEGRATION_ID \
  -H "Authorization: Bearer emk_your_integration_key" \
  -H "Content-Type: application/json" \
  -d '{ "event_type": "product_deleted", "product": { "external_id": "SKU-123" } }'

Response

{ "status": "processed", "event_type": "product_updated" }
GET/api/v1/ecommerce/products

Read the catalog back — the same field names you sent.

curl https://cdp-api.sidcorp.co/api/v1/ecommerce/products -H "X-API-Key: emk_your_key"

Product feeds

A feed is a saved, filtered set of catalog products — what an email Product block shows when its data source is a feed (its feedId). Order it by sort_by, or rank it with a recommendation_type; the personalized ones (recently viewed, added to cart, may also like) rank per recipient when the email is sent. A value a feed cannot use is refused with a 400 that names the field. Every reply carries how many products the feed matches now.

POST/api/v1/product-feeds

Create a feed. Only name is required.

ParameterTypeRequiredDescription
namestringRequiredFeed name.
recommendation_typestringOptionalbest_selling | most_viewed | best_selling_and_most_viewed | newest | random | recently_viewed | added_to_cart | may_also_like — or "" for a plain sort.
sort_bystringOptionalnewest (default) | price_asc | price_desc | title_asc. Used when there is no recommendation_type.
max_itemsintegerOptional1–100, default 10.
categories / exclude_categoriesstring[]OptionalWith categories_op / exclude_categories_op: any (default) or all.
price_min / price_maxnumberOptionalPrice bounds.
stock_min / stock_max / stock_statusinteger / stringOptionalstock_status: in_stock | out_of_stock. Products with no reported quantity are excluded by the bounds.
sourcestringOptionalOnly products from this integration, e.g. magento2:shop.example.com.
metadataarrayOptional[{ key, operator: equals | not_equals | contains | not_contains | exists | not_exists, value }].
variant_options / variant_in_stockobject / booleanOptional{ "size": ["XL"] } — a variant must match every axis (and be in stock).
curl -X POST https://cdp-api.sidcorp.co/api/v1/product-feeds \
  -H "X-API-Key: emk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Winback picks",
    "recommendation_type": "may_also_like",
    "source": "magento2:shop.example.com",
    "stock_status": "in_stock",
    "max_items": 6
  }'

Response

{ "feed": { "id": "6f1c…", "name": "Winback picks", "recommendation_type": "may_also_like", "max_items": 6, … }, "product_count": 42 }
GET/api/v1/product-feeds

List feeds, each with its product count.

curl https://cdp-api.sidcorp.co/api/v1/product-feeds -H "X-API-Key: emk_your_key"
GET/api/v1/product-feeds/{id}?preview=5

One feed; preview=N adds its first N products (plain order — personalized rankings need a recipient).

curl "https://cdp-api.sidcorp.co/api/v1/product-feeds/FEED_ID?preview=5" -H "X-API-Key: emk_your_key"
PUT/api/v1/product-feeds/{id}

Change a feed — only the fields you send. clear_stock_min / clear_stock_max remove a bound.

curl -X PUT https://cdp-api.sidcorp.co/api/v1/product-feeds/FEED_ID \
  -H "X-API-Key: emk_your_key" -H "Content-Type: application/json" \
  -d '{ "max_items": 8, "sort_by": "price_asc", "recommendation_type": "" }'
DELETE/api/v1/product-feeds/{id}

Delete a feed. Product blocks whose feedId points at it lose their data source.

curl -X DELETE https://cdp-api.sidcorp.co/api/v1/product-feeds/FEED_ID -H "X-API-Key: emk_your_key"

Naming a product on an event

Behaviour data is joined to the catalog by the product id inside it. Send it as product_id on events and as external_id on line items. Older spellings (ProductID, productId, item_id) are still read for data already in the system, but use the first spelling for anything new.

# A product view — feeds "most viewed" and "recently viewed"
curl -X POST https://cdp-api.sidcorp.co/api/v1/events \
  -H "X-API-Key: emk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_id": "CONTACT_ID",
    "event_name": "viewed_product",
    "properties": { "product_id": "SKU-123", "price": 29.99 }
  }'

# An order line item — feeds "best selling" and "may also like"
#   "items": [{ "external_id": "SKU-123", "title": "Cotton T-Shirt", "quantity": 1, "price": 29.99 }]

What each recommendation needs

A product feed can rank by a recommendation instead of a plain sort. Each one reads different data, which is why a store can see one work and another come back empty.

RecommendationData it needsPer recipient
Best-selling productsbest_sellingOrders with items[] (last 90 days)No
Most viewed productsmost_viewedProduct-view events (last 90 days)No
Both best-selling and most viewedbest_selling_and_most_viewedOrders AND product-view events (last 90 days)No
Newest productsnewestCatalog onlyNo
Random productsrandomCatalog onlyNo
Products a customer recently viewedrecently_viewedThat contact's product-view events (last 90 days)Yes
Products a customer added to cartadded_to_cartThat contact's add-to-cart events OR their carts — either is enough (last 90 days)Yes
Products a customer may also likemay_also_likeOrders across the store (for co-occurrence) + that contact's own orders/carts, topped up by categoriesYes

A product block came out empty — what to check

  1. Is the product in the catalog at all? Read it back with GET /api/v1/ecommerce/products.
  2. Does the behaviour data name the product with the same id as external_id? This is by far the most common cause, and it fails silently.
  3. Is the data recent enough? Behaviour rankings look back 90 days, so a store whose events are older ranks nothing.
  4. For a per-recipient recommendation, is there a recipient? Previews have no contact and fall back to the feed's plain ordering.
  5. Are the feed's own filters (category, price, stock) narrower than the ranking's result?
  6. Stock filters returning nothing usually means stock_qty was never sent — unknown quantities are excluded rather than guessed.

An email product block never has to ship empty: Product List and Product Grid widgets take a fallback (best selling, most viewed, or the recipient's cart/order items) that tops the block up when the main source returns fewer products than the block asks for.