Skip to main content

Check pending

Check if the server wants Hoard to sync.

curl example

Response:

Response fields

reason values

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.

Sync status

Where the account is in the sync pipeline right now, and when prices last changed.
Works with a Bearer token or a signed-in browser session. The dashboard polls it every five seconds during Sync Now and once a minute otherwise.

curl example

Response:

Response fields

phase values

Read-only accounts never report push_queued or pushing, even if a stale push task exists from before the mode changed.

Agent heartbeat

Signal that Hoard is alive and check for updates.
Request body (optional):

Request fields

Response:

Response fields

Accounts without a paid entitlement are held read-only: sync_mode is pinned to read_only regardless of the stored value, upload_approved is always false, and pending price-push tasks are cancelled. An expired supported price claim can still set has_pending_tasks so the claim endpoint can cancel it. Price tasks blocked by consent, billing, or automatic-pricing pauses remain queued but do not set has_pending_tasks, leaving the agent free for other eligible work. They become visible again when the block clears. Watch Only or loss of entitlement cancels pending price tasks on heartbeat or claim. Heartbeat leaves claimed tasks intact, but advertises supported claims older than 10 minutes so an idle agent calls claim. Claim then recovers expired work or cancels price work for Watch Only/unentitled accounts. Fresh claims remain completable. Ready rollbacks and published Quick Add work remain visible during pricing pauses. Rejected Quick Add rows and fresh in-flight listings do not bypass pauses. Historical non-rejected pending listings become retryable after 30 minutes. New member-owned additions stay held until inventory confirmation; an uncertain additive upload is never replayed automatically in either delivery mode.

Upload inventory

Upload a gzipped inventory CSV from TCGplayer.
The request body is the gzip-compressed CSV data. The server queues it for processing, matches cards to the catalog, and calculates price updates. Response (HTTP 202):
Error responses:

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 CSV bytes directly to R2.
  3. POST /api/v1/sync with Content-Type: application/json and body {key, product_line, sync_cycle_id} — Hoard validates the key, creates the canonical cycle placeholder, enqueues the worker, and returns 202.
The presigned-URL flow is the only sync path on the server; it is not gated by any per-account flag. The raw-CSV POST documented above is still accepted for older agents, and internally writes the body to R2 before queueing the same worker.

Step 1: Mint a presigned URL

Request body (optional):
Response (HTTP 200):
Error responses:

Step 2: PUT the CSV to R2

The PUT goes to Cloudflare, not Hoard — no Authorization header required (the presigned URL is the auth). The PUT body may be either the raw CSV or a gzipped CSV. The object key keeps the .csv suffix either way; the server detects gzip by sniffing the 1f8b magic bytes when it reads the object. The presigned URL signs only the method, key, and expiry, so compressing needs no header or signature change. Current agent builds gzip the CSV before the PUT.

Step 3: Notify Hoard with the key

Request body:
Response (HTTP 202):
Error responses:

curl example (V4 round trip)

Get price updates

Download a CSV of price changes to import back to TCGplayer.
Returns a CSV string (text/csv), not JSON. If a price rollback is pending, the rollback CSV is served instead. Concurrent intake can finish while export waits; unpublished drafts remain excluded. A header-only CSV with no data rows is a normal 200 response, not an error. Treat it as “nothing to push”. It is returned when no updates are available, and also when:
  • The account has no paid entitlement (free tier).
  • The account has not granted TCGplayer write consent.
  • The account is past due on payment.
  • The account is in read-only (Watch Only) mode and an apply-price-updates task is already claimed and in flight.

Empty export reasons

A header-only 200 response includes X-Price-Export-Empty-Reason, with one or more comma-separated codes: The header is absent on nonempty CSV responses and 304 responses. A temporary block at task claim leaves price work pending; unrelated supported tasks remain claimable. Ready rollbacks and explicitly published Quick Add listings can still use a price task during automatic-pricing pauses, while automatic price rows remain held at export. Watch Only or loss of entitlement cancels pending price tasks.

Conditional GET (ETag)

When no rollback or Quick Add work is pending and the account is in read-only (Watch Only) sync mode, 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. Auto-push accounts (sync_mode other than read_only) also always return 200 and never send an ETag, because the export claims a row lock on every call.

curl example

Request manual sync

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

curl example

Response:

Cooldown responses (402 and 429)

Two separate cooldown gates apply, depending on the account. When the per-account credits_metering_enabled flag is off (the default), accounts on a paid plan (including comp, founder, and active trials) always return 200. Free accounts inside the post-sync cooldown window return 429:
The 429 body carries only status and cooldown_remaining_seconds. It has no balance and no top_up_path. When the flag is on AND the user is still within the post-sync cooldown window, the endpoint returns 402:
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.

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

curl example

Response (200, charged):

Success statuses (HTTP 200)

Error responses

Cancel sync

Request that an active sync stop safely.
If the inventory worker holds a live mutation lease, cancellation is asynchronous. The server records the request, the worker stops at its next slab boundary, and only then releases the lease and clears sync_in_progress:
If no worker lease is active (for example, the agent is only holding its cool-down flag), the flag is cleared immediately and the response is {"status":"cancelled"}. Returns 409 with code: "no_sync_in_progress" if no sync is running. Stale agent state is also cleared automatically after 5 minutes.

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.
Request body:

Request fields

Response (HTTP 201): Empty body.

Upload debug screenshot

Send a Base64-encoded screenshot to the server when a sync step fails.
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:
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:

Member-owned additions

Quick Add contributions remain separate while staged. Publishing combines only permitted contributions with the same exact SKU into one additive CSV row. Conflicting requested prices require explicit recovery. Later additions cannot change a previously exported quantity and wait for its confirmation. A fresh inventory observation must reach the pre-export live quantity plus the sum of exported copies; the mere presence of an existing SKU is insufficient. Sales or external stock changes can prevent this conservative check from resolving, so keep the pending contribution visible and do not resubmit its copies. Generic repricing_events/failure calls do not requeue managed contributions, including when the price-decision ledger is disabled. Per-delivery human recovery supersedes only the affected delivery rows. An old token’s acceptance or failure callback ignores these rows and cannot change a newer attempt; unrelated manifest rows continue normally. Untokened legacy rejection callbacks cannot distinguish uploads, so they leave explicitly recovered contributions held for inventory reconciliation or further verified recovery. Explicitly recovered historical contributions are also excluded from untokened failure and timeout requeue. Once human recovery records evidence, only another verified resolution or inventory reconciliation may change their held outcome.