Skip to main content

Cart

5 operations under /v1/cart (POST): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.

18 min read
View MarkdownEdit on GitHub

Base URL: https://api.codespar.dev

Every operation below requires a Bearer token. See Authentication.

POST /v1/cart/checkout

POSThttps://api.codespar.dev/v1/cart/checkout
Can move money

an order exists on the merchant's side, in whatever pending state that merchant uses

Finalize a cart into something payable

Takes a cart to the end of the merchant's own checkout and returns the instrument that settles it. STILL NOT A PAYMENT: what comes back is a Pix copia-e-cola or a checkout URL, and paying it is a separate act on a separate rail. What HAS happened by the time this returns is that an order exists on the merchant's side, in whatever pending state that merchant uses.

WHICH FIELD CARRIES THE RESULT DEPENDS ON THE TIER, and you should read both rather than assume. A Tier 0 merchant (the hosted browser drove its storefront) fills pix_copia_e_cola with the store's real, payable BR code. A Tier 1 merchant (a connected API, Nuvemshop today) fills checkout_url with the storefront page where the buyer completes payment through the merchant's own PSP, and leaves the Pix field empty. rail tells you which one answered.

TWO WAYS TO SAY WHAT IS BEING BOUGHT, and they are not interchangeable. Merchants with a catalog take items. Mercado Livre has no buyer-purchase API at all, so it takes url — the listing page — and refuses without it (meli_url_required). Sending neither is items_required.

MERCADO LIVRE BUYS ON A PERSON'S OWN ACCOUNT. Pass consumer_id and the checkout resolves THAT consumer's connected Mercado Livre context, so the order lands on their account rather than a shared one. In live this is required, not preferred: without a connected context the call is refused with meli_not_connected rather than quietly falling back to the shared demo context, which is test-only. The connection is made first through POST /v1/cart/meli/connect/start.

THE BUYER AND ADDRESS FIELDS ARE OPTIONAL IN THE SCHEMA AND REQUIRED IN LIVE, and the difference is deliberate. In TEST the hosted browser fills sandbox defaults so an integration can run end to end without inventing a real person. In LIVE the checkout REFUSES — buyer_identity_required, shipping_address_required — rather than fabricate identity or shipping details on an order that will really be placed and really be delivered somewhere. The refusal message names the missing fields. Read the optionality as an environment rule, not as permission to omit them on a real order.

SLOW BY CONSTRUCTION ON TIER 0. A browser-driven checkout is a real browser walking a real storefront; the worker is allowed a retry budget of roughly 165 seconds plus a final in-flight attempt, and the client waits past that. Allow at least 230 seconds and do not retry on timeout without checking the merchant: a second attempt can place a second order.

A RAIL THAT CANNOT FINISH SYNCHRONOUSLY IS REFUSED, not queued. If a connected rail comes back from its own session step in a non-terminal state, the answer is 502 rail_async_not_supported. There is no session-to-rail persistence behind this route, so there would be nothing to resume; saying so is more useful than handing back a handle that leads nowhere.

WHAT IS NOT HERE. No idempotency key, so a retried request is a retried checkout. No route to read a checkout back by id. No cancellation: an order placed on the merchant is cancelled at the merchant.

Request body

FieldTypeRequiredDescription
addressobjectno—
buyerobjectno—
consumer_idstringno—
itemsarray of objectno—
merchantstringyes—
urlstring (uri)no—

Responses

StatusBodyDescription
200objectOK
400objectThe body did not match the schema — for example a url that is not a URL, or a country that is not two characters.
422objectNo rail supports this merchant at all.
502objectThe merchant, the hosted browser, or a connected rail failed. rail_async_not_supported is the one that will not resolve on retry: the rail answered with a non-terminal status and this route has nowhere to park it.
503objectNo hosted browser is configured for this deployment, so no Tier 0 checkout can run. An operator's configuration to supply; retrying cannot fix it.

