---
updatedAt: 2026-08-13T08:14:33.000Z
---

Fetch the complete documentation index at: https://api-docs.omnisend.com/llms.txt. Use this file to discover all available pages before exploring further.

# Migrate from v3 to v2026-03-15

## Migrate from v3 to v2026-03-15

This guide outlines the key changes when migrating your integration from the v3 API to the v2026-03-15 API.

If you use an AI coding assistant, see [Migrate from v3 with an AI agent](https://api-docs.omnisend.com/v2026-03-15/docs/migrate-from-v3-with-an-ai-agent) for a ready-made skill that drives the migration from this guide.

***

## Generic changes

### Base URL

* **v3**: `https://api.omnisend.com/v3/`
* **v2026-03-15**: `https://api.omnisend.com/api/`

All endpoints, including contacts, products, categories, batches, and events, have moved to the `/api/` base path.

### Authentication

The API key header name has changed:

|                    | v3                 | v2026-03-15                                                |
| ------------------ | ------------------ | ---------------------------------------------------------- |
| **Header**         | `X-API-KEY: {key}` | `Authorization: Omnisend-API-Key {key}`                    |
| **Version header** | Not required       | `Omnisend-Version: 2026-03-15` (required on every request) |

**v3 example:**

```shell
curl --request GET \
  --url 'https://api.omnisend.com/v3/contacts' \
  --header 'X-API-KEY: YOUR-API-KEY'
```

**v2026-03-15 example:**

```shell
curl --request GET \
  --url 'https://api.omnisend.com/api/contacts' \
  --header 'Authorization: Omnisend-API-Key YOUR-API-KEY' \
  --header 'Omnisend-Version: 2026-03-15'
```

OAuth client credentials (`Authorization: Bearer {access-token}`) are also supported and are what scope names refer to.

### HTTP methods

| Method   | v2026-03-15 semantics            |
| -------- | -------------------------------- |
| `POST`   | Inserts a resource               |
| `PATCH`  | Changes only the supplied fields |
| `PUT`    | Replaces the full resource       |
| `DELETE` | Removes the resource             |

Where v3 offered `PUT` and v2026-03-15 offers only `PATCH` (product categories), omitted fields are no longer erased.

### Pagination

Contacts, campaigns, segments, and automations use cursor-based pagination:

|                            | v3                                      | v2026-03-15                            |
| -------------------------- | --------------------------------------- | -------------------------------------- |
| **Next page**              | `offset` / `paging.next` (full URL)     | `paging.cursors.after` (opaque token)  |
| **Previous page**          | `offset` / `paging.previous` (full URL) | `paging.cursors.before` (opaque token) |
| **More results indicator** | Value in `paging.next`                  | `paging.hasMore: true`                 |

**v2026-03-15 response:**

```json
{
  "contacts": [...],
  "paging": {
    "limit": 100,
    "hasMore": true,
    "cursors": {
      "after": "eyJpZCI6IjEyMyJ9",
      "before": "eyJpZCI6IjEifQ=="
    }
  }
}
```

Pass the cursor token as a query parameter: `GET /api/contacts?after=eyJpZCI6IjEyMyJ9`. Cursors are opaque — never construct or decode them, and do not combine `after` and `before`. Filters and sorting are encoded inside the cursor; changing them mid-pagination returns `400 Bad Request`.

Products, product categories, and batches remain offset-based (`offset`, `limit`, `sort`), so v3 paging loops can be reused for those resources.

### Error responses

|            | v3                | v2026-03-15                                                |
| ---------- | ----------------- | ---------------------------------------------------------- |
| **Format** | `{error, fields}` | RFC 9457 `{type, title, status, detail, instance, errors}` |

```json
{
  "type": "https://problems.omnisend.com/not-found",
  "title": "Not found",
  "status": 404,
  "detail": "Segment '000000000000000000000001' not found.",
  "instance": "urn:omnisend:request:550e8400-e29b-41d4-a716-446655440000",
  "errors": []
}
```

The `errors` array carries field-level failures (`field`, `code`, `message`) and is empty when the problem is not a validation failure. Validation failures use `type: https://problems.omnisend.com/validation-failed` and return every invalid field at once. Rate-limit problems may add `retryAfter` in seconds. Branch on `type`, which is a stable URI, and log `instance` for support requests.

`410 Gone` is a new status code, returned when an API version has been retired.

See [Responses](https://api-docs.omnisend.com/v2026-03-15/reference/responses) for the full error format and status code reference.

### Rate limits

|             | v3                                                           | v2026-03-15                                                                                                                                                                                                   |
| ----------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Model**   | One global limit                                             | Per brand, per endpoint, on a sliding window                                                                                                                                                                  |
| **Values**  | 400 requests/minute globally; 1 RPS per client for campaigns | 400 requests/minute by default; `/segments` 100/minute for `GET` and `DELETE` and 15/minute for `POST` and `PUT`; `/contacts/tags` 60/minute; render endpoints 40/minute; analytics 10/minute and 55/24 hours |
| **Headers** | `X-Rate-Limit-Limit` / `-Remaining` / `-Reset`               | Use `429` responses and `retryAfter` for backoff                                                                                                                                                              |

See [Rate limit, timeouts, errors](https://api-docs.omnisend.com/v2026-03-15/reference/rate-limit-timeouts-errors) for the current per-endpoint limits and enforcement details.

***

## Resource changes

### Contacts

**Base path**: `/v3/contacts` → `/api/contacts`

#### Endpoint changes

| v3                               | v2026-03-15                         | Notes                                                              |
| -------------------------------- | ----------------------------------- | ------------------------------------------------------------------ |
| `GET /v3/contacts`               | `GET /api/contacts`                 | Cursor pagination, new filters                                     |
| `GET /v3/contacts/{contactID}`   | `GET /api/contacts/{id}`            | Path parameter renamed                                             |
| `POST /v3/contacts`              | `POST /api/contacts`                | Upserts: `201` created, `200` updated                              |
| `PATCH /v3/contacts/{contactID}` | `PATCH /api/contacts/{id}`          | Path parameter renamed                                             |
| —                                | `PATCH /api/contacts?email={email}` | New: update by email                                               |
| —                                | `POST /api/contacts/tags`           | New: add tags to contacts selected by ID, email, phone, or segment |
| —                                | `DELETE /api/contacts/tags`         | New: remove tags from the same selection                           |

#### POST /contacts is an upsert

`POST /api/contacts` returns `201` when a contact is created and `200` when an existing contact matching the identifier is updated. Code that treated a repeated `POST` as a duplicate error must branch on the status code instead.

#### Field changes

| Field       | v3                                                        | v2026-03-15     | Notes   |
| ----------- | --------------------------------------------------------- | --------------- | ------- |
| `contactID` | Response field                                            | `id`            | Renamed |
| —           | `segments`, `statuses`, `optIns`, `consents`, `updatedAt` | Response fields | New     |

Identifiers, channel statuses (`subscribed`, `unsubscribed`, `nonSubscribed`), and `customProperties` name/value rules are unchanged, including removing a custom property by sending `""` or `null`.

#### Filters

`GET /api/contacts` adds `updatedAtFrom` and supports `sort=createdAt|updatedAt` with `direction`. `tag` and `status` cannot be combined, and `updatedAtFrom` cannot be combined with `email`, `phone`, `status`, `segmentID`, or `tag`. URL-encode `+` in emails as `%2B`.

#### Contacts are also written by events

`POST /api/events` creates or updates the contact supplied in the `contact` object, including `customProperties`, `tags`, `optIns`, `optOuts`, and `consents`. Send only the contact fields you intend to persist.

**v3 example:**

```shell
curl --request POST \
  --url 'https://api.omnisend.com/v3/contacts' \
  --header 'X-API-KEY: YOUR-API-KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "identifiers": [{"type": "email", "id": "jane@example.com",
      "channels": {"email": {"status": "subscribed", "statusDate": "2019-05-30T14:11:12Z"}}}],
    "firstName": "Jane"
  }'
```

**v2026-03-15 example:**

```shell
curl --request POST \
  --url 'https://api.omnisend.com/api/contacts' \
  --header 'Authorization: Omnisend-API-Key YOUR-API-KEY' \
  --header 'Omnisend-Version: 2026-03-15' \
  --header 'Content-Type: application/json' \
  --data '{
    "identifiers": [{"type": "email", "id": "jane@example.com",
      "channels": {"email": {"status": "subscribed", "statusDate": "2019-05-30T14:11:12Z"}}}],
    "firstName": "Jane"
  }'
```

***

### Carts

Carts are no longer a resource. Cart activity is sent as events.

#### Endpoint changes

| v3                                                   | v2026-03-15        | Notes                                                                 |
| ---------------------------------------------------- | ------------------ | --------------------------------------------------------------------- |
| `POST /v3/carts`                                     | `POST /api/events` | `eventName: "started checkout"`                                       |
| `PUT /v3/carts/{cartID}`                             | `POST /api/events` | Re-send the cart event with the full cart                             |
| `PATCH /v3/carts/{cartID}`                           | `POST /api/events` | Re-send the cart event with the full cart                             |
| `POST /v3/carts/{cartID}/products`                   | `POST /api/events` | `eventName: "added product to cart"`                                  |
| `PUT /v3/carts/{cartID}/products/{cartProductID}`    | `POST /api/events` | Re-send with the full `lineItems` array                               |
| `PATCH /v3/carts/{cartID}/products/{cartProductID}`  | `POST /api/events` | Re-send with the full `lineItems` array                               |
| `GET /v3/carts`                                      | —                  | No replacement: cart state is not readable through the API            |
| `GET /v3/carts/{cartID}`                             | —                  | No replacement                                                        |
| `DELETE /v3/carts/{cartID}`                          | —                  | No replacement: a cart cannot be retracted                            |
| `DELETE /v3/carts/{cartID}/products/{cartProductID}` | —                  | No replacement: re-send the cart event with the remaining `lineItems` |

`added product to cart` and `started checkout` both contribute to the Cart abandonment automation.

#### Behavior changes

Cart state is no longer stored or readable through the API — the events stream is append-only, and your system is the source of truth for current cart contents. Send the full cart in `lineItems` on every change instead of deltas. Cart line data moves into `properties`: `cartID`, `abandonedCheckoutURL`, `value`, `currency`, `addedItem`, and `lineItems[]`. `eventVersion` is empty for cart events.

Each cart update is a separate event, so give every send its own `eventID` — reusing one `eventID` across cart updates is not a way to replace a previous cart state, and omitting it makes Omnisend generate one for you. Deduplication of the `eventID` and `eventTime` pair applies to historical events only, so retried real-time sends of the same cart can be processed more than once.

#### Prices and money fields change unit

v3 `cartSum` and cart product prices were integers in cents. The cart event properties `value` and `lineItems[].productPrice` are floats in the store currency, so `1999` becomes `19.99`. Copying values unchanged inflates every amount 100×, which affects cart-abandonment emails and value-based segmentation.

#### Field mapping

| v3 cart field     | v2026-03-15 event property        | Notes                                                                               |
| ----------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
| `cartID`          | `properties.cartID`               | Unchanged                                                                           |
| `currency`        | `properties.currency`             | Unchanged                                                                           |
| `cartSum`         | `properties.value`                | Integer cents become a float                                                        |
| `cartRecoveryUrl` | `properties.abandonedCheckoutURL` | Renamed                                                                             |
| `products[]`      | `properties.lineItems[]`          | `cartProductID` has no target; identify lines by `productID` and `productVariantID` |
| `updatedAt`       | `eventTime`                       | Each update is its own event                                                        |

#### Identifying the contact

`POST /api/events` identifies the contact from the `contact` object, which needs at least one of `contact.id`, `contact.email`, or `contact.phone`, so no prior identification step is required for server-side sends. The "captured only for identified contacts" note on the cart event pages applies to the JavaScript API path, where the contact must already be identified in the browser.

**v2026-03-15 example:**

```shell
curl --request POST \
  --url 'https://api.omnisend.com/api/events' \
  --header 'Authorization: Omnisend-API-Key YOUR-API-KEY' \
  --header 'Omnisend-Version: 2026-03-15' \
  --header 'Content-Type: application/json' \
  --data '{
    "eventName": "added product to cart",
    "eventID": "f1eeb5bd-736c-42c7-9cfe-6990f7f59b35",
    "origin": "api",
    "eventVersion": "",
    "contact": {"email": "john@example.com"},
    "properties": {
      "cartID": "134035",
      "abandonedCheckoutURL": "https://example.com/cart/134035",
      "value": 19.99,
      "currency": "EUR",
      "addedItem": {"productID": "373", "productTitle": "Super duper product", "productPrice": 19.99,
        "productURL": "https://example.com/p/373", "productImageURL": "https://example.com/i/373.jpg"},
      "lineItems": [{"productID": "373", "productTitle": "Super duper product", "productPrice": 19.99,
        "productURL": "https://example.com/p/373", "productImageURL": "https://example.com/i/373.jpg"}]
    }
  }'
```

***

### Orders

Orders are no longer a resource. Order activity is sent as events with `eventVersion: "v2"`.

#### Endpoint changes

| v3                            | v2026-03-15         | Notes                                                                                 |
| ----------------------------- | ------------------- | ------------------------------------------------------------------------------------- |
| `POST /v3/orders`             | `POST /api/events`  | `eventName: "placed order"`                                                           |
| `PUT /v3/orders/{orderID}`    | `POST /api/events`  | Re-send the matching lifecycle event                                                  |
| `PATCH /v3/orders/{orderID}`  | `POST /api/events`  | `paid for order`, `order fulfilled`, `order canceled`, `order refunded`               |
| Historical import             | `POST /api/batches` | `endpoint: "events"`                                                                  |
| `GET /v3/orders`              | —                   | No replacement: order state is not readable through the API                           |
| `GET /v3/orders/{orderID}`    | —                   | No replacement                                                                        |
| `DELETE /v3/orders/{orderID}` | —                   | No replacement: `order canceled` records a cancellation but does not remove the order |

#### Behavior changes

* Status updates become distinct events instead of a field update. Send every lifecycle event you have — omitting `order refunded` or `order canceled` leads to incorrect reporting data.
* Order properties map onto the `placed order` property set: `orderID`, `orderNumber`, `totalPrice`, `subTotalPrice`, `totalTax`, `totalDiscount`, `currency`, `paymentStatus`, `paymentMethod`, `fulfillmentStatus`, `shippingMethod`, `shippingPrice`, `billingAddress`, `shippingAddress`, `discounts`, `tags`, `note`, `orderStatusURL`, `tracking`, `createdAt`, and `lineItems[]`. Only `orderID` is strictly required; `totalPrice` is required for revenue reporting.
* Set `eventTime` to the real order timestamp when importing history. Leaving it empty defaults to the current date and time, which can trigger unwanted automations.
* Before importing historical orders, make sure no automations are configured that could message contacts based on the imported data.

#### Prices and money fields change unit

v3 `orderSum`, `subTotalSum`, `taxSum`, `discountSum`, and `shippingSum` were integers in cents. The event properties `totalPrice`, `subTotalPrice`, `totalTax`, `totalDiscount`, and `shippingPrice` are floats in the store currency, so `3748` becomes `37.48`. Copying values unchanged inflates revenue reporting 100×.

#### Field mapping

| v3 order field                                                    | v2026-03-15 event property                                                             | Notes                                                                                |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `orderID`, `orderNumber`                                          | `properties.orderID`, `properties.orderNumber`                                         | Unchanged                                                                            |
| `orderSum`, `subTotalSum`, `taxSum`, `discountSum`, `shippingSum` | `properties.totalPrice`, `subTotalPrice`, `totalTax`, `totalDiscount`, `shippingPrice` | Renamed; integer cents become floats                                                 |
| `orderUrl`                                                        | `properties.orderStatusURL`                                                            | Renamed                                                                              |
| `trackingCode`, `courierTitle`, `courierUrl`                      | `properties.tracking.code`, `tracking.courierTitle`, `tracking.courierURL`             | Moved into the `tracking` object; `tracking.code` is documented on `order fulfilled` |
| `discountCode`, `discountValue`, `discountType`                   | `properties.discounts[].code`, `discounts[].amount`, `discounts[].type`                | A single discount becomes a list                                                     |
| `contactNote`                                                     | `properties.note`                                                                      | Renamed                                                                              |
| `products[]`                                                      | `properties.lineItems[]`                                                               | Renamed                                                                              |
| `source`, `cancelReason`, `canceledDate`                          | —                                                                                      | No documented target property                                                        |

#### `ordered product` complements `placed order`

Product-level segmentation ("what customers bought") and the `totalOrderedProductUnits` and `attributedOrderedProductUnits` analytics metrics are driven by the [`ordered product`](https://api-docs.omnisend.com/v2026-03-15/docs/ordered-product) event rather than by `placed order` line items. Its payload carries `orderID` plus a single `product` object, so send one `ordered product` event per ordered line item, with `eventVersion: "v2"`, alongside each `placed order` you send.

**v2026-03-15 example:**

```shell
curl --request POST \
  --url 'https://api.omnisend.com/api/events' \
  --header 'Authorization: Omnisend-API-Key YOUR-API-KEY' \
  --header 'Omnisend-Version: 2026-03-15' \
  --header 'Content-Type: application/json' \
  --data '{
    "eventName": "paid for order",
    "origin": "api",
    "eventVersion": "v2",
    "eventTime": "2026-01-14T09:12:00Z",
    "eventID": "6f1c1d0e-2f0a-4a53-9b56-2d0a3f0b7c11",
    "contact": {"email": "john@example.com"},
    "properties": {"orderID": "4122111", "totalPrice": 37.48, "currency": "EUR", "paymentStatus": "paid"}
  }'
```

***

### Custom Events

**Base path**: `/v3/events` → `/api/events`

#### Endpoint changes

| v3                          | v2026-03-15                      | Notes                                                                                                      |
| --------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `POST /v3/events`           | `POST /api/events`               | Trigger by `eventName` instead of `systemName`                                                             |
| `POST /v3/events/{eventID}` | `POST /api/events`               | Events are addressed by name and origin, not by definition ID                                              |
| `GET /v3/events`            | `POST /api/event-metadata/query` | Discover event names, origins, and properties; requires a body with `category`                             |
| `GET /v3/events/{eventID}`  | `POST /api/event-metadata/query` | Definitions are not addressable by ID; filter by name with `events: ["..."]` and `includeProperties: true` |
| —                           | `POST /api/event-metadata`       | New: declare an event schema                                                                               |
| —                           | `PUT /api/event-metadata`        | New: merge into an existing event schema                                                                   |

#### Requests are accepted asynchronously

`POST /api/events` returns `202 Accepted` and the event is queued, where v3 returned `204 No Content` after synchronous processing. Do not read back state immediately after sending.

Request fields: `eventName` and `origin` identify the event, `contact` needs at least one of `id`, `email`, or `phone`, and `properties`, `eventID` (UUID), `eventTime` (RFC 3339), and `eventVersion` (used by recommended events) are optional. The swagger does not mark any field as required, so validate against the endpoint reference rather than assuming.

#### Event discovery requires a query body

`GET /v3/events` listed every event definition. `POST /api/event-metadata/query` requires a request body with `category`, which selects the consumer the list is resolved for: `events` for the event stream, which is the closest match to the v3 list, `automations` for events usable as automation triggers, and `segments` for events usable in segment filters. A missing or unsupported `category` returns `400`.

**v2026-03-15 example:**

```shell
curl --request POST \
  --url 'https://api.omnisend.com/api/event-metadata/query' \
  --header 'Authorization: Omnisend-API-Key YOUR-API-KEY' \
  --header 'Omnisend-Version: 2026-03-15' \
  --header 'Content-Type: application/json' \
  --data '{
    "category": "events",
    "includeProperties": true
  }'
```

Optional body fields narrow the response: `events` filters by exact event name, `origins` filters by origin (OR-combined), `includeProperties: true` adds the nested property tree that is otherwise omitted, and `excludeCustomEvents: true` leaves out brand-custom events.

Which events a category exposes depends on the requested `Omnisend-Version`, so the same query can return a different list on another version. Brand-custom events are not version-scoped and are returned regardless of the requested version.

#### Events are identified by name and origin

`origin` is a required field. Use `api` for a custom store integration, or your app name for a third-party app; e-commerce platform integrations must contact Omnisend. Segment filters and automation triggers accept an optional `origin`, which is required only when the same event name exists under multiple origins.

Segments and automations built against your v3 events were authored without an origin, so events sent with `origin: "api"` do not match them. Until the rules are aligned, those segments stop filling and those automations stop enrolling contacts, and the event list, contact timeline, and event picker in the app show the same event name once per origin.

To keep existing segmentation and automation rules matching your events during migration, pass the `compatibility: no-origin` header on your event sends. The header is read per request, so send it on every event; `origin` stays required in the body. Alternatively, send with `origin: "api"` and update each affected segment (`filters[].origin`) and automation trigger (`trigger.condition.origin`).

#### Validation moves from send-time to schema-time

v3 rejected a send whose field type conflicted with the stored definition. In v2026-03-15 the send is accepted with `202`, and type conflicts are reported as `409 Conflict` on `POST /api/event-metadata` and `PUT /api/event-metadata`. Declare your event schemas before sending.

Once a property is explicitly defined, its type cannot be changed, and properties are never deleted by omission on `PUT`.

#### Property type changes

| v3 types                 | v2026-03-15 types        |
| ------------------------ | ------------------------ |
| `int`                    | `integer`, `integerList` |
| `float`                  | `float`, `floatList`     |
| `bool`                   | `boolean`, `booleanList` |
| `string`, `email`, `url` | `string`, `stringList`   |
| `date`, `dateTime`       | `date`, `dateList`       |
| —                        | `struct`, `structList`   |

Map `email` and `url` fields to `string`, and `dateTime` fields to `date`, before declaring schemas.

#### Deduplication

The `eventID` and `eventTime` pair is processed once for historical events. Real-time automation events are not deduplicated, so retries must assume at-least-once delivery.

**v3 example:**

```shell
curl --request POST \
  --url 'https://api.omnisend.com/v3/events' \
  --header 'X-API-KEY: YOUR-API-KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "name": "My Custom Event",
    "systemName": "myCustomEvent",
    "email": "test@example.com",
    "fields": {"size": "M", "bust": 100, "waterproof": true}
  }'
```

**v2026-03-15 example:**

```shell
curl --request POST \
  --url 'https://api.omnisend.com/api/events' \
  --header 'Authorization: Omnisend-API-Key YOUR-API-KEY' \
  --header 'Omnisend-Version: 2026-03-15' \
  --header 'compatibility: no-origin' \
  --header 'Content-Type: application/json' \
  --data '{
    "eventName": "myCustomEvent",
    "origin": "api",
    "eventTime": "2026-01-14T09:12:00Z",
    "contact": {"email": "test@example.com"},
    "properties": {"size": "M", "bust": 100, "waterproof": true}
  }'
```

***

### Campaigns

**Base path**: `/v3/campaigns` → `/api/campaigns`

#### Endpoint changes

| v3                                  | v2026-03-15                  | Notes                          |
| ----------------------------------- | ---------------------------- | ------------------------------ |
| `GET /v3/campaigns`                 | `GET /api/campaigns`         | Cursor pagination, new filters |
| `GET /v3/campaigns/{campaignID}`    | `GET /api/campaigns/{id}`    | Path parameter renamed         |
| `DELETE /v3/campaigns/{campaignID}` | `DELETE /api/campaigns/{id}` | Direct replacement             |

v2026-03-15 additionally exposes campaign create, update, send, cancel, and copy operations that v3 did not have.

#### Create, update, and send constraints

* `content.email.templateID` is required when creating a regular email campaign; creation is rejected without it, even when the campaign is only saved as a draft. Boosters inherit the parent campaign's template, and A/B test campaigns carry a `templateID` per variant under `abTest.variants`.
* Leave `content.email.senderEmail` and `content.email.replyToEmail` out unless a specific address is required. Omnisend then sends from the address configured for the brand and replies go to it. Which addresses a brand may send from follows from its sender setup and is not exposed by the API, so an address that is guessed or copied from another campaign is rejected. When the brand has no verified sender to fall back to, creation returns `422` with code `sender-email-not-available`, which retrying cannot resolve.
* `PATCH /api/campaigns/{id}` and `POST /api/campaigns/{id}/send` accept campaigns in `draft` status only; any other status returns `409 Conflict`.
* `POST /api/campaigns/{id}/copy` creates a new draft campaign with its own ID, named `Copy of: {name}`, and leaves the original unchanged. Use it to edit or send again a campaign that is no longer a draft.

#### Statistics moved to Analytics

v3 campaign objects embedded `sent`, `opened`, `clicked`, `bounced`, `complained`, `unsubscribed`, `abTestWinner`, and `byDevices`. The v2026-03-15 campaign object contains none of them:

| v3                              | v2026-03-15                                                                                                                                                                                                                                         |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Counters on the campaign object | `POST /api/analytics/reports` — `sent`, `failed`, `openedUnique`, `opened`, `openRate`, `clickedUnique`, `clicked`, `clickRate`, `unsubscribedUnique`, `markedAsSpamUnique`, `attributedOrders`, `attributedRevenue`, `totalOrders`, `totalRevenue` |
| `byDevices` breakdown           | `POST /api/analytics/statistics` — device breakdown via `clientDeviceType`                                                                                                                                                                          |

Reports are grouped by send date and are complete up to the last completed hour.

#### Field and enum changes

| Field                                                                             | v3                     | v2026-03-15 | Notes   |
| --------------------------------------------------------------------------------- | ---------------------- | ----------- | ------- |
| `campaignID`                                                                      | Response field         | `id`        | Renamed |
| `type`                                                                            | `standart`             | `regular`   | Renamed |
| `content`, `audience`, `sendingSettings`, `abTest`, `boosterSettings`, `language` | —                      | Objects     | New     |
| `startedAt`, `endedAt`                                                            | `startDate`, `endDate` | Timestamps  | Renamed |

New list filters: `nameContains`, `status`, `channel`, `type`, `parentCampaignID`, `createdAtFrom`, `createdAtTo`, `updatedAtFrom`, `updatedAtTo`. `sort` accepts `createdAt`, `updatedAt`, or `name`, and `direction` (`asc` or `desc`) is only valid together with `sort`. Only documented query parameters are accepted; an unknown parameter returns `400`.

***

### Products

**Base path**: `/v3/products` → `/api/products`

#### Endpoint changes

| v3                                | v2026-03-15                        | Notes                           |
| --------------------------------- | ---------------------------------- | ------------------------------- |
| `GET /v3/products`                | `GET /api/products`                | Offset pagination retained      |
| `GET /v3/products/{productID}`    | `GET /api/products/{productID}`    | Direct replacement              |
| `POST /v3/products`               | `POST /api/products`               | Returns `201`                   |
| `PUT /v3/products/{productID}`    | `PUT /api/products/{productID}`    | Still replaces the full product |
| `DELETE /v3/products/{productID}` | `DELETE /api/products/{productID}` | Direct replacement              |

#### Field changes

| v3                                           | v2026-03-15                                            | Notes                 |
| -------------------------------------------- | ------------------------------------------------------ | --------------------- |
| `productID`                                  | `id`                                                   | Renamed, required     |
| `productUrl`                                 | `url`                                                  | Renamed, required     |
| —                                            | `defaultImageUrl`                                      | New                   |
| `images[]{imageID,url,isDefault,variantIDs}` | `images[]` array of URL strings                        | Type changed          |
| `variants[].variantID`                       | `variants[].id`                                        | Renamed               |
| `variants[].productUrl`                      | `variants[].url`                                       | Renamed               |
| `variants[].price` (integer cents)           | `variants[].price` (float)                             | Type changed          |
| `variants[].oldPrice` (integer cents)        | `variants[].strikeThroughPrice` (float)                | Renamed, type changed |
| `variants[].imageID`                         | `variants[].images[]` (URLs)                           | Replaced              |
| —                                            | `variants[].description`, `variants[].defaultImageUrl` | New                   |

Prices live on variants only: the product object has no `price` or `strikeThroughPrice`. A product requires `currency`, `id`, `status`, `title`, and `url`; each variant requires `id`, `price`, `title`, and `url`.

#### Date format

`createdAt` and `updatedAt` are documented as UTC timestamps in the form `YYYY-MM-DDTHH:MM:SSZ`, for example `2021-01-01T00:00:00Z`. v3 accepted any RFC 3339 value, including a UTC offset such as `2021-01-01T00:00:00+02:00`, so convert timestamps to UTC before sending them.

#### Prices change unit

v3 prices were integers in cents; v2026-03-15 prices are floats in the store currency. `1999` becomes `19.99`. Copying values unchanged inflates every price 100×, which affects product blocks in emails and price-based segmentation.

**v3 example:**

```shell
curl --request POST \
  --url 'https://api.omnisend.com/v3/products' \
  --header 'X-API-KEY: YOUR-API-KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "productID": "123", "title": "Book1", "currency": "EUR", "productUrl": "https://shop/book1",
    "images": [{"imageID": "img1", "url": "https://shop/book1.jpg", "isDefault": true}],
    "variants": [{"variantID": "abc", "title": "Book1", "price": 500, "oldPrice": 700,
      "productUrl": "https://shop/book1", "imageID": "img1"}]
  }'
```

**v2026-03-15 example:**

```shell
curl --request POST \
  --url 'https://api.omnisend.com/api/products' \
  --header 'Authorization: Omnisend-API-Key YOUR-API-KEY' \
  --header 'Omnisend-Version: 2026-03-15' \
  --header 'Content-Type: application/json' \
  --data '{
    "id": "123", "title": "Book1", "currency": "EUR", "url": "https://shop/book1",
    "defaultImageUrl": "https://shop/book1.jpg", "images": ["https://shop/book1.jpg"],
    "variants": [{"id": "abc", "title": "Book1", "price": 5.00, "strikeThroughPrice": 7.00,
      "url": "https://shop/book1", "images": ["https://shop/book1.jpg"]}]
  }'
```

***

### Product Categories

**Base path**: `/v3/categories` → `/api/product-categories`

#### Endpoint changes

| v3                                   | v2026-03-15                                   | Notes                                                          |
| ------------------------------------ | --------------------------------------------- | -------------------------------------------------------------- |
| `GET /v3/categories`                 | `GET /api/product-categories`                 | Offset pagination, `sort` by `title`, `updatedAt`, `createdAt` |
| `GET /v3/categories/{categoryID}`    | `GET /api/product-categories/{categoryID}`    | Direct replacement                                             |
| `POST /v3/categories`                | `POST /api/product-categories`                | Returns `201`                                                  |
| `PUT /v3/categories/{categoryID}`    | `PATCH /api/product-categories/{categoryID}`  | Replace becomes partial update                                 |
| `DELETE /v3/categories/{categoryID}` | `DELETE /api/product-categories/{categoryID}` | Direct replacement                                             |

Fields are unchanged: `categoryID`, `title`, `createdAt`, `updatedAt`.

#### Replace becomes partial update

v3 `PUT /v3/categories/{categoryID}` overwrote omitted fields, including resetting `createdAt` to the current date. `PATCH /api/product-categories/{categoryID}` changes only the fields you supply and preserves the rest.

***

### Batches

**Base path**: `/v3/batches` → `/api/batches`

#### Endpoint changes

| v3                                         | v2026-03-15                        | Notes                                                                |
| ------------------------------------------ | ---------------------------------- | -------------------------------------------------------------------- |
| `POST /v3/batches`                         | `POST /api/batches`                | Returns `201` with `{batchID, totalCount}`                           |
| `GET /v3/batches`                          | `GET /api/batches`                 | `endpoint` query parameter required                                  |
| `GET /v3/batches/{batchID}`                | `GET /api/batches/{batchID}`       | Direct replacement                                                   |
| `GET /v3/batches/{batchID}/items`          | `GET /api/batches/{batchID}/items` | Per-item errors read from the list                                   |
| `GET /v3/batches/{batchID}/items/{itemID}` | —                                  | No replacement for reading a single item: read it from the item list |

#### Field and limit changes

|                       | v3                                                       | v2026-03-15                                                                                                           |
| --------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| **Items per batch**   | 1000                                                     | 100                                                                                                                   |
| **`endpoint` values** | `contacts`, `products`, `orders`, `events`, `categories` | `contacts`, `products`, `events`, `categories` — `orders` removed                                                     |
| **Event batches**     | Required a top-level `eventID`                           | Each item is a full event payload (`eventName`, `origin`, `contact`, `properties`); a top-level `origin` is supported |
| **Statuses**          | `pending`, `inProgress`, `finished`, `stopped`           | Unchanged                                                                                                             |

Import order history as `events` batches. Before sending a batch of events, make sure no automations are configured that could message contacts based on the imported data, to avoid duplicate messages.

`GET /api/batches` requires `endpoint` and accepts `contacts`, `products`, or `events`, so `categories` batches can be created but not listed.

***

## No longer available in v2026-03-15

Seven v3 operations have no replacement, all of them reads or deletes of cart and order state that is now an append-only event stream:

| v3 operation                                         | Consequence                                                                                      |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `GET /v3/carts`                                      | Cart state is not readable through the API; your system is the source of truth                   |
| `GET /v3/carts/{cartID}`                             | As above                                                                                         |
| `DELETE /v3/carts/{cartID}`                          | A cart cannot be retracted                                                                       |
| `DELETE /v3/carts/{cartID}/products/{cartProductID}` | Re-send the cart event with the remaining `lineItems` instead                                    |
| `GET /v3/orders`                                     | Order state is not readable through the API                                                      |
| `GET /v3/orders/{orderID}`                           | As above                                                                                         |
| `DELETE /v3/orders/{orderID}`                        | An order cannot be removed; `order canceled` records a cancellation, which is not the same thing |

Two further v3 operations have only a partial replacement: `GET /v3/events/{eventID}` becomes a filtered `POST /api/event-metadata/query`, and a single batch item is read from `GET /api/batches/{batchID}/items`.