> ## 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.

# Sync

> Inventory sync endpoints — check pending, upload inventory, cancel, log, debug.

## Check pending

Check if the server wants Hoard to sync.

```
GET /api/v1/sync/pending
```

### curl example

```bash theme={null}
curl -H "Authorization: Bearer YOUR_API_KEY" \
  https://www.tryhoard.com/api/v1/sync/pending
```

**Response:**

```json theme={null}
{
  "action": "wait",
  "reason": "no_sync_needed",
  "sync_requested": false
}
```

### Response fields

| Field            | Type    | Description                                                             |
| ---------------- | ------- | ----------------------------------------------------------------------- |
| `action`         | string  | `"sync"` — run a cycle now. `"wait"` — poll again later.                |
| `reason`         | string  | Why the server made this decision. See values below.                    |
| `sync_requested` | boolean | Whether a sync was explicitly requested by the user from the dashboard. |

### `reason` values

| Value              | Meaning                                                                                                  |
| ------------------ | -------------------------------------------------------------------------------------------------------- |
| `manual_request`   | A sync is pending — either the user clicked "Sync Now" or the scheduler decided the interval has elapsed |
| `no_sync_needed`   | No action needed, poll again                                                                             |
| `in_progress`      | A sync is already running                                                                                |
| `auto_sync_paused` | Auto-sync is disabled for this account                                                                   |

Clients do not need to distinguish manual from scheduled — the server is the single decision-maker. The original trigger is recorded server-side on `sync_cycle_logs.trigger_source` for analytics.

## Agent heartbeat

Signal that Hoard is alive and check for updates.

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

**Request body (optional):**

```json theme={null}
{
  "version": "0.4.9",
  "platform": "darwin/arm64"
}
```

**Response:**

```json theme={null}
{
  "update_available": false,
  "force_update": false,
  "product_lines": [
    {"name": "magic", "category_id": 1},
    {"name": "pokemon", "category_id": 2}
  ]
}
```

### Response fields

| Field              | Type    | Description                                                                                 |
| ------------------ | ------- | ------------------------------------------------------------------------------------------- |
| `update_available` | boolean | Whether a newer Hoard version is available.                                                 |
| `force_update`     | boolean | Whether the local install must update before continuing (kill-switched version).            |
| `latest_version`   | string  | Latest version string. Only present when `update_available` is true.                        |
| `download_url`     | string  | URL to download the new binary. Only present when `update_available` is true.               |
| `signature`        | string  | Ed25519 signature for verifying the download. Only present when `update_available` is true. |
| `sha256`           | string  | SHA-256 checksum for verifying the download. Only present when `update_available` is true.  |
| `product_lines`    | array   | Product lines configured for this account. Each has `name` and `category_id`.               |

## Upload inventory

Upload a gzipped inventory CSV from TCGplayer.

```
POST /api/v1/sync
Content-Type: application/gzip
X-Product-Line: magic
```

The request body is the gzip-compressed CSV data. The server queues it for processing, matches cards to the catalog, and calculates price updates.