Response 200

FieldTypeRequiredDescription
checkout_urlstringnoTIER 1. The storefront page where the buyer completes payment through the merchant's own PSP. Empty on Tier 0.
merchantstringno—
order_form_idstringnoThe merchant's own order handle, where the rail exposes one.
order_statusstringnoThe merchant's own status string for the order just placed. The vocabulary is the merchant's, not ours.
pix_copia_e_colastringnoTIER 0. The store's real, payable Pix BR code, minted by the merchant's own checkout. Pay it on a Pix rail; nothing in this call moved money.
railstringyesWhich rail finalized the order.
total_minorintegernoMinor units. Absent when the rail did not return a priced total; do not infer zero.
Example request
curl -X POST https://api.codespar.dev/v1/cart/checkout \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "merchant": "string",
       "items": [
         {
           "variant_id": "variant_0000000000000000",
           "quantity": 0,
           "seller": "string"
         }
       ],
       "url": "https://example.com/hook",
       "consumer_id": "csm_0000000000000000",
       "buyer": {
         "email": "person@example.com",
         "firstName": "Example",
         "lastName": "Example",
         "document": "string",
         "phone": "string"
       },
       "address": {
         "postalCode": "string",
         "street": "string",
         "number": "string",
         "neighborhood": "string",
         "city": "string",
         "state": "string",
         "complement": "string",
         "country": "string"
       }
     }'
POST /v1/cart/checkout HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "merchant": "string",
  "items": [
    {
      "variant_id": "variant_0000000000000000",
      "quantity": 0,
      "seller": "string"
    }
  ],
  "url": "https://example.com/hook",
  "consumer_id": "csm_0000000000000000",
  "buyer": {
    "email": "person@example.com",
    "firstName": "Example",
    "lastName": "Example",
    "document": "string",
    "phone": "string"
  },
  "address": {
    "postalCode": "string",
    "street": "string",
    "number": "string",
    "neighborhood": "string",
    "city": "string",
    "state": "string",
    "complement": "string",
    "country": "string"
  }
}
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/cart/checkout",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "merchant": "string",
      "items": [
        {
          "variant_id": "variant_0000000000000000",
          "quantity": 0,
          "seller": "string"
        }
      ],
      "url": "https://example.com/hook",
      "consumer_id": "csm_0000000000000000",
      "buyer": {
        "email": "person@example.com",
        "firstName": "Example",
        "lastName": "Example",
        "document": "string",
        "phone": "string"
      },
      "address": {
        "postalCode": "string",
        "street": "string",
        "number": "string",
        "neighborhood": "string",
        "city": "string",
        "state": "string",
        "complement": "string",
        "country": "string"
      }
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/cart/checkout", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "merchant": "string",
    "items": [
      {
        "variant_id": "variant_0000000000000000",
        "quantity": 0,
        "seller": "string"
      }
    ],
    "url": "https://example.com/hook",
    "consumer_id": "csm_0000000000000000",
    "buyer": {
      "email": "person@example.com",
      "firstName": "Example",
      "lastName": "Example",
      "document": "string",
      "phone": "string"
    },
    "address": {
      "postalCode": "string",
      "street": "string",
      "number": "string",
      "neighborhood": "string",
      "city": "string",
      "state": "string",
      "complement": "string",
      "country": "string"
    }
  }),
});

const data = await res.json();
const r = await cs.api.response("post", "/v1/cart/checkout", {
  body: {
    merchant: "string",
    items: [
      {
        variant_id: "variant_0000000000000000",
        quantity: 0,
        seller: "string"
      }
    ],
    url: "https://example.com/hook",
    consumer_id: "csm_0000000000000000",
    buyer: {
      email: "person@example.com",
      firstName: "Example",
      lastName: "Example",
      document: "string",
      phone: "string"
    },
    address: {
      postalCode: "string",
      street: "string",
      number: "string",
      neighborhood: "string",
      city: "string",
      state: "string",
      complement: "string",
      country: "string"
    }
  }
});
// r.status is one of the documented statuses (200, 422),
// each with its own body shape in r.data; nothing here throws on 422.
if (r.ok) {
  console.log(r.data);
}
Example response 200
application/json
{
  "merchant": "string",
  "rail": "string",
  "order_form_id": "orderform_0000000000000000",
  "total_minor": 1,
  "pix_copia_e_cola": "string",
  "checkout_url": "https://example.com/hook",
  "order_status": "string"
}

