Return policies and reasons

A policy is how you stop deciding returns by hand. It says what is returnable, for how long, on whose shipping account, at what fee, and whether Flint may approve it without asking you.

Policies are versioned. The policy has a stable ID and a name; each version is an immutable set of rules. When a Return is created, the matching version is frozen onto it, so a Return decided last March can still show the rules it was judged against even after you changed them twice.

Write a policy#

Bash
curl -X POST https://api.withflintpay.com/v1/return-policies \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: policy-standard-001" \
  -d '{
    "name": "Standard returns",
    "version": {
      "priority": 100,
      "scope": { "match_type": "all" },
      "eligibility_result": "eligible",
      "return_window": { "duration_seconds": 2592000, "starts_at_event": "delivered" },
      "allowed_resolution_types": ["refund", "exchange"],
      "approval_mode": "automatic",
      "resolution_mode": "manual",
      "completion_mode": "automatic",
      "refund_timing": "after_receipt",
      "is_merchandise_return_required": true,
      "is_inspection_required": false,
      "receiving_location_id": "loc_1kmn0aExample",
      "return_shipping": { "payer": "buyer" },
      "restocking_fee": { "calculation_type": "percent", "percent": 10 }
    }
  }'

That reads as: everything is returnable for 30 days from delivery, the buyer may ask for a refund or an exchange, we approve automatically, the buyer pays return shipping to that location, we keep 10 percent, and the money goes back once the goods arrive.

A version is validated when you write it, so these are rejected at create and publish rather than surfacing later:

  • An ineligible policy commits no resolution behavior. When eligibility_result is ineligible, you must omit allowed_resolution_types, resolution_mode, refund_timing, and completion_mode entirely. Nothing is returnable under it, so there is nothing to decide. The rest of the rules below apply only to eligible and review_required versions.
  • completion_mode is required on an eligible or review-required version.
  • refund_timing is required whenever refund is in allowed_resolution_types. It also constrains what else you can say: after_handoff and after_receipt need is_merchandise_return_required: true, and after_inspection needs both handback and is_inspection_required: true.
  • is_merchandise_return_required: true requires a receiving_location_id, because it becomes the destination the buyer ships to.
  • resolution_mode: "automatic" requires resolution_selection_mode, and manual must not set it or automatic_resolution_type.
  • Automatic resolution may only offer outcomes Flint can construct alone. Combining resolution_mode: "automatic" and resolution_selection_mode: "buyer_requested" with exchange or replacement in allowed_resolution_types is rejected, because picking replacement merchandise needs a person.

One check happens later than you might expect. Creating or publishing a version only confirms that receiving_location_id is present. Whether that Location exists, is active, and carries a postal address is checked when you activate the policy, which fails with a conflict naming the unusable location. A version can therefore be stored and only rejected at activation.

Publishing a change means publishing a new version, not editing the old one:

Bash
curl -X POST https://api.withflintpay.com/v1/return-policies/rpol_1kmn0aExample/versions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: policy-standard-002" \
  -d '{
    "expected_return_policy_revision": 1,
    "expected_current_return_policy_version_id": "rpolv_1kmn0aExample",
    "version": {
      "priority": 100,
      "scope": { "match_type": "all" },
      "eligibility_result": "eligible",
      "return_window": { "duration_seconds": 5184000, "starts_at_event": "delivered" },
      "allowed_resolution_types": ["refund", "exchange"],
      "approval_mode": "automatic",
      "resolution_mode": "manual",
      "completion_mode": "automatic",
      "refund_timing": "after_receipt",
      "is_merchandise_return_required": true,
      "receiving_location_id": "loc_1kmn0aExample"
    }
  }'

Publishing takes two guards, not one: expected_return_policy_revision for the policy and expected_current_return_policy_version_id for the version you believe is current. Together they stop two people publishing over each other while both looked at the same screen.

PATCH /v1/return-policies/{return_policy_id} changes identity fields such as the name. It does not change rules. If you find yourself wanting to patch a window, publish a version instead.

A policy is evaluated only while it is active. /activate, /deactivate, and /archive control that, and none of them disturb Returns that already froze a version.

Which policy wins#

Policies match by scope and resolve by priority, so you layer a general rule under specific ones.

match_type is either all or include. With all, every other selector must be empty: it is the catch-all. With include, you name what it covers through product_ids, variant_ids, categories, location_ids, or channel_types.

That combination is how you express final sale. Give the catch-all a low priority and eligibility_result: "eligible", then add a higher-priority policy scoped to the products you will not take back with eligibility_result: "ineligible" and an ineligibility_reason. Buyers see the item as not returnable in the eligibility check, with the reason attached.

Do not copy the resolution fields across from your eligible policy when you write that one. An ineligible version must omit allowed_resolution_types, resolution_mode, refund_timing, and completion_mode, and is rejected if it sets any of them.

When nothing matches at all, an API caller can still create a Return. 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 the buyer sees no return option.

Windows#

A window is a duration plus the event it counts from.

