Check pending
Check if the server wants Hoard to sync.curl example
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.curl example
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 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.
Response (HTTP 202):
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:POST /api/v1/sync/upload-request— Hoard returns a 10-minute presigned PUT URL.PUT <upload_url>— agent uploads the CSV bytes directly to R2.POST /api/v1/syncwithContent-Type: application/jsonand body{key, product_line, sync_cycle_id}— Hoard validates the key, creates the canonical cycle placeholder, enqueues the worker, and returns202.
Step 1: Mint a presigned URL
Error responses:
Step 2: PUT the CSV to R2
The PUT goes to Cloudflare, not Hoard — noAuthorization 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
Response (HTTP 202):
curl example (V4 round trip)
Get price updates
Download a CSV of price changes to import back to TCGplayer.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-only200 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 weakETag 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".
curl example
Cooldown responses (402 and 429)
Two separate cooldown gates apply, depending on the account. When the per-accountcredits_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:
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:
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 theCredits::SkipWaitCharge service which uses a FOR UPDATE row lock plus a reservation pattern so concurrent calls with the same idempotency key cannot double-charge.
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
Success statuses (HTTP 200)
Error responses
Cancel sync
Request that an active sync stop safely.sync_in_progress:
{"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 fields
Response (HTTP 201): Empty body.
Upload debug screenshot
Send a Base64-encoded screenshot to the server when a sync step fails.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:
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. Genericrepricing_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.