POST /v1/cart/ifood/connect/start

POSThttps://api.codespar.dev/v1/cart/ifood/connect/start

Open an iFood login for a shopper (connect-once, and iFood cannot be bought from)

Same mechanism as the Mercado Livre start: a hosted browser on a fresh persistent context, opened on ifood.com.br, and a live-view URL the SHOPPER logs into (phone number plus SMS code).

READ THIS BEFORE ASKING ANYONE TO LOG IN. iFood has NO checkout on this API, in live or in test. That is why every response below carries checkout_supported: false and a note saying so in words: the disclosure has to reach the person who is about to type an SMS code, and an agent relaying only the final result of a connect would otherwise never see it. The refusal on the checkout side is not a capability gap that might resolve itself on retry either. runStoreCheckout keeps iFood in a connect-only set and refuses with 400 merchant_not_supported before it looks at any infrastructure, telling the caller not to retry. The login is stored for a capability that does not exist yet.

BEFORE this call returns, the server writes a pending row binding the session it just opened to the context it issued and to the calling org and project. That row is what makes a finish possible, it is single use, and it stops being finishable 30 minutes after the start. A shopper who never logs in costs nothing: the row simply expires.

THE REQUEST BODY IS NOT READ. The handler passes only the merchant and the caller's own authenticated context, so there is no field to send and none to get wrong. One caveat, measured against this repository's Fastify 5 rather than assumed: a request that sets Content-Type: application/json and then sends an EMPTY or malformed body is refused 400 by the framework's body parser before the handler runs, with Fastify's default error body (statusCode, code, error, message, where code is FST_ERR_CTP_EMPTY_JSON_BODY or FST_ERR_CTP_INVALID_JSON_BODY). That is a different shape from the { error, message } below and its code is in neither enum. Sending {}, or sending no body and no content-type, reaches the handler.

SLOWER THAN THE MERCADO LIVRE START. iFood sits behind bot management, and the worker deliberately waits about 15 seconds for that challenge to clear before handing over the live view, so the shopper does not open it on an interstitial. The worker client allows the call at least 120 seconds.

Scope: connections:write, not one of the cart:* scopes. Starting a login is the same act as beginning any other authorization dance that ends in a stored credential, and a key allowed only to build carts should not be able to begin one that later spends on a person's account.

A 502 does not tell you how far the attempt got. The hosted browser call and the pending-row insert are inside the same try, so the code is identical whether the worker never answered or the row failed to write after a browser session was already open. When it is the second, the open session is left to time out on its own. Retrying is safe: every start mints a new context and a new session.

Responses

StatusBodyDescription
200objectOK
502objectThe hosted browser worker refused or failed, or the pending session could not be recorded. Not terminal: retry mints a new context and session.
503objectNo browser worker is configured for this deployment, so no connect can be started at all. Terminal for the caller: retrying cannot fix it, and it is an operator's configuration to supply.

Response 200

