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

# Orders

> List orders and upload TCGplayer order data and seller feedback to Hoard.

## List orders

Read the seller's order history, newest first, with the same filters the Hoard
dashboard exposes. Each order can optionally embed its line items.

```
GET /api/orders
```

This endpoint is mounted at `/api/orders`, **not** `/api/v1/orders` — the
`/api/v1/orders` POST is a separate upload surface (see below). Bearer
authentication is supported (`Authorization: Bearer YOUR_API_KEY`) for AI
assistants and integrations.

### Line items

By default, each order in the response carries up to **20 line items**,
sorted by id ascending. The response also includes `items_truncated` and
`items_total_count` so the caller knows whether they got every item.

* `?items_per_order=N` — change the cap. Clamped to `0..200`. `0` omits
  items entirely (legacy callers that only need order headers).
* `?items=full` — return every line item on every order. Use this when
  building a packing slip or auditing a refund. Pairs poorly with very wide
  pages — prefer `per_page=10&items=full` over `per_page=200&items=full`.

When items are returned, each item has this shape:

| Field          | Type    | Description                                                      |
| -------------- | ------- | ---------------------------------------------------------------- |
| `id`           | integer | Line item id.                                                    |
| `product_name` | string  | Card or product name.                                            |
| `product_line` | string  | Game (`Magic`, `Pokemon`, `Lorcana`, etc.).                      |
| `condition`    | string  | TCGplayer condition string (`Near Mint`, `Lightly Played`, ...). |
| `quantity`     | integer | Units sold.                                                      |
| `unit_price`   | string  | USD as a string (preserves trailing zeros).                      |
| `line_total`   | string  | `quantity × unit_price`, rounded to two decimals.                |

Refund, discount, tax, and internal id fields are intentionally omitted from
the embedded line-item shape. If you need per-line refund detail, the dashboard
exposes it on the individual order page.

Order-**level** refund fields are present on each order header: `refund_type`
(`full` / `partial`), `refund_origin` (who initiated it, e.g. `tcgplayer`),
`refund_amt` (refunded dollars as a USD string), `refund_reason`, and
`refund_note`. All are `null` when the order was not refunded; `refund_amt`
being absent means "not refunded", distinct from a `$0` refund. `refund_reason`
and `refund_note` are buyer/marketplace-authored free text — on this read
surface they are length-bounded and stripped of control / zero-width characters.

### Other filters

All standard order filters work — pass any combination:

* `page` (default 1), `per_page` (default 50, max 200)
* `sort` = `date | total | buyer | status | number` (default `date`),
  `direction` = `asc | desc` (default `desc`)
* `status` — one status, or `status[]=Completed&status[]=Refunded` for many
* `search` — order number or buyer name (case-insensitive substring)
* `date_from`, `date_to` — `YYYY-MM-DD` window on `order_date`
* `product_line` — restrict to orders containing at least one item in the
  given game
* `refund=true` — only refunded orders
* `unfeedbacked=true` — only orders with no seller feedback yet
* `shipping_type` = `pwe | tracked | expedited`
* `ship_mark=pending` — only orders the seller has marked Shipped that have not
  yet synced back from TCGplayer (the "pushing to TCGplayer" working set). Each
  order row carries `shipped_marked_at` (timestamp, null if never marked) and
  `marked_shipped_pending` (boolean, true while the optimistic mark is still
  ahead of the synced status).
* `pull_session` — a pull session id, or one of: `active` (the open session),
  `none` (available to pull — excludes dismissed orders), `dismissed` (orders
  excluded from pulls). Each order row includes `pull_dismissed_at` (timestamp,
  null when the order is still pullable).

### Example response

```json theme={null}
{
  "orders": [
    {
      "id": 12345,
      "order_number": "ORD-12345",
      "buyer_name": "Alice",
      "order_date": "2026-05-18",
      "order_placed_at": "2026-05-18T14:32:00Z",
      "status": "Completed",
      "product_amt": "24.99",
      "shipping_amt": "0.99",
      "total_amt": "25.98",
      "shipping_type": "pwe",
      "item_count": 2,
      "shipped_marked_at": null,
      "marked_shipped_pending": false,
      "items": [
        {
          "id": 901,
          "product_name": "Lightning Bolt",
          "product_line": "Magic",
          "condition": "Near Mint",
          "quantity": 2,
          "unit_price": "1.50",
          "line_total": "3.00"
        },
        {
          "id": 902,
          "product_name": "Counterspell",
          "product_line": "Magic",
          "condition": "Lightly Played",
          "quantity": 1,
          "unit_price": "21.99",
          "line_total": "21.99"
        }
      ],
      "items_truncated": false,
      "items_total_count": 2
    }
  ],
  "total": 1,
  "page": 1,
  "per_page": 50,
  "total_pages": 1,
  "status_counts": {"Completed": 1},
  "pull_sessions": []
}
```

