SamCart API Documentation API v1 API v2 v1 to v2 Migration

Migrating from v1 to v2

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.

The one idea behind v2

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:

Worked example: the charges on an order

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.

Endpoint mapping

Same path, ULID identifiers

These carry over one for one. The only change is that path and body identifiers are ULIDs instead of integers.

v1v2
GET /v1/productsGET /v2/products
GET /v1/products/{id}GET /v2/products/{id}
GET /v1/ordersGET /v2/orders
GET /v1/orders/{id}GET /v2/orders/{id}
GET /v1/chargesGET /v2/charges
GET /v1/charges/{id}GET /v2/charges/{id}
GET /v1/customersGET /v2/customers
GET /v1/customers/{id}GET /v2/customers/{id}
GET /v1/refundsGET /v2/refunds
GET /v1/failed-chargesGET /v2/failed-charges
GET /v1/subscriptionsGET /v2/subscriptions
GET /v1/subscriptions/{id}GET /v2/subscriptions/{id}
GET /v1/subscriptions/{id}/historyGET /v2/subscriptions/{id}/history
POST /v1/subscriptions/{id}/cancelPOST /v2/subscriptions/{id}/cancel
POST /v1/orders/{id}/add-to-orderPOST /v2/orders/{id}/add-to-order
POST /v1/orders/batch-add-to-orderPOST /v2/orders/batch-add-to-order
GET /v1/orders/batch-add-to-order/{batchId}GET /v2/orders/batch-add-to-order/{batchId}

Nested route becomes a filter

Each of these v1 endpoints is replaced by a filter on the resource you actually want back.

v1v2
GET /v1/orders/{id}/chargesGET /v2/charges?order_id={id}
GET /v1/customers/{id}/chargesGET /v2/charges?customer_id={id}
GET /v1/subscriptions/{id}/chargesGET /v2/charges?subscription_id={id}
GET /v1/charges/{id}/refundsGET /v2/refunds?charge_id={id}
GET /v1/customers/{id}/ordersGET /v2/orders?customer_id={id}
GET /v1/products/{id}/ordersGET /v2/orders?product_id={id}
GET /v1/orders/{id}/subscriptionsGET /v2/subscriptions?order_id={id}
GET /v1/customers/{id}/subscriptionsGET /v2/subscriptions?customer_id={id}

Detail route becomes a filtered list

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.

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

Nested route folded into the detail response

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.

v1v2Where 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

Changed shape

v1v2What 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

No v2 equivalent yet

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.

New in v2

These resources have no v1 counterpart.

EndpointWhat 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

Behavior changes to plan for

Identifiers are ULIDs

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.

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

Pagination is cursor based

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.

A single record is returned bare

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.

Errors have a machine readable code

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.

Rate limits are shared between v1 and v2

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.

Deleted records still resolve by id

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:

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.

Order money fields roll up from the cart items

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.

Field level changes on orders

The cart items on an order changed shape in a few places:

v1 fieldv2 fieldNote
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

Field level changes on products

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 fieldv2 fieldNote
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}

Amounts and timestamps are unchanged

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.

Field level changes on charges

Most payloads carry over, but charges are worth calling out because of a null handling change:

v1 fieldv2 fieldNote
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.

Authentication is the same

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.

Suggested order of work

  1. Find every v1 call in your integration and look it up in the mapping above, so you know which ones change shape rather than just changing version.
  2. Replace nested calls with filtered ones. This is where the code usually gets shorter, because loops that fetched per record become one filtered request.
  3. Rewrite pagination to the cursor loop, and set limit explicitly so the smaller v2 default cannot surprise you.
  4. Widen stored identifiers to strings, then translate the ids you have keyed by v1 integers using the legacy_id filter.
  5. Run both versions side by side against a known marketplace and compare results before cutting over. Filtering with test_mode is useful here.