starts_at_eventCounts from
fulfilledThe fulfillment completing
deliveredThe carrier delivering
picked_upThe buyer collecting in person
service_completedThe work being finished

delivered is usually what a buyer thinks "30 days" means. fulfilled starts the clock while the parcel is still on a truck, which quietly shortens the window by the transit time.

Fees and who pays shipping#

return_shipping.payer is merchant or buyer, and it is a statement of policy rather than a charge. Flint does not buy labels, so the amount is not on the policy. Where a fee is charged, it lands as a return_shipping_fee adjustment on the resolution, in the same arithmetic as the refund.

restocking_fee is either flat with a flat_money, or percent with a whole-number percent (10 means 10 percent). It surfaces on the resolution as a restocking_fee adjustment and appears in the Return's financial_summary as a deduction, never netted invisibly into the refund.

What automatic actually decides#

Two modes work together. approval_mode decides whether Flint approves the Return itself. resolution_mode decides whether it also creates and confirms the buyer's outcome.

Automatic resolution is deliberately limited to refund and no_monetary_action. An exchange or replacement means picking merchandise, which is a judgement Flint will not make on your behalf. Those still land in front of a person even under an automatic policy.

resolution_selection_mode decides who picks:

  • policy_default uses automatic_resolution_type without asking.
  • buyer_requested waits for the buyer to choose from allowed_resolution_types. Until they do, the Return sits in requested and previews are the way to show them what each option is worth.

completion_mode closes the loop. Under automatic, Flint completes the Return when the last completion blocker clears. Under manual, you call /complete yourself once the list is empty.

Decide a request by hand#

Without an automatic policy, or when you want to overrule one, decide the Return directly. Each line picks one of two decision styles, and they take different fields.

decision_basis: policy_evaluation

Accept the policy proposal

Send approved_quantity and, when approving less than asked, the decline context. The explicit fields are rejected here.

JSON
{
  "expected_return_revision": 1,
  "completion_mode": "automatic",
  "line_items": [
    {
      "return_line_item_id": "retli_1kmn0aExample",
      "decision_basis": "policy_evaluation",
      "approved_quantity": 1
    }
  ]
}

decision_basis: explicit

State the outcome yourself

return_required_quantity is required. Approving any quantity also requires allowed_resolution_types and resolution_mode.

JSON
{
  "expected_return_revision": 1,
  "completion_mode": "automatic",
  "line_items": [
    {
      "return_line_item_id": "retli_1kmn0aExample",
      "decision_basis": "explicit",
      "approved_quantity": 1,
      "return_required_quantity": 1,
      "allowed_resolution_types": ["refund"],
      "selected_resolution_type": "refund",
      "resolution_mode": "automatic",
      "refund_timing": "after_receipt",
      "is_inspection_required": false,
      "receiving_location_id": "loc_1kmn0aExample"
    }
  ]
}

Several fields on the explicit arm are easy to miss because what they require depends on other fields:

  • resolution_mode: "automatic" also requires selected_resolution_type. Flint will not pick the outcome for you. With manual you can leave it unset and decide later.
  • Any return_required_quantity above 0 requires both is_inspection_required and receiving_location_id. Merchandise coming back needs somewhere to go and an explicit answer on whether anyone checks it.
  • refund_timing: "after_inspection" requires is_inspection_required: true, since otherwise the gate never opens.

A returnless line inverts all of that. With return_required_quantity: 0, is_inspection_required cannot be true, and refund_timing cannot be after_handoff, after_receipt, or after_inspection, because none of those events will ever happen. Use after_approval or manual, or leave the timing unset.

Deviating from an active policy requires an override_reason. Deciding where no policy matched does not, because there was nothing to override.

Returnless refunds

Set return_required_quantity: 0 while approving a quantity and the buyer keeps the merchandise. The Return still settles the money and still records why, so it reconciles like any other. Use it when return shipping costs more than the item is worth.

Reasons, and the three vocabularies it is not#

Return reasons are the buyer-facing list: why they say they are sending it back.

Bash
curl -X POST https://api.withflintpay.com/v1/return-reasons \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: reason-too-small-001" \
  -d '{
    "handle": "too_small",
    "name": "Too small",
    "categories": ["fit"],
    "is_note_required": false
  }'

Flint provides defaults; anything you add carries a merchant source. Archiving one stops buyers selecting it, and Returns that already recorded it keep the frozen name, so old Returns never lose their meaning when you tidy the list.

Four vocabularies use the word "reason" and they are not interchangeable:

VocabularyWho supplies itWhere it lives
Return reasonThe buyerreturn_reason_id on the Return line
Inspection findingThe warehousefinding_codes on an inspection line
Decline reasonThe merchantdecline_reason on a decision
Refund reasonFlintreason on the Refund, mapped from the buyer's return reason

A buyer saying "damaged" and a warehouse finding damaged are different claims about the same parcel, and the difference is exactly what you want when one of them is wrong. Keep them separate.

Next steps#

Rate this doc