Skip to content

PayPal sandbox only. No real money moves.

API reference

GAVEL host API, version 0.1.0. PayPal sandbox only: no real money moves. This page is generated from openapi/gavel.yaml when the site is built, so it always matches the spec. Download openapi.yaml.

Servers

  • https://gavel-auction-z2nm.onrender.com Live sandbox host (PayPal sandbox only).
  • http://localhost:8080 Local host for development.

Overview

GAVEL is an auction where every bid is backed by a PayPal hold and only the winner pays. This document describes the public HTTP API of the host (packages/server). It is written by hand from the route files in packages/server/src/http/ and the schemas in packages/shared/src/, and a test (scripts/tests/openapi-routes.test.mjs) fails when a route in the code is missing here or a path here has no route in the code.

Sandbox only. The host talks to the PayPal sandbox. No real money moves.

Authentication

Several kinds of credential exist. None of them is a real value in this document.

CredentialHow it is sentWho gets it
Operator keyheader X-Admin-Keythe operator who runs the host (environment setting ADMIN_KEY)
Auctioneer tokenAuthorization: Bearer <token>, valid 12 hoursreturned as a link when an auction is created or rotated
Screen tokenAuthorization: Bearer <token>, valid 12 hoursreturned as a link when an auction is created
Session (bidder) tokenAuthorization: Bearer <token>, valid 4 hoursreturned by the join routes
Receipt tokenAuthorization: Bearer <token>, valid 4 hoursreturned by the receipt-link route
Reviewer tokenAuthorization: Bearer <token>, valid 12 hoursreturned as reviewerToken and reviewerLink when a judge room is created; read only, a screen's view plus the room ledger
Judge keyheader X-Judge-Keya limited key for judges, gvl_judge_<id>_<secret> (per-judge entries in JUDGE_KEYS, or the shared JUDGE_KEY; never the operator key); optional, only on the judge-room create and key-check routes
Invite tokenfield invite in the body of the join route, valid 24 hours, single useminted by the auctioneer

Every token is bound to one auction (its aud) and to one kind. A token of the wrong kind or for another auction answers 401. A token whose role may not do the thing answers 403. Tokens are opaque strings: do not parse them. A reviewer token can read the snapshot, the event log and the room ledger of its own judge room, and nothing else. Judge rooms need no key; the judge key only lifts the public limits when a host has one set (see POST /api/judge/key). The judge key is sent only in the X-Judge-Key header, never in a URL or body.