| Header           | Required | Description                                                                                                           |
| ---------------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`   | Yes      | Must be `application/gzip` or `application/octet-stream`                                                              |
| `X-Product-Line` | No       | Which product line this upload belongs to (e.g., `magic`, `pokemon`, `yugioh`). Must match a configured product line. |

**Response (HTTP 202):**

```json theme={null}
{"status": "accepted"}
```

**Error responses:**

| Code | Error code               | Meaning                                                             |
| ---- | ------------------------ | ------------------------------------------------------------------- |
| 413  | `payload_too_large`      | Request body exceeds 10 MB on the wire                              |
| 413  | `decompressed_too_large` | Decompressed gzip body exceeds 50 MB                                |
| 422  | `invalid_csv`            | CSV could not be parsed                                             |
| 422  | `invalid_gzip`           | `Content-Type` is `application/gzip` but the body is not valid gzip |
| 422  | `invalid_product_line`   | X-Product-Line value not configured for account                     |
| 422  | `csv_processing_failed`  | Unexpected error processing the CSV                                 |

## Upload inventory (V4 presigned-URL flow)

Newer agent builds upload the CSV **directly to Cloudflare R2** using a
short-lived presigned PUT URL, then notify Hoard with the resulting R2
key. Hoard's bandwidth and Ruby heap never see the bytes.

The flow is three calls:

1. `POST /api/v1/sync/upload-request` — Hoard returns a 10-minute presigned PUT URL.
2. `PUT <upload_url>` — agent uploads the raw CSV bytes directly to R2.
3. `POST /api/v1/sync` with `Content-Type: application/json` and body `{key, product_line}` — Hoard validates the key, enqueues the worker, and returns `202`.

Both steps 1 and 3 require `users.use_r2_sync_pipeline = true`; otherwise
they return `403 r2_pipeline_disabled` and the agent should fall back to
the raw-CSV POST documented above.

### Step 1: Mint a presigned URL

```
POST /api/v1/sync/upload-request
Content-Type: application/json
```

**Request body (optional):**

```json theme={null}
{"product_line": "Magic"}
```

**Response (HTTP 200):**

```json theme={null}
{
  "upload_url": "https://<account>.r2.cloudflarestorage.com/hoard-sync-payloads/syncs/u42/<uuid>.csv?X-Amz-...",
  "key": "syncs/u42/<uuid>.csv",
  "expires_at": "2026-05-19T18:30:00Z"
}
```

| Field        | Type   | Description                                                                          |
| ------------ | ------ | ------------------------------------------------------------------------------------ |
| `upload_url` | string | Presigned R2 PUT URL. Send the raw CSV bytes as the PUT body.                        |
| `key`        | string | R2 object key. Echo this back in step 3. Matches `syncs/u<your_user_id>/<uuid>.csv`. |
| `expires_at` | string | ISO-8601 expiry (10 minutes from issue).                                             |

**Error responses:**

| Code | Error code             | Meaning                                                |
| ---- | ---------------------- | ------------------------------------------------------ |
| 403  | `r2_pipeline_disabled` | `users.use_r2_sync_pipeline` is false for this account |
| 422  | `invalid_product_line` | `product_line` is not configured for this account      |

### Step 2: PUT the CSV to R2

The PUT goes to Cloudflare, not Hoard — no `Authorization` header
required (the presigned URL is the auth). Send the raw CSV bytes as the
PUT body.

### Step 3: Notify Hoard with the key

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

**Request body:**

```json theme={null}
{"key": "syncs/u42/<uuid>.csv", "product_line": "Magic"}
```

| Field          | Required | Description                                                        |
| -------------- | -------- | ------------------------------------------------------------------ |
| `key`          | Yes      | The R2 key returned by step 1. Must be scoped to your user id.     |
| `product_line` | No       | Same semantics as the `X-Product-Line` header on the raw-CSV path. |

**Response (HTTP 202):**

```json theme={null}
{"status": "accepted"}
```

**Error responses:**

| Code | Error code             | Meaning                                                              |
| ---- | ---------------------- | -------------------------------------------------------------------- |
| 403  | `r2_pipeline_disabled` | `users.use_r2_sync_pipeline` is false for this account               |
| 422  | `invalid_r2_key`       | `key` is missing or doesn't match `syncs/u<your_user_id>/<uuid>.csv` |

### curl example (V4 round trip)

```bash theme={null}
# 1. Mint the presigned URL
RESP=$(curl -s -X POST https://www.tryhoard.com/api/v1/sync/upload-request \
  -H "Authorization: Bearer $HOARD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"product_line":"Magic"}')
UPLOAD_URL=$(echo "$RESP" | jq -r .upload_url)
KEY=$(echo "$RESP" | jq -r .key)

# 2. PUT CSV straight to R2
curl -X PUT "$UPLOAD_URL" --data-binary @inventory.csv

# 3. Notify Hoard
curl -X POST https://www.tryhoard.com/api/v1/sync \
  -H "Authorization: Bearer $HOARD_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"key\":\"$KEY\",\"product_line\":\"Magic\"}"
```

## Get price updates

Download a CSV of price changes to import back to TCGplayer.

```
GET /api/v1/export/price-updates
```

Returns a CSV string (`text/csv`), not JSON. Empty string if no updates are available. If a price rollback is pending, the rollback CSV is served instead.

### Conditional GET (ETag)

When no rollback or Quick Add work is pending, the response includes a weak `ETag` fingerprint of the underpriced-cards set. Clients that send `If-None-Match: <last-etag>` will receive `304 Not Modified` if nothing has changed since the last pull — avoiding the CSV serialization roundtrip on idle polls.

Rollback and Quick Add responses always return `200` because they carry side-effectful state transitions that must run on every poll.

### curl example

```bash theme={null}
curl -H "Authorization: Bearer YOUR_API_KEY" \
  https://www.tryhoard.com/api/v1/export/price-updates \
  -o price-updates.csv
```

## Request manual sync

Mark the account as having an explicit user-requested sync. The next poll on `/api/v1/sync/pending` returns `action: "sync"`.

```
POST /api/v1/sync/request
```

### curl example

```bash theme={null}
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \
  https://www.tryhoard.com/api/v1/sync/request
```

**Response:**

```json theme={null}
{"status": "requested"}
```

### Cooldown response (402)

When the per-account `credits_metering_enabled` flag is **off** (the default), this endpoint always returns 200 — behavior is unchanged from before the credits-metering feature shipped.

When the flag is **on** AND the user is still within the post-sync cooldown window, the endpoint returns 402:

```json theme={null}
{
  "status": "cooldown",
  "cooldown_remaining_seconds": 878,
  "balance": 4,
  "top_up_path": "/billing/credit_packs"
}
```

The user can either wait the remaining seconds or call `POST /api/v1/sync/skip_wait` (below) to spend a credit and skip the wait.

| Field                        | Type    | Description                                                             |
| ---------------------------- | ------- | ----------------------------------------------------------------------- |
| `status`                     | string  | Always `"cooldown"` for this 402 branch.                                |
| `cooldown_remaining_seconds` | integer | Whole seconds until the next manual sync is allowed.                    |
| `balance`                    | integer | The user's current credit balance.                                      |
| `top_up_path`                | string  | Relative path to the top-up flow if the user wants to buy more credits. |

## Skip wait

Spend 1 credit to bypass the manual-sync cooldown. Wraps the `Credits::SkipWaitCharge` service which uses a `FOR UPDATE` row lock plus a reservation pattern so concurrent calls with the same idempotency key cannot double-charge.

```
POST /api/v1/sync/skip_wait
X-Idempotency-Key: <UUID>
```

This endpoint is independent of `credits_metering_enabled` — calling it IS the user's explicit opt-in. A flag-OFF user with a balance can call it just fine; the gate is the balance, not the flag.

### Headers

| Header              | Required | Description                                                                                                                          |
| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `Authorization`     | Yes      | `Bearer YOUR_API_KEY`                                                                                                                |
| `X-Idempotency-Key` | Yes      | Caller-supplied idempotency key (UUID recommended). Replaying the same key returns the original transaction without a second charge. |

### curl example

```bash theme={null}
curl -X POST \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "X-Idempotency-Key: f47ac10b-58cc-4372-a567-0e02b2c3d479" \
  https://www.tryhoard.com/api/v1/sync/skip_wait
```

**Response (200, charged):**

```json theme={null}
{
  "status": "charged",
  "balance": 4,
  "cooldown_remaining_seconds": 852,
  "transaction_id": 18
}
```

### Success statuses (HTTP 200)

| Status             | Meaning                                                                                                            |
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `charged`          | First call — 1 credit deducted, sync requested. Returns the new `transaction_id`.                                  |
| `replayed`         | Same `X-Idempotency-Key` as a prior call. The original `transaction_id` is returned with no second charge.         |
| `no_charge_needed` | User is already past cooldown — no charge taken.                                                                   |
| `sync_in_flight`   | A sync is already running; from the caller's POV the effect is achieved. Only `status` is returned in this branch. |

### Error responses

| Code | Status                 | Body                                                                                         | Meaning                                                                                                 |
| ---- | ---------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| 400  | —                      | `{ "error": "X-Idempotency-Key header is required", "code": "idempotency_key_required" }`    | The `X-Idempotency-Key` header is missing or blank.                                                     |
| 402  | `insufficient_balance` | `{ "status": "insufficient_balance", "balance": 0, "top_up_path": "/billing/credit_packs" }` | User has 0 credits. Direct them to the top-up flow.                                                     |
| 409  | `mutex_held`           | `{ "status": "mutex_held", "retry_after_seconds": 30 }`                                      | A sync, rollback, or Preview Prices run is currently writing prices. Retry after `retry_after_seconds`. |

## Cancel sync

Release the sync lock if something goes wrong mid-sync.

```
POST /api/v1/sync/cancel
```

**Response:**

```json theme={null}
{"status": "cancelled"}
```

Returns `409` with `code: "no_sync_in_progress"` if no sync is running. The server auto-clears stale locks after 5 minutes, so this is a courtesy call.

## Log sync cycle

Report the results of a completed sync cycle. Hoard sends this at the end of every cycle, whether it succeeded or failed.

```
POST /api/v1/sync/log
Content-Type: application/json
```

**Request body:**

```json theme={null}
{
  "status": "success",
  "sync_cycle_id": "20260506-abc123",
  "steps": [
    {"name": "login", "status": "success", "duration_ms": 1200},
    {"name": "export_inventory", "status": "success", "duration_ms": 8500},
    {"name": "upload_inventory", "status": "success", "duration_ms": 3200},
    {"name": "export_orders", "status": "failed", "error": "timeout waiting for download"}
  ],
  "agent_version": "0.4.9",
  "platform": "darwin/arm64",
  "duration_ms": 45000,
  "started_at": "2026-04-20T09:00:00Z"
}
```

### Request fields

| Field           | Type    | Required | Description                                                                                                      |
| --------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| `status`        | string  | Yes      | Overall result: `"success"`, `"partial"`, or `"failed"`                                                          |
| `sync_cycle_id` | string  | No       | Stable client-generated identifier for this sync cycle. Used to merge debug screenshots with the final step log. |
| `steps`         | array   | No       | Each step with a `name`, `status`, optional `duration_ms`, and optional `error`                                  |
| `agent_version` | string  | No       | The version of Hoard that ran this cycle                                                                         |
| `platform`      | string  | No       | Platform (e.g., `darwin/arm64`)                                                                                  |
| `duration_ms`   | integer | No       | Total cycle duration in milliseconds                                                                             |
| `started_at`    | string  | No       | ISO 8601 start time. Defaults to server time if omitted.                                                         |

**Response (HTTP 201):** Empty body.

## Upload debug screenshot

Send a Base64-encoded screenshot to the server when a sync step fails.

```
POST /api/v1/sync/debug
Content-Type: application/json
```

The screenshot must be Base64-encoded PNG and must not exceed 2 MB decoded. Include `sync_cycle_id` so the server can attach the screenshot to the correct cycle even if the final sync log has not arrived yet. Without `sync_cycle_id`, at least one existing sync log entry must exist.

**Request body:**

```json theme={null}
{
  "sync_cycle_id": "20260506-abc123",
  "step_name": "login",
  "page_url": "https://store.tcgplayer.com/admin",
  "screenshot": "<base64-encoded PNG>",
  "captured_at": "2026-04-20T09:01:30Z"
}
```

**Response (HTTP 201):** Empty body.

If `sync_cycle_id` is present and the final sync log has not arrived yet, the server creates a placeholder failed log row and updates that row when `POST /api/v1/sync/log` arrives with the same identifier.

**Error responses:**

| Code | Error code          | Meaning                         |
| ---- | ------------------- | ------------------------------- |
| 404  | —                   | No sync log entry exists yet    |
| 413  | `payload_too_large` | Screenshot exceeds 2 MB decoded |
