Skip to main content
View as Markdown
Version: v2

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.
  • 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.
  • Webhook delivery history. GET /v2/webhook-deliveries 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 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.
  • 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.

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.
  • Restart billing on a subscription. POST /v2/subscriptions/{id}/reactivate restarts a subscription whose billing stopped. See 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.
  • 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, 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. 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.

  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.

  6. Handle 402 on POST /v2/payments/{id}/authorize, or branch on type instead of the status. See 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.

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

    FieldSent toSend instead
    cancelImmediatelyPOST /subscriptions/{id}/cancelcancelAt
    billingSchedulePOST /prices, POST /prices/{id}recurringSchedule
    activePOST /secret-keys/{id}Nothing. Delete the key 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.

  10. Read the section for each resource you use in Changes by resource, then check your remaining routes against the v2 API reference.

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 and 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 operationIn v2What to do
POST /payments/{id}/record-chargebackPOST /v2/payments/{id}/chargebacksRepoint. A chargeback is an object you create, not a change to the payment's state.
POST /payments/{id}/create-apple-pay-sessionPOST /v2/payments/{id}/apple-pay-sessionsRepoint. 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}/deleteRepoint. 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.
GET /webhooksGET /v2/webhook-deliveriesRepoint. The rows are delivery attempts; what you configure is a webhook subscriber, at /v2/webhook-subscribers.
GET /merchant-configsGET /v2/merchant-configRepoint. You have exactly one merchant configuration.
POST /merchant-configs/{id}POST /v2/merchant-configRepoint, 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-policySee 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.

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.

See Pagination, filtering, sorting in v2 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 forv1, via pageSizev2, via limit
50010 rows — the default100 rows — the cap
010 rowsno rows — v2 reads it as an explicit request for none
A non-number10 rows10 rows
5050 rows50 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). 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 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 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;
  • 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.

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 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 fromFieldRead instead
GET /v2/customers/{id}paymentMethodsGET /v2/payment-methods?customer[eq]={id}
GET /v2/customers/{id}subscriptionsGET /v2/subscriptions?customer[eq]={id}
GET /v2/subscriptions/{id}invoicesGET /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.
  • 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 and the list-endpoint changes 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 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 402s 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 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.

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.

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 — 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.