### curl example

```bash theme={null}
# Header-only (no line items)
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://www.tryhoard.com/api/orders?items_per_order=0"

# Every line item on every order in the page
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://www.tryhoard.com/api/orders?per_page=25&items=full"
```

***

## Upload orders

Upload parsed order data from TCGplayer's order export. The request is shape-validated
synchronously and queued for upsert in a background job — the response is `202 Accepted`
and the database write happens asynchronously. Orders are upserted by
`(user_id, order_number)` so re-uploading the same orders is safe.

```
POST /api/v1/orders
Content-Type: application/json
```

The endpoint accepts both raw JSON and gzip-compressed JSON. For large batches
(typically the first full-history upload), gzip is preferred — set
`Content-Type: application/gzip` and gzip the JSON body. The server transparently
decompresses gzip and falls back to raw bytes when the body is not gzip-encoded.

**Request body:**

```json theme={null}
[
  {
    "order_number": "ORD-12345",
    "buyer_name": "John Doe",
    "order_date": "3/27/2026",
    "status": "Completed",
    "product_amt": 24.99,
    "shipping_amt": 0.99,
    "total_amt": 25.98
  }
]
```

### Order object fields

| Field          | Type   | Required | Description                                                       |
| -------------- | ------ | -------- | ----------------------------------------------------------------- |
| `order_number` | string | Yes      | TCGplayer order number. Used as the unique key for upserts.       |
| `buyer_name`   | string | No       | Buyer's display name from the order.                              |
| `order_date`   | string | No       | Order date as parsed from TCGplayer CSV (e.g., `"3/27/2026"`).    |
| `status`       | string | No       | Order status from TCGplayer (e.g., `"Completed"`, `"Cancelled"`). |
| `product_amt`  | number | No       | Product subtotal in USD.                                          |
| `shipping_amt` | number | No       | Shipping amount in USD.                                           |
| `total_amt`    | number | No       | Total order amount in USD.                                        |

**Response:**

```json theme={null}
{"status": "accepted", "count": 1}
```

HTTP status is `202 Accepted` — the server has validated the payload shape and
enqueued the upsert. The `count` field reflects the number of orders in the
request (not the number of new rows — duplicates are updated in place).

If an active Pull session exists, the same upload also checks refreshed orders
against Pull eligibility. Orders that are now cancelled, shipped, refunded, or
otherwise no longer pullable stay in the session but are marked for review so
the tablet queue can warn the puller instead of silently dropping work.

### curl example

```bash theme={null}
curl -X POST \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"order_number":"ORD-12345","buyer_name":"John Doe","order_date":"3/27/2026","status":"Completed","product_amt":24.99,"shipping_amt":0.99,"total_amt":25.98}]' \
  https://www.tryhoard.com/api/v1/orders
```

For a large batch, gzip the JSON first:

```bash theme={null}
gzip -c orders.json | curl -X POST \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/gzip" \
  --data-binary @- \
  https://www.tryhoard.com/api/v1/orders
```

### Error responses

| Code | Error code               | Meaning                                                                  |
| ---- | ------------------------ | ------------------------------------------------------------------------ |
| 422  | `invalid_json`           | Request body is not valid JSON                                           |
| 422  | `invalid_gzip`           | `Content-Type` is `application/gzip` but the body is not valid gzip      |
| 422  | `invalid_payload`        | Body is not an array of order hashes, or a row is missing `order_number` |
| 422  | `empty_payload`          | The orders array is empty                                                |
| 413  | `payload_too_large`      | Request body exceeds 10 MB on the wire                                   |
| 413  | `decompressed_too_large` | Decompressed gzip body exceeds 50 MB                                     |
| 413  | `too_many_rows`          | More than 50,000 orders in a single request                              |

## Notes

