Second life and repairs
In the Circular mode shoppers offer products to the shop for a second life and are paid for what the shop accepts; in the Repair mode they send products to be repaired and pay for the service. Both flows are driven from the shop side with the same shape as returns, described in Process returns and exchanges: a list and a detail, an optional appraisal before the shopper ships, a verification when the parcel arrives, and a processing that settles the amounts. This page lists what changes, and covers the second-life stock and its publication in the shop.
Both modes are enabled per shop; when they are not, every endpoint answers 503 Circular is not available or 503 Repair is not available. Second-life offers are called purchases in the API (/purchases), repairs live under /repairs.
Second-life offers
List and detail
GET /purchases takes the same filters as GET /returns (same two-month default window, fifteen rows per page, forValidations=true to see the offers waiting for an appraisal) and answers the same list row. GET /purchases/{id} takes the Drelife identifier of the offer (id of the list); to find an offer from the reference shown to the shopper or from a tracking number use GET /purchases/{reference}/internal, which answers { "id", "internalID" }.
The detail adds to the return shape:
| Field | Meaning |
|---|---|
source | Who created the offer: Customer, or Merchant and Admin for the self-listings described below |
totalValue, shippingValue | What the shop pays and what the shopper pays for the shipping |
paymentMethod, allowedPaymentMethods, ibanLast4, holderName | The payout method (StoreCredit, GiftCard, DiscountCode, CreditCard or BankTransfer); allowedPaymentMethods only lists what can be selected for a second life on this CMS; the bank account is only shown to owners, managers and approvers, the full IBAN through GET /purchases/{id}/iban |
discountCode, mockDiscountCode | The Shopify discount-code issuance of a DiscountCode payout (see below): its status (pending, issued, failed, mock), failureReason, and for an issued or mocked code its amount and currency (shop currency), endsAt, minimumSubtotal, collections and restrictToCustomer. The code and shareableUrl are only returned to owners, managers and approvers |
canDownloadVerificationLabel, preparationSetAt, preparationEditable, publicationSummary | The second-life steps after processing: the verification labels, the preparation photos and the publications (total, pending, publishing, failed, published, sold) |
items[].offerRangeMin, items[].offerRangeMax, items[].reason, items[].isCategory | The offer per unit: a range when the reason is a range, and the category when the shopper offered a product of the catalog tree |
items[].preApprovalImageUrls, items[].preApprovalImageIds | The verification photos of the unit and their ids, aligned (an id links the photo to another unit, see Verify below) |
items[].preparationImageUrls, items[].publicationState, items[].publicationId, items[].hasPublicationDraft | The preparation photos and the publication of the unit, and whether it holds a saved publish form (the row of its lot in the acquisitions list) |
1. Appraise
When the policy has allowCircularPreValidation, the shop values each unit before the shopper ships:
POST /purchases/{id}/pre-validate
Content-Type: application/json
{
"items": [
{ "purchaseItemId": "d38fce81-...", "validationStatus": "Accepted", "amountOffer": 18.00 },
{ "purchaseItemId": "e49a0f22-...", "validationStatus": "Rejected", "validationReasonInfo": "Model not accepted for second life" }
],
"message": "The jacket is accepted at 18 euros."
}Roles: StoreOwner, Manager, Approver, PreValidator. Every unit must appear (400 You must provide a decision for each purchase item); amountOffer is required when accepting (400 Amount offer not found for item with id ...), validationReasonInfo when rejecting, and a unit may be sent as Pending to decide it later. The offer becomes Accepted when no unit is pending and one at least is accepted, Rejected (and Declined) when all are rejected; totalValue becomes the sum of the offers, and the shopper is notified to confirm and ship (userPreValidationStatus). Errors: 400 Pre-validations are not allowed in this marketplace, 400 The purchase has been pre-validated already, 400 Purchase is not pending.
2. Verify
POST /purchases/{id}/pre-process works exactly as for returns: items[] of { "purchaseItemId", "preApprovalReasonID", "preApprovalReasonInfo", "attachments" }, where preApprovalReasonInfo is always present (send "" when there is nothing to add) and attachments is optional, uploaded through POST /purchases/{id}/pre-approval/upload-url and .../delete-upload. Several units may carry the same upload (identical units verified together): it is moved once and the one file is linked to each of them. linkedImageIds (optional, per item) links verification photos other units of the same second life already have, by their items[].preApprovalImageIds; an id from outside the second life answers 400 Linked images must belong to this second life before anything is linked. Roles: StoreOwner, Manager, PreApprover, Shop. The errors name the resource Second life (400 Second life is not pending, 400 Second life is not pre-validated, 400 Pre-approvals are not allowed for second life in this marketplace). Lines an approval draft left Approved/Declined are accepted while the second life is still Pending and unverified; while a discount code is being issued the call is refused with 409 DiscountCodeIssuancePending.
Once verified, GET /purchases/{id}/verification-label downloads a PDF label to stick on each unit: ?itemId=... prints one unit, ?itemIds=<id>,<id> several, and with neither every unit that was not rejected (400 Second life must be verified before printing the labels, 404 Second life item not found for a rejected or unknown unit).
3. Process
POST /purchases/{id}/process
Content-Type: application/json
{
"items": [
{ "returnItemId": "d38fce81-...", "status": "Approved", "amount": 18.00 },
{ "returnItemId": "f50b1c33-...", "status": "Declined", "amount": 0, "motive": "Damaged beyond repair" }
],
"manuallyRefunded": false
}Roles: StoreOwner, Manager, Approver. The item identifier is called returnItemId here, for historical reasons; there is no restock field. The payload lists exactly the processable units. amount is capped at the amount offered and, when the call processes an offer of a shopper, must be greater than zero for an approved unit. When every unit is decided, the payout is issued in your platform through the payout method (any method but BankTransfer), the offer becomes Approved, Declined or PartiallyApproved, and the shopper is notified. With BankTransfer nothing is issued: the shop wires the money itself with the IBAN of GET /purchases/{id}/iban. manuallyRefunded: true records the payout without issuing it; process: false saves the decisions without processing.
Errors: 405 PurchaseLocked (concurrent call; retry in a few seconds), 400 The second life must be pre-processed before being processed, 400 The purchase must be paid by the customer before being processed, 400 Refund amount must be greater than 0 for approved item ..., 404 Marketplace address not found (no Circular address), 404 Purchase not found (also once the offer is no longer pending).
POST /purchases/{id}/payment-method with { "paymentMethod": "GiftCard" } changes the payout method while the offer is pending, within allowedPaymentMethodsCircular (400 Refund method not allowed for this marketplace policy) and before any payout exists (400 Cannot change refund method: refund already issued). DiscountCode must be explicitly listed, on a Shopify store, with its settings in place (422 UnsupportedRefundMethod / 422 DiscountCodeSettingsMissing). POST /purchases/{id}/set-as-paid (owners and managers) records that the shopper paid the shipping offline.
Self-listings
A shop can put its own products into the second-life flow without a shopper, for instance returned stock it wants to refurbish and resell:
POST /purchases/onboard
Content-Type: application/json
{
"items": [
{ "variantId": "0b1c2d3e-...", "quantity": 2 },
{ "newProduct": { "name": "Vintage denim jacket", "variantName": "M", "sku": "VDJ-M", "price": 49.90, "images": ["https://storage.googleapis.com/.../products/manual/<marketplaceId>/....jpg"] }, "quantity": 1 }
],
"methodId": null,
"senderAddress": null
}Roles: StoreOwner, Manager, Publisher. Each item has either variantId (a variant of the catalog) or newProduct. A unit without SKU gets a generated one, the same for all the units of the product, from the counter and prefix described under publishing (see below): a newProduct whose sku is absent or blank, and a catalog variant the shop has no SKU for, which keeps reusing the SKU an earlier import gave it so its units are grouped and published together (the catalog variant itself is not changed). For such a variant you may send your own sku on the item instead, shared by the lines of that variant sent without one; it is refused with 400 beside newProduct (use newProduct.sku) and for a variant that has a SKU. The units report it as their sku; with methodId and a senderAddress a label is generated to ship the units to the second-life address, without them the units are considered on site. The offer is created pending, with the appraisal already accepted and marked as paid, named SL-<timestamp>, with source Merchant. Images of new products are uploaded first with POST /purchases/onboard/upload-url ({ "mimetype" }, images only). It answers 201 with the offer.
The form can be saved half-way and resumed later, by anyone with these roles, as a draft: POST /purchases/onboard/drafts with { "lines": [...], "methodId", "shippingType", "pickUpDate": "YYYY-MM-DD", "senderAddress" } creates one (201), PUT /purchases/onboard/drafts/{id} replaces it, GET /purchases/onboard/drafts lists the drafts of the shop (the last saved first, with createdBy, updatedBy and version), GET /purchases/onboard/drafts/{id} reads one and DELETE /purchases/onboard/drafts/{id}?version= discards it (204, also when it is already gone). Every save bumps the draft's version: send the one you loaded in the PUT body and in the DELETE; when someone else saved, discarded or imported it meanwhile the answer is 409 with code: DRAFT_CONFLICT, and nothing is written. Each line keeps what the form shows (label, sku, image, quantity, the PVR as typed in recommendedRetailPrice) and exactly one of variantId (with its catalog productId) or newProduct; only lines is required. A draft creates nothing: no offer, no hidden product, no SKU. Send its id as draftId in POST /purchases/onboard and it is deleted with the import. A draft already imported or discarded answers 409 with code: DRAFT_GONE and nothing is created, so the same draft is never imported twice; send the import without draftId to create it anyway. The method, the pick-up day and the catalog variants of a draft are checked again on import.
The second-life stock
Once an offer is processed, its approved units become stock to prepare and publish. GET /purchase-items lists the units (statuses, sku, productName, purchaseName, origin, publicationState, sortBy, paged with page), GET /purchase-items/{id} reads one; both take the StoreOwner, Manager and Approver roles. The steps below also accept the Publisher role, a user dedicated to the publication.
Prepare
Preparation is the studio step: photos of the refurbished unit, stamped with the circularity logo. Upload each photo with POST /purchases/{id}/preparation/upload-url ({ "purchaseItemId", "filename", "mimetype" }, images only, answers a signed form and a path), then save the set:
POST /purchases/{id}/preparation
Content-Type: application/json
{
"items": [
{ "purchaseItemId": "d38fce81-...", "keptImageIds": ["7a8b..."], "attachments": [ { "path": "temp/purchases/<id>/<itemId>/preparation/....jpg", "filename": "front.jpg", "mimetype": "image/jpeg", "size": 348211 } ] }
]
}Roles: StoreOwner, Manager, PreApprover, Shop. Saved photos not listed in keptImageIds are removed from the unit, and deleted once no unit of the second life shows them; keptImageIds may also name a photo another unit of the same second life already has, which is then linked to this one too. Several units may carry the same upload: it is stamped once and shared. Units not listed are untouched. The offer must be verified and not fully published (400 Second life is not verified yet, 400 Second life is fully published and can no longer be prepared); a preparation started by another user is locked for them (403 PREPARATION_LOCKED, with preparedBy), owners and managers override it.
Publish (Shopify)
Publication creates or updates a product in the shop; it is available for Shopify shops. The units ready to publish are listed by GET /purchase-items/circular/acquisitions (approved units without a publication, and failed publications), each with its price (the amount paid), image and hasBaseProduct. The identical units of an outlet import come as ONE row, a lot, with units = how many it holds: units of the same second life and SKU, with the same verification verdict (reason, comment, refurbishment), the same preparation photos and the same publication state (pending or failed); the row is the unit holding the lot's draft, else its first unit, and a draft saved on any unit makes the whole lot a draft. Any other unit is a row of its own (units: 1), and the 15-row pages count lots. The detail at /acquisitions/{id} adds every available image, skuPrefix, the root of the SKU the unit gets when it is published without one (see below), publicationDefaults (locationId, collectionId, salesChannelIds), the location, collection and channels the shop's policy suggests (see The policy), not applied for you: send them in the publish body, lotItemIds: the units of its lot still to publish, that one first (units, their count), and lotPublication: the listing the lot already has, if any (publicationId, title, price, currency, image, effectiveSku, groupingKey and unitsForSale, its stock), null otherwise. Four lookups feed the form: GET /purchase-items/base-products?query= (the catalog products to attach the unit to, with their variants at /base-products/{id}), GET /purchase-items/circular/shopify/locations, .../publication-channels, .../categories?query= and .../collections?query=.
POST /purchase-items/circular/acquisitions/{purchaseItemId}/publish
Content-Type: application/json
{
"resolutionMode": "BASE_MATCH",
"baseProductId": "0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
"baseVariantId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
"locationId": "gid://shopify/Location/1",
"salesChannelIds": ["gid://shopify/Publication/1"],
"collectionId": "gid://shopify/Collection/1",
"title": "Essentials slim T-shirt (second life)",
"description": "Refurbished, minor wear.",
"price": 12.90,
"currency": "EUR",
"selectedExistingImageUrls": [
"https://storage.googleapis.com/.../preparation-images/front.jpg",
"https://storage.googleapis.com/.../preparation-images/back.jpg"
],
"imageOrder": [
"https://storage.googleapis.com/.../preparation-images/back.jpg",
"https://storage.googleapis.com/.../preparation-images/front.jpg"
]
}resolutionMode is BASE_MATCH (a new variant of an existing product; baseProductId required) or CREATE_NEW (a new product; forced for products created with newProduct). baseVariantName names the variant of the product created in Shopify: under BASE_MATCH the base variant's title, split into option values by / (M / Rojo); under CREATE_NEW one option value as sent (the size given to a newProduct at the outlet import, Black/White included); without it the product has no variant name. baseProductId and baseVariantId are the id values returned by GET /purchase-items/base-products and /base-products/{id}, Drelife catalog identifiers, not Shopify ones; the location, channels and collection are Shopify identifiers. Required: locationId, salesChannelIds (one at least), title, price above zero, currency, one image at least (existing URLs in selectedExistingImageUrls, from the preparation, customer or verification photos, or files uploaded through POST /purchase-items/{id}/cms/upload-url passed in selectedUploadedImagePaths). imageOrder is optional and sets the order of the product's photos in Shopify: the entries of selectedExistingImageUrls and selectedUploadedImagePaths, as sent, in the order they will show, the first one being the cover; selected images it leaves out go last and entries matching no selected image are ignored. Without it, the image in primaryImageUrl goes first and the rest follow in the order sent. sku is optional: when it is absent or blank the API generates a unique one, <prefix>-<number> (DL-000123), where the prefix is the base policy's skuPrefixCircular (DL until one is set, see The policy) and the number comes from a platform-wide counter that grows by one on every generation, so two publications never get the same generated SKU, even when they run at the same time; a number spent by a publication that then failed is not reused, but a retry of that failed publication keeps the SKU it was given. A SKU sent in the body is used as is and may repeat one already in the shop: units sharing a SKU are reported together by the refurbished groups below. It answers 201 with the publication: publicationId, state (PUBLISHING, PUBLISHED_EDITABLE, SOLD_LOCKED, PUBLISH_FAILED, UPDATE_FAILED), externalId, groupingKey, effectiveSku, publishedAt and imageUrls, the images it ended up with (primary first, uploads already stamped). A lot is published as one Shopify variant with stock: send quantity (default 1) to publish that many units of the unit's lot, the first quantity ids of lotItemIds. The product is created with this unit in stock at locationId; then every other unit gets its own publication on the same externalId and variant, with the same data, SKU and images, and only then does the stock grow by the units that joined, so the shop never sells a unit without its publication. A unit that could not join meanwhile (published by another call, no longer approved) is never counted. If Shopify refuses the stock, this unit stays published and the others go back to PUBLISH_FAILED (lastErrorCode: LOT_STOCK_NOT_ADDED) to be added with lot-units; if Shopify does not answer whether it applied the change (a timeout), they stay published and the case is logged to check the variant's stock by hand. The answer adds units, the units the listing holds. A lot is published once: while one of its units is being published, publishing another one answers 409 LOT_PUBLISH_IN_PROGRESS, and once it is listed, 409 LOT_ALREADY_LISTED. Errors: 400 Only approved purchase items can be published, 400 At least one image is required, 400 INVALID_IMAGE_URL (images must be on the Shopify or Drelife storage hosts, over HTTPS), 400 LOT_QUANTITY_EXCEEDED (more units than the lot has waiting), 409 LOT_ALREADY_LISTED (the lot has a listing: add the units to it instead), 409 LOT_PUBLISH_IN_PROGRESS (another unit of the lot is being published: add this one to its listing once it is listed), 409 Purchase item already published, 409 A publish for this purchase item is already in progress, 404 Shopify shop URL not configured.
The form can be saved as a draft and resumed later by any publisher, from any device: PUT /purchase-items/circular/acquisitions/{purchaseItemId}/draft takes the same fields as the publish body but quantity, all optional (price and weight may be zero), plus uploadedImagePaths (every file uploaded for the form, picked or not) and version. Nothing reaches Shopify and no SKU is generated. Send the version of the draft the form was loaded from, and none when there was no draft: when the draft changed meanwhile (saved by someone else, discarded or published) it answers 409 DRAFT_CONFLICT, so nobody overwrites a draft they have not seen; a unit being published or already published answers 409 DRAFT_NOT_ALLOWED. It answers 200 with the draft: version, updatedAt, updatedBy (id, name), payload (the fields saved) and uploadedImages (path, publicUrl, selected). The detail at /acquisitions/{id} returns the same object as draft (null without one), and the list flags hasDraft. DELETE .../draft?version= discards it with its uploads (204). The publish itself saves what it publishes as the draft, under the same lock that reserves it and without checking version (send uploadedImagePaths in the publish body too, so the draft lists every uploaded file): a successful publish removes the draft, a failed one (PUBLISH_FAILED) leaves it with exactly what was tried, ready to fix and publish again. A publish refused before it starts (a 400, or 409 A publish for this purchase item is already in progress) does not touch the draft. The list takes publicationStates, comma-separated: PENDING (no publication nor draft), DRAFT (a draft, no publication) and PUBLISH_FAILED (a failed publish, with or without a draft).
The units still waiting of a lot that is already listed join that listing, never a second one:
POST /purchase-items/circular/acquisitions/{purchaseItemId}/lot-units
Content-Type: application/json
{ "quantity": 2 }It takes that unit and the next ones waiting in lotItemIds, gives each a publication on the listing's variant (its data and images) and raises the variant's stock by the units that joined. Same roles as publishing. It answers 201 with the listing's publication and units, its units for sale. Errors: 409 LOT_NOT_LISTED (the lot has no listing yet: publish it), 409 LOT_UNITS_UNAVAILABLE (none of the units can join). When Shopify refuses the stock change the call fails and the units that joined go back to PUBLISH_FAILED (lastErrorCode: LOT_STOCK_NOT_ADDED), waiting in the acquisitions list to retry; when it does not answer whether it applied it, they stay joined and the case is logged to check the stock by hand.
Afterwards: PATCH /purchase-items/circular/publications/{publicationId} updates title, price, images or channels of a published unit (with imageOrder as above, sent together with the image lists, the product's photos in Shopify are reordered too; imageOrder alone answers 400 IMAGE_ORDER_WITHOUT_IMAGES) (409 Publication is sold and locked once sold); POST .../publications/{publicationId}/sync-sale checks in Shopify whether the unit was sold, or marks it sold with { "soldAt", "soldOrderExternalId" }; GET /purchase-items/circular/refurbished/groups (and /groups/{groupingKey}) reports the published units grouped by product, with counts, sales and average price.
The units of a lot share the listing, so these calls act on it: editing a unit for sale carries the change to the lot's other units for sale; sync-sale sells a unit with the oldest live, paid order line of the variant that still has units nobody took (among the 30 most recent orders; each unit is one sale of quantity 1, and a unit already marked sold with that order holds one of its units), and with none when every line is taken — a line not paid yet is left to the processor, which allocates it on payment; two syncs of the same lot run one after the other; marking a unit sold by hand takes it off the variant's stock. Each order line of a lot is split across its units, the oldest for sale first (two orders processed at the same time never take the same unit: the second takes the next one for sale), and every sale is fulfilled in Shopify as one unit of the line. In the refurbished group detail the units of a lot are separate publications sharing externalId.
Repairs
Repairs mirror the second-life flow under /repairs, with repairItemId in the appraisal and the verification, returnItemId in the processing, items[].reasons[] (a list) instead of reason, the errors naming Repair, and these differences:
- The quote is what the shopper pays, so in
POST /repairs/{id}/pre-validatetheamountOfferof an accepted unit is zero or negative:-25for a repair that costs 25 (400 Invalid amount offer for item with id ...for a positive value).totalValueis the sum andisPaidtells whether the shopper has paid it; with MultiSafePay the payment is collected online (paymentURL), otherwise the shop collects it and records it withPOST /repairs/{id}/set-as-paid. POST /repairs/{id}/processhas nomanuallyRefundedand issues nothing in your platform: approving a unit means it is repaired and goes back to the shopper. When the shop uses Drelife direct logistics, the return shipment label is generated at that moment and the detail exposes it asdirectLabelURL,directTracking,directTrackingNumberanddirectCarrierName. Errors:405 RepairLocked,400 The repair must be pre-processed before being processed,400 The repair must be paid by the customer before being processed,404 Marketplace address not found(no Repair address).GET /repairs/{id}/report?lang=enanswers the URL of a PDF report of the repair.- There is no stock, preparation or publication for repairs.
Roles at a glance
| Action | Roles |
|---|---|
List, read, /internal | StoreOwner, Manager, Approver, PreApprover, Shop (PreValidator reads the detail only) |
Appraise (/pre-validate) | StoreOwner, Manager, Approver, PreValidator |
| Verify, upload verification photos, prepare | StoreOwner, Manager, PreApprover, Shop |
| Process, change the payout method, read the IBAN | StoreOwner, Manager, Approver |
| Set as paid, self-listings | StoreOwner, Manager |
| Publish and manage publications | StoreOwner, Manager, Approver, Publisher |
Discount codes (Shopify second lives)
With DiscountCode in allowedPaymentMethodsCircular (see set up your shop), processing a second life on Shopify issues a single-use code discount for the payout plus its own bonus (discountCodeExtraQuantityCircular) instead of store credit. Unlike a gift card it is not stored value: it applies to the products of the cart (never shipping), whatever the cart does not consume is lost, and an order paid with it refunds only the money actually paid when it is returned. The customer gets the code through the Approved email and the customer portal; Shopify sends nothing.
How a process works with it:
- Before writing anything, the API refuses a non-Shopify store (
422 UnsupportedRefundMethod), missing settings (422 DiscountCodeSettingsMissing) or an order in another currency without a known exchange rate (422 DiscountCodeExchangeRateMissing, change the refund method). A code restricted to the customer resolves the Shopify customer first. - The item decisions, the refund and the issuance (a transaction with gateway
DiscountCode, the code and its frozen conditions, statuspendingormock) are written in one database transaction. Exactly one live issuance can exist per purchase. - Shopify is called after that commit. A definitive rejection (invalid collection, missing scope...) closes the issuance as
failedand answers422 DiscountCodeRejectedwith the reason: the purchase can be processed again (a fresh code) or its refund method changed. An unknown outcome (timeout, Shopify unavailable) answers409 DiscountCodeIssuancePendingand leaves itpending. - While an issuance is
pending,issuedormock, a newPOST /purchases/{id}/processresumes it: the body items are ignored,manuallyRefunded: true,POST /purchases/{id}/pre-validateandPOST /purchases/{id}/pre-processare refused with409 DiscountCodeIssuancePending, and the refund method cannot be changed. The resume looks the code up in Shopify before creating it, so a retry never issues twice.process: falsealso resumes: it issues, approves and notifies.
The amount is converted to the shop currency with the order's exchange rate; the minimum purchase is the code value plus the configured percentage over it. Every code is bound to the customer of the request and combines with the shop's other discounts.
Updated 5 days ago
