[relaybell docs › Developers › Catalog & order import API](https://relaybell.com/docs/catalog-api) · [All docs as Markdown](https://relaybell.com/docs.md) · [Index for AI assistants](https://relaybell.com/llms.txt)

# Catalog & order import API

> Push products, articles, courses and listings to relaybell (POST /catalog/items), remove them, run a full sync, and import past orders and actions (POST /events/import).

Recommendations (`{rec.title}`, `{rec.summary}` …) pick from your website's **catalog**. It can come from a feed relaybell reads ([Personalization → Catalog](https://relaybell.com/docs/personalization#catalog)), or your own server can send the items itself with this API. The **relaybell WordPress plugin** does exactly that: it sends your WooCommerce products and your posts, and keeps them up to date as you edit them. You only need this page if you build your own integration.

All three endpoints take an API key with the `events:write` scope (WordPress plugin keys have it) as `Authorization: Bearer rb_live_…`. They are limited per key to **60 calls a minute** (each call can carry 500 items or events: answered `429 rate_limited` with a `Retry-After` header past that), and a call's body can be up to **5 MB**.

## Send items {#items}

**POST** `/api/v1/catalog/items`

```bash
curl -X POST https://relaybell.com/api/v1/catalog/items \
  -H "Authorization: Bearer rb_live_xxxxx" -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "id": "1042", "type": "product", "title": "Ridge Hiking Boot",
        "url": "https://shop.example/p/ridge", "image": "https://shop.example/i/ridge.jpg",
        "price": 7499, "salePrice": 6499, "currency": "INR",
        "category": "Footwear > Boots", "brand": "Northwind", "availability": "in_stock"
      },
      {
        "id": "post-311", "type": "article", "title": "How to choose hiking boots",
        "url": "https://shop.example/blog/boots", "image": "https://shop.example/i/boots-guide.jpg",
        "category": "Guides > Hiking", "tags": ["boots", "trail"],
        "summary": "Fit, grip and support: what matters on the trail.",
        "publishedAt": "2026-10-20T09:00:00Z"
      }
    ]
  }'
```

Answer (`200`):

```json
{ "ok": true, "upserted": 2, "skipped": [] }
```

Up to **500 items** per call. Each item is checked on its own: a bad one is left out and listed in `skipped` with its `id` and a `reason`, and the others are stored. Sending an item again updates it (by `id`), so sending the same call twice is harmless.

| Field | Description |
| --- | --- |
| `id` \* | Your id for the item, 1–128 characters without spaces. For products use the id your events send as `productId` (only plain ids of up to 64 letters, digits, `_ . : -` can match product events); for content, the id your `content.viewed` events send as `itemId`. |
| `type` | `product` (default), `article`, `course`, `listing` or `other`. |
| `title` \* | Up to 300 characters (longer is cut). |
| `url`, `image` | Absolute `http(s)` addresses. |
| `price`, `salePrice` | Numbers (or text like `"3,999.00"`), below 10¹². A `salePrice` that isn't lower than `price` is ignored. |
| `currency` | 3-letter code, e.g. `INR`, `USD`. |
| `category` | A path like `Guides > Hiking` (or a list of levels), up to 300 characters. |
| `brand` | Up to 120 characters. |
| `tags` | A list of up to 20 short texts (each up to 60 characters). |
| `summary` | Plain text; HTML is removed and it is cut to 300 characters. Fills `{rec.summary}`. |
| `availability` | `in_stock` (default), `out_of_stock`, `preorder` or `unavailable`. Only `in_stock` and `preorder` items are recommended. |
| `publishedAt` | ISO date. Among equally good picks, newer content comes first. |

`skipped[].reason` is one of `invalid_item` (not an object), `invalid_id`, `invalid_type`, `missing_title`, `invalid_url`, `invalid_image`, `invalid_price`, `invalid_currency`, `invalid_category`, `invalid_brand`, `invalid_tags`, `invalid_summary`, `invalid_availability`, `invalid_published_at`, `duplicate_id` (the same id earlier in the same call: the last copy is stored) and `catalog_full`. A website can hold **20,000** live items sent this way (items marked unavailable don't count); known ids always update. Past that, a new article, course, listing or other item takes the place of the oldest one sent this way (by `publishedAt`, then id), which is marked unavailable, so the newest content is always there; it is skipped with `catalog_full` only when it is older than all of them. A new product past the limit is skipped with `catalog_full`.

A call that isn't `{ "items": [...] }`, has more than 500 items or a malformed `fullSync` answers `400 invalid_items` with a `message`; broken JSON answers `400 invalid_json` and a body over 5 MB `413 payload_too_large`.

Items sent this way show in **Settings → Catalog** with the source **WordPress / API**. A feed never changes them: if your feed lists the same id, the API's copy is kept. Removing the feed removes only the feed's items.

## Full sync {#full-sync}

To make relaybell's copy match your site exactly, send **every** item in as many calls as it takes, all with the same `fullSync.id`, and `done: true` on the last one:

```bash
# first batches
-d '{ "items": [ …500 items… ], "fullSync": { "id": "2026-10-26T05:00", "done": false } }'
# last batch (may be empty)
-d '{ "items": [ …the rest… ], "fullSync": { "id": "2026-10-26T05:00", "done": true } }'
```

When the `done` batch arrives, every item you sent through the API that no batch of this sync listed is marked unavailable, and the answer says how many: `{ "ok": true, "upserted": 120, "skipped": [], "removed": 3 }`. Unavailable items are never recommended and are deleted after 30 days unless they come back. Items from a feed are never touched.

- `fullSync.id` is any text of 1–64 characters without spaces; use a new one for each full sync.
- One full sync runs at a time per website. A new id replaces the one before: if an older sync's batches still arrive, their items are stored but its `done` marks nothing (`removed: 0`).
- An item you send *without* `fullSync` while a full sync is running (a post saved meanwhile) counts as listed by it.
- Each finished full sync adds one line to **Account → Activity**.

## Remove items {#delete}

**POST** `/api/v1/catalog/items/delete`

```bash
curl -X POST https://relaybell.com/api/v1/catalog/items/delete \
  -H "Authorization: Bearer rb_live_xxxxx" -H "Content-Type: application/json" \
  -d '{ "ids": ["1042", "post-311"] }'
```

Answer: `{ "ok": true, "removed": 2 }`. Up to 500 ids. Those items become unavailable straight away (they stop being recommended) and are deleted after 30 days. Only items sent through the API are affected; unknown ids are ignored. A body that isn't `{ "ids": [...] }` with at most 500 ids answers `400 invalid_ids`.

## Import past orders and actions {#import}

New subscribers' interests build up as they browse. To give recommendations a head start, import what your existing subscribers did in the **last 90 days** — the WordPress plugin imports WooCommerce orders this way when you connect it.

**POST** `/api/v1/events/import`

```bash
curl -X POST https://relaybell.com/api/v1/events/import \
  -H "Authorization: Bearer rb_live_xxxxx" -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "event": "purchase", "externalId": "user_42", "timestamp": "2026-10-12T14:03:00Z",
        "properties": {
          "orderId": 5512, "orderNumber": "NW-1001", "value": 7499, "currency": "INR",
          "items": [{ "productId": "1042", "product": "Ridge Hiking Boot", "category": "Footwear > Boots", "price": 7499, "quantity": 1 }]
        }
      }
    ]
  }'
```

Answer (`200`):

```json
{ "ok": true, "imported": 1, "skipped": { "unknownSubscriber": 0, "tooOld": 0, "duplicate": 0, "invalid": 0 } }
```

Up to **500 events** per call, each `{ event, externalId, properties, timestamp }` like a [server event](https://relaybell.com/docs/events-automation), plus the time it happened. An event is stored only when:

- `externalId` is one of the website's subscribers (else `unknownSubscriber`: people who never subscribed aren't added);
- `timestamp` is within the last 90 days — or your shorter [data retention](https://relaybell.com/docs/website-settings) — (else `tooOld`), and not in the future (else `invalid`);
- it isn't there already (else `duplicate`). An order event (`purchase`, `checkout.started`, `order.completed`, `checkout.completed` with an `orderId`) is kept once per order, by the same key as live events: `orderNumber` when you send it, else `orderId`. So importing again, or an order that was also reported live (before or after the import), never counts twice. Other events are kept once per subscriber, name and time.
- it has a name and an `externalId` without control characters, a readable time, and `properties` as an object of at most 32 KB (else `invalid`).

A body that isn't `{ "events": [...] }`, or has more than 500 events, answers `400 invalid_events`. A website can import up to **50,000 events a day** (UTC): a call whose events don't all fit stores nothing and answers `429 rate_limited` with a `Retry-After` header (until midnight UTC), so send it again then.

Imported events are stored at the time they happened and **only** feed interest profiles (with the decay of their real age: a purchase 10 days old already counts a bit less) and [segments](https://relaybell.com/docs/segments-topics). They never start an automation or journey, never count as a conversion or towards revenue, never appear in the Goals report and never send a webhook or a product alert. Each call that imported something adds one line to **Account → Activity**.

## Content events {#content-events}

For articles, courses and listings, report what people read with two events (from the browser SDK's `track()` or `POST /events`):

| Event | Properties |
| --- | --- |
| `content.viewed` | `itemId` (the item's `id`), `title`, `type` (`article` by default), `category`, `tags` (a list), `url` |
| `content.read` | The same, when the reader scrolled 75% of the page or stayed 30 seconds. It counts twice as much as a view. |

The category and tags become the subscriber's **topics**; when the event leaves them out they are taken from the catalog item (matched by `itemId`, else by `url`). See [Personalization](https://relaybell.com/docs/personalization#content).
