# Upgrading from v1 to v2

Core API v2 is the current version of the API. **v1 stops being served at 00:00 UTC on 4 November 2026**, so 3 November is the last day it answers. Upgrade before then. You can move one flow at a time, and nothing moves to v2 for you.

Two things stop working on that date:

* **Requests to v1.** That includes `/v1` routes and unprefixed routes such as `GET /payments`. Every v1 response carries `Deprecation`, `Sunset` and `Link` headers, so logging them is the quickest way to find the code you still have to move. From the sunset, every v1 request answers `410 Gone` with `code: "api_version_sunset"`, whether or not its API key is valid. Retrying does not help: send the request under `/v2`.
* **Checkout SDK versions earlier than `1.12.0`.** Version `1.12.0` is the first release that calls only API v2. Upgrade with `npm install @odus/checkout@latest`.

Webhook payload versions are separate from API versions, and this date does not apply to them. A subscriber on payload version 1 keeps receiving it after 4 November 2026.

## What v2 gives you

### For building and debugging your integration

* **Webhook types you can generate.** The v2 OpenAPI document describes every webhook event and its body, so you can generate types for your webhook handlers as well as for your requests. See the [Webhooks section of the API reference](https://docs.odus.com/docs/api/core/v2/webhook-customer-created).
* **A document current generators read.** The v2 OpenAPI document is OpenAPI 3.1.1 with JSON Schema 2020-12 schemas. The v1 document is OpenAPI 3.0.0. See [If you generate code from the OpenAPI document](#if-you-generate-code-from-the-openapi-document).
* **Webhook delivery history.** [`GET /v2/webhook-deliveries`](https://docs.odus.com/docs/api/core/v2/webhook-controller-find-all-deliveries-paginated) lists every delivery attempt. Filter it by subscriber, event type or `success` to see what failed and whether it is still being retried.
* **An event timeline for any object.** [`GET /v2/events`](https://docs.odus.com/docs/api/core/v2/event-controller-find-all-paginated) returns everything that happened to the payments, subscriptions or other objects you name, in order, whether or not a webhook was sent for it.
* **Routes you can predict.** Every request is a `GET` or a `POST`, and the same naming rules apply across the whole API. See [How v2 names routes](#how-v2-names-routes).
* **Published rate-limit terms.** A `429` tells you when to retry, and we announce a reduction in a limit at least 30 days ahead. See [Rate limits](https://docs.odus.com/docs/guides/api-features/errors.md#rate-limits).

### For payments, subscriptions and lists

* **Declines have their own status.** A payment that did not go through answers `402`, so you can tell it apart from a malformed request, which answers `400`.
* **More precise decline codes.** Eight new values of `outcome.errorCode` and a new decline type, `not_attempted`, separate problems with your account or request from problems with the customer's payment method. Each comes with a `retryStrategy`. See [Codes added in v2](https://docs.odus.com/docs/guides/payments/error-codes.md#codes-added-in-v2).
* **Restart billing on a subscription.** [`POST /v2/subscriptions/{id}/reactivate`](https://docs.odus.com/docs/api/core/v2/subscription-controller-reactivate) restarts a subscription whose billing stopped. See [Reactivating a subscription whose billing stopped](https://docs.odus.com/docs/guides/subscriptions/canceling-and-resuming.md#reactivating-a-subscription-whose-billing-stopped).
* **Cursor pagination on every list.** Pages stay consistent while data changes, and a page holds up to 100 rows. Send `withTotal=true` when you need a count. See [Pagination, filtering, sorting](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md).
* **Case-insensitive search when you ask for it.** The `ilike` filter matches regardless of case, and supports wildcards for partial matches.

You can also move your webhook subscribers to [payload version 2](https://docs.odus.com/docs/guides/upgrading/webhook-payload-v2.md), which sends each resource as it was at the moment of the event. It is optional and has no deadline.

## Before you start

Select v2 by adding the `/v2` prefix to Core API URLs:

```
GET /v2/payments

POST /v2/subscriptions/{id}/cancel
```

The prefix is the only way to choose a version. There is no version header and no `Accept` parameter. The prefix applies to the whole request, so you cannot mix versions within one call. Unprefixed routes and `/v1` routes keep v1 behaviour until the sunset date. Any other version-shaped prefix, such as `/v3/payments` or `/v2beta/payments`, answers `404`, and nothing falls back to v1. A typo in the prefix fails loudly instead of quietly serving you the old contract.

Much of the API is unchanged. A renamed route keeps the request body, response body and permissions it has in v1; only the path moves. Logical `$and` and `$or` filters work as they do in v1.

How to read this page

Most of what changed tells you so: a route that moved answers `404` under `/v2`, and a parameter v2 no longer accepts answers `400`. A test run against `/v2` finds those for you.

The rest do not. They answer `200` while returning different rows, or accept your payload and act on it differently. Each one is marked **Silent change**, and reading your own code is the only way to find them. The one marked in red can keep billing a subscription you meant to cancel.

## Upgrade in this order

1. **Upgrade the Checkout SDK if you use it.** Install `@odus/checkout` `1.12.0` or later before 4 November 2026. Version `1.12.0` is the first Checkout SDK release that calls only API v2. Any SDK route that still uses API v1 stops working when v1 stops being served.

2. **Point a non-production copy of your integration at `/v2`,** one flow at a time.

3. **Look up every route you call** in the [route map](#route-map). Repoint the ones that moved, and find replacements for the ones marked **Retired** — those have no v2 equivalent and stop working when v1 does. A route missing from that table kept its path — it has not necessarily kept its behaviour, which is what the rest of this page covers.

4. **Move list calls to cursor pagination.** Replace `page` and `pageSize` with `limit`, and page by sending the `pagination.nextCursor` you got back as the next request's `cursor`. Check what reads `itemCount` or `pageCount` — v2 returns neither, but `withTotal=true` gives you a capped `totalCount` in their place. If you ask for a page size above 100, expect 100 rows back where v1 gave you 10. See [Lists page by cursor only](#lists-page-by-cursor-only).

5. **Change every text filter whose value's case you do not control.** Send `ilike` instead of `eq`, and replace any `like` you were using as a substring match. Email filters are the exception and need no change. On an ID or a fixed value such as `status` or `currency`, keep `eq` or `in` and send the value in the case the API returns it: those fields refuse `like` and `ilike` with `400`. See [String filters are case-sensitive](#string-filters-are-case-sensitive).

6. **Handle `402` on `POST /v2/payments/{id}/authorize`,** or branch on `type` instead of the status. See [Payments](#payments).

7. **Check what reads `outcome.errorCode` or `outcome.declineType`.** v2 returns eight decline codes v1 never sends, and a third decline type. Three of the eight also change retry strategy relative to the v1 code they replace; the other five were already `never_retry`. See [Payments](#payments).

8. **Stop sending the request fields v2 dropped.** Search your payloads for each one:

   | Field               | Sent to                             | Send instead                                         |
   | ------------------- | ----------------------------------- | ---------------------------------------------------- |
   | `cancelImmediately` | `POST /subscriptions/{id}/cancel`   | [`cancelAt`](#subscriptions)                         |
   | `billingSchedule`   | `POST /prices`, `POST /prices/{id}` | [`recurringSchedule`](#prices)                       |
   | `active`            | `POST /secret-keys/{id}`            | Nothing. [Delete the key](#secret-keys) to revoke it |

   note

   `cancelImmediately` and `billingSchedule` are still accepted silently on v2, so a test run against `/v2` will not find them for you. `active` is refused with `400`.

9. **Stop reading the nested collections v2 dropped.** `customer.paymentMethods`, `customer.subscriptions` and `subscription.invoices` are no longer in the response; read each from its own list endpoint. See [Growing collections moved out of the resource](#growing-collections-moved-out-of-the-resource).

10. **Read the section for each resource you use** in [Changes by resource](#changes-by-resource), then check your remaining routes against the [v2 API reference](https://docs.odus.com/docs/api/core/v2/odus-core-api).

## Where your v1 routes went

### Route map

Every v1 route whose **path** moves or disappears in v2. Called under `/v2`, a route that moved answers `404`, so a test run against `/v2` finds them all; their v1 paths keep working until the sunset.

A route that is not listed keeps its path, but may still behave differently — see [Changes to every list endpoint](#changes-to-every-list-endpoint) and [Changes by resource](#changes-by-resource).

Rows marked **Retired** are the ones to plan around: they have no v2 replacement and none is coming, so they go away with v1 on 4 November 2026 rather than waiting for you.

| v1 operation                                                                                                                | In v2                                           | What to do                                                                                                                                                                         |
| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /payments/{id}/record-chargeback`                                                                                     | `POST /v2/payments/{id}/chargebacks`            | Repoint. A chargeback is an object you create, not a change to the payment's state.                                                                                                |
| `POST /payments/{id}/create-apple-pay-session`                                                                              | `POST /v2/payments/{id}/apple-pay-sessions`     | Repoint. The session is an object you create.                                                                                                                                      |
| `POST /checkout/init`                                                                                                       | —                                               | Create the payment first, then read `GET /v2/payments/{id}/checkout-details`.                                                                                                      |
| `GET /payments/{id}/supported-payment-methods`                                                                              | —                                               | Use `GET /v2/payments/{id}/checkout-details` for Checkout payment-method availability.                                                                                             |
| `POST /subscriptions/bulk-update`                                                                                           | —                                               | **Retired.** No replacement. Update each subscription with `POST /v2/subscriptions/{id}`.                                                                                          |
| `POST /subscriptions/bulk-update/preview`                                                                                   | —                                               | **Retired.** No replacement. Preview one at a time with `POST /v2/subscriptions/{id}/preview`.                                                                                     |
| `POST /customers`                                                                                                           | —                                               | **Retired.** No replacement. Customers stay readable in v2 (`GET /v2/customers`, `GET /v2/customers/{id}`); new ones are created by the Checkout flows rather than by an API call. |
| `DELETE /blacklist/{id}`                                                                                                    | `POST /v2/blacklist/{id}/delete`                | Repoint. Removal has preconditions and emits `blacklist.removed`, so it is a command like every other change.                                                                      |
| `GET /blacklist/check`                                                                                                      | —                                               | Filter the list instead — see [Blacklist](#blacklist).                                                                                                                             |
| `GET /webhooks`                                                                                                             | `GET /v2/webhook-deliveries`                    | Repoint. The rows are delivery attempts; what you configure is a webhook *subscriber*, at `/v2/webhook-subscribers`.                                                               |
| `GET /merchant-configs`                                                                                                     | `GET /v2/merchant-config`                       | Repoint. You have exactly one merchant configuration.                                                                                                                              |
| `POST /merchant-configs/{id}`                                                                                               | `POST /v2/merchant-config`                      | Repoint, and stop fetching the id first — you no longer need it to update.                                                                                                         |
| `POST /retry-policies`, `GET /retry-policies/default`, `GET /retry-policies/{id}`, `POST /retry-policies/{id}`              | `POST /v2/retry-policy`, `GET /v2/retry-policy` | See [Retry policy](#retry-policy).                                                                                                                                                 |
| `GET /jobs`, `GET /jobs/{id}`, `GET /jobs/{id}/items`                                                                       | —                                               | **Retired.** No replacement.                                                                                                                                                       |
| `GET /publishable-keys/active`, `GET /publishable-keys/{id}`, `POST /publishable-keys/{id}`, `POST /publishable-keys/roll`  | —                                               | **Retired.** No replacement.                                                                                                                                                       |
| `GET /users`, `POST /users`, `GET /users/{id}`, `POST /users/{id}`, `DELETE /users/{id}`, `GET /users/me`, `POST /users/me` | —                                               | **Retired.** No replacement. Users are part of Dashboard identity, not the Core API.                                                                                               |
| `POST /login`, `POST /logout`, `POST /password-reset/request`, `POST /password-reset/confirm`                               | —                                               | **Retired.** No replacement. Signing in is part of Dashboard identity, not the Core API.                                                                                           |
| `POST /newsletter/subscribe`                                                                                                | —                                               | **Retired.** No replacement.                                                                                                                                                       |
| `POST /token-exchange`                                                                                                      | —                                               | **Retired.** No replacement. Use payment authorization and the Checkout SDK flows instead.                                                                                         |

### How v2 names routes

These rules cover the whole v2 surface, so once you have repointed a route you can usually predict the next one rather than look it up:

* **Every request is a `GET` or a `POST`.** There is no `PATCH`, `PUT` or `DELETE` anywhere in v2, and none will be added. A `GET` is always safe; anything that changes something is a `POST`. The reverse does not hold, so branch on the route, not the method: `POST /v2/subscriptions/{id}/preview` is a read served over `POST` because its input does not fit in a URL. It changes nothing, it is safe to repeat, an `X-Idempotency-Key` sent with it is ignored rather than replayed, and it is the only such route on the v2 surface.
* **Deleting is a command:** `POST /v2/{resource}/{id}/delete`.
* **Moving a resource through its lifecycle is a verb:** `POST /v2/payments/{id}/capture`, `POST /v2/subscriptions/{id}/cancel`.
* **Producing a new object is a create on a sub-resource:** `POST /v2/payments/{id}/chargebacks`, not `record-chargeback`.
* **Changing fields on a resource needs no action at all:** `POST /v2/{resource}/{id}`.
* **A resource you have exactly one of is a singleton** — singular, and with no id: `GET /v2/merchant-config`, `POST /v2/retry-policy`.
* **"The one that is mine" is always `self`:** `/v2/accounts/self`.

## Changes to every list endpoint

This section covers `GET /v2/payments`, `GET /v2/customers` and every other list route. If your integration calls none of them, skip to [Changes by resource](#changes-by-resource).

### Lists page by cursor only

`page` and `pageSize` are not part of the v2 contract. A v2 request carrying either is answered with `400`, and `errors` names the parameter — they are not ignored, because `?page=5` would otherwise return the first page's rows while looking like the fifth.

`limit` is no longer the switch that opts into cursor pagination; in v2 it is the page size, defaulting to 10 and capped at 100. A request with no pagination parameters returns the first cursor page:

```
GET /v2/payments

GET /v2/payments?limit=25
```

Follow `pagination.nextCursor` to walk the result set, and send it back in `cursor` with the same `sortBy` and `sortDirection` — the cursor encodes those, and a request that disagrees is rejected. Stop when `pagination.hasNextPage` is `false`.

What you give up is jump-to-page: v2 responses carry no `itemCount` and no `pageCount`, and there is no way to ask for "page 5". If you were reading `itemCount`, send `withTotal=true` and read `totalCount` instead — it counts what your filters match, up to 10,000, and tells you with `totalCountIsExact` when the real figure is higher. See [Counting the matches](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md#counting-the-matches).

See [Pagination, filtering, sorting in v2](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md) for the full flow.

#### A page size over the cap is corrected, not refused

Silent change

Neither version refuses an out-of-range page size, and the parameter you migrate *from* corrects it differently from the one you migrate *to*.

| Page size you ask for | v1, via `pageSize`    | v2, via `limit`                                           |
| --------------------- | --------------------- | --------------------------------------------------------- |
| `500`                 | 10 rows — the default | 100 rows — the cap                                        |
| `0`                   | 10 rows               | **no rows** — v2 reads it as an explicit request for none |
| A non-number          | 10 rows               | 10 rows                                                   |
| `50`                  | 50 rows               | 50 rows                                                   |

Both answer `200`, and nothing in the response says the value was changed. So an oversized number that quietly gave you 10 rows a page in v1 quietly gives you 100 in v2.

This matters if you treat the page size as a promise — sizing a buffer from it, or assuming one oversized request drained the result set. Read the length of the `data` array you were actually given, and keep paging until `pagination.hasNextPage` is `false`. (`nextCursor` is always present; it is `null` on the last page rather than absent, so a client waiting for the field to disappear waits forever.) v1's `limit` clamps the same way v2's does; it is `pageSize`, which has no v2 equivalent, that behaves differently.

### String filters are case-sensitive

Silent change

In v1, ordinary string filters lowercased both the stored value and yours, so a filter could match despite a case mismatch. In v2 they compare the stored value as it is, and a query that no longer matches returns an empty page rather than an error. Nothing in the response says the filter was the reason.

```
GET /v1/payments?status[eq]=Succeeded   # matches a stored "succeeded"

GET /v2/payments?status[eq]=Succeeded   # empty page
```

Send a fixed value such as a `status` in the case the API returns it: v2 accepts only `eq`, `ne`, `in` and `nin` on those fields (see [Operators v2 changed](#operators-v2-changed)). On a text field, send `ilike` wherever you need case-insensitive matching:

```
GET /v2/products?name[ilike]=Gift Card
```

`like` changed too. In v1 it matched a substring, wrapping your value as `%value%`; in v2 it matches a prefix, `value%`. So a v1 substring search returns fewer rows in v2 without failing. `ilike` compares the value exactly as you send it, wildcards included, which is how you get a substring match back. `%` is the wildcard, percent-encoded as `%25` in a URL:

```
GET /v2/customers?name[ilike]=%25doe%25
```

That matches any name containing "doe", in any case. `%` and `_` are wildcards in the value you send, so if the value comes from a search box or any input you do not control, escape or strip them first — a shopper typing `%` otherwise matches every row.

`contains` is not a text search: it applies only to a field that holds several values, such as `supportedPaymentMethods`, and the API reference lists it on those fields. On an ordinary string field such as `name`, a request that sends it is answered with `400`. Use `like` or `ilike`.

#### Filter customer names with `ilike`, not `eq`

For an enum-like field such as `status`, exact case is what you want: the values are a fixed, known set. For `name` on `/v2/customers` it is not, because the stored value is whatever a merchant or shopper typed, so looking one up by a differently-cased spelling is a normal thing to do:

```
GET /v2/customers?name[ilike]=John Doe
```

That is an exact match, ignoring case; wrap the value in `%25` as above if you want partial matches. `ilike` on `name` or `phoneNumber` carries no performance penalty.

`email` needs no change, and should stay on `eq`: ODUS stores it lowercased and lowercases the value you filter by, so `eq` matches whichever case you send — and it is the faster path for an address. The same holds for `email` on `/v2/blacklist` and `customer.email` on `/v2/subscriptions`. See [Case sensitivity](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md#case-sensitivity) for the details. Casing in a name is meaningful and is preserved as typed, which is why `name` is not treated the same way.

#### Operators v2 changed

Compared with v1, the v2 schema:

* adds `ilike` to text filters, for explicit case-insensitive matching;
* keeps `eq`, `ne`, `like`, `in` and `nin` on text filters;
* accepts only `eq`, `ne`, `in` and `nin` on fields matched against their whole value: IDs, references to other objects, fields that hold one of a fixed set of values such as `status`, `currency` or `type`, and card digits such as `card.lastFour`. `like` or `ilike` on one of them is answered with `400`, naming the field. If you match one of these by part of its value, send the whole value with `eq`, or list the values you want with `in`. The [endpoint tables](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md#supported-v2-endpoints) mark each one as String (exact match);
* accepts only `eq` and `in` on the two processor ID filters, `processorId` on `GET /v2/transactions` and `latestTransaction.processorId` on `GET /v2/payments`, and on `url` on `GET /v2/logs`. Any other operator on them is answered with `400`. If you search by part of a processor ID, send the whole ID with `eq` instead, in the case the processor returned it. See [Filtering](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md#filtering);
* keeps `eq`, `ne`, `like`, `in` and `nin` on JSON metadata values;
* removes one filter field from a list endpoint both versions serve: `active` on `GET /v2/secret-keys`, which v2 ignores rather than refuses. See [Secret keys](#secret-keys).

`contains` changed in both versions at once, so it is not a v2 migration step. It now applies only to a field that holds several values, and both versions answer `400` when you send it on any other field. If your query builder sends `contains` on a string, number, boolean or date field, remove it: express the condition with `eq`, `ne`, `in`, `nin`, a `gte`/`lte` range, or — for text — `like` or `ilike`.

info

A request that previously sent `contains` on one of those fields never returned the rows you asked for. It now names the field in a `400` instead.

List endpoints that v2 does not serve take their filterable and sortable fields with them: Users, Jobs and Job Items have no v2 list endpoint at all. See [Pagination, filtering, sorting in v2](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md) for the exact endpoint and field list.

## Growing collections moved out of the resource

Silent change

Three fields are gone from v2 responses. Nothing errors — the field is simply absent, so code that reads it gets `undefined`, and code that loops over it fails where you wrote the loop rather than where the response arrived.

| Gone from                    | Field            | Read instead                                |
| ---------------------------- | ---------------- | ------------------------------------------- |
| `GET /v2/customers/{id}`     | `paymentMethods` | `GET /v2/payment-methods?customer[eq]={id}` |
| `GET /v2/customers/{id}`     | `subscriptions`  | `GET /v2/subscriptions?customer[eq]={id}`   |
| `GET /v2/subscriptions/{id}` | `invoices`       | `GET /v2/invoices?subscription[eq]={id}`    |

Each of those grows for as long as the customer keeps buying or the subscription keeps billing, and a field nested in one resource has no page to stop at. Its own list endpoint does: you get the page you asked for, sorted the way you asked, and the same rows — the filters above select exactly what the nested field used to carry.

Only those three moved. The nested objects around them are unchanged, so a v2 subscription still carries its `customer`, its `price` and its `upcomingInvoice`, and a v2 customer still carries `billingAddress` and `shippingAddress`.

Every route answering with a customer or a subscription is affected, not only the `GET`. `POST /v2/customers/{id}`, `POST /v2/customers/{id}/restrictions`, `POST /v2/subscriptions/{id}`, `POST /v2/subscriptions/{id}/cancel` and `POST /v2/subscriptions/{id}/resume` all return the narrower shape.

Three things to check as you repoint:

* **The rows now arrive a page at a time.** The nested field returned everything at once; a list endpoint returns 10 rows by default and up to 100. Keep paging until `pagination.hasNextPage` is `false` — see [Lists page by cursor only](#lists-page-by-cursor-only).
* **Your key needs the permission for what you are reading.** `customer:read` was enough to receive the nested payment methods and subscriptions; reading them now needs `payment_method:read` and `subscription:read`, and the invoices need `invoice:read`. A key without them is answered `403` with `type` `permission_error`.
* **You can narrow further than the nested field allowed.** `GET /v2/subscriptions?customer[eq]={id}&status[eq]=active` returns only the active ones, and `GET /v2/payment-methods?customer[eq]={id}&isDefault[eq]=true` returns the customer's default payment method — whatever its type — without you filtering the array yourself.

All three fields are unchanged in v1, so an integration that needs the nested shape can stay on v1 for those calls while moving the rest.

## Changes by resource

A resource that is not listed here changes only through its rows in the [route map](#route-map) and the [list-endpoint changes](#changes-to-every-list-endpoint) above.

### Payments

**A declined payment answers `402`, not `400`.** `POST /v2/payments/{id}/authorize` answers a payment the API processed but could not complete with `402 Payment Required`; v1 answers the same failure with `400`. Usually that is a refusal from the issuer, the processor or risk — but not always, since a payment that never reached an authorization answers `402` too.

`type` is `payment_error` in both versions, so an integration that branches on `type` — which is what [Errors](https://docs.odus.com/docs/guides/api-features/errors.md) recommends — needs no change. The body is not always identical: for the eight codes above, v1 derives its message from the broader code it collapses to, so the wording differs between versions. If you branch on the status, treat `402` as "the payment did not go through" and keep `400` for "the request was malformed".

Whether to try again is not encoded in the status — plenty of `402`s are soft declines that succeed on a later attempt. Read `outcome.retryStrategy` on the transaction to decide, and `outcome.errorCode` to find out why it failed, rather than the error body.

**Eight decline codes are new, and a third decline type comes with them.**

Silent change — decline handling

`outcome.errorCode` on the transaction can carry eight values on v2 that v1 never returns: `already_captured`, `merchant_account_restricted`, `merchant_configuration_error`, `invalid_request`, `blocked_by_policy`, `order_not_permitted`, `payment_method_unusable` and `stored_credential_revoked`. Each one is a case that v1 reports as a broader code — `already_captured`, for instance, arrives as `generic_hard_decline` on v1.

`outcome.declineType` gains a third value alongside them, `not_attempted`, which v1 never returns either. It marks a payment that was never authorized against the payment method, so nothing was refused: all eight codes above carry it.

**If you branch on `hard_decline` to decide a stored payment method is unusable, add explicit `payment_method_unusable` and `stored_credential_revoked` branches.** That branch stops firing for all eight on v2 — correctly for six of them, none of which is about the customer's payment method: they concern your account, your request, the order, a policy rule, or a capture that already succeeded. The other two *are* about the stored method, and they are the cases where you still have to ask the customer for a new one. Without those branches your integration stops asking.

Nothing errors. If you branch on `errorCode` or `declineType`, the new values fall through to whatever your default branch does, and `retryStrategy` moves with them: all eight are `never_retry`, where three of the codes they replace are retryable on v1. Handle them, or key your retry logic on `retryStrategy` rather than on the code. [Error Codes](https://docs.odus.com/docs/guides/payments/error-codes.md#codes-added-in-v2) lists each one against the v1 code it replaces.

### Subscriptions

**`invoices` is no longer in the response.** Read the subscription's billing history from `GET /v2/invoices?subscription[eq]={id}` — see [Growing collections moved out of the resource](#growing-collections-moved-out-of-the-resource).

**`cancelImmediately: true` must become `cancelAt: "immediately"`.**

Silent change — billing timing

In v1 you cancelled a subscription immediately with `{ "cancelImmediately": true }`. In v2, send `{ "cancelAt": "immediately" }` instead.

If you drop `cancelImmediately` without adding `cancelAt`, ODUS applies the default, `end_of_current_cycle`. The subscription then stays active — and keeps billing — until the end of the current cycle, and the request succeeds without an error.

`cancelAt` accepts `immediately`, `end_of_current_cycle` and `end_of_next_cycle`. If you were sending `cancelImmediately: false`, drop the field: `end_of_current_cycle` is already the default.

Send one field or the other, not both. `cancelAt` wins wherever both are present, except that `cancelImmediately: true` combined with any `cancelAt` other than `immediately` is refused with `400`, and `errors` names `cancelAt`.

### Customers

**`paymentMethods` and `subscriptions` are no longer in the response.** Read each from its own list endpoint — see [Growing collections moved out of the resource](#growing-collections-moved-out-of-the-resource).

`POST /customers` is retired and has no v2 route: new customers are created by the Checkout flows rather than by an API call. `GET /v2/customers` and `GET /v2/customers/{id}` are otherwise unchanged. If you look customers up by name, [filter with `ilike`](#filter-customer-names-with-ilike-not-eq) — this is the change most likely to affect a support tool.

### Prices

`billingSchedule` is no longer a field you can *send*. v2 recurring prices are created with `recurringSchedule`, which v1 accepts too — so you can switch to it before you move to v2 and confirm the prices you create are the ones you expect. Replace the field rather than adding to it: a request carrying both is refused. You can still read it: `GET /v2/prices/{id}` returns `billingSchedule` on prices that have one, but only when the request is authenticated with a secret key. The list route, `GET /v2/prices`, does not return it at all.

It is not a renamed `billingSchedule`: the two describe the schedule differently, so renaming the key is not enough. `recurringSchedule` requires `intervalUnit` and `intervalCount`, which a `billingSchedule` value does not carry, and the request is refused as invalid. Transform the value as well as the key.

```
{

  "name": "Pro Plan — Monthly",

  "type": "recurring",

  "currency": "usd",

  "amount": 2999,

  "product": "prod_2QGNGy637hJ9MnD5cK7thAjSvPV",

  "recurringSchedule": { "intervalUnit": "month", "intervalCount": 1 }

}
```

`intervalUnit` and `intervalCount` are required, `trial` is optional, and the amount charged each cycle is the price's own `amount` rather than one carried per cycle. `billingSchedule.statementDescriptor` has no `recurringSchedule` equivalent: the descriptor that reaches the bank statement is the product's `statementDescriptor`, so set it there.

Multi-cycle prices are retired

A `billingSchedule` with several `cycleDefinitions` — a different amount or interval per position — has no `recurringSchedule` equivalent, and none is coming. v2 does not accept `billingSchedule` on `POST /v2/prices` or `POST /v2/prices/{id}`, and v1 stops serving on 4 November 2026, so after that date there is no route that creates one.

If your integration creates multi-cycle prices, raise it with us before that date rather than planning a migration around it.

### Blacklist

**Removing an entry is a command:** `POST /v2/blacklist/{id}/delete` replaces `DELETE /blacklist/{id}`.

**Checking an email is a filter.** `GET /blacklist/check` is not served by v2. Filter the list instead:

```
GET /v2/blacklist?email[eq]=shopper@example.com
```

An empty `data` array means the email is not blacklisted. Blacklisted emails are stored and compared in lowercase, so the case you send does not matter.

### Retry policy

`POST /v2/retry-policy` replaces four v1 routes: `POST /retry-policies`, `GET /retry-policies/default`, `GET /retry-policies/{id}` and `POST /retry-policies/{id}`.

* It **creates** the policy if you have none and **replaces** it if you do, so send the whole policy — `title` and the full `steps` list — every time.
* `GET /v2/retry-policy` answers **`404`** until a policy exists, where v1 answered `200` with a null body. Treat that `404` as "no retry policy configured" rather than as a failure, and `POST /v2/retry-policy` to create one.
* `isDefault` is no longer in the v2 response. You have one retry policy, so the flag never carried information: treat it as absent and do not branch on it.

You can drop the "only one policy" error handling

In v1, a second `POST /retry-policies` fails with `Only one Retry Policy is allowed`. `POST /v2/retry-policy` succeeds whether or not a policy exists, so any code handling that error can go.

### Secret keys

**You revoke a key by deleting it.** v2 has no `active` flag on secret keys:

* It is not in any v2 secret key response, including the one `POST /v2/secret-keys` returns.
* `POST /v2/secret-keys/{id}` answers `400` when the body sends `active`. Send only `note` and `permissions`.
* To stop a key working, delete it with `POST /v2/secret-keys/{id}/delete`. If something still needs a key, create its replacement with `POST /v2/secret-keys` and switch over first.

Silent change

`GET /v2/secret-keys` ignores an `active` filter rather than refusing it, so `active[eq]=true` returns every key. Remove the filter, and delete any key you no longer use.

## If you generate code from the OpenAPI document

The v2 document is OpenAPI 3.1.1 with JSON Schema 2020-12 schemas. The v1 document remains OpenAPI 3.0.0, so a generator pinned to 3.0 needs upgrading before it can read v2.

One difference is easy to miss: **`initiator` on request-log entries is an open string in v2.** v1 constrains it to a fixed set of credential types; v2 publishes it as a nullable string with no enumeration. Do not generate an exhaustive `switch` over it.

The other is that **the `POST` that is a read no longer documents `409`.** v2 lists `409 Conflict` only on operations that change something, so a client generated from the v2 document has no conflict branch for `POST /v2/subscriptions/{id}/preview`.