Links the host returns carry the token in the URL fragment (#token=) so it never reaches a server log.

Errors

Every error is JSON. The fields vary slightly by route family because the host grew in layers; read them like this and you are safe on every route:

- message: text for a person. Always present on /api/** errors. - code: the stable error code (see ErrorCode). Present on the core routes (auctions, control, invites, read, receipts, auctioneer tokens) and on judge routes. - error: the same stable code on the hold routes, the judge routes and the operator routes. Judge routes also send code. Operator routes send only error. - detail: optional extra text, for example the field paths that failed validation.

A client should read code ?? error. See the Error schema.

Messages never contain tokens or stack traces (they are scrubbed on the server).

Status codes in use: 400 bad request, 401 missing or bad credential, 403 not allowed, 404 not found, 409 conflict, 410 gone (a judge room that ended), 413 body over 64 KiB, 415 body is not application/json, 429 rate limited, 500 internal, 503 unavailable.

Rate limits

Limits are published as the vendor extension x-rate-limit on the operations they apply to, and as x-rate-limits at the top of this document for the host-wide defaults. A 429 always carries a Retry-After header in seconds. The numbers are the defaults and can be changed by the operator through environment settings.

Request ids and CORS

Send X-Request-Id (1 to 64 characters of A-Z a-z 0-9 _ -) to correlate a request; every response carries X-Request-Id, and the server mints one when you send none or an invalid one. Browsers are allowed only from the origins the operator lists; other origins get no CORS headers. Allowed request headers are Authorization, Content-Type, X-Request-Id, X-Admin-Key and X-Judge-Key; readable response headers are X-Request-Id and Retry-After (also on early 429, 404 and 500 answers).

Not in this document

- WebSocket protocol. Live bidding runs on wss://<host>/ws and OpenAPI cannot model it. See the section "WebSocket protocol" below and https://github.com/harshbhx/gavel-auction/blob/main/docs/API.md. - PayPal webhook. POST /webhooks/paypal is called by PayPal only, is verified by PayPal's signature, and is not part of the client API. - Demo routes. /api/demo/reset, /api/demo/seed and /api/demo/rooms exist only when the host runs with DEMO_MODE=1 on the fake payments adapter and never in a production-like configuration. They answer 404 otherwise.

WebSocket protocol

Endpoint: wss://<host>/ws. The browser must send an Origin that the operator allows; a request without one is refused. A plain HTTP request to /ws answers 426. Frames are JSON text, at most 16 KiB from the client (a larger or binary frame closes the socket) and at most 256 KiB from the server. Every frame is {"v":1,"t":"<type>", ...}; an unknown type, extra field or wrong version is bad_message (a join with another version is unsupported_version).

Client to server, the only three types accepted:

tPurposeFields
joinFirst frame. Opens the session.token (session, auctioneer, screen or reviewer token; a reviewer joins with a screen's view), auctionId, optional lastSeq (resume after this sequence number), optional client ({name, version})
bidPlace a bid.mid (id you mint, for idempotency), amount (cents), source (voice, qr or manual), optional bidderId, transcript, confidence, ambiguous, audioTsMs, audioEndMs, clientTs
confirm_responseAnswer a spoken-bid confirmation.confirmId, accept

Server to client:

tPurpose
registeredYour bidder id, paddle and role after join.
stateFull room snapshot (same shape as RoomSnapshot), sent after join and when resyncing.
priceNew price, leader, end time and next minimum.
bid_accepted / bid_rejectedResult of your bid (match on mid); a rejection carries reason (an ErrorCode), retryable and optional retryAfterMs.
confirm_requestA spoken bid needs the bidder's yes or no.
hammerLot ended: winner, price, ranking.
settlement_updateSettlement progress (holds captured or released).
audit_eventOne audit row, filtered by role.
errorcode, message, optional retryAfterMs; may close the socket.

Socket close codes follow ErrorCode: 1008 bad message, 4002 unsupported version, 4003 not joined, 4401 unauthorized, 4009 room or auction full, 4010 conflict or host fenced, 4429 rate limited (frame flood), 1011 internal. Limits: bids 3 per second (burst 5), frames 20 per second (burst 40), at most 20 open sockets per source address.

The full protocol, with every field, is in the repository file docs/specs/realtime/SPEC-realtime.md and on the docs site page "WebSocket protocol".

Credentials

adminKey header X-Admin-Key

The operator key, set by the person who runs the host. Compared in constant time. Never put it in a browser or a public repository. No real value appears in this document.

auctioneerAuth Authorization: Bearer

Auctioneer token (12 hours), carried in the auctioneer link fragment. Bound to one auction. Revoked by rotation.

screenAuth Authorization: Bearer

Screen token (12 hours) for the projector page. Read only, no private fields.

reviewerAuth Authorization: Bearer

Reviewer token (12 hours), returned by judge-room creation. Read only: the screen's view of the snapshot and event log, and the room ledger. Bound to one judge room.

judgeKey header X-Judge-Key

A limited per-judge key, gvl_judge_<id>_<secret> (set by the host operator, never the operator key). Only the judge-room create and key-check routes read it; it never opens an operator route. It has an expiry, a room cap and an hourly cap of its own. No real value appears in this document.

sessionAuth Authorization: Bearer

Session (bidder) token (4 hours) returned by the join routes. Names one bidder in one auction.

receiptAuth Authorization: Bearer

Receipt token (4 hours) returned by the receipt-link route. Opens only GET /api/auctions/{id}/receipt.

Error codes

Stable codes. HTTP status per code: 400 bad_message, unsupported_version, not_joined, bad_amount; 401 unauthorized; 403 forbidden, unregistered, ceiling_revoked; 404 not_found; 409 invite_used, closed, already_leading, lot_not_open, unconfirmed, already_bid, auction_paused, conflict, host_fenced; 422 below_increment, over_ceiling, below_reserve, not_higher_than_own_max, limit_exceeded (these arise on the socket, in `bid_rejected`); 429 rate_limited; 500 internal; 503 room_full, auction_full, room_cap, store_unavailable.

bad_message unsupported_version not_joined unauthorized forbidden invite_used rate_limited room_full auction_full room_cap unregistered below_increment over_ceiling below_reserve closed already_leading bad_amount lot_not_open ceiling_revoked unconfirmed not_higher_than_own_max already_bid limit_exceeded auction_paused store_unavailable conflict host_fenced not_found internal

Endpoints

Auctions

Create an auction and read its public facts.

post/api/auctions

Create an auction

Operator only. Creates the auction, reserves a 6 character join code, and returns links for the auctioneer console and the projector screen. The tokens are inside the links (fragment #token=), valid 12 hours.

Authentication

  • adminKey (header X-Admin-Key)

Request body

application/json (required)

CreateAuctionRequest

FieldTypeDescription
titlestringLot title shown to bidders.min length 1; max length 120; default "Untitled auction"
config (required)AuctionConfigSettings of one auction. Amounts are in cents, durations in milliseconds.
modestringBidding mode. Defaults to english.one of "english", "proxy", "second_price"; default "english"
currency (required)const "USD"Currency of every amount. Only USD is accepted.
reserve (required)integerReserve price in cents (at least 100).min 100
minIncrement (required)integerSmallest raise in cents.min 1
endTs (required)EpochMsMilliseconds since the Unix epoch.
softCloseWindowMs (required)integerA bid this close to the end extends the lot.min 0; max 600000
softCloseExtensionMs (required)integerHow long a soft close extends the lot, in milliseconds.min 0; max 600000
maxExtensions (required)integerMost times the lot can be extended by soft close.min 0; max 50
ceilingPresets (required)array of integerStrictly ascending ceiling amounts in cents, none below the reserve.
confirmConfidenceThreshold (required)numberSpoken bids below this confidence need the bidder to confirm.min 0; max 1
autoHammerDelayMsintegerMilliseconds after closing begins before the host hammers the lot by itself. When absent, the auctioneer hammers by hand.min 0
holdScopestringWhether one hold covers the whole event or a single lot. The engine default is event.one of "event", "lot"
incrementLadderarray of objectTiered increments. Must start at fromCents 0 and strictly ascend.
fromCents (required)integerPrice in cents from which this increment applies.min 0
incrementCents (required)integerSmallest raise in cents at this price tier.min 1
fundraiserGoalCentsintegerOptional goal; the projector shows a thermometer only when it is set.min 1; max 100000000000
{
  "title": "Charity quilt",
  "config": {
    "mode": "english",
    "currency": "USD",
    "reserve": 10000,
    "minIncrement": 500,
    "endTs": 1767225600000,
    "softCloseWindowMs": 30000,
    "softCloseExtensionMs": 30000,
    "maxExtensions": 5,
    "ceilingPresets": [
      10000,
      25000,
      50000
    ],
    "confirmConfidenceThreshold": 0.85
  },
  "fundraiserGoalCents": 500000
}

Responses

201 Auction created.

application/json

CreateAuctionResponse

FieldTypeDescription
auctionId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
joinCode (required)JoinCodeSix letters and digits without look-alikes (no 0, O, 1, I, L).pattern ^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$
lotTitle (required)stringTitle of the lot, as given in the request.
auctioneerLink (required)string (uri)Link to the auctioneer console. The auctioneer token is in the URL fragment.
screenLink (required)string (uri)Link to the projector screen. The screen token is in the URL fragment.
{
  "auctionId": "01JC5ZP4M7Q8R9S0T1V2W3X4Y5",
  "joinCode": "K7M2QX",
  "lotTitle": "Charity quilt",
  "auctioneerLink": "https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/console#token=AUCTIONEER_TOKEN",
  "screenLink": "https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/screen#token=SCREEN_TOKEN"
}

400 The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.

application/json

{
  "code": "bad_message",
  "message": "invalid request body",
  "detail": "config.reserve: reserve must be at least 100 cents"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

get/api/auctions/{id}

Public facts of an auction

No credential. id is the auction id or its join code (any case; spaces, dashes and a GVL- prefix are ignored). Looking up by code is guess-protected: after 5 wrong codes a source address is locked out for 15 minutes (429 code_locked).

Authentication

No credential needed.

Parameters

NameInTypeDescription
id (required)pathstringAuction id, or its 6 character join code.

Responses

200 The auction.

application/json

AuctionPublic

FieldTypeDescription
auctionId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
title (required)stringLot title.
joinCodeJoinCodeSix letters and digits without look-alikes (no 0, O, 1, I, L).pattern ^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$
judgeRoombooleanPresent and true for a judge room.
registrationClosedbooleanPresent and true once the room no longer takes registrations.
stoppedbooleanPresent and true once the auctioneer ended the lot.
status (required)AuctionStatusLifecycle state of the auction, from SCHEDULED to DONE.one of "SCHEDULED", "OPEN", "PAUSED", "CLOSING", "SETTLING", "DONE"
endTs (required)EpochMsMilliseconds since the Unix epoch.
bidderCount (required)integerNumber of registered bidders.min 0
currentCentsCentsMoney in integer US cents. There are no decimals anywhere in the API.min 0
leaderPaddleintegerPaddle number of the current leader; absent when there is none.min 1; max 9999
{
  "auctionId": "01JC5ZP4M7Q8R9S0T1V2W3X4Y5",
  "title": "Sandbox demo lot",
  "joinCode": "K7M2QX",
  "judgeRoom": true,
  "status": "OPEN",
  "endTs": 1767225600000,
  "bidderCount": 2,
  "currentCents": 1500,
  "leaderPaddle": 2
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

post/api/auctions/{id}/auctioneer/rotate

Rotate the auctioneer link

Operator only. Kills every auctioneer token issued before now and returns a fresh link. A leaked auctioneer link cannot do this, by design.

Authentication

  • adminKey (header X-Admin-Key)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Responses

200 New auctioneer link.

application/json

object

FieldTypeDescription
auctioneerLink (required)string (uri)
{
  "auctioneerLink": "https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/console#token=AUCTIONEER_TOKEN"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

post/api/auctions/{id}/auctioneer/revoke-all

Revoke every auctioneer token

Operator only. Kills every auctioneer token of the auction and issues no new one.

Authentication

  • adminKey (header X-Admin-Key)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Responses

200 Revoked.

application/json

object

FieldTypeDescription
revoked (required)const true
{
  "revoked": true
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

post/api/auctions/{id}/auctioneer/revoke

Revoke one auctioneer token by jti

Operator only. For a lost or leaked link when you know its token id.

Authentication

  • adminKey (header X-Admin-Key)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Request body

application/json (required)

object

FieldTypeDescription
jti (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
{
  "jti": "01JC5ZQ0A1B2C3D4E5F6G7H8J9"
}

Responses

200 Revoked.

application/json

object

FieldTypeDescription
revoked (required)const true
{
  "revoked": true
}

400 The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.

application/json

{
  "code": "bad_message",
  "message": "invalid request body",
  "detail": "config.reserve: reserve must be at least 100 cents"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

Bidding access

Invites, join and session tokens.

post/api/auctions/{id}/invites

Mint bidder invites

Auctioneer token only. Each invite is a single-use link, valid 24 hours, for one new bidder id. Between 1 and 50 per call.

Authentication

  • auctioneerAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Request body

application/json

object

FieldTypeDescription
countintegermin 1; max 50; default 1
{
  "count": 2
}

Responses

201 Invites minted.

application/json

object

FieldTypeDescription
invites (required)array of Invite
bidderId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
expiresAt (required)EpochMsMilliseconds since the Unix epoch.
link (required)string (uri)Registration link with the invite token in the fragment.
{
  "invites": [
    {
      "bidderId": "01JC5ZR1B2C3D4E5F6G7H8J9K0",
      "expiresAt": 1767312000000,
      "link": "https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/register#token=INVITE_TOKEN"
    }
  ]
}

400 The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.

application/json

{
  "code": "bad_message",
  "message": "invalid request body",
  "detail": "config.reserve: reserve must be at least 100 cents"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

403 The credential is valid but its role may not do this (`forbidden`).

application/json

{
  "code": "forbidden",
  "message": "not allowed"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

post/api/auctions/{id}/bidders/{bidderId}/invite

Re-issue an invite for an existing bidder

Auctioneer token only. A bidder who lost their tab gets a fresh single-use invite for the same bidder id, so their hold and bids stay theirs. Answers 404 for a bidder who has no hold in the room.

Authentication

  • auctioneerAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.
bidderId (required)pathstringId of the registered bidder to re-invite.example "01J9ZZ0000000000000000000B"

Responses

201 New invite.

application/json

Invite

FieldTypeDescription
bidderId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
expiresAt (required)EpochMsMilliseconds since the Unix epoch.
link (required)string (uri)Registration link with the invite token in the fragment.

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

403 The credential is valid but its role may not do this (`forbidden`).

application/json

{
  "code": "forbidden",
  "message": "not allowed"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

post/api/auctions/{id}/join

Exchange an invite for a session token

No header credential: the invite token goes in the body. id may be the auction id or its join code. The invite is burned on use; a second use answers 409 invite_used. Returns the session token for bidding and the WebSocket address.

Authentication

No credential needed.

Parameters

NameInTypeDescription
id (required)pathstringAuction id, or its 6 character join code.

Request body

application/json (required)

object

FieldTypeDescription
invite (required)stringThe invite token from an invite link.min length 1; max length 2048
{
  "invite": "INVITE_TOKEN"
}

Responses

200 Session created.

application/json

JoinResponse

FieldTypeDescription
sessionToken (required)stringSession token for `Authorization` and for the WebSocket `join` frame.
bidderId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
wsUrl (required)string (uri)WebSocket address, `wss://<host>/ws`.
{
  "sessionToken": "SESSION_TOKEN",
  "bidderId": "01JC5ZR1B2C3D4E5F6G7H8J9K0",
  "wsUrl": "wss://gavel-auction-z2nm.onrender.com/ws"
}

400 The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.

application/json

{
  "code": "bad_message",
  "message": "invalid request body",
  "detail": "config.reserve: reserve must be at least 100 cents"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

409 The request conflicts with the state of the lot or the credential: `conflict` (for example a hammer refused because the price or leader changed), `closed`, `invite_used`, `lot_not_open`.

application/json

{
  "code": "conflict",
  "message": "The lot has already ended"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

Control

Auctioneer controls and the Stop button.

post/api/auctions/{id}/control

Run an auctioneer control

Auctioneer token only (a session or screen token authenticates but is refused with 403). Idempotent by controlId: repeat the same id with the same action and you get the same answer. A hammer needs args.closeNow: 1 while the lot is still open; it is refused with 409 when the price or leader changed since the console last looked.

Authentication

  • auctioneerAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Request body

application/json (required)

ControlRequest

FieldTypeDescription
controlId (required)stringIdempotency key you mint. The same id with the same action is a repeat.min length 1; max length 64
action (required)stringThe control to apply.one of "open", "pause", "resume", "extend", "hammer"
argsobject`extend` needs `extraMs` (whole number, 1 to 600000). `hammer` needs `closeNow: 1` while the lot is open.
{
  "controlId": "ctl-001",
  "action": "pause"
}

Responses

200 The control was applied.

application/json

ControlResponse

FieldTypeDescription
ok (required)const trueAlways true on success.
controlId (required)stringThe controlId you sent.
hammerobjectThe hammer frame, present when the control ended the lot.
{
  "ok": true,
  "controlId": "ctl-003"
}

400 The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.

application/json

{
  "code": "bad_message",
  "message": "invalid request body",
  "detail": "config.reserve: reserve must be at least 100 cents"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

403 The credential is valid but its role may not do this (`forbidden`).

application/json

{
  "code": "forbidden",
  "message": "not allowed"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

409 The request conflicts with the state of the lot or the credential: `conflict` (for example a hammer refused because the price or leader changed), `closed`, `invite_used`, `lot_not_open`.

application/json

{
  "code": "conflict",
  "message": "The lot has already ended"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

post/api/auctions/{id}/kill

Stop the lot (unsold hammer)

Auctioneer token only. Ends the lot as an unsold hammer so settlement voids every hold. A repeat, or a lot that is already over, is a no-op that answers 200 with the current state. The body must carry confirm: "STOP".

Authentication

  • auctioneerAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Request body

application/json (required)

object

FieldTypeDescription
controlIdstringmin length 1; max length 64
confirm (required)const "STOP"
{
  "confirm": "STOP"
}

Responses

200 The lot is stopped.

application/json

object

FieldTypeDescription
ok (required)const true
controlIdstring
state (required)object
status (required)AuctionStatusLifecycle state of the auction, from SCHEDULED to DONE.one of "SCHEDULED", "OPEN", "PAUSED", "CLOSING", "SETTLING", "DONE"
stopped (required)boolean
registrationClosed (required)boolean
{
  "ok": true,
  "state": {
    "status": "DONE",
    "stopped": true,
    "registrationClosed": true
  }
}

400 The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.

application/json

{
  "code": "bad_message",
  "message": "invalid request body",
  "detail": "config.reserve: reserve must be at least 100 cents"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

403 The credential is valid but its role may not do this (`forbidden`).

application/json

{
  "code": "forbidden",
  "message": "not allowed"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

Read

Snapshot and event log, filtered by the caller's role.

get/api/auctions/{id}/snapshot

Room snapshot for the caller's role

The same snapshot the WebSocket sends in a state frame, filtered by role: a bidder sees only their own ceiling and hold, a screen sees no private fields, the auctioneer sees the full audit rows. A reviewer token gets the screen's view.

Authentication

  • auctioneerAuth (Authorization: Bearer)
  • screenAuth (Authorization: Bearer)
  • sessionAuth (Authorization: Bearer)
  • reviewerAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Responses

200 The state frame.

application/json

StateFrame The `state` frame of the WebSocket protocol, returned over HTTP.

FieldTypeDescription
v (required)const 1Protocol version of the frame.
t (required)const "state"Frame type. Always state.
seqintegerSequence number of the last event the snapshot includes.
snapshot (required)RoomSnapshotRoom state for one viewer. Fields not listed here may appear; clients must ignore what they do not know. Private fields (`you`, `yourHold`, ceilings) appear only for the bidder they belong to.
auctionId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
config (required)AuctionConfigSettings of one auction. Amounts are in cents, durations in milliseconds.
modestringBidding mode. Defaults to english.one of "english", "proxy", "second_price"; default "english"
currency (required)const "USD"Currency of every amount. Only USD is accepted.
reserve (required)integerReserve price in cents (at least 100).min 100
minIncrement (required)integerSmallest raise in cents.min 1
endTs (required)EpochMsMilliseconds since the Unix epoch.
softCloseWindowMs (required)integerA bid this close to the end extends the lot.min 0; max 600000
softCloseExtensionMs (required)integerHow long a soft close extends the lot, in milliseconds.min 0; max 600000
maxExtensions (required)integerMost times the lot can be extended by soft close.min 0; max 50
ceilingPresets (required)array of integerStrictly ascending ceiling amounts in cents, none below the reserve.
confirmConfidenceThreshold (required)numberSpoken bids below this confidence need the bidder to confirm.min 0; max 1
autoHammerDelayMsintegerMilliseconds after closing begins before the host hammers the lot by itself. When absent, the auctioneer hammers by hand.min 0
holdScopestringWhether one hold covers the whole event or a single lot. The engine default is event.one of "event", "lot"
incrementLadderarray of objectTiered increments. Must start at fromCents 0 and strictly ascend.
fromCents (required)integerPrice in cents from which this increment applies.min 0
incrementCents (required)integerSmallest raise in cents at this price tier.min 1
status (required)AuctionStatusLifecycle state of the auction, from SCHEDULED to DONE.one of "SCHEDULED", "OPEN", "PAUSED", "CLOSING", "SETTLING", "DONE"
price (required)Cents | nullCurrent price in cents, or null before the first accepted bid.
leaderId (required)Ulid | nullBidder id of the current leader, or null when there is none.
endTs (required)EpochMsMilliseconds since the Unix epoch.
minNext (required)CentsMoney in integer US cents. There are no decimals anywhere in the API.min 0
pausedbooleanTrue while the auctioneer has paused the lot.
remainingMsintegerMilliseconds left until the lot ends.min 0
stoppedbooleanTrue once the auctioneer used the Stop button.
registrationClosedbooleanTrue once the room no longer takes registrations.
hammerobjectResult of the lot, present once it was hammered.
winnerId (required)Ulid | nullBidder id of the winner, or null when the lot did not sell.
price (required)CentsMoney in integer US cents. There are no decimals anywhere in the API.min 0
lotTitlestringTitle of the lot.
joinCodeJoinCodeSix letters and digits without look-alikes (no 0, O, 1, I, L).pattern ^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$
fundraiserobjectFundraiser goal, present only when one was set.
goalCents (required)integerGoal in cents.min 1
roster (required)array of objectRegistered bidders, at most 200.
bidderId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
paddle (required)integerPaddle number of the bidder.min 1; max 9999
name (required)stringDisplay name of the bidder.
hold (required)stringHELD when the bidder has a hold, otherwise NONE.one of "HELD", "NONE"
recentAudit (required)array of objectThe most recent audit rows visible to this viewer, at most 50.
settlementobjectSettlement progress, present once the lot ended.
youobjectThe viewing bidder's own ceiling and high bid. Bidder sessions only.

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

403 The credential is valid but its role may not do this (`forbidden`).

application/json

{
  "code": "forbidden",
  "message": "not allowed"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

get/api/auctions/{id}/events

Page through the event log

Auctioneer, screen or reviewer token (a reviewer sees what a screen sees). Events after sequence after, at most limit. A screen sees full rows only for public event types; every other event is a bare {seq} so the log stays whole. Ceilings, transcripts, hold references and the auctioneer subject are never returned. Pass next back as after to continue.

Authentication

  • auctioneerAuth (Authorization: Bearer)
  • screenAuth (Authorization: Bearer)
  • reviewerAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.
afterqueryintegerReturn events after this sequence number. Pass the `next` of the previous page to continue.min 0; default 0; example 0
limitqueryintegerMost events to return in one page.min 1; max 1000; default 100; example 100

Responses

200 A page of events.

application/json

object

FieldTypeDescription
events (required)array of RoomEvent
seq (required)integerPosition of the event in the ledger, starting at 1.min 1
typestringEvent type, for example auction_created or bid_accepted. Absent for non-public events seen by a screen.
next (required)integermin 0
{
  "events": [
    {
      "seq": 1,
      "type": "auction_created"
    }
  ],
  "next": 1
}

400 The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.

application/json

{
  "code": "bad_message",
  "message": "invalid request body",
  "detail": "config.reserve: reserve must be at least 100 cents"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

Holds

PayPal hold orders for a bidder session.

post/api/holds

Create a PayPal hold order

Session token only. The bidder is the one in the token; a body that names a bidder is rejected. The ceiling must be one of the auction's offered presets. Errors on the hold routes are {error, message} with the codes in HoldErrorCode.

<!-- FEAT-61 begin: raise a limit --> A bidder who already has a HELD hold can raise the limit with this same route: send a ceilingCents above the held one and up to the server cap. The answer is a new order that replaces the old hold once it is approved; the old authorization is voided only after the room has switched to the new one, so the bidder always has a hold. The same request twice returns the same order. A ceiling that is not higher answers 409 hold_exists; one above the cap answers 400 ceiling_out_of_range; once the lot is hammered the route answers 409 closed. Still held, not charged (sandbox only). <!-- FEAT-61 end -->

Authentication

  • sessionAuth (Authorization: Bearer)

Request body

application/json (required)

object

FieldTypeDescription
auctionId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
ceilingCents (required)integerHighest amount this bidder allows, in cents.min 1
{
  "auctionId": "01JC5ZP4M7Q8R9S0T1V2W3X4Y5",
  "ceilingCents": 25000
}

Responses

201 Hold order created; approve it with PayPal, then call the approve route.

application/json

CreateHoldResponse

FieldTypeDescription
holdId (required)stringId of the hold on this host.
orderId (required)stringPayPal sandbox order id for the hold.
approveUrlstring (uri)PayPal's own approval page, for when the popup cannot open.
{
  "holdId": "hold_01JC5ZS2C3D4E5F6G7H8J9K0M1",
  "orderId": "ORDER-EXAMPLE",
  "approveUrl": "https://www.sandbox.paypal.com/checkoutnow?token=ORDER-EXAMPLE"
}

400 Invalid body (`bad_message`) or a ceiling the auction does not offer (`ceiling_out_of_range`).

application/json

{
  "error": "ceiling_out_of_range",
  "message": "ceiling is not one of the offered amounts"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such hold, or it belongs to another bidder (`order_not_found`).

application/json

{
  "error": "order_not_found",
  "message": "no such hold"
}

409 A hold already exists or is authorized (`hold_exists`), the order is not approved yet (`order_not_approved`) or the auction no longer takes holds (`closed`).

application/json

{
  "error": "order_not_approved",
  "message": "the order is not approved"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

503 PayPal is unreachable (`paypal_unavailable`), the kill switch is on (`kill_switch_engaged`) or the host is not in sandbox mode (`payments_not_sandbox`).

application/json

{
  "error": "paypal_unavailable",
  "message": "PayPal is not reachable"
}

post/api/holds/{holdId}/approve

Authorize an approved hold order

Session token of the hold's owner. Another bidder's hold answers 404, exactly like an unknown hold. When name is sent, the bidder is also registered in the room once the hold is authorized (idempotent).

Authentication

  • sessionAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
holdId (required)pathstringId of the hold, as returned by the create-hold route.example "hold_01"

Request body

application/json (required)

object

FieldTypeDescription
orderId (required)stringmin length 1
nameBidderNameDisplay name. Control characters, invisible direction marks and angle brackets are refused.min length 1; max length 64
{
  "orderId": "ORDER-EXAMPLE",
  "name": "Asha"
}

Responses

200 Hold authorized.

application/json

object

FieldTypeDescription
holdId (required)string
status (required)const "HELD"
ceilingCents (required)integermin 1
{
  "holdId": "hold_01JC5ZS2C3D4E5F6G7H8J9K0M1",
  "status": "HELD",
  "ceilingCents": 25000
}

400 Invalid body (`bad_message`) or a ceiling the auction does not offer (`ceiling_out_of_range`).

application/json

{
  "error": "ceiling_out_of_range",
  "message": "ceiling is not one of the offered amounts"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such hold, or it belongs to another bidder (`order_not_found`).

application/json

{
  "error": "order_not_found",
  "message": "no such hold"
}

409 A hold already exists or is authorized (`hold_exists`), the order is not approved yet (`order_not_approved`) or the auction no longer takes holds (`closed`).

application/json

{
  "error": "order_not_approved",
  "message": "the order is not approved"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

503 PayPal is unreachable (`paypal_unavailable`), the kill switch is on (`kill_switch_engaged`) or the host is not in sandbox mode (`payments_not_sandbox`).

application/json

{
  "error": "paypal_unavailable",
  "message": "PayPal is not reachable"
}

get/api/holds/{holdId}

Read your own hold

Session token of the hold's owner. Anyone else's hold answers 404.

Authentication

  • sessionAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
holdId (required)pathstringId of the hold, as returned by the create-hold route.example "hold_01"

Responses

200 The hold.

application/json

object

FieldTypeDescription
holdId (required)string
status (required)HoldStatusone of "PENDING_APPROVAL", "HELD", "CAPTURED", "VOIDED", "EXPIRED", "DENIED", "UNKNOWN"
ceilingCents (required)integermin 1
{
  "holdId": "hold_01JC5ZS2C3D4E5F6G7H8J9K0M1",
  "status": "HELD",
  "ceilingCents": 25000
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such hold, or it belongs to another bidder (`order_not_found`).

application/json

{
  "error": "order_not_found",
  "message": "no such hold"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

delete/api/holds/{holdId}

Abandon an unapproved hold

Session token of the hold's owner. Idempotent. A hold that is already authorized is not released: it answers 409 hold_exists.

Authentication

  • sessionAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
holdId (required)pathstringId of the hold, as returned by the create-hold route.example "hold_01"

Responses

200 Released (now or before).

application/json

object

FieldTypeDescription
holdId (required)string
released (required)const true
{
  "holdId": "hold_01JC5ZS2C3D4E5F6G7H8J9K0M1",
  "released": true
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such hold, or it belongs to another bidder (`order_not_found`).

application/json

{
  "error": "order_not_found",
  "message": "no such hold"
}

409 A hold already exists or is authorized (`hold_exists`), the order is not approved yet (`order_not_approved`) or the auction no longer takes holds (`closed`).

application/json

{
  "error": "order_not_approved",
  "message": "the order is not approved"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

Judge rooms

Key-less sandbox rooms for reviewers.

get/api/judge/capabilities

What judge rooms this host offers

No credential. Tells a client whether it can create a room right now.

Authentication

No credential needed.

Responses

200 Capabilities.

application/json

object

FieldTypeDescription
judgeRooms (required)booleanJudge rooms are switched on for this host.
accepting (required)booleanRooms are on, not paused and under the live-room cap.
simHolds (required)booleanThe simulated-hold route is available.
keyedconst truePresent and true only when the host has a judge key set.
{
  "judgeRooms": true,
  "accepting": true,
  "simHolds": true,
  "keyed": true
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

post/api/judge/rooms

Create a judge room

No credential needed. Creates a short sandbox auction (10 minute lot, small ceilings) that ends after 30 minutes. The creator gets auctioneer, screen and reviewer tokens for that room only; no operator route opens. Answers 404 when judge rooms are off.

With the optional X-Judge-Key header the request is judged by the key alone: the public limits do not apply, the room does not use a public slot, and a wrong key is 401 judge_key_invalid (it never falls back to the public path). An empty header is 401 judge_key_invalid without a strike. Wrong keys have a lockout of their own, separate from the room-code lockout: 5 wrong keys, 15 minutes, per source address (429 judge_key_locked); it has no effect on joins. The response then carries unlocked: true.

Authentication

  • No credential needed.
  • judgeKey (header X-Judge-Key)

Request body

application/json

object

FieldTypeDescription
titlestringmin length 1; max length 60; default "Sandbox demo lot"
fundraiserGoalCentsintegerOptional goal; the projector shows a thermometer only when it is set.min 1; max 100000000000
{
  "title": "Sandbox demo lot"
}

Responses

201 Room created.

application/json

JudgeRoom

FieldTypeDescription
auctionId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
joinCode (required)JoinCodeSix letters and digits without look-alikes (no 0, O, 1, I, L).pattern ^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$
lotTitle (required)stringTitle of the lot in the room.
auctioneerToken (required)stringAuctioneer token for this room (12 hours). Send it as a bearer token.
screenToken (required)stringScreen token for this room (12 hours). Read only.
auctioneerLink (required)string (uri)Link to the auctioneer console with the token in the URL fragment.
screenLink (required)string (uri)Link to the projector screen with the token in the URL fragment.
reviewerTokenstringRead-only reviewer token for this room (12 hours).
reviewerLinkstring (uri)Audit page link with the reviewer token in the fragment.
expiresAt (required)EpochMsMilliseconds since the Unix epoch.
biddingEndsAt (required)EpochMsMilliseconds since the Unix epoch.
unlockedconst truePresent and true when the room was made with a valid judge key.
{
  "auctionId": "01JC5ZP4M7Q8R9S0T1V2W3X4Y5",
  "joinCode": "K7M2QX",
  "lotTitle": "Sandbox demo lot",
  "auctioneerToken": "AUCTIONEER_TOKEN",
  "screenToken": "SCREEN_TOKEN",
  "auctioneerLink": "https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/console#token=AUCTIONEER_TOKEN",
  "screenLink": "https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/screen#token=SCREEN_TOKEN",
  "reviewerToken": "REVIEWER_TOKEN",
  "reviewerLink": "https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/audit#token=REVIEWER_TOKEN",
  "expiresAt": 1767227400000,
  "biddingEndsAt": 1767226200000
}

400 The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.

application/json

{
  "code": "bad_message",
  "message": "invalid request body",
  "detail": "config.reserve: reserve must be at least 100 cents"
}

401 The judge key was not accepted (`judge_key_invalid`), it has expired (`judge_key_expired`), it was revoked (`judge_key_revoked`), or the host has none set.

application/json

{
  "error": "judge_key_invalid",
  "code": "judge_key_invalid",
  "message": "That key was not accepted."
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many creates from this address or from the host, or from this key (`judge_rate_limited`; `Retry-After` is the time until the oldest create leaves the hour window), the live-room cap is reached (`judge_room_cap`, 60 s), a keyed create hit its own room cap (`judge_key_room_cap`; `Retry-After` is the time until the soonest keyed room ends), or the source is locked out of keyed calls (`judge_key_locked`). The body is `{error, code, message}`; `code` repeats `error`.

application/json

{
  "error": "judge_rate_limited",
  "code": "judge_rate_limited",
  "message": "Too many tries from this network. Wait a few minutes and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

503 Judge rooms are paused (`judge_rooms_paused`, retry after 60 s), or the host-wide bound on keyed rooms or keyed creates per hour is reached (`judge_host_busy`; `Retry-After` is the time until a keyed room ends or the hour window frees a slot). Unlike `judge_key_room_cap`, this is not the caller's own limit.

application/json

{
  "error": "judge_rooms_paused",
  "code": "judge_rooms_paused",
  "message": "Demo rooms are paused right now. Try again in a minute."
}

post/api/judge/key

Check a judge key

Send the key in the X-Judge-Key header, with no body. Creates nothing; it lets a panel say whether the key works. A wrong key is 401 judge_key_invalid and counts toward a lockout of its own, separate from the room-code lockout (5 wrong keys, 15 minutes, per source address: 429 judge_key_locked, even with the right key; joins are not affected). A blank key is 401 without a strike. When the host has no judge key set, every key answers as wrong. Answers 404 when judge rooms are off.

Authentication

  • judgeKey (header X-Judge-Key)

Responses

200 The key is accepted.

application/json

object

FieldTypeDescription
ok (required)const true
keyIdstringThe id of the key (`legacy` for the shared host key). Never the secret.
roomsMax (required)integerMost rooms this key may keep alive at once.
roomsLive (required)integerRooms of this key alive now.
expiresAtstring (date-time)When the key stops working. Absent for the legacy shared key.
{
  "ok": true,
  "keyId": "a1b2c3d4",
  "roomsMax": 2,
  "roomsLive": 0,
  "expiresAt": "2026-11-30T23:59:00Z"
}

401 The judge key was not accepted (`judge_key_invalid`), it has expired (`judge_key_expired`), it was revoked (`judge_key_revoked`), or the host has none set.

application/json

{
  "error": "judge_key_invalid",
  "code": "judge_key_invalid",
  "message": "That key was not accepted."
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

get/api/judge/rooms/{id}/ledger

Verifiable ledger of a judge room

Reviewer or auctioneer token of that room. The room's hash-chained ledger as a replay bundle with the private fields removed. The private chain and the public chain are both checked before anything is sent: a broken chain answers 409 ledger_unverified instead of a bundle that looks fine. 404 before the ledger has any entry, for an id that is not a judge room, or when judge rooms are off.

Authentication

  • reviewerAuth (Authorization: Bearer)
  • auctioneerAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Responses

200 The bundle.

application/json

ReplayBundle A verifiable export of the ledger (`packages/shared/src/replay-bundle.ts`). Hash with `verifyPublicExport` and compare `headHash`.

FieldTypeDescription
version (required)integerVersion of the bundle format.
exportVersion (required)integerVersion of the export rules used to build the bundle.
auctionId (required)stringId of the exported auction.
generatedAt (required)EpochMsMilliseconds since the Unix epoch.
count (required)integerNumber of events; equals the last event seq.min 0
headHash (required)string | nullHash of the last public event, or null for an empty ledger.
closedbooleanTrue when the last event closes the auction and names the event before it.
events (required)array of objectThe public events in order, one object per event.
{
  "version": 1,
  "exportVersion": 4,
  "auctionId": "01JC5ZP4M7Q8R9S0T1V2W3X4Y5",
  "generatedAt": 1767226300000,
  "count": 0,
  "headHash": null,
  "events": []
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

409 The ledger does not verify (`ledger_unverified`).

application/json

{
  "error": "ledger_unverified"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

post/api/judge/join

Join a judge room with its code

No credential. A bidder joins a judge room with its 6 character code and a display name and gets a session token. Only judge rooms: the code of any other auction answers exactly like an unknown code (404). Wrong codes are counted; 5 wrong codes lock a source address out for 15 minutes (429 judge_join_locked). At most 20 bidders join one room (429 judge_room_full). An ended room answers 410.

Authentication

No credential needed.

Request body

application/json (required)

object

FieldTypeDescription
code (required)stringThe join code, in any case; spaces, dashes and a GVL- prefix are ignored.min length 1; max length 40
displayName (required)BidderNameDisplay name. Control characters, invisible direction marks and angle brackets are refused.min length 1; max length 64
{
  "code": "gvl-k7m 2qx",
  "displayName": "Asha"
}

Responses

200 Joined.

application/json

object

FieldTypeDescription
auctionId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
sessionToken (required)string
bidderId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
displayName (required)string
wsUrl (required)string (uri)
{
  "auctionId": "01JC5ZP4M7Q8R9S0T1V2W3X4Y5",
  "sessionToken": "SESSION_TOKEN",
  "bidderId": "01JC5ZR1B2C3D4E5F6G7H8J9K0",
  "displayName": "Asha",
  "wsUrl": "wss://gavel-auction-z2nm.onrender.com/ws"
}

400 The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.

application/json

{
  "code": "bad_message",
  "message": "invalid request body",
  "detail": "config.reserve: reserve must be at least 100 cents"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

410 The judge room ended (`judge_room_expired` or `judge_room_ended`).

application/json

{
  "error": "judge_room_ended",
  "code": "judge_room_ended",
  "message": "That room has ended"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

post/api/judge/rooms/{id}/sim-hold

Place a simulated hold in a judge room

Session token of a bidder in that judge room. Writes an already-authorized hold with no PayPal call and puts the bidder in the room, so the whole path can be tried without a PayPal login. The ceiling is any whole number of cents from 100 up to the room's ceiling cap (the top preset); the presets are only quick picks, and a value outside the range is 400 bad_message. Any other auction answers 404. Answers 404 when simulated holds are off.

Authentication

  • sessionAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Request body

application/json (required)

object

FieldTypeDescription
ceilingCents (required)integerWhole cents, at least 100 and at most the room's ceiling cap.min 100
name (required)BidderNameDisplay name. Control characters, invisible direction marks and angle brackets are refused.min length 1; max length 64
{
  "ceilingCents": 5000,
  "name": "Asha"
}

Responses

200 Simulated hold placed.

application/json

object

FieldTypeDescription
holdId (required)string
simulated (required)const true
ceilingCents (required)integer
{
  "holdId": "sim-hold-01JC5ZR1B2C3D4E5F6G7H8J9K0",
  "simulated": true,
  "ceilingCents": 5000
}

400 The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.

application/json

{
  "code": "bad_message",
  "message": "invalid request body",
  "detail": "config.reserve: reserve must be at least 100 cents"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

403 The credential is valid but its role may not do this (`forbidden`).

application/json

{
  "code": "forbidden",
  "message": "not allowed"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

409 The request conflicts with the state of the lot or the credential: `conflict` (for example a hammer refused because the price or leader changed), `closed`, `invite_used`, `lot_not_open`.

application/json

{
  "code": "conflict",
  "message": "The lot has already ended"
}

410 The judge room ended (`judge_room_expired` or `judge_room_ended`).

application/json

{
  "error": "judge_room_ended",
  "code": "judge_room_ended",
  "message": "That room has ended"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

post/api/judge/pause

Pause or resume judge-room creation

Operator only. A global kill switch for the create route; the flag survives a restart.

Authentication

  • adminKey (header X-Admin-Key)

Request body

application/json (required)

object

FieldTypeDescription
paused (required)boolean
{
  "paused": true
}

Responses

200 New state.

application/json

object

FieldTypeDescription
paused (required)boolean
{
  "paused": true
}

400 The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.

application/json

{
  "code": "bad_message",
  "message": "invalid request body",
  "detail": "config.reserve: reserve must be at least 100 cents"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

post/api/judge/rooms/{id}/bids

Place a bid in a judge room over HTTP

Bid with plain HTTP, for curl and generated clients that cannot open the WebSocket (the socket needs a browser Origin; this route does not, and the socket rules are unchanged). Send the session token from POST /api/judge/join as a bearer token, after the bidder has a hold (POST /api/judge/rooms/{id}/sim-hold). The bid takes the same path as a socket bid: the per-bidder bid limit, the registered check and the auction engine all apply. amount is whole cents. mid is optional; send the same mid again to retry safely, the room answers with the original result and records nothing twice.

A bid the engine refuses (too low, over your ceiling, lot closed) is 409 with accepted: false and a reason. Sending a mid again with a different amount is 409 conflict ({error, code, message}). Bids sent too fast are 429 rate_limited with Retry-After. When the room cannot take the bid (auction_full, store_unavailable, room_full) the answer is 503. Only session tokens work here, and only for judge rooms: any other auction answers 404. A judge key is not needed on this route.

Authentication

  • sessionAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Request body

application/json (required)

object

FieldTypeDescription
amount (required)integerBid in cents.min 1; max 99999999999
midstringOptional message id (ULID) that makes a retry safe.pattern ^[0-9A-HJKMNP-TV-Z]{26}$
{
  "amount": 1500
}

Responses

200 The bid was accepted.

application/json

object

FieldTypeDescription
accepted (required)const true
mid (required)string
bidId (required)string
amount (required)integerThe accepted bid in cents.
seq (required)integerPosition of the bid in the room's event log.
{
  "accepted": true,
  "mid": "01JC5ZR1B2C3D4E5F6G7H8J9K0",
  "bidId": "01JC5ZR1B2C3D4E5F6G7H8J9K1",
  "amount": 1500,
  "seq": 7
}

202 The room is holding the bid for the bidder to confirm; nothing was placed yet.

application/json

object

FieldTypeDescription
accepted (required)const false
pending (required)const true
mid (required)string
{
  "accepted": false,
  "pending": true,
  "mid": "01JC5ZR1B2C3D4E5F6G7H8J9K0"
}

400 The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.

application/json

{
  "code": "bad_message",
  "message": "invalid request body",
  "detail": "config.reserve: reserve must be at least 100 cents"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

403 The credential is valid but its role may not do this (`forbidden`).

application/json

{
  "code": "forbidden",
  "message": "not allowed"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

409 The engine refused the bid (`bid_rejected`: `accepted` is false and `reason` says why), or the same `mid` was reused with a different amount (`conflict`).

application/json

{
  "accepted": false,
  "error": "bid_rejected",
  "mid": "01JC5ZR1B2C3D4E5F6G7H8J9K0",
  "reason": "below_increment",
  "retryable": false
}

410 The judge room ended (`judge_room_expired` or `judge_room_ended`).

application/json

{
  "error": "judge_room_ended",
  "code": "judge_room_ended",
  "message": "That room has ended"
}

413 The body is larger than 64 KiB (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "request body too large"
}

415 A body was sent that is not `application/json` (`bad_message`).

application/json

{
  "code": "bad_message",
  "message": "content-type must be application/json"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

503 The room could not take the bid (`auction_full`, `store_unavailable`, `room_full`). Retry after a moment.

application/json

{
  "error": "store_unavailable",
  "code": "store_unavailable",
  "message": "Bid refused"
}

get/api/judge/keys

List judge keys

Operator only (X-Admin-Key). Lists every judge key by id with its label, limits and state. The secret and its hash are never returned. A judge key on this route is 401.

Authentication

  • adminKey (header X-Admin-Key)

Responses

200 The keys.

application/json

object

FieldTypeDescription
keys (required)array of object
id (required)string
label (required)string
maxRooms (required)integer
perHour (required)integer
roomsLive (required)integer
revoked (required)boolean
expired (required)boolean
expiresAtstring (date-time)
{
  "keys": [
    {
      "id": "a1b2c3d4",
      "label": "Asha",
      "maxRooms": 2,
      "perHour": 10,
      "roomsLive": 1,
      "revoked": false,
      "expired": false,
      "expiresAt": "2026-11-30T23:59:00Z"
    }
  ]
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

post/api/judge/keys/{keyId}/revoke

Revoke a judge key

Operator only (X-Admin-Key). The key is refused from now on (401 judge_key_revoked) and stays revoked after a restart. Rooms it already opened run until they end. A judge key on this route is 401.

Authentication

  • adminKey (header X-Admin-Key)

Parameters

NameInTypeDescription
keyId (required)pathstringThe 8 character id inside the key (`gvl_judge_<id>_<secret>`), or `legacy`.

Responses

200 The key is revoked.

application/json

object

FieldTypeDescription
id (required)string
revoked (required)const true
{
  "id": "a1b2c3d4",
  "revoked": true
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

503 The revoke could not be saved (`store_unavailable`). Nothing changed and the key is still usable; try again.

application/json

{
  "error": "store_unavailable",
  "code": "store_unavailable",
  "message": "the revoke could not be saved, try again"
}

Receipts

Shareable read-only receipts.

get/api/auctions/{id}/receipt

Read a receipt

Receipt token only. A cut-down snapshot: the lot, the hammer outcome, the viewer's own roster row and own hold. The winner keeps a paddle but loses the name, every other bidder is dropped, and the ceiling, the join code and the audit rows are removed.

Authentication

  • receiptAuth (Authorization: Bearer)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Responses

200 The receipt.

application/json

object

FieldTypeDescription
bidderId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
snapshot (required)RoomSnapshotRoom state for one viewer. Fields not listed here may appear; clients must ignore what they do not know. Private fields (`you`, `yourHold`, ceilings) appear only for the bidder they belong to.
auctionId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
config (required)AuctionConfigSettings of one auction. Amounts are in cents, durations in milliseconds.
modestringBidding mode. Defaults to english.one of "english", "proxy", "second_price"; default "english"
currency (required)const "USD"Currency of every amount. Only USD is accepted.
reserve (required)integerReserve price in cents (at least 100).min 100
minIncrement (required)integerSmallest raise in cents.min 1
endTs (required)EpochMsMilliseconds since the Unix epoch.
softCloseWindowMs (required)integerA bid this close to the end extends the lot.min 0; max 600000
softCloseExtensionMs (required)integerHow long a soft close extends the lot, in milliseconds.min 0; max 600000
maxExtensions (required)integerMost times the lot can be extended by soft close.min 0; max 50
ceilingPresets (required)array of integerStrictly ascending ceiling amounts in cents, none below the reserve.
confirmConfidenceThreshold (required)numberSpoken bids below this confidence need the bidder to confirm.min 0; max 1
autoHammerDelayMsintegerMilliseconds after closing begins before the host hammers the lot by itself. When absent, the auctioneer hammers by hand.min 0
holdScopestringWhether one hold covers the whole event or a single lot. The engine default is event.one of "event", "lot"
incrementLadderarray of objectTiered increments. Must start at fromCents 0 and strictly ascend.
fromCents (required)integerPrice in cents from which this increment applies.min 0
incrementCents (required)integerSmallest raise in cents at this price tier.min 1
status (required)AuctionStatusLifecycle state of the auction, from SCHEDULED to DONE.one of "SCHEDULED", "OPEN", "PAUSED", "CLOSING", "SETTLING", "DONE"
price (required)Cents | nullCurrent price in cents, or null before the first accepted bid.
leaderId (required)Ulid | nullBidder id of the current leader, or null when there is none.
endTs (required)EpochMsMilliseconds since the Unix epoch.
minNext (required)CentsMoney in integer US cents. There are no decimals anywhere in the API.min 0
pausedbooleanTrue while the auctioneer has paused the lot.
remainingMsintegerMilliseconds left until the lot ends.min 0
stoppedbooleanTrue once the auctioneer used the Stop button.
registrationClosedbooleanTrue once the room no longer takes registrations.
hammerobjectResult of the lot, present once it was hammered.
winnerId (required)Ulid | nullBidder id of the winner, or null when the lot did not sell.
price (required)CentsMoney in integer US cents. There are no decimals anywhere in the API.min 0
lotTitlestringTitle of the lot.
joinCodeJoinCodeSix letters and digits without look-alikes (no 0, O, 1, I, L).pattern ^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$
fundraiserobjectFundraiser goal, present only when one was set.
goalCents (required)integerGoal in cents.min 1
roster (required)array of objectRegistered bidders, at most 200.
bidderId (required)Ulid26 character id (Crockford base 32).pattern ^[0-9A-HJKMNP-TV-Z]{26}$
paddle (required)integerPaddle number of the bidder.min 1; max 9999
name (required)stringDisplay name of the bidder.
hold (required)stringHELD when the bidder has a hold, otherwise NONE.one of "HELD", "NONE"
recentAudit (required)array of objectThe most recent audit rows visible to this viewer, at most 50.
settlementobjectSettlement progress, present once the lot ended.
youobjectThe viewing bidder's own ceiling and high bid. Bidder sessions only.
expiresAt (required)EpochMsMilliseconds since the Unix epoch.

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

Export

Event log and ledger export.

get/api/auctions/{id}/export

Stream the event log as NDJSON

Auctioneer token or operator key. One JSON object per line, in sequence order, with personal fields removed for everyone but the operator. If the store has a gap the connection is cut instead of ending quietly, so a short file never looks complete. This route is served by the host directly: its errors are {"error": "<code>"} only.

Authentication

  • auctioneerAuth (Authorization: Bearer)
  • adminKey (header X-Admin-Key)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Responses

200 NDJSON stream.

application/x-ndjson

string One RoomEvent JSON object per line.

{"seq":1,"type":"auction_created"}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

403 The credential is valid but its role may not do this (`forbidden`).

application/json

{
  "code": "forbidden",
  "message": "not allowed"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

405 Wrong method for a route the host serves directly. The `Allow` header names the right one.

application/json

{
  "error": "method_not_allowed"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

Health

Liveness, readiness and build version.

get/healthz

Liveness

Always 200 while the process runs. Never rate limited.

Authentication

No credential needed.

Responses

200 Alive.

application/json

Status

FieldTypeDescription
status (required)const "ok"Always `ok` when the host answers.
{
  "status": "ok"
}

get/readyz

Readiness

200 when every migration is applied and the store answers; 503 otherwise. Never rate limited.

Authentication

No credential needed.

Responses

200 Ready.

application/json

Status

FieldTypeDescription
status (required)const "ok"Always `ok` when the host answers.
{
  "status": "ok"
}

503 Not ready.

application/json

{
  "status": "unavailable",
  "reason": "store"
}

get/version

Deployed build

The commit the running build was made from, or unknown. Never rate limited.

Authentication

No credential needed.

Responses

200 Build sha.

application/json

object

FieldTypeDescription
sha (required)string
{
  "sha": "1e516633"
}

get/warmup

Warm the host's dependencies

No credential. Runs the warm-up steps in parallel within a 3 second budget; 200 when all are warm, 503 otherwise.

Authentication

No credential needed.

Responses

200 All steps warm.

application/json

WarmupReport

FieldTypeDescription
ok (required)booleanTrue when every warm-up step succeeded.
elapsedMs (required)integerTotal time of the warm-up in milliseconds.
steps (required)array of objectOne entry per warm-up step, in the order they ran.
name (required)stringName of the warm-up step.
ok (required)booleanTrue when this step succeeded.
ms (required)integerTime this step took in milliseconds.

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

503 A step failed or the budget ran out.

application/json

Operator

Routes that need the operator key.

get/admin/export

Private ledger and payment state export

Operator only. Output carries every bidder's ceiling, hold reference and bid transcript: never publish it. Modes by query: auction=<id> (the hash-chained ledger as NDJSON, last line {head}); list=1 (all auction ids); kind=payments&auction=<id> (settlement journal and holds); kind=payments-global (webhook event ids and kill switch); kind=roomlog&auction=<id> (room events as NDJSON, last line {end}). Secret-looking keys are replaced with [redacted] and webhook bodies are dropped. Errors are {"error": "<code>"}.

Authentication

  • adminKey (header X-Admin-Key)

Parameters

NameInTypeDescription
auctionquerystringAuction id (`auctionId` is accepted as an alias).
listquerystringSend `1` to list every auction id instead of exporting one auction.example "1"
kindquerystringWhich export to return. Without it, `auction` returns the ledger.one of payments, payments-global, roomlog; example "payments"

Responses

200 JSON or NDJSON depending on the mode.

application/x-ndjson

string

application/json

object

{
  "auctions": [
    "01JC5ZP4M7Q8R9S0T1V2W3X4Y5"
  ]
}

400 A bad query on an operator route (`bad_auction_id`, `bad_kind`).

application/json

{
  "error": "bad_auction_id"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

405 Wrong method for a route the host serves directly. The `Allow` header names the right one.

application/json

{
  "error": "method_not_allowed"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

501 The host does not support this export mode.

application/json

{
  "error": "payments_unsupported"
}

get/admin/replay-bundle

Replay bundle of one auction

Operator only. A self-contained bundle of the auction's ledger for replay and verification. Query auctionId is required. 409 when the hash chain is broken.

Authentication

  • adminKey (header X-Admin-Key)

Parameters

NameInTypeDescription
auctionId (required)querystringId of the auction to bundle.example "01J9ZZ0000000000000000000A"

Responses

200 The bundle (format defined in `packages/shared/src/replay-bundle.ts`).

application/json

object

400 A bad query on an operator route (`bad_auction_id`, `bad_kind`).

application/json

{
  "error": "bad_auction_id"
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

405 Wrong method for a route the host serves directly. The `Allow` header names the right one.

application/json

{
  "error": "method_not_allowed"
}

409 The request conflicts with the state of the lot or the credential: `conflict` (for example a hammer refused because the price or leader changed), `closed`, `invite_used`, `lot_not_open`.

application/json

{
  "code": "conflict",
  "message": "The lot has already ended"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

500 Something failed on the host (`internal`). The message never carries internals.

application/json

{
  "code": "internal",
  "message": "internal error"
}

get/metrics

Metrics snapshot (JSON)

Operator only: this route is not public. Own limit of 60 requests per minute on top of the host-wide limit.

Authentication

  • adminKey (header X-Admin-Key)

Responses

200 The collector snapshot.

application/json

object

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

405 Wrong method for a route the host serves directly. The `Allow` header names the right one.

application/json

{
  "error": "method_not_allowed"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

get/metrics/prometheus

Metrics in Prometheus text format

Operator only.

Authentication

  • adminKey (header X-Admin-Key)

Responses

200 Prometheus exposition text.

text/plain

string

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

405 Wrong method for a route the host serves directly. The `Allow` header names the right one.

application/json

{
  "error": "method_not_allowed"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

get/ops/summary

Operator summary

Operator only. Recent logs, settlement sagas, PayPal webhook outcomes and metrics.

Authentication

  • adminKey (header X-Admin-Key)

Responses

200 Summary.

application/json

object

FieldTypeDescription
logs (required)array of string
sagas (required)array of object
auctionId (required)string
lotId (required)string
state (required)string
paypal (required)object
metrics (required)object

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

405 Wrong method for a route the host serves directly. The `Allow` header names the right one.

application/json

{
  "error": "method_not_allowed"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

get/ops/rooms

Rooms the host has loaded

Operator only.

Authentication

  • adminKey (header X-Admin-Key)

Responses

200 Loaded rooms.

application/json

object

FieldTypeDescription
rooms (required)array of object
auctionId (required)string
seq (required)integer
status (required)string
connections (required)integer
mailboxDepth (required)integer
{
  "rooms": [
    {
      "auctionId": "01JC5ZP4M7Q8R9S0T1V2W3X4Y5",
      "seq": 12,
      "status": "OPEN",
      "connections": 3,
      "mailboxDepth": 0
    }
  ]
}

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

405 Wrong method for a route the host serves directly. The `Allow` header names the right one.

application/json

{
  "error": "method_not_allowed"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

post/ops/auctions/{id}/void-all

Void every uncaptured hold of a hammered auction

Operator only. For example while the kill switch keeps a capture waiting. Audited with the actor admin. 409 busy while a settlement run holds the lease, 409 no_settlement before the lot was hammered.

Authentication

  • adminKey (header X-Admin-Key)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Responses

200 Done.

application/json

object

FieldTypeDescription
status (required)const "done"
holds (required)objectHold id to the outcome the payments package recorded.

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

405 Wrong method for a route the host serves directly. The `Allow` header names the right one.

application/json

{
  "error": "method_not_allowed"
}

409 A settlement run holds the lease (`busy`), there is no settlement (`no_settlement`) or the settlement is not in review (`not_in_review`).

application/json

{
  "error": "busy"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}

post/ops/auctions/{id}/release-review

Release the holds a settlement review keeps

Operator only. Releases the fallback candidates' holds of a settlement in review. 409 with busy, no_settlement or not_in_review.

Authentication

  • adminKey (header X-Admin-Key)

Parameters

NameInTypeDescription
id (required)pathstringAuction id.

Responses

200 Released.

application/json

object

FieldTypeDescription
status (required)const "released"

401 The credential is missing, expired, revoked, of the wrong kind or for another auction.

application/json

{
  "code": "unauthorized",
  "message": "access token required"
}

404 No such auction, bidder, room or route (`not_found`).

application/json

{
  "code": "not_found",
  "message": "no such auction"
}

405 Wrong method for a route the host serves directly. The `Allow` header names the right one.

application/json

{
  "error": "method_not_allowed"
}

409 A settlement run holds the lease (`busy`), there is no settlement (`no_settlement`) or the settlement is not in review (`not_in_review`).

application/json

{
  "error": "busy"
}

429 Too many requests (`rate_limited`), a locked-out source (`code_locked`, `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or create limit. Wait `Retry-After` seconds.

application/json

{
  "error": "rate_limited",
  "message": "Too many tries. Wait a minute and try again."
}