FieldTypeRequiredDescription
checkout_supportedfalseyesAlways present and always false on this route. Its absence on the Mercado Livre response is the signal that the merchant CAN be bought from; do not read a missing field as false.
context_idstringyesThe persistent browser context the login is about to be written into, fresh for this connect. Bind it to nothing yourself: the server remembers which context it issued for this session and later checkouts resolve it from the stored mapping.
live_view_urlstringyesThe interactive URL a HUMAN opens to log into their own marketplace account. MAY COME BACK AS AN EMPTY STRING, and the call still answers 200: the worker builds it as debuggerFullscreenUrl || debuggerUrl || "" from the hosted browser's debug endpoint, and nothing between there and the response checks it. An empty value means the start failed in practice even though it succeeded on the wire. Discard the session_id and start another connect; there is nothing for the shopper to open.
merchant"ifood"yes—
notestringyesA fixed disclosure sentence, held as a constant in cart/connect-core.ts and repeated verbatim by the finish call: iFood checkout is not supported yet, the login is stored for a future capability, and no purchase can be made on iFood today in live or in test.
session_idstringyesThe live browser session. It is also the handle for the finish call, and the server keeps a single-use pending row for it. Nobody else can present it: a finish is refused unless the row was started by the same org and project.
Example request
curl -X POST https://api.codespar.dev/v1/cart/ifood/connect/start \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
POST /v1/cart/ifood/connect/start HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/cart/ifood/connect/start",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/cart/ifood/connect/start", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.post("/v1/cart/ifood/connect/start");
Example response 200
application/json
{
  "merchant": "ifood",
  "context_id": "context_0000000000000000",
  "session_id": "ses_0000000000000000",
  "live_view_url": "https://example.com/hook",
  "checkout_supported": false,
  "note": "string"
}

POST /v1/cart/meli/connect/start

POSThttps://api.codespar.dev/v1/cart/meli/connect/start

Open a Mercado Livre login for a shopper (connect-once, step 1 of 2)

Opens a hosted browser on a fresh persistent context, points it at mercadolivre.com.br, and returns a live-view URL. The SHOPPER opens that URL and logs into their own Mercado Livre account, 2FA included. The login lands in the context, not here.

WHAT IT BUYS YOU LATER. Once the matching finish call has run, a Mercado Livre checkout made for that consumer drives the browser on THEIR account instead of a shared one. Until then this call has changed nothing a shopper can see.

BEFORE this call returns, the server writes a pending row binding the session it just opened to the context it issued and to the calling org and project. That row is what makes a finish possible, it is single use, and it stops being finishable 30 minutes after the start. A shopper who never logs in costs nothing: the row simply expires.

THE REQUEST BODY IS NOT READ. The handler passes only the merchant and the caller's own authenticated context, so there is no field to send and none to get wrong. One caveat, measured against this repository's Fastify 5 rather than assumed: a request that sets Content-Type: application/json and then sends an EMPTY or malformed body is refused 400 by the framework's body parser before the handler runs, with Fastify's default error body (statusCode, code, error, message, where code is FST_ERR_CTP_EMPTY_JSON_BODY or FST_ERR_CTP_INVALID_JSON_BODY). That is a different shape from the { error, message } below and its code is in neither enum. Sending {}, or sending no body and no content-type, reaches the handler.

SLOW BY CONSTRUCTION. The call waits on a real browser starting up and loading the site; the worker client allows it at least 90 seconds. Set a client timeout to match rather than retrying into a second abandoned session.

Scope: connections:write, not one of the cart:* scopes. Starting a login is the same act as beginning any other authorization dance that ends in a stored credential, and a key allowed only to build carts should not be able to begin one that later spends on a person's account.

A 502 does not tell you how far the attempt got. The hosted browser call and the pending-row insert are inside the same try, so the code is identical whether the worker never answered or the row failed to write after a browser session was already open. When it is the second, the open session is left to time out on its own. Retrying is safe: every start mints a new context and a new session.

Responses

StatusBodyDescription
200objectOK
502objectThe hosted browser worker refused or failed, or the pending session could not be recorded. Not terminal: retry mints a new context and session.
503objectNo browser worker is configured for this deployment, so no connect can be started at all. Terminal for the caller: retrying cannot fix it, and it is an operator's configuration to supply.

Response 200

