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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| E-commerce webhook | /ecom/{platform}/{integrationId} | Required | 300 req/min per integration + 1,200 req/min per source IP (defaults; configurable per deployment). |
| Contact Sync / Collect | /collect/* | Required | 1,000 req/min per IP · 5,000 req/min per site_id · 200 req/min per IP + site_id. |
| Auth | /api/v1/auth/* | Required | 10 req/min per IP (register, login, refresh). |
| Consent pages | /cp/* | Required | 30 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).
/collect/identifyCreate or identify a contact by email. Subsequent events are linked to this contact.
| Parameter | Type | Required | Description |
|---|---|---|---|
| site_id | string | Required | Your workspace / site ID |
| string | Required | Contact email — the contact is created if it doesn't exist | |
| properties | object | Optional | first_name, last_name, phone, … (set on create or when empty) |
| anonymous_id | string | Optional | Anonymous 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]"
}/collect/eventTrack an event for a contact. Use a standard event name (see the JavaScript SDK) or any custom name, e.g. signed_up.
| Parameter | Type | Required | Description |
|---|---|---|---|
| site_id | string | Required | Your workspace / site ID |
| event_name | string | Required | Standard (viewed_product, …) or custom (signed_up) |
| string | Optional | Contact email to attach the event to (or use contact_id / anonymous_id) | |
| contact_id | string | Optional | Contact ID to attach the event to |
| properties | object | Optional | Event 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
/api/v1/eventsTrack 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| contact_id | string | Required | Contact ID the event belongs to |
| event_name | string | Required | Event name — a standard name (viewed_product, added_to_cart, …) or your own |
| properties | object | Optional | Event properties as JSON |
| source | string | Optional | Origin 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"
}/api/v1/events/batchTrack 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| events | array | Required | Array of event objects (max 1000) |
| events[].email | string | Required | Email of the contact the event belongs to (case-insensitive) |
| events[].event_name | string | Required | Event name — standard (viewed_product, …) or your own |
| events[].properties | object | Optional | Event properties as JSON |
| events[].source | string | Optional | Origin of the event (e.g. crm, website) |
| events[].timestamp | string | Optional | RFC3339 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)" }
]
}/api/v1/events/batchCorrect 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| events | array | Required | Array of event corrections (max 1000) |
| events[].email | string | Required | Email of the contact the event belongs to |
| events[].event_name | string | Required | Event name (must match the stored event) |
| events[].dedupe_key | string | Required | Matches the event's properties.dedupe_key — identifies which event to update |
| events[].timestamp | string | Optional | RFC3339 corrected event time → sets created_at |
| events[].source | string | Optional | Corrected source |
| events[].properties | object | Optional | Replacement properties (omit to keep existing) |
| upsert | boolean | Optional | Insert 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": []
}/api/v1/eventsList 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
- Dedicated endpoint
POST /api/v1/events/shipment(below) — for a full carrier payload; the order is matched byorder.origin_order_idand the status comes fromshipment_status.slug. - Generic events API — a plain
POST /api/v1/events(or/events/batch) withevent_name: "shipping_information"plusproperties.order_numberandproperties.tracking_status. This also advances the order's Shipping Status and fires the registeredshipping_informationtrigger — not the generic custom-event trigger. The order is matched byorder_number/origin_order_id, zero-pad-insensitive (6775matches000006775):{ "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.
/api/v1/events/shipmentReport 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| tracking_number | string | Optional | Carrier tracking number |
| shipment_status.slug | string | Required | InfoReceived | InTransit | OutForDelivery | Delivered | Exception | FailedAttempt | Expired | Pending |
| order.origin_order_id | string | Required | Your merchant order id / number — used to match and update the order's Shipping Status |
| order.site.platform.slug | string | Optional | Platform slug (e.g. magento2, shopify) |
| customer.email | string | Optional | Customer email — resolves/creates the contact for the flow trigger |
| carrier.name | string | Optional | Carrier 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
}/api/v1/events/order-updateReport 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| origin_order_id | string | Required | Your merchant order id / number |
| order_status.slug | string | Required | Order status slug (e.g. delivered, cancelled, processing) |
| order_status.name | string | Optional | Human label for the status |
| customer.email | string | Optional | Customer 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) | |
|---|---|---|
| Endpoint | POST /ecom/website/{integrationId} | POST /api/v1/ecommerce/orders |
| Identifies the customer by | email — a new address creates the contact for you | contact_id — our UUID, which you have to look up first |
| Safe to send twice | Yes — a repeat of the same external_id is ignored | No — every call inserts another order and adds its amount again |
| Campaign / flow revenue | Yes — the order is attributed on arrival | No — the order is stored with no campaign, so those reports stay at $0 |
| Contact lifetime revenue | Yes | Yes |
Starts order_placed flows | Yes | Yes |
| Auth | The integration's own key | Your 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:
- Create a Website integration in Settings → Integrations, then copy its
integrationIdandapi_key. - POST an
order_placedevent from your backend whenever an order is paid — from the server, not the browser, so the key stays private. - Confirm it landed: the order shows on the contact's timeline, and revenue appears under Analytics → Revenue.
/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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| Authorization | header | Required | Bearer <integration api_key> — the key shown on the Website integration, NOT your emk_ workspace key. |
| event_type | string | Required | order_placed | fulfilled_order | cancelled_order | refunded_order | added_to_cart | started_checkout | customer_created | customer_updated | product_created | product_updated | product_deleted |
| string | Required | The 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_id | string | number | Optional | Recommended. 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". |
| order | object | Optional | Required for order_placed, fulfilled_order, cancelled_order and refunded_order. Omit it and we store nothing while still answering 200. |
| order.external_id | string | number | Required | Your 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_amount | number | Required | Order total in order.currency. This is the number revenue reports sum. |
| order.currency | string | Required | ISO code, e.g. USD. Omitting it stores an empty currency rather than a default, which leaves revenue totals ambiguous. |
| order.status | string | Optional | Your own status string, e.g. paid. Stored as sent; empty if omitted. |
| order.ordered_at | string | Optional | RFC 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_number | string | Optional | Human-facing number shown in emails via {{ order.id }}. |
| order.items | array | Optional | Line items: external_id, title, quantity, price. items[].external_id must be the same product id you send to the catalog. |
| cart | object | Optional | Required for added_to_cart and started_checkout: external_id, total_amount, currency, checkout_url, items. |
| identity_token | string | Optional | Cross-device attribution — forward the _ir_id cookie. Also accepted as _ir_id or _emk. |
| properties | object | Optional | On 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.
/api/v1/ecommerce/ordersInsert 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| contact_id | string | Required | Our contact UUID. Look it up with GET /api/v1/contacts?email=… — this endpoint does not accept an email address. |
| platform | string | Required | Free-form label for where the order came from, e.g. website. |
| order_number | string | Required | Human-facing order number. |
| total_amount | number | Required | Order total. Must be greater than zero — a 0 is treated as a missing field. |
| currency | string | Required | ISO code, e.g. USD. |
| status | string | Required | Your own status string, e.g. paid. |
| ordered_at | string | Required | RFC 3339 timestamp. |
| external_id | string | Optional | Your order id. Stored for reference — it is NOT a duplicate key on this endpoint. |
| items | array | Optional | Line 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"
}/api/v1/ecommerce/productsList 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
| Field | Type | Send it? | What it is for |
|---|---|---|---|
| external_id | string | Required | The 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. |
| title | string | Required | Display name in product blocks. |
| price | number | Required | Unit 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. |
| currency | string (ISO 4217) | Recommended | Currency of price and compare_at_price. Defaults to USD — which is the wrong money in an email if your store is not USD. |
| url | string | Recommended | Product page link. Without it a product card renders with nothing to click. |
| image_url | string | Recommended | Main 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_stock | boolean | Recommended | Whether 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_qty | integer or null | Optional | Units 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. |
| categories | string[] | Recommended | Category / 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_price | number or null | Optional | Original price when on sale; rendered as the struck-through "was" price. |
| description | string | Optional | Long description, available to product blocks as {{ item.description }}. |
| product_type | string | Optional | Your platform's own type/kind for the product. Independent of categories. |
| weight | number or null | Optional | Shipping weight in your own unit. Stored, not interpreted. |
| variants | json | Optional | The 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. |
| metadata | object<string,string> | Optional | Any extra attributes (manufacturer, colour, short_description…). This is the place for fields the contract has no column for. |
| platform | string | Set by Bozuro | Taken 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. |
| source | string | Set by Bozuro | "platform:shop_domain" of the integration that wrote the row, so a multi-store workspace can filter one store's catalog. |
| synced_at | timestamp | Set by Bozuro | When 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.
/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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| Authorization | header | Required | Bearer <integration api_key> |
| event_type | string | Required | product_created | product_updated | product_deleted |
| product | object | Required | The 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" }/api/v1/ecommerce/productsRead 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.
/api/v1/product-feedsCreate a feed. Only name is required.
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Feed name. |
| recommendation_type | string | Optional | best_selling | most_viewed | best_selling_and_most_viewed | newest | random | recently_viewed | added_to_cart | may_also_like — or "" for a plain sort. |
| sort_by | string | Optional | newest (default) | price_asc | price_desc | title_asc. Used when there is no recommendation_type. |
| max_items | integer | Optional | 1–100, default 10. |
| categories / exclude_categories | string[] | Optional | With categories_op / exclude_categories_op: any (default) or all. |
| price_min / price_max | number | Optional | Price bounds. |
| stock_min / stock_max / stock_status | integer / string | Optional | stock_status: in_stock | out_of_stock. Products with no reported quantity are excluded by the bounds. |
| source | string | Optional | Only products from this integration, e.g. magento2:shop.example.com. |
| metadata | array | Optional | [{ key, operator: equals | not_equals | contains | not_contains | exists | not_exists, value }]. |
| variant_options / variant_in_stock | object / boolean | Optional | { "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 }/api/v1/product-feedsList feeds, each with its product count.
curl https://cdp-api.sidcorp.co/api/v1/product-feeds -H "X-API-Key: emk_your_key"
/api/v1/product-feeds/{id}?preview=5One 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"
/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": "" }'/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.
| Recommendation | Data it needs | Per recipient |
|---|---|---|
Best-selling productsbest_selling | Orders with items[] (last 90 days) | No |
Most viewed productsmost_viewed | Product-view events (last 90 days) | No |
Both best-selling and most viewedbest_selling_and_most_viewed | Orders AND product-view events (last 90 days) | No |
Newest productsnewest | Catalog only | No |
Random productsrandom | Catalog only | No |
Products a customer recently viewedrecently_viewed | That contact's product-view events (last 90 days) | Yes |
Products a customer added to cartadded_to_cart | That contact's add-to-cart events OR their carts — either is enough (last 90 days) | Yes |
Products a customer may also likemay_also_like | Orders across the store (for co-occurrence) + that contact's own orders/carts, topped up by categories | Yes |
A product block came out empty — what to check
- Is the product in the catalog at all? Read it back with GET /api/v1/ecommerce/products.
- 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. - Is the data recent enough? Behaviour rankings look back 90 days, so a store whose events are older ranks nothing.
- For a per-recipient recommendation, is there a recipient? Previews have no contact and fall back to the feed's plain ordering.
- Are the feed's own filters (category, price, stock) narrower than the ranking's result?
- Stock filters returning nothing usually means
stock_qtywas 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.