Buyer-initiated returns

A buyer opens their order, picks an item, says why, and gets a label. Everything after that happens over days, not seconds, which is what separates this flow from a counter return: the decision, the parcel, and the money each land at their own time.

For a buyer standing at a register instead, see In-store returns.

Show what can come back#

Start with an eligibility check. It evaluates every remaining fulfilled allocation on the order, so your client never has to work out what earlier Returns already consumed.

Bash
curl -X POST https://api.withflintpay.com/v1/return-eligibility-checks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": "ord_1kmn0aExample",
    "selection": { "selection_type": "all_remaining_fulfilled" }
  }'

Each result carries the order_line_item_id, the resolved fulfillment_id, and the item's frozen name, SKU, image, options, and modifiers. Its eligibility object holds the rest: eligible_quantity, expires_at for the deadline, allowed_resolution_types, and a status of eligible, ineligible, or review_required with a reason explaining it. That is enough to render the whole screen without a second read.

is_self_service_enabled on each line tells you whether to show a return button at all. Use it rather than deciding from status yourself.

To check only part of an order, use selection_type: "line_items" and list them. An empty array is not a shorthand for everything; it is rejected.

The check reserves nothing and may go stale the moment it returns. Treat it as what to show, never as a promise.

Create the request#

Send the line, the quantity, and the reason the buyer picked. Include fulfillment_id when the same order line shipped more than once. If you leave it out where it is ambiguous, the error names the candidate fulfillments rather than guessing.

Bash
curl -X POST https://api.withflintpay.com/v1/returns \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: buyer-return-ord-001" \
  -d '{
    "order_id": "ord_1kmn0aExample",
    "line_items": [
      {
        "order_line_item_id": "li_1kmn0aExample",
        "fulfillment_id": "ful_1kmn0aExample",
        "requested_quantity": 1,
        "return_reason_id": "rrsn_1kmn0aExample"
      }
    ]
  }'

The Return comes back in requested. Render the item from the frozen snapshot on the Return line and the buyer's next step from handoff_requirements. Neither needs a call to Orders or Locations.

Note what this request leaves out: no resolution type. The buyer has not committed to a refund or an exchange yet, and the next section is why.

When no policy matches, an API caller can still create the request. It goes to manual review, and deciding it needs no override reason because there was no policy to override. A hosted buyer portal treats an active matching policy as your opt-in to self-service, so no policy means no return button there.

Let the buyer compare outcomes#

Refund or exchange is a real choice, and buyers make it badly when the numbers are guesses. Price each option server side instead.

Bash
curl -X POST https://api.withflintpay.com/v1/return-resolution-previews \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "return_id": "ret_1kmn0aExample",
    "expected_return_revision": 1,
    "resolution_type": "refund",
    "line_items": [
      { "return_line_item_id": "retli_1kmn0aExample", "quantity": 1 }
    ]
  }'

Call it once per option you want to show. A preview approves nothing, reserves nothing, and starts nothing. Each response names the based_on_return_revision it was calculated against, so a preview computed before the Return changed is recognisably stale rather than quietly wrong.

Once the buyer chooses, record it on the line:

Bash
curl -X PATCH https://api.withflintpay.com/v1/returns/ret_1kmn0aExample/line-items/retli_1kmn0aExample \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expected_return_revision": 1,
    "requested_resolution_type": "refund"
  }'

This is still a preference, not a commitment. An unambiguous automatic policy then decides the Return and creates and confirms the matching resolution on its own, attributed to a system actor. Without a policy, it waits for you to decide it.

A portal session sees a narrower Return

Reading a Return with a buyer portal session returns fewer fields than the same read with a merchant API key. supported_actions is trimmed to at most ["cancel"], completion_blockers is filtered to the four a buyer can personally clear (handoff_pending, resolution_not_selected, resolution_requires_action, resolution_failed), and merchant-private fields are removed. This is deliberate, not a bug: the rest of the list is warehouse and merchant work, and showing it reads as "your return is stuck" when nothing is wrong.

Return labels and tracking#

Return shipping does not live on the Return. It uses the same shipment resource as outbound delivery, with the direction flipped.

Bash
curl -X POST https://api.withflintpay.com/v1/fulfillments/ful_1kmn0aExample/shipments \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "direction": "return",
    "return_id": "ret_1kmn0aExample",
    "return_line_items": [
      { "return_line_item_id": "retli_1kmn0aExample", "quantity": 1 }
    ],
    "carrier": "usps",
    "tracking_number": "9400100000000000000000",
    "label_url": "https://labels.example.com/label.pdf"
  }'

A few things follow from that shape:

  • direction is set once at create and cannot be patched. Outbound and return fields do not mix: an outbound shipment rejects return fields with OUTBOUND_SHIPMENT_RETURN_FIELDS_FORBIDDEN, and a return shipment needs both a return_id and at least one allocation (RETURN_SHIPMENT_LINE_ITEMS_REQUIRED).
  • Find the inbound leg later by listing shipments filtered by return_id.
  • handed_off_at on a return shipment satisfies an after_handoff refund gate for the lines it declares. That is how you refund on drop-off rather than on arrival.

Flint does not buy labels. Generate one with your carrier or 3PL and record it here so the buyer, the warehouse, and your support team all read the same tracking number.

Track it back#

The buyer's view of progress is handoff_requirements and completion_blockers. A handoff requirement drops out of the array entirely once satisfied, so an empty array means nothing is waiting on the buyer.

Drive your own UI from webhooks rather than polling:

EventWhat changed
return.decision_recordedYou approved, partially approved, or declined
return_receipt.createdThe parcel arrived
return_receipt.verifiedMerchandise that arrived unidentified was matched to a line
return_resolution.updatedThe refund or exchange moved
return.completedEverything is settled

When the buyer owes money#

An exchange for something more expensive leaves a balance. The resolution links a Return-scoped PaymentIntent, and you launch the standard hosted checkout for it:

Bash
curl -X POST https://api.withflintpay.com/v1/return-resolutions/retres_1kmn0aExample/checkout-session \
  -H "Authorization: Bearer YOUR_API_KEY"

The 201 uses the normal checkout launch envelope: data.checkout_session and data.hosted_checkout with its URL and bounded authorization token. From there it is the ordinary payment_collection and current_payment_action flow, unchanged. Do not copy processor secrets onto the Return.

Details, including what happens when that payment fails, are in Return resolutions.

Next steps#

Rate this doc