> ## Documentation Index
> Fetch the complete documentation index at: https://docs.closient.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Returns Verification

> Verify a serialized unit was actually sold by your organization, and hasn't already been returned — one call for the return desk.

Returns Verification answers a narrower, sharper question than a general product lookup: **did this organization sell this exact unit, and has it already come back?** It's built for a return-desk scanner or POS plugin — one call, one decision.

<Note>
  Don't confuse this with [Serial Verification](/guides/verification-service) (`gs1:verificationService`). That's a public, unauthenticated anti-counterfeit check anyone can run from a consumer scan. This is an authenticated, retailer-scoped returns check — a different question, a different API, a different audience.
</Note>

## How a unit becomes verifiable

Verification reads from your organization's own [EPCIS 2.0](/guides/epcis-events) event history — there's no separate "sale record" table, and no state to keep in sync by hand. Get the events in, and the verify call reads them back out.

### Recording a sale

Two ways to get sale events in, both landing on the same event log:

**Real-time, per serial** — capture it like any other EPCIS event:

```bash theme={null}
curl -X POST https://www.closient.com/epcis/api/2.0/capture \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "ObjectEvent",
    "action": "OBSERVE",
    "eventTime": "2026-07-15T14:32:00Z",
    "eventTimeZoneOffset": "+00:00",
    "epcList": ["https://id.gs1.org/01/00614524740108/21/ABC123XYZ"],
    "bizStep": "retail_selling",
    "disposition": "retail_sold",
    "bizTransactionList": [{"type": "po", "bizTransactionID": "TXN-88213"}]
  }'
```

**Nightly batch** — upload your POS export as a CSV instead of one call per sale:

```
POST /epcis/api/v1/sales-imports/csv
GET  /epcis/api/v1/sales-imports/template   # sample CSV with the expected columns
```

Each row is translated into the same `retail_selling` / `retail_sold` event server-side. Re-uploading a file is safe — rows already captured are reported as `skipped`, not duplicated, so a replayed nightly export is a no-op. Maximum upload size 10 MiB, maximum 10,000 rows per upload.

### Recording a return

Closing the loop uses the same capture endpoint with a different business step:

```json theme={null}
{
  "bizStep": "returning",
  "disposition": "returned",
  "epcList": ["https://id.gs1.org/01/00614524740108/21/ABC123XYZ"]
}
```

Once that lands, the next verify call on the same serial reports `already_returned` — which is what stops the same unit being refunded twice.

## The verify call

```
GET /epcis/api/2.0/verification/serial?gtin={gtin}&serial={serial}
```

| Parameter | Format                                                                        |
| --------- | ----------------------------------------------------------------------------- |
| `gtin`    | GTIN-8/12/13/14. Normalized to GTIN-14; an invalid check digit returns `422`. |
| `serial`  | GS1 AI(21) serial number, 1–255 characters.                                   |

Requires an `X-API-Key` for an organization membership with **OWNER or MANAGER** role — this is a privileged, retailer-side call, not a public scan.

```json theme={null}
{
  "decision": "already_returned",
  "gtin": "00614524740108",
  "serial": "ABC123XYZ",
  "lot": "L2847",
  "sold_at": "2026-07-15T14:32:00Z",
  "sale_transaction_reference": "TXN-88213",
  "returned_at": "2026-07-22T09:10:00Z",
  "recall": null
}
```

### Decision states

| `decision`         | Meaning                                                                                                                                                                                                                                  |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sold`             | This organization sold this exact unit and it hasn't come back.                                                                                                                                                                          |
| `already_returned` | This organization already accepted a return on this unit — a second return attempt is the double-return pattern this exists to catch.                                                                                                    |
| `never_sold`       | This organization has events for this serial (received it, shipped it) but never recorded selling it. A return attempt with `never_sold` didn't come from this store's shelf.                                                            |
| `unknown_serial`   | This organization has no event history for this GTIN + serial at all.                                                                                                                                                                    |
| `recalled_lot`     | The unit belongs to a lot under an open recall. This outranks every state above it — even a legitimately sold-and-returnable unit reports `recalled_lot` if its lot is recalled, and the sale/return fields stay populated alongside it. |

`recall` is populated only when `decision` is `recalled_lot`, and carries `recall_id`, `title`, `severity`, and the matched `lot`.

## Why the states catch what they catch

Serialization at the return desk is a targeted defense, not a blanket one — it's worth being specific about what each pattern trips:

* **Receipt fraud** (a valid receipt paired with a different, unpurchased unit) — the receipt matches a transaction, but the physical serial being returned was never the one sold under it, so it reports `never_sold` regardless of how convincing the paperwork looks.
* **Double returns** — the second attempt on an already-processed unit reports `already_returned` instead of quietly refunding twice.
* **Cross-retailer arbitrage** (bought at one retailer, returned at another for a better refund) — because verification only ever reads *your* organization's own events, a unit this store never sold reports `never_sold` or `unknown_serial` here even though another retailer's system would show it as legitimately sold.
* **Counterfeit-swap returns** (genuine unit purchased, a counterfeit substituted back into the box before return) — this only works if the serial is scanned off the *unit itself* at the return desk, not read off the outer packaging. A code that lives only on the box comes back unchanged no matter what's inside it.

## Tenant isolation

Verification answers are computed **only** from the querying organization's own events. Two retailers selling the same GTIN + serial (unlikely, but the model doesn't assume otherwise) hold entirely separate event histories and neither can see the other's. The one deliberate exception is recall status: a recall is public product-safety information, not one retailer's private event, so an open recall on a unit is reported regardless of which organization published it or which organization is asking.

## Performance

The read is a single indexed lookup against denormalized columns cached on the serial's row — never a walk through the event log. This is what makes it safe to call inline at a return desk rather than as a background check.

## Errors

| Status | When                                                         |
| ------ | ------------------------------------------------------------ |
| `401`  | Missing or invalid API key.                                  |
| `403`  | Authenticated, but not an OWNER/MANAGER on the organization. |
| `422`  | GTIN fails GS1 check-digit validation.                       |
| `429`  | Rate limit exceeded (default 300/min, 10,000/day per key).   |

## Related

* [EPCIS 2.0 Events](/guides/epcis-events) — the event log this reads from
* [Serial Verification](/guides/verification-service) — the *different*, unauthenticated anti-counterfeit check; don't confuse the two
