A map from every v1 endpoint to its v2 equivalent, and the handful of behavior changes worth knowing before you start.
You do not have to migrate. v1 and v2 run side by side, and existing v1 integrations keep working unchanged. Move over when v2 gives you something you need.
In v1, if you wanted something related to a record, you asked for it through
that record's own URL. Charges for an order lived under the order:
GET /v1/orders/1001/charges. Refunds for a charge lived under the
charge: GET /v1/charges/1337/refunds. Every relationship needed
its own endpoint, and the top-level list endpoints could barely be narrowed at
all. In v1, GET /v1/charges accepts only a date range and a test
mode flag, which is exactly why those nested routes had to exist.
v2 inverts that. There is one endpoint per resource, and relationships are expressed as filters on it. You decide what you want back first, go to that resource, then narrow it:
You want charges, so you start at the charges resource, not the order. Then you apply the order as a filter.
v1 GET /v1/orders/1001/charges
v2 GET /v2/charges?order_id=01J4X6Z0V8Q3W9GJ4X6Z0V8Q3W
The payoff is that filters compose, which nested routes never could. The same charges endpoint answers questions v1 had no route for at all:
# Refunded charges on one order
GET /v2/charges?order_id=01J4X6Z0V8Q3W9GJ4X6Z0V8Q3W&status=refunded
# Every subscription rebill for one customer in January, live orders only
GET /v2/charges?customer_id=01J4X6ZCUST0MER0GJ4X6Z0V8&type=recurring
&created_at_min=2026-01-01&created_at_max=2026-01-31&test_mode=false
The same shift applies to the other nested v1 routes.
GET /v1/customers/{id}/orders becomes
GET /v2/orders?customer_id=, and
GET /v1/charges/{id}/refunds becomes
GET /v2/refunds?charge_id=. The rule of thumb: the resource
you want is the path, and everything you know about it is a query
parameter.
These carry over one for one. The only change is that path and body identifiers are ULIDs instead of integers.
| v1 | v2 |
|---|---|
GET /v1/products | GET /v2/products |
GET /v1/products/{id} | GET /v2/products/{id} |
GET /v1/orders | GET /v2/orders |
GET /v1/orders/{id} | GET /v2/orders/{id} |
GET /v1/charges | GET /v2/charges |
GET /v1/charges/{id} | GET /v2/charges/{id} |
GET /v1/customers | GET /v2/customers |
GET /v1/customers/{id} | GET /v2/customers/{id} |
GET /v1/refunds | GET /v2/refunds |
GET /v1/failed-charges | GET /v2/failed-charges |
GET /v1/subscriptions | GET /v2/subscriptions |
GET /v1/subscriptions/{id} | GET /v2/subscriptions/{id} |
GET /v1/subscriptions/{id}/history | GET /v2/subscriptions/{id}/history |
POST /v1/subscriptions/{id}/cancel | POST /v2/subscriptions/{id}/cancel |
POST /v1/orders/{id}/add-to-order | POST /v2/orders/{id}/add-to-order |
POST /v1/orders/batch-add-to-order | POST /v2/orders/batch-add-to-order |
GET /v1/orders/batch-add-to-order/{batchId} | GET /v2/orders/batch-add-to-order/{batchId} |
Each of these v1 endpoints is replaced by a filter on the resource you actually want back.
| v1 | v2 |
|---|---|
GET /v1/orders/{id}/charges | GET /v2/charges?order_id={id} |
GET /v1/customers/{id}/charges | GET /v2/charges?customer_id={id} |
GET /v1/subscriptions/{id}/charges | GET /v2/charges?subscription_id={id} |
GET /v1/charges/{id}/refunds | GET /v2/refunds?charge_id={id} |
GET /v1/customers/{id}/orders | GET /v2/orders?customer_id={id} |
GET /v1/products/{id}/orders | GET /v2/orders?product_id={id} |
GET /v1/orders/{id}/subscriptions | GET /v2/subscriptions?order_id={id} |
GET /v1/customers/{id}/subscriptions | GET /v2/subscriptions?customer_id={id} |
Refunds and failed charges are not individually addressable in v2: there is
no GET /v2/refunds/{id} and no
GET /v2/failed-charges/{id}. Fetch them through the list
endpoint instead, filtered down to the record you hold.
| v1 | v2 |
|---|---|
GET /v1/refunds/{id} | GET /v2/refunds?legacy_id={id} |
GET /v1/failed-charges/{id} | GET /v2/failed-charges?legacy_id={id} |
GET /v1/charges/{id}/refunds/{refund_id} | GET /v2/refunds?charge_id={id} |
The legacy_id filter takes the v1 integer id you already hold and
returns the matching record in a one-item list. Refunds for one charge come
back as a filtered list on charge_id; each refund in it carries
its own id, amount and created_at.
These v1 endpoints existed to fetch one slice of a record. In v2 that data is already on the single-record response, so the extra request is gone.
| v1 | v2 | Where it moved |
|---|---|---|
GET /v1/orders/{id}/customer |
GET /v2/orders/{id} |
the customer object |
GET /v1/subscriptions/{id}/customer |
GET /v2/subscriptions/{id} |
the customer object |
GET /v1/subscriptions/{id}/plan |
GET /v2/subscriptions/{id} |
plan fields inline, such as price, subscription_interval, rebill_days and total_rebills |
GET /v1/customers/{id}/addresses |
GET /v2/customers/{id} |
the addresses array |
| v1 | v2 | What changed |
|---|---|---|
POST /v1/subscriptions/{id}/scheduleCancel |
POST /v2/subscriptions/{id}/schedule-cancel |
path is kebab-case, not camelCase |
POST /v1/subscriptions/{id}/update |
PATCH /v2/subscriptions/{id} |
updating the subscription is a PATCH on the subscription itself |
POST /v1/refunds/charges/{id} |
POST /v2/refunds |
the charge moves out of the path and into the body as
charge_id. Send an amount in cents for a
partial refund, or omit it for a full one. The created refund comes back
with 201
|
PUT /v1/orders/{id} |
PATCH /v2/orders/{id} |
v1 writes one custom field per request via
custom_field_slug and custom_field_data. v2
takes a custom_fields object keyed by slug and writes
several in one request. Passing an empty string or null
erases the stored value
|
Two v1 routes have no v2 counterpart: GET /v1/funnels/{id}/orders
and GET /v1/upsells/{id}/orders. GET /v2/orders has
no funnel or upsell filter today. An order's attribution.funnel_id
and each cart item's upsell_id still identify where a given order
came from, but there is no server-side filter to list orders by funnel or
upsell. Keep those two calls on v1 until an equivalent ships.
These resources have no v1 counterpart.
| Endpoint | What it gives you |
|---|---|
GET/v2/coupons, /v2/coupons/{id} |
Read coupons, filtered by code, product_id or archived |
POST/v2/coupons |
Create a coupon |
GET/v2/prospects, /v2/prospects/{id} |
People who entered details on a checkout without completing a purchase, captured per product |
v1 identifiers are integers such as 1337. v2 identifiers are
26 character ULID strings such as
01J4X6Z0V8Q3W9GJ4X6Z0V8Q3W, so any column, cache key or
spreadsheet holding a SamCart id needs to accept a 26 character string.
Not every identifier changed. funnel_id, inside an order's
attribution object, is still an integer in v2, so do not assume
every field ending in _id became a ULID. Check the
v2 reference for the field you are reading rather than
converting a whole payload at once.
legacy_id to look up the records you already have
You do not have to re-resolve your stored records by hand. Every v2 list
endpoint accepts a legacy_id filter that takes the original v1
integer id, so you can find a record by the only id you currently hold and read
its ULID from the response:
GET /v2/orders?legacy_id=1001
Available on charges, coupons, customers, failed charges, orders, products, prospects, refunds and subscriptions. This is the most direct way to migrate stored ids: look each one up, keep the ULID you get back, then switch your reads over to it.
The filter takes one id per request. It must be a positive integer with no
leading zeros, so legacy_id=0, legacy_id=-1 and
legacy_id=0042 are rejected with a 400. An id that
matches nothing returns 200 with an empty data array,
and passing the parameter with no value is treated as not passing it at all.
Unlike filters such as email, it does not accept a comma separated
or repeated list.
Translate one id at a time. v2 payloads carry ULIDs only, by design: the integer ids are internal and v2 does not publish them. Because the response never echoes the id you asked for, one lookup per id is also exactly what you want when building a mapping from an old id to a ULID - pair each request with the record it returns and store the pair.
On customers, legacy_id behaves like the id filter and
also resolves deleted customers, so a stored reference keeps returning a record
instead of going quiet. Every other endpoint returns live records only.
Once you hold ULIDs, customers is also the one resource that reads them in bulk:
GET /v2/customers?id= accepts up to 25 ULIDs, comma separated or by
repeating the parameter. That is for verifying a finished mapping, not for
building one, since legacy_id stays one id per request everywhere.
v1 pages with offset, limit and
dir, and returns a pagination object holding
next and prev URLs. v2 pages with an opaque
cursor, and returns a meta object:
v1 { "data": [ ... ], "pagination": { "next": "https://...?offset=1337&dir=next", "prev": null } }
v2 { "data": [ ... ], "meta": { "next_cursor": "eyJpZCI6...", "prev_cursor": null, "limit": 25 } }
To walk a v2 list, pass the next_cursor you were given back as
cursor and stop when it comes back null. Cursors are
opaque: do not build or edit them, and do not store them as long lived
bookmarks. Sort order is controlled with sort=id or
sort=-id, and it cannot be changed partway through a cursor walk.
The one exception is GET /v2/subscriptions/{id}/history, which
sorts with sort=change_date or sort=-change_date
instead.
The default order is reversed. v1 walks a list oldest first.
v2 defaults to sort=-id, which is newest first. Anything that
reads only the first page, or stops early once it recognizes a record, will
see a different set of records under v2 unless you pass
sort=id explicitly.
Watch the default page size. v1 returns 100 records when you
omit limit. v2 returns 25. Code that omitted
limit and assumed a full page will silently start doing four
times as many round trips. The maximum is still 100.
This is unchanged from v1 and worth stating because it surprises people: list
endpoints wrap results in data, but single record endpoints return
the object itself, with no envelope. The one exception is
POST /v2/coupons, which returns {"data": [ ... ]}
because creating a coupon for several products creates one coupon resource per
product, and every created resource is returned.
v2 failures return a consistent body, so you can branch on
error.code instead of parsing prose:
{ "error": { "code": "invalid_parameter", "message": "The sort field must be id or -id." } }
The failures raised before a request reaches the endpoint are the exception, and
they are shaped the same in v1 and v2. A 401 returns:
{ "success": false, "error": "Unauthorized access to API.", "data": null }
A 403 (the plan does not include API access, or the account is not
active) and a 429 (rate limited) both return a plain
{ "message": "..." }. Treat error.code as present on
endpoint level failures such as 400, 404 and
422, and read the status code first.
The limit is per marketplace, not per key and not per version, so v1 and v2
requests draw down the same allowance. This matters most during a cutover: while
you are running both versions side by side to compare results, you are spending
twice the requests against one budget. Responses to authenticated requests
carry RateLimit-Limit, RateLimit-Remaining and
RateLimit-Reset headers, so you can watch your remaining allowance
as you go. A 429 adds Retry-After; wait it out rather
than retrying immediately. The limits per plan are listed in the
v2 reference under Rate Limiting.
In v1 a record that had been deleted returned 404. In v2 a stored
id keeps resolving on the resources where deletion exists, and each one marks
it slightly differently:
GET /v2/products/{id} returns the product with
deleted set to true.
GET /v2/coupons/{id} returns the coupon with a non-null
deleted_at timestamp.
GET /v2/customers/{id} and carry a
non-null deleted_at. On the list endpoint, deleted customers are
returned only when filtering by id or legacy_id.
404, as in v1.
List endpoints still leave deleted records out, so a reference held from v1 no longer goes silent, but it will not reappear in a listing either.
In v2, an order's subtotal, taxes,
shipping and total are computed from its cart
items, on both the list and the single-order response. On the single-order
response, where each item carries its prices, the arithmetic always holds:
subtotal is the sum of the item subtotals, taxes is
the sum of the item taxes, and total equals the sum of the item
totals plus shipping. Declined and upgraded items are excluded
from the sums. Shipping is charged at the order level and only appears there,
not per item.
Totals report amounts at checkout. In v1, order totals
shrink after a refund. In v2 they do not: money fields keep reporting what
was charged at checkout, and refund state is read from the order and item
status fields and the refunds endpoints
(GET /v2/refunds?order_id=). If you reconcile revenue from
order totals, subtract refunds explicitly under v2.
The cart items on an order changed shape in a few places:
| v1 field | v2 field | Note |
|---|---|---|
cart_items[].coupon |
cart_items[].coupon_id |
v1 embeds the coupon object with its integer id. v2 returns the
coupon's ULID only; read code and discount detail from
GET /v2/coupons/{id}
|
cart_items[].initial_price.shipping |
not present |
Shipping is order-level in v2, and initial_price.total no
longer includes it (v1's did). recurring_price.shipping
stays, because rebills genuinely charge it
|
| not present | cart_items[].initial_price.prorated_amount |
Upgrade credit applied to the item, already subtracted from the item's
total. Null when no proration applies
|
cart_items[].id |
not present |
v2 line items are not separately addressable. Use
charge_id, subscription_id and
upsell_id, which are all ULIDs
|
The single-product response now carries the offers attached to the product.
In v2 these appear on GET /v2/products/{id} only; the list
endpoint stays slim.
| v1 field | v2 field | Note |
|---|---|---|
order_bumps[].product_id |
order_bumps[].product_id |
Same meaning, the product offered as the bump, but the id is now that product's ULID |
order_bumps[].product_name |
order_bumps[].name |
Renamed. Still the bump name shown at checkout |
upsell_funnel |
upsells |
v1 gives only the funnel's name as a string. v2 returns the upsells
themselves: id, name,
product_id (the offered product's ULID),
price, currency, archived and
position. The id is the same ULID an order's
cart_items[].upsell_id reports, so you can tie a purchase
back to the offer it came through
|
bundled_products |
bundled_product_ids |
v1 embeds product_id and product_name pairs;
v2 returns the bundled products' ULIDs only. Read names from
GET /v2/products/{id}
|
Monetary amounts are integers in the smallest currency unit in both versions,
so 10025 means 100.25 USD. Timestamps are ISO 8601 in UTC. There
is nothing to convert here.
Most payloads carry over, but charges are worth calling out because of a null handling change:
| v1 field | v2 field | Note |
|---|---|---|
charge_refund_status |
status |
In v1 this is null when nothing was refunded, and otherwise
refunded or partially_refunded. In v2 it is never
null: an unrefunded charge reports charged. Drop the null check.
|
subscription_rebill_id |
subscription_id |
Not a rename of the same value. v1 gives the id of the individual rebill; v2 gives the id of the subscription, so every charge on one subscription shares it. |
| not present | type |
one_time, recurring or limited |
Charges do not carry a refunded amount in v2. The total reports
what the processor captured and is not reduced by refunds; status
tells you whether a refund happened. To get the refunded amounts themselves,
read the refunds for the charge with
GET /v2/refunds?charge_id={id} and sum each refund's
amount.
v2 uses the same sc-api header and the same key as v1, so there is
nothing to re-issue to start calling v2. If your key is restricted to specific
resources rather than full or read only access, reading the resources that are
new in v2 may require that access to be extended. Contact
support@samcart.com if a v2 request
returns 401 or 403 while the same key works on v1.
limit explicitly
so the smaller v2 default cannot surprise you.
legacy_id filter.
test_mode is useful
here.