FieldTypeRequiredDescription
context_idstringyesThe persistent browser context the login is about to be written into, fresh for this connect. Bind it to nothing yourself: the server remembers which context it issued for this session and later checkouts resolve it from the stored mapping.
live_view_urlstringyesThe interactive URL a HUMAN opens to log into their own marketplace account. MAY COME BACK AS AN EMPTY STRING, and the call still answers 200: the worker builds it as debuggerFullscreenUrl || debuggerUrl || "" from the hosted browser's debug endpoint, and nothing between there and the response checks it. An empty value means the start failed in practice even though it succeeded on the wire. Discard the session_id and start another connect; there is nothing for the shopper to open.
merchant"meli"yes—
session_idstringyesThe live browser session. It is also the handle for the finish call, and the server keeps a single-use pending row for it. Nobody else can present it: a finish is refused unless the row was started by the same org and project.
Example request
curl -X POST https://api.codespar.dev/v1/cart/meli/connect/start \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
POST /v1/cart/meli/connect/start HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/cart/meli/connect/start",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/cart/meli/connect/start", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.post("/v1/cart/meli/connect/start");
Example response 200
application/json
{
  "merchant": "meli",
  "context_id": "context_0000000000000000",
  "session_id": "ses_0000000000000000",
  "live_view_url": "https://example.com/hook"
}

POST /v1/cart/nuvemshop/connect/start

POSThttps://api.codespar.dev/v1/cart/nuvemshop/connect/start

Begin a Nuvemshop app install for a store (OAuth, step 1 of 2)

Mints a one-time state and returns the Nuvemshop install URL a MERCHANT opens to authorize the CodeSpar app on their own store. Nothing is connected when this returns: the connection exists after the merchant authorizes and Nuvemshop redirects to redirect_uri, which the server exchanges for a store token out of band.

THIS IS A DIFFERENT KIND OF CONNECT FROM THE MARKETPLACE ONES. meli and ifood open a hosted browser and ask a SHOPPER to log in, and what persists is a browser context. This is ordinary OAuth against a store's admin, the person who opens the link is the MERCHANT, and what persists is an API token. Once it exists, this project's checkouts on that store stop going through a browser and go through Nuvemshop's API instead (Tier 1).

THE DOMAIN IS CHECKED AGAINST NUVEMSHOP'S OWN HOSTING, not merely parsed. merchant_domain must end with a Nuvemshop-hosted suffix (.lojavirtualnuvem.com.br, .tiendanube.com, and the platform's other hosted suffixes); a store's custom domain is refused with 400 even though it serves the same shop. Send the hosted domain.

country PICKS THE API ROOT, not a language. br resolves the Brazilian root; ar and mx resolve the international one. Omitting it is fine; it becomes a resolver default rather than an error.

THE STATE IS BOUND TO THE CALLER'S ORG AND PROJECT before the URL is handed back, which is what makes the unauthenticated callback safe: the redirect carries no bearer, so the tenant is recovered from the state row and nothing else. Treat the URL as single use and do not hand the same one to two merchants.

THIS ROUTE ANSWERS ITS OWN 401, unlike the rest of the client surface. The shared preHandler admits service-to-service callers that carry no org or project, and this handler needs both to bind the state, so it refuses again with { error: "auth_required" } — a body no generic 401 handler produces. The 503 is the other one worth reading: it means this DEPLOYMENT has no Nuvemshop app registered (NUVEMSHOP_APP_ID and friends unset), which no caller can fix by retrying.

Request body

FieldTypeRequiredDescription
country"br" | "ar" | "mx"no—
merchant_domainstringyes—

Responses

StatusBodyDescription
200objectOK
400objectThe body did not match the schema. The usual cause is a merchant_domain that is a store's custom domain rather than its Nuvemshop-hosted one; the issue's message says which suffixes are accepted.
401objectThe caller is authenticated but carries no org and project — a service-to-service key, which the shared preHandler admits and this route cannot use. A different body from the cross-cutting 401.
502objectThe state could not be minted. Safe to retry: nothing was handed to the merchant.
503objectThis deployment has no Nuvemshop app registered, so no install can be started. An operator sets NUVEMSHOP_APP_ID, NUVEMSHOP_APP_SECRET and NUVEMSHOP_REDIRECT_URI; retrying cannot fix it.

