Second life (Circular)
In the Circular mode the shopper does not send an order back: they offer products to the shop for a second life, the shop values them, and the shopper is paid for what the shop accepts. This page describes the API behind the second-life portal. The returns and exchanges flow is covered in Create a return and Create an exchange; most concepts (sessions, shipping methods, labels, the error body) are the same.
The mode is enabled per shop. Every endpoint below answers an error when the shop has no Circular: 400 Circular is not enabled when the code is requested (POST /otp; the other login calls answer 400 Marketplace not found), 503 Catalog is not available for the current mode on the catalog.
Authentication
The second-life portal identifies the shopper by email alone, with a one-time code, because the products offered do not have to come from an order.
- Request the code.
marketplaceNameis the URL name of the shop (the one in the portal address),modeandlangare query parameters, both required:
POST /otp?mode=Circular&lang=en
Content-Type: application/json
{ "email": "[email protected]", "marketplaceName": "your-shop" }{ "message": "Verification code sent" }- Exchange the six-digit code received by email for a session token:
POST /otp/verify
Content-Type: application/json
{ "email": "[email protected]", "marketplaceName": "your-shop", "code": "123456", "mode": "Circular" }{
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": "2026-09-16T11:12:00.000Z"
}The code is valid for seven minutes by default and for one use; five wrong attempts lock it. Each of /otp and /otp/verify is limited to six requests per minute per IP and email. A wrong or expired code answers 401 Unauthorized; an email the shop has blacklisted (or not whitelisted) answers 401 as well. The session is scoped to the shop and the email, not to an order: the endpoints that need an order (/products/{id}/options, /products/{id}/search, PATCH /orders/contact) do not work with it.
This flow is built for the portal: without a key, the call must come from a browser on a portal domain of the shop (Origin or Referer header: 401 ApiKeyRequired when there is neither, 400 Domain is not allowed from any other site). The partner API key described in Getting started identifies a server-side integration by itself and lifts that check.
A second way to open a session is the link the shopper receives by email once an offer exists: it carries a signed token that POST /auth exchanges for a session on that offer ({ "mode": "Circular", "token": "...", "internalId": "<id of the offer>" }).
What can be offered
Shops decide how the shopper picks products, and the answer of GET /orders reflects it. It is a list of pseudo-orders built from up to three sources, depending on the shop configuration:
- Catalog: one entry with every product of the shop catalog as items (
returnabletrue,cannotBeExchangedtrue, no real fulfillment). - Categories: one entry carrying
categories, a tree whose leaves havereasonsandquestions, for shops that value by category instead of by product. - Orders: the real orders of the shopper in the shop, with their line items and the units still available.
Two more entry points help build a picker:
GET /orders/list: the orders of the shopper rendered as catalog products (id,name,createdAt,products[]with their variants, reasons and questions), skipping units already returned or offered.GET /products/catalog/{marketplaceUrlName}?mode=Circular&numResults=15&query=shirt: the public catalog of the shop, no session needed, paged withpageCursorandcursorAction(afterorbefore; up to 15 products per page). Always passmode=Circular: without it the endpoint answers the Returns catalog.GET /categories/{marketplaceUrlName}?mode=Circular: the category tree alone, public as well, with thereasonsandquestionsof each leaf; the same treeGET /ordersembeds, useful before the shopper logs in.
In every source each variant or category leaf carries the reasons the shopper can choose. Reasons decide the money: amount is the amount offered for the item (a percentage of the catalog price when isAmountPercentage is true), a reason with amountMax is a range between amount and amountMax the shop settles at appraisal, and an assessable reason means the shop will value the item after seeing it. The order entries of GET /orders expose only amount and isAmountPercentage; amountMax and assessable come with the catalog and category entries and with GET /products/catalog/.... Reasons also say what the shopper must provide: requireCustomerInfo, requireCustomerImage, minPicsRequired, minVideosRequired (see Attachments). questions are the extra questions of the shop, answered in answers.
The policy
GET /policies returns the rules of the shop for the mode of the token. In Circular the relevant fields are:
| Field | Meaning |
|---|---|
needValidation | The shop values the offer before accepting it: create the offer with /purchases/request (see below) |
userAcceptation | After the appraisal the shopper must accept or reject the amounts |
allowedPaymentMethods | Payout methods: BankTransfer (needs an IBAN), StoreCredit, GiftCard, DiscountCode (Shopify) |
bankTransferReductionCircular / isBankTransferReductionPercentageCircular | Amount or percentage the shop deducts when the shopper chooses a bank transfer |
storeCreditExtraQuantity / isStoreCreditExtraQuantityPercentage | Bonus the shop advertises for store credit |
discountCode | Circular only: the bonus and conditions of the DiscountCode payout (a single-use Shopify discount code): bonusQuantity / isBonusPercentage, validityDays (null = no expiry), minimumPurchasePercentage over the code value (evaluated after other discounts), collections (empty = the whole order). In this version minimumPurchaseAmount is always null and restrictToCustomer always true: every code is bound to the customer of the request and combines with the shop's other discounts. Null for other modes |
maxNumberOfItems | Maximum units of one offer |
termsAndConditions / explicitTermsAndConditions | Terms to show; the creation requires their acceptance |
shopListUrl | Page with the physical shops that take second-life items, when the Shop shipping type exists |
allowedPaymentMethods and maxNumberOfItems are not enforced by the API in Circular: honour them in your integration.
Shipping methods
An offer that ships needs a method. In Circular the API prices by weight and origin address rather than by order items:
POST /carriers/methods
Content-Type: application/json
{
"weight": 1200,
"addressParam": { "firstName": "Jane", "lastName": "Doe", "address1": "Calle Mayor 1", "city": "Madrid", "zip": "28013", "countryCode": "ES", "provinceCode": "M", "phone": "+34600000000" }
}weight is in grams (20 to 80000). The response is the same list of methods as in returns, each with id (the methodId of the creation), shippingMethodId, shippingMethodType, price, needSchedule and, for pick ups, pickUpDates (see the returns guide). Without weight the request fails validation (400 ValidationFailed) unless items is sent instead; without addressParam the endpoint answers an empty list, not an error. The alternative POST /carriers/group-methods prices by country and province plus the selected products, categories or itemIds, grouped by shipping type.
Create the offer
There are two endpoints, and the policy decides which one to use.
Shops that accept offers directly
POST /purchases/create creates the offer with its shipping and payout in one call. The offer is accepted as it is (validationStatus Accepted), the label is generated (unless the shop pays through MultiSafePay and the payout does not cover the shipping, in which case the shopper pays first) and the shop receives the parcel.
POST /purchases/create?lang=en
Content-Type: application/json
{
"contact": { "firstName": "Jane", "lastName": "Doe", "address1": "Calle Mayor 1", "city": "Madrid", "zip": "28013", "countryCode": "ES", "provinceCode": "M", "phone": "+34600000000" },
"products": [
{
"id": "8253231888",
"items": [
{ "variantId": "47046763118885", "reasonId": "9e09f0f3-55bd-4be9-ab86-971eeb773ded", "customerInfo": "Worn twice", "customerImageNames": ["c0ffee00-1111-4a4a-9c9c-bbbbbbbbbbbb"], "answers": [] }
]
}
],
"methodId": "fdba82c7-abd2-441f-9498-15227cfa0514",
"shippingMethodId": "BNS_PCL",
"shippingType": "DropOff",
"paymentMethod": "BankTransfer",
"iban": "ES9121000418450200051332",
"holderName": "Jane Doe",
"termsAndConditionsAcceptance": true
}| Field | Rules |
|---|---|
contact | Required. The email defaults to the one of the session |
products[], categories[], orders[] | At least one. products and categories take { id, items[] }; orders takes the order line items directly ({ id, reasonId, ... }) |
items[] | One element per unit offered: two units are two elements, each with its reasonId and optional variantId, customerInfo (512 characters), customerImageNames, answers |
methodId, shippingMethodId, shippingType | From the method chosen; desiredPickUp: { date } is required for PickUp: 00:00 UTC of one of the pickUpDates the method lists |
paymentMethod | One of allowedPaymentMethods; iban and holderName are required with BankTransfer. The IBAN is stored encrypted and only its last four digits are ever returned |
paymentMethod: "DiscountCode" | Shopify shops only, and only when allowedPaymentMethods lists it (400 Invalid Payment Method otherwise). The code is issued when the shop processes the purchase; GET /purchases/{internalId} then carries discountCode (code, amount and currency in shop currency, endsAt, minimumSubtotal, collections, restrictToCustomer, shareableUrl). While the shop is issuing it, /validate and /reject answer 400 Purchase is being processed |
termsAndConditionsAcceptance | Must be true |
Shops that value first
When the policy says needValidation, use POST /purchases/request with only the items and the acceptance of the terms; no contact, shipping or payout yet:
{
"products": [
{
"id": "8253231888",
"items": [
{
"variantId": "47046763118885",
"reasonId": "9e09f0f3-55bd-4be9-ab86-971eeb773ded"
}
]
}
],
"termsAndConditionsAcceptance": true
}The shop then values each item from its dashboard. The shopper receives an email with a link to the offer; from your integration, reopen it with POST /auth (token flow above) and read the appraisal:
GET /purchases/{internalId}/tokenIt returns the offer with validationStatus (Accepted or Rejected by the shop), includeUserPreValidation (whether the shopper has a say) and each item with its id, validationStatus, price (the amount offered) and validationReasonInfo when the shop declined it. The shopper closes the offer with one of two calls:
POST /purchases/{internalId}/validate:acceptandrejectlists of{ "itemId" }(the itemidof the token response;rejectentries may addreasonInfo),refundMethod(always required, withibanandholderNameforBankTransfer), and the shipping (methodId,shippingMethodId,shippingType,desiredPickUpfor pick ups, andcontact, which an offer opened with/requestneeds because it has no address yet). Approved items left out of both lists stayPending, so decide on all of them; when the shop does not let the shopper reject (includeUserPreValidationfalse), every approved item must be inaccept. Accepting nothing marks the offer as rejected.POST /purchases/{internalId}/reject: no body; rejects the whole appraisal.
Calling /purchases/request on a shop without validation answers 400 Purchase prevalidation is not enabled. The opposite is not blocked: /purchases/create on a shop that values first skips the appraisal, so read needValidation before choosing.
The response and the follow-up
Both creation endpoints answer the offer:
{
"internalId": "1789734400123",
"name": "P-1789734400123",
"state": "Pending",
"creationDate": "2026-09-16T10:12:00.000Z",
"currency": "EUR",
"totalValue": 18.0,
"shippingCost": 0,
"paymentMethod": "BankTransfer",
"shippingType": "DropOff",
"needLabel": true,
"items": [
{
"name": "Essentials slim T-shirt",
"price": 18.0,
"status": "OnHold",
"reason": { "id": "9e09f0f3-55bd-4be9-ab86-971eeb773ded" }
}
]
}internalIdis the identifier of the offer in every other call (GET /purchases/{internalId},/token,/label,/validate,/reject);GET /purchases/historylists the offers of the shopper withinternalId,nameandcreationDate.items[].priceis the amount offered for the unit; range reasons exposeofferRangeMinandofferRangeMaxand carry the upper bound inpriceuntil the shop settles them; a plain assessable reason with no catalog price shows0until the appraisal.totalValueis the sum after the bank-transfer deduction, if any.validationStatus,userValidationStatus,ibanLast4andholderNameare missing from the creation responses; read them fromGET /purchases/{internalId}(or/tokenfor the validation fields).items[].idis only meaningful in/token, where it is the offer item identifier that/validateexpects; in the other responses it is the category id for category items and absent for product items.statefollows the same values as returns (Pending,PendingApproval,Approved,PartiallyApproved,Declined,Cancelled) anditems[].statusstarts atOnHolduntil the shop processes the parcel.- The label (
labelURL,qrCodeURL) and the tracking are generated asynchronously after the creation or after the shopper's validation; pollGET /purchases/{internalId}with bounded retries.GET /purchases/{internalId}/labelanswers404while no document exists. - The payout is decided by the shop when it processes the offer; the API exposes no payment flag to the shopper beyond
state.
Errors specific to this flow
| HTTP | errorCode | Message | Meaning |
|---|---|---|---|
| 400 | BadRequest | Circular is not enabled | The shop has no second life |
| 400 | BadRequest | Marketplace not found | Unknown marketplaceName at the login |
| 400 | BadRequest | At least one category, product, or order must be provided | Empty offer |
| 400 | BadRequest | Purchase prevalidation is not enabled | /purchases/request on a shop without validation |
| 400 | BadRequest | Item {id} has not enough quantity to purchase | More units than the order line item still has |
| 400 | BadRequest | Variant {id} has no price for market {country} and the reason is a percentage of it | Only /purchases/create: a percentage reason needs a price the catalog does not have for that country |
| 400 | BadRequest | Reason {id} is assessable and needs the validation of the shop | Only /purchases/create: an assessable reason is valued by the shop, use the request flow |
| 400 | BadRequest | Reason {id} requires the items to have customer image and the other reason rules | Missing comment, pictures or videos required by the reason (see Attachments) |
| 400 | BadRequest | IBAN is required when the refund method is BankTransfer / Account holder name is required when the refund method is BankTransfer | Missing IBAN or holder name (normally caught earlier as 400 ValidationFailed) |
| 400 | BadRequest | Purchase is not pre-validated, Purchase already validated by user, Purchase does not allow user rejection, All approved items must be accepted, Trying to accept an item not approved, No decisions provided | Rules of the validate and reject calls |
| 400 | MethodNotAvailable, PickUpDateTooSoon, PickUpDateNotWorkingDay, MaxPickUpDaysExceeded | MaxPickUpDaysExceeded: 7 for the last one | Shipping method or pick up date (not one of the pickUpDates of the method) rejected |
| 400 | CountryIsRequired, ProvinceIsRequired | Incomplete contact address | |
| 404 | NotFound | Not found | Unknown offer for this shopper, or no label yet |
| 503 | ServiceUnavailable | Catalog is not available for the current mode | Catalog of a shop without second life |
The generic codes (ValidationFailed, Unauthorized, TooManyRequests, ServiceUnavailable) apply as described in Getting started.
Updated about 1 month ago