* Hoard parses TCGplayer's order export CSV and converts it to this JSON format
* First sync sends full order history (from January 2020)
* Subsequent syncs send a rolling window of recent orders to keep their status current; older history is sent once and not re-sent every sync
* Focused `refresh_orders` tasks reuse the same upload endpoint after Hoard fetches updated orders
* Order upload is non-fatal. If it fails, the inventory sync still completes.
* Active Pull sessions keep their snapshot stable; refreshed orders that drift
  out of eligibility are flagged in the Pull queue.

***

## Upload order refunds

Upload parsed refund rows from TCGplayer's order export. The request is
shape-validated synchronously and queued for upsert in a background job — the
response is `202 Accepted` and the database write happens asynchronously.
Refund rows are upserted by `(user_id, order_number)`, either creating new
orders or enriching existing ones with refund metadata.

```
POST /api/v1/order_refunds
Content-Type: application/json
```

The endpoint accepts both raw JSON and gzip-compressed JSON. For large
batches, set `Content-Type: application/gzip` and gzip the JSON body. The
server transparently decompresses gzip and falls back to raw bytes when the
body is not gzip-encoded.

**Request body:**

```json theme={null}
[
  {
    "order_number": "ORD-12345",
    "buyer_name": "John Doe",
    "product_amt": 24.99,
    "shipping_amt": 0.99,
    "total_amt": 25.98,
    "order_date": "3/27/2026",
    "status": "Refunded",
    "refund_type": "full",
    "refund_origin": "tcgplayer"
  }
]
```

**Response:**

```json theme={null}
{"status": "accepted", "count": 1}
```

HTTP status is `202 Accepted` — the server has validated the payload shape
and enqueued the upsert. The `count` field reflects the number of refund rows
in the request.

### curl example

```bash theme={null}
curl -X POST \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"order_number":"ORD-12345","buyer_name":"John Doe","total_amt":25.98,"refund_type":"full","refund_origin":"tcgplayer"}]' \
  https://www.tryhoard.com/api/v1/order_refunds
```

For a large batch, gzip the JSON first:

```bash theme={null}
gzip -c refunds.json | curl -X POST \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/gzip" \
  --data-binary @- \
  https://www.tryhoard.com/api/v1/order_refunds
```

### Error responses

| Code | Error code               | Meaning                                                             |
| ---- | ------------------------ | ------------------------------------------------------------------- |
| 422  | `invalid_json`           | Request body is not valid JSON                                      |
| 422  | `invalid_gzip`           | `Content-Type` is `application/gzip` but the body is not valid gzip |
| 422  | `invalid_payload`        | Body is not an array of objects, or a row is missing `order_number` |
| 422  | `empty_payload`          | The refunds array is empty                                          |
| 413  | `payload_too_large`      | Request body exceeds 10 MB on the wire                              |
| 413  | `decompressed_too_large` | Decompressed gzip body exceeds 50 MB                                |
| 413  | `too_many_rows`          | More than 50,000 refund rows in a single request                    |

***

## Upload order line items

Upload parsed line items for existing orders. The request is shape-validated
synchronously and queued for upsert in a background job — the response is
`202 Accepted` and the database write happens asynchronously. Items are
upserted by `(order_id, sku_id)`. Items whose `order_number` has no matching
order owned by the user are silently skipped, and multiple sku-less rows per
order are allowed.

```
POST /api/v1/order_items
Content-Type: application/json
```

The endpoint accepts both raw JSON and gzip-compressed JSON. The same gzip
fallback rules as `/api/v1/orders` apply.

**Request body:**

```json theme={null}
[
  {
    "order_number": "ORD-12345",
    "product_name": "Lightning Bolt",
    "product_line": "Magic",
    "condition": "Near Mint",
    "set_name": "Alpha",
    "rarity": "C",
    "quantity": 2,
    "sku_id": 12345,
    "main_photo_url": "https://example.com/bolt.jpg"
  }
]
```

**Response:**

```json theme={null}
{"status": "accepted", "count": 1}
```

### curl example

```bash theme={null}
curl -X POST \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"order_number":"ORD-12345","product_name":"Lightning Bolt","sku_id":12345,"quantity":2}]' \
  https://www.tryhoard.com/api/v1/order_items
```

For a large batch, gzip the JSON first:

```bash theme={null}
gzip -c items.json | curl -X POST \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/gzip" \
  --data-binary @- \
  https://www.tryhoard.com/api/v1/order_items
```

### Error responses