Response 200

FieldTypeRequiredDescription
install_urlstringyesThe Nuvemshop authorize URL the MERCHANT opens. It carries the one-time state; hand it to one merchant and treat it as spent once opened.
redirect_uristringyesWhere Nuvemshop sends the merchant after they authorize. Echoed back so an integrator can confirm it matches what the app is registered with — a mismatch fails at Nuvemshop, not here.
Example request
curl -X POST https://api.codespar.dev/v1/cart/nuvemshop/connect/start \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "merchant_domain": "string",
       "country": "br"
     }'
POST /v1/cart/nuvemshop/connect/start HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "merchant_domain": "string",
  "country": "br"
}
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/cart/nuvemshop/connect/start",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "merchant_domain": "string",
      "country": "br"
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/cart/nuvemshop/connect/start", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "merchant_domain": "string",
    "country": "br"
  }),
});

const data = await res.json();
const result = await cs.api.post("/v1/cart/nuvemshop/connect/start", {
  body: {
    merchant_domain: "string",
    country: "br"
  }
});
Example response 200
application/json
{
  "install_url": "https://example.com/hook",
  "redirect_uri": "https://example.com/hook"
}

POST /v1/cart/sessions

POSThttps://api.codespar.dev/v1/cart/sessions

Price a cart at a merchant

Builds a cart at the merchant and returns it priced, with line items, totals and the payment methods the merchant's checkout accepts. NOTHING IS BOUGHT and no money moves: this is the quote step, and the numbers in totals are what the merchant says the order would cost right now.

WHICH RAIL ANSWERS IS RESOLVED FOR YOU. The dispatcher tries its rails in registration order and the first one that claims the merchant handles the call; the rail that won is named in rail, so you can tell a connected-API answer from a browser-driven one without knowing the routing table. When no rail claims the merchant, the answer is 422 no_cart_rail, and that is a statement about coverage, not about the merchant being closed or the items being unavailable.

IDENTIFIERS COME FROM A SEARCH, NOT FROM GUESSWORK. variant_id is the merchant's own id for a specific purchasable variant (a size, a colour), which is the granularity a cart needs; a product id will not do. seller is only meaningful on marketplaces where the same variant is offered by several sellers.

LIMITS ARE THE VALIDATOR'S, and they refuse rather than truncate: at least 1 and at most 50 line items, and a quantity between 1 and 99 per line.

THE SESSION IS NOT A RESERVATION. Nothing here holds stock or freezes a price, and there is no route to read a session back later: the object in the 200 is the whole of it. Treat id as the merchant-side handle it is and re-price rather than assume the quote survives.

A TIER 0 (BROWSER-DRIVEN) MERCHANT MAY ANSWER 422 HERE EVEN THOUGH IT SUPPORTS CHECKOUT. The browser rail's own session step is deliberately not implemented — its cart is assembled during checkout, in one browser run — so for those merchants POST /v1/cart/checkout is the entry point and this route is not a required first step.

Request body

FieldTypeRequiredDescription
itemsarray of objectyes—
merchantstringyes—

Responses

StatusBodyDescription
200objectOK
400objectThe body did not match the schema. Most often an empty items, a quantity outside 1..99, or a variant_id that is not a string.
422objectNo rail supports this merchant, or the rail that does cannot assemble a cart outside a checkout run. Not a transient failure: retrying the same merchant answers the same way until a rail covers it.
502objectThe merchant, or the hosted browser in front of it, failed or refused. message relays the underlying failure. Safe to retry: no cart persisted here.

Response 200

