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
/v1routes and unprefixed routes such asGET /payments. Every v1 response carriesDeprecation,SunsetandLinkheaders, so logging them is the quickest way to find the code you still have to move. From the sunset, every v1 request answers410 Gonewithcode: "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. Version1.12.0is the first release that calls only API v2. Upgrade withnpm 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-deliverieslists every delivery attempt. Filter it by subscriber, event type orsuccessto see what failed and whether it is still being retried. - An event timeline for any object.
GET /v2/eventsreturns 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
GETor aPOST, and the same naming rules apply across the whole API. See How v2 names routes. - Published rate-limit terms. A
429tells 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 answers400. - More precise decline codes. Eight new values of
outcome.errorCodeand a new decline type,not_attempted, separate problems with your account or request from problems with the customer's payment method. Each comes with aretryStrategy. See Codes added in v2. - Restart billing on a subscription.
POST /v2/subscriptions/{id}/reactivaterestarts 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=truewhen you need a count. See Pagination, filtering, sorting. - Case-insensitive search when you ask for it. The
ilikefilter 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.
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
-
Upgrade the Checkout SDK if you use it. Install
@odus/checkout1.12.0or later before 4 November 2026. Version1.12.0is 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. -
Point a non-production copy of your integration at
/v2, one flow at a time. -
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.
-
Move list calls to cursor pagination. Replace
pageandpageSizewithlimit, and page by sending thepagination.nextCursoryou got back as the next request'scursor. Check what readsitemCountorpageCount— v2 returns neither, butwithTotal=truegives you a cappedtotalCountin 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. -
Change every text filter whose value's case you do not control. Send
ilikeinstead ofeq, and replace anylikeyou were using as a substring match. Email filters are the exception and need no change. On an ID or a fixed value such asstatusorcurrency, keepeqorinand send the value in the case the API returns it: those fields refuselikeandilikewith400. See String filters are case-sensitive. -
Handle
402onPOST /v2/payments/{id}/authorize, or branch ontypeinstead of the status. See Payments. -
Check what reads
outcome.errorCodeoroutcome.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 alreadynever_retry. See Payments. -
Stop sending the request fields v2 dropped. Search your payloads for each one:
Field Sent to Send instead cancelImmediatelyPOST /subscriptions/{id}/cancelcancelAtbillingSchedulePOST /prices,POST /prices/{id}recurringScheduleactivePOST /secret-keys/{id}Nothing. Delete the key to revoke it notecancelImmediatelyandbillingScheduleare still accepted silently on v2, so a test run against/v2will not find them for you.activeis refused with400. -
Stop reading the nested collections v2 dropped.
customer.paymentMethods,customer.subscriptionsandsubscription.invoicesare no longer in the response; read each from its own list endpoint. See Growing collections moved out of the resource. -
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 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. |
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. |
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
GETor aPOST. There is noPATCH,PUTorDELETEanywhere in v2, and none will be added. AGETis always safe; anything that changes something is aPOST. The reverse does not hold, so branch on the route, not the method:POST /v2/subscriptions/{id}/previewis a read served overPOSTbecause its input does not fit in a URL. It changes nothing, it is safe to repeat, anX-Idempotency-Keysent 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, notrecord-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
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
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
iliketo text filters, for explicit case-insensitive matching; - keeps
eq,ne,like,inandninon text filters; - accepts only
eq,ne,inandninon fields matched against their whole value: IDs, references to other objects, fields that hold one of a fixed set of values such asstatus,currencyortype, and card digits such ascard.lastFour.likeorilikeon one of them is answered with400, naming the field. If you match one of these by part of its value, send the whole value witheq, or list the values you want within. The endpoint tables mark each one as String (exact match); - accepts only
eqandinon the two processor ID filters,processorIdonGET /v2/transactionsandlatestTransaction.processorIdonGET /v2/payments, and onurlonGET /v2/logs. Any other operator on them is answered with400. If you search by part of a processor ID, send the whole ID witheqinstead, in the case the processor returned it. See Filtering; - keeps
eq,ne,like,inandninon JSON metadata values; - removes one filter field from a list endpoint both versions serve:
activeonGET /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.
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
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.hasNextPageisfalse— see Lists page by cursor only. - Your key needs the permission for what you are reading.
customer:readwas enough to receive the nested payment methods and subscriptions; reading them now needspayment_method:readandsubscription:read, and the invoices needinvoice:read. A key without them is answered403withtypepermission_error. - You can narrow further than the nested field allowed.
GET /v2/subscriptions?customer[eq]={id}&status[eq]=activereturns only the active ones, andGET /v2/payment-methods?customer[eq]={id}&isDefault[eq]=truereturns 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.
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".
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.
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 —
titleand the fullstepslist — every time. GET /v2/retry-policyanswers404until a policy exists, where v1 answered200with a null body. Treat that404as "no retry policy configured" rather than as a failure, andPOST /v2/retry-policyto create one.isDefaultis 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.
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-keysreturns. POST /v2/secret-keys/{id}answers400when the body sendsactive. Send onlynoteandpermissions.- To stop a key working, delete it with
POST /v2/secret-keys/{id}/delete. If something still needs a key, create its replacement withPOST /v2/secret-keysand switch over first.
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.