| Code | Error code               | Meaning                                                             |
| ---- | ------------------------ | ------------------------------------------------------------------- |
| 422  | `invalid_json`           | Request body is not valid JSON                                      |
| 422  | `invalid_gzip`           | `Content-Type` is `application/gzip` but the body is not valid gzip |
| 422  | `invalid_payload`        | Body is not an array of objects                                     |
| 422  | `empty_payload`          | The items array is empty                                            |
| 413  | `payload_too_large`      | Request body exceeds 10 MB on the wire                              |
| 413  | `decompressed_too_large` | Decompressed gzip body exceeds 50 MB                                |
| 413  | `too_many_rows`          | More than 50,000 line items in a single request                     |

***

## Upload order shipping

Upload shipping enrichment (carrier, address, tracking, weight) for existing
orders. The request is shape-validated synchronously and queued for a bulk
SQL update in a background job — the response is `202 Accepted` and the
database write happens asynchronously. Updates are keyed on
`(user_id, order_number)`; unknown order numbers are silently skipped.

```
POST /api/v1/orders/shipping
Content-Type: application/json
```

The endpoint accepts both raw JSON and gzip-compressed JSON. The same gzip
fallback rules as `/api/v1/orders` apply.

**Request body:**

```json theme={null}
[
  {
    "order_number": "ORD-12345",
    "shipping_method": "USPS First Class",
    "city": "Portland",
    "state": "OR",
    "postal_code": "97201",
    "tracking_number": "9400111899223100001234",
    "item_count": 3,
    "product_weight": 0.5
  }
]
```

**Response:**

```json theme={null}
{"status": "accepted", "count": 1}
```

### curl example

```bash theme={null}
curl -X POST \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"order_number":"ORD-12345","shipping_method":"USPS First Class","city":"Portland","state":"OR","postal_code":"97201","tracking_number":"9400111899223100001234"}]' \
  https://www.tryhoard.com/api/v1/orders/shipping
```

For a large batch, gzip the JSON first:

```bash theme={null}
gzip -c shipping.json | curl -X POST \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/gzip" \
  --data-binary @- \
  https://www.tryhoard.com/api/v1/orders/shipping
```

### Error responses

| Code | Error code               | Meaning                                                             |
| ---- | ------------------------ | ------------------------------------------------------------------- |
| 422  | `invalid_json`           | Request body is not valid JSON                                      |
| 422  | `invalid_gzip`           | `Content-Type` is `application/gzip` but the body is not valid gzip |
| 422  | `invalid_payload`        | Body is not an array of objects, or a row is missing `order_number` |
| 422  | `empty_payload`          | The shipping array is empty                                         |
| 413  | `payload_too_large`      | Request body exceeds 10 MB on the wire                              |
| 413  | `decompressed_too_large` | Decompressed gzip body exceeds 50 MB                                |
| 413  | `too_many_rows`          | More than 50,000 shipping rows in a single request                  |

***

## Upload seller feedback

Upload seller feedback ratings scraped from TCGplayer. Matches each item to an
existing order by `order_number` and stores the rating and comment.

Items with no matching order or ratings outside 1–5 are silently skipped.

```
POST /api/v1/order_feedbacks
Content-Type: application/json
```

**Request body:**

```json theme={null}
[
  {
    "order_number": "ORD-12345",
    "rating": 5,
    "comment": "Fast shipping, great packaging!"
  }
]
```

### Feedback object fields

| Field          | Type    | Required | Description                                                                 |
| -------------- | ------- | -------- | --------------------------------------------------------------------------- |
| `order_number` | string  | Yes      | TCGplayer order number to attach feedback to. Must match an existing order. |
| `rating`       | integer | Yes      | Seller rating from 1 (lowest) to 5 (highest).                               |
| `comment`      | string  | No       | Optional buyer comment.                                                     |

**Response:**

```json theme={null}
{"status": "ok", "updated": 1}
```

The `updated` field is the number of orders that had feedback written to them.

### curl example

```bash theme={null}
curl -X POST \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"order_number":"ORD-12345","rating":5,"comment":"Great seller!"}]' \
  https://www.tryhoard.com/api/v1/order_feedbacks
```

### Error responses

| Code | Error code      | Meaning                        |
| ---- | --------------- | ------------------------------ |
| 422  | `invalid_json`  | Request body is not valid JSON |
| 422  | `empty_payload` | The feedback array is empty    |