FieldTypeRequiredDescription
accepts_pixbooleannoConvenience mirror of payment_handlers. ABSENT means the rail did not say, not false.
checkout_urlstringnoA hosted checkout page, where the rail exposes one at this step.
currencystringyes—
fulfillment_addressobjectno—
fulfillment_optionsarray of objectno—
idstringyesThe merchant-side handle for this cart. Not a reservation and not readable back through this API.
line_itemsarray of objectyes—
merchantstringyes—
payment_handlersarray of objectno—
railstringyesWhich rail answered (vtex, nuvemshop, browser, acp, ucp, ...). The practical split is connected-API rails against the browser rail, which is what decides latency and what a checkout can return.
raw—noThe rail's own object, passed through for debugging. Its shape is the merchant's and changes without notice; nothing in this API depends on it and neither should you.
status"in_progress" | "not_ready_for_payment" | "ready_for_payment" | "completed" | "canceled"yesready_for_payment is the one that matters: the cart can be taken to checkout. not_ready_for_payment means the merchant still wants an address or a fulfillment choice, which this route does not collect. canceled is terminal for this cart.
totalsarray of objectyes—
Example request
curl -X POST https://api.codespar.dev/v1/cart/sessions \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "merchant": "string",
       "items": [
         {
           "variant_id": "variant_0000000000000000",
           "quantity": 0,
           "seller": "string"
         }
       ]
     }'
POST /v1/cart/sessions HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "merchant": "string",
  "items": [
    {
      "variant_id": "variant_0000000000000000",
      "quantity": 0,
      "seller": "string"
    }
  ]
}
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/cart/sessions",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "merchant": "string",
      "items": [
        {
          "variant_id": "variant_0000000000000000",
          "quantity": 0,
          "seller": "string"
        }
      ]
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/cart/sessions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "merchant": "string",
    "items": [
      {
        "variant_id": "variant_0000000000000000",
        "quantity": 0,
        "seller": "string"
      }
    ]
  }),
});

const data = await res.json();
const r = await cs.api.response("post", "/v1/cart/sessions", {
  body: {
    merchant: "string",
    items: [
      {
        variant_id: "variant_0000000000000000",
        quantity: 0,
        seller: "string"
      }
    ]
  }
});
// r.status is one of the documented statuses (200, 422),
// each with its own body shape in r.data; nothing here throws on 422.
if (r.ok) {
  console.log(r.data);
}
Example response 200
application/json
{
  "id": "obj_0000000000000000",
  "rail": "string",
  "merchant": "string",
  "status": "in_progress",
  "currency": "BRL",
  "line_items": [
    {
      "id": "obj_0000000000000000",
      "item": {
        "id": "obj_0000000000000000",
        "name": "Example",
        "unit_amount": 1000
      },
      "quantity": 0,
      "name": "Example",
      "unit_amount": 1000,
      "sku": "string",
      "variant_id": "variant_0000000000000000",
      "images": [
        "string"
      ],
      "availability_status": "string",
      "totals": [
        {
          "type": "string",
          "display_text": "string",
          "amount": 1000
        }
      ]
    }
  ],
  "totals": [
    {
      "type": "string",
      "display_text": "string",
      "amount": 1000
    }
  ],
  "fulfillment_options": [
    {
      "type": "shipping",
      "id": "obj_0000000000000000",
      "title": "Example",
      "totals": [
        {
          "type": "string",
          "display_text": "string",
          "amount": 1000
        }
      ]
    }
  ],
  "fulfillment_address": {
    "name": "Example",
    "line_one": "string",
    "line_two": "string",
    "city": "string",
    "state": "string",
    "country": "string",
    "postal_code": "string"
  },
  "payment_handlers": [
    {
      "id": "obj_0000000000000000",
      "name": "Example",
      "kind": "pix",
      "psp": "string"
    }
  ],
  "accepts_pix": true,
  "checkout_url": "https://example.com/hook"
}
Cart | CodeSpar