logo

Dutch Auctions (Beta)

āš ļø Beta. Dutch auctions are enabled per account on request. Get in touch before you go live.
This page walks through a complete integration: create a sale with a single multi-unit lot, publish it, start bidding, follow it live from a browser or monitor the sale from your dashboard.

Concepts

Term
Meaning
Sale (DutchSale)
Container with an open and close date. Holds one or more lots.
Lot (DutchSaleItem)
A multi-unit item with its own bidding window (openTime → endTime) and price schedule.
Schedule (DutchSchedule)
The opening price plus every dated drop. Exposed to clients, so the whole descent is public up front.
Clock price (price.current)
The price in effect right now, derived from the schedule and server time.
Units
availableUnits is fixed at creation. unitsRemaining counts down as bids are accepted.
Pricing mode
UNIFORM_CLEARING — every winner pays the same final price (the lowest accepted price, or the clock floor if the lot does not sell out), regardless of the price they accepted at.
Two APIs are involved:
API
Endpoint
Auth
Use for
Management
https://management.api.basta.app/graphql
x-account-id + x-api-key
Creating sales and lots, minting bidder tokens. Server-side only.
Client
https://client.api.basta.app/graphql wss://client.api.basta.app/graphql
none for reads, Authorization: Bearer <bidderToken> for bidding
Reading sales, placing bids, live updates. Safe for browsers.
Amounts are integers in the sale currency's minor unit — 24000 is $240.00. Timestamps are RFC 3339 strings in UTC.

Setup

Two small transport helpers, used by every example below.
javascript
// basta.ts const MANAGEMENT_URL = "https://management.api.basta.app/graphql"; const CLIENT_URL = "https://client.api.basta.app/graphql"; const ACCOUNT_ID = process.env.BASTA_ACCOUNT_ID!; const API_KEY = process.env.BASTA_API_KEY!; /** Management API. Never call this from the browser — the API key is account-wide. */ export async function management<T>( query: string, variables: Record<string, unknown> = {}, ): Promise<T> { const res = await fetch(MANAGEMENT_URL, { method: "POST", headers: { "Content-Type": "application/json", "x-account-id": ACCOUNT_ID, "x-api-key": API_KEY, }, body: JSON.stringify({ query, variables: { accountId: ACCOUNT_ID, ...variables } }), }); const { data, errors } = await res.json(); if (errors?.length || !data) throw new Error(errors?.[0]?.message ?? `HTTP ${res.status}`); return data as T; } /** Client API. Pass a bidder token to bid; omit it for public reads. */ export async function client<T>( query: string, variables: Record<string, unknown> = {}, bidderToken?: string, ): Promise<T> { const res = await fetch(CLIENT_URL, { method: "POST", headers: { "Content-Type": "application/json", ...(bidderToken ? { Authorization: `Bearer ${bidderToken}` } : {}), }, body: JSON.stringify({ query, variables }), }); const { data, errors } = await res.json(); if (errors?.length || !data) throw new Error(errors?.[0]?.message ?? `HTTP ${res.status}`); return data as T; }

1. Create the sale

createDutchSale takes a title, a description and the sale window. The lots come next.
javascript
const CREATE_SALE = ` mutation CreateDutchSale($accountId: String!, $input: CreateDutchSaleInput!) { createDutchSale(accountId: $accountId, input: $input) { id status dates { openDate closingDate } } } `; const { createDutchSale: sale } = await management<{ createDutchSale: { id: string; status: string }; }>(CREATE_SALE, { input: { title: "Vault Drop 01 — Kaiju Club", description: "Twenty hand-finished resin figures from the Kaiju Club series. " + "Price drops every five minutes until the run is claimed.", dates: { openDate: "2026-08-14T17:00:00.000Z", closingDate: "2026-08-14T19:00:00.000Z", }, }, }); console.log(sale.id); // e.g. "1ec51eba19-665c3f000002000e"

2. Add the lot and its price ladder

A lot needs its unit count (# of objects available), its bidding window, and a schedule — the opening price plus every timed price drop. Drops must be in ascending time order and strictly decreasing in price, beginning at or after openTime.
javascript
interface Drop { at: string; price: number } /** * Build a uniform descending ladder: start at `startPrice` and step down by * `step` every `intervalMinutes` until `floorPrice` is reached. */ function buildLadder(opts: { openTime: string; startPrice: number; floorPrice: number; step: number; intervalMinutes: number; }): { startingAmount: number; drops: Drop[] } { const drops: Drop[] = []; let price = opts.startPrice; let at = Date.parse(opts.openTime); while (price > opts.floorPrice) { price = Math.max(opts.floorPrice, price - opts.step); at += opts.intervalMinutes * 60_000; drops.push({ at: new Date(at).toISOString(), price }); } return { startingAmount: opts.startPrice, drops }; }
For this drop: open at $240, floor at $90, down $10 every five minutes. That is 15 drops over 75 minutes.
javascript
const openTime = "2026-08-14T17:00:00.000Z"; const schedule = buildLadder({ openTime, startPrice: 24000, // $240.00 floorPrice: 9000, // $90.00 step: 1000, // $10.00 intervalMinutes: 5, }); const CREATE_ITEM = ` mutation CreateDutchItem($accountId: String!, $input: CreateDutchItemForSaleInput!) { createDutchItemForSale(accountId: $accountId, input: $input) { id status availableUnits openTime endTime schedule { startingAmount drops { at price } } config { pricingMode maxBidsPerBidder maxUnitsPerBidder } } } `; const { createDutchItemForSale: lot } = await management<{ createDutchItemForSale: { id: string }; }>(CREATE_ITEM, { input: { saleId: sale.id, title: "Kaiju Club — Ember Variant", description: "Cast resin, hand-painted, 18 cm. Numbered edition of 20, " + "each shipped with a signed certificate.", availableUnits: 20, openTime, closingTime: "2026-08-14T19:00:00.000Z", schedule, config: { pricingMode: "UNIFORM_CLEARING", maxBidsPerBidder: 2, maxUnitsPerBidder: 4, }, }, });
The resulting schedule looks like this — the client API returns the same shape, so your UI can draw the full staircase before the lot even opens:
javascript
{ "startingAmount": 24000, "drops": [ { "at": "2026-08-14T17:05:00.000Z", "price": 23000 }, { "at": "2026-08-14T17:10:00.000Z", "price": 22000 }, { "at": "2026-08-14T17:15:00.000Z", "price": 21000 }, "… 11 more …", { "at": "2026-08-14T18:15:00.000Z", "price": 9000 } ] }
The per-bidder caps in config are worth setting deliberately. With maxUnitsPerBidder: 4 on a 20-unit lot, no single buyer can take more than a fifth of the run.

3. Publish the sale

Nothing is visible to bidders until you publish.
javascript
const PUBLISH = ` mutation PublishSale($accountId: String!, $input: PublishSaleInput!) { publishSale(accountId: $accountId, input: $input) { id status } } `; await management(PUBLISH, { input: { saleId: sale.id } });
From there the lifecycle runs on its own:
  • The sale moves PUBLISHED → OPENED at its openDate.
  • Each lot moves NOT_OPEN → OPEN at its own openTime, and CLOSED at endTime or when it sells out.
Bids placed before a lot is OPEN are rejected with NOT_OPEN.

4. Mint a bidder token

Reads are public, but bidding is done as a specific user. Mint a short-lived bidder token on your server, keyed to your own user id, and hand it to the browser. The API key never leaves your backend.
javascript
// POST /api/basta/bidder-token — your server, your session check const CREATE_BIDDER_TOKEN = ` mutation CreateBidderToken($accountId: String!, $input: BidderTokenInput!) { createBidderToken(accountId: $accountId, input: $input) { token expiration } } `; export async function mintBidderToken(userId: string) { const { createBidderToken } = await management<{ createBidderToken: { token: string; expiration: string }; }>(CREATE_BIDDER_TOKEN, { input: { metadata: { userId, // your user id, as stored in your system ttl: 180, // minutes }, }, }); return createBidderToken; }
Bids and allocations are attributed to userId, so use a stable identifier. Refresh the token when it nears expiration.

5. Read the lot

No auth needed — pass a bidder token only if you want mine on bids to be accurate.
javascript
const SALE_SNAPSHOT = ` query SaleSnapshot($id: String!) { saleV2(id: $id) { id title status saleFormat dates { openDate closingDate } ... on DutchSale { items(first: 10) { edges { node { id title description status availableUnits unitsRemaining totalBids openTime endTime price { current nextDrop { price at } } schedule { startingAmount drops { at price } } images { id url order } bids(first: 20) { edges { node { id amount placedAt mine } } } } } } } } } `; const { saleV2 } = await client(SALE_SNAPSHOT, { id: sale.id }, bidderToken);
price.current is the live clock price. price.nextDrop is null when the price is not descending — either the lot has not opened or it has reached its floor.
The ladder is a staircase, not a slide — the price holds steady between drops, so there is nothing to interpolate and no need to poll. Keep the latest price.current in state, refreshed by the subscription in step 7, and run a timer only for the countdown to price.nextDrop.at.
schedule is there for drawing the descent rather than for deriving the price: the whole staircase is public, so you can chart what is coming before a lot opens.
āš ļø Don't compute the price from a local clock. It would be correct only while the browser's clock is, and a skewed clock shows the bidder one price while the server holds another — they would click a figure they don't get charged. Display what the server sent.
To list a bidder's available drops instead of a single sale, salesV2(accountId:, filter:) returns the same polymorphic nodes:
graphql
query LiveDrops($accountId: String!) { salesV2(accountId: $accountId, first: 20, filter: { statuses: [PUBLISHED, OPENED] }) { edges { node { id title status saleFormat ... on DutchSale { items(first: 10) { edges { node { id title unitsRemaining price { current } } } } } } } } }

6. Place a bid

placeDutchBid accepts a quantity and the per-unit amount the bidder is agreeing to. amount must equal the lot's current clock price exactly. If the clock ticked past a drop boundary before the bid landed, it is rejected with PRICE_MISMATCH — re-read price.current and retry.
javascript
const PLACE_BID = ` mutation PlaceDutchBid( $saleId: String! $itemId: String! $quantity: Int! $amount: Int! ) { placeDutchBid(saleId: $saleId, itemId: $itemId, quantity: $quantity, amount: $amount) { __typename ... on DutchBidPlacedSuccess { id amount quantityRequested quantityAllocated placedAt } ... on DutchBidPlacedError { errorCode } } } `; type BidResult = | { __typename: "DutchBidPlacedSuccess"; id: string; amount: number; quantityRequested: number; quantityAllocated: number; placedAt: string; } | { __typename: "DutchBidPlacedError"; errorCode: string }; const CURRENT_PRICE = ` query CurrentPrice($id: String!) { saleV2(id: $id) { ... on DutchSale { items(first: 10) { edges { node { id price { current } } } } } } } `; async function readCurrentPrice(saleId: string, itemId: string): Promise<number> { const { saleV2 } = await client<any>(CURRENT_PRICE, { id: saleId }); return saleV2.items.edges .map((e: any) => e.node) .find((n: any) => n.id === itemId).price.current; } /** * Accept at the clock price. * * `currentPrice` is the server's `price.current` — take it from your snapshot * or, in a live client, from the most recent subscription push. Since drops are * pushed, that number is already in hand and costs no extra round-trip. * * On PRICE_MISMATCH, re-read `price.current` and retry once. */ export async function accept( saleId: string, itemId: string, quantity: number, currentPrice: number, bidderToken: string, ): Promise<BidResult> { const attempt = (amount: number) => client<{ placeDutchBid: BidResult }>( PLACE_BID, { saleId, itemId, quantity, amount }, bidderToken, ).then((d) => d.placeDutchBid); let result = await attempt(currentPrice); if ( result.__typename === "DutchBidPlacedError" && result.errorCode === "PRICE_MISMATCH" ) { result = await attempt(await readCurrentPrice(saleId, itemId)); } return result; }
āš ļø Bid the server's price, not one you derived. A client whose clock is off by more than a drop interval would fail every bid, and retrying against the same clock fails identically. Re-reading price.current is the only retry that converges.

Error codes

errorCode
Meaning
Suggested handling
PRICE_MISMATCH
The declared amount is no longer the clock price.
Re-read price.current and retry once.
NOT_OPEN
The lot has not opened yet.
Disable the accept button until status is OPEN.
ENDED
The bidding window has closed.
Show the closed state.
SOLD_OUT
Bidding is open but every unit is taken.
Show sold out; refresh unitsRemaining.
CLOSED
The lot has been closed.
Show the closed state.
BIDDER_LIMIT_REACHED
This bidder has hit maxBidsPerBidder or maxUnitsPerBidder.
Explain the per-bidder cap.

7. Subscribe to live activity

saleActivityV2 pushes sale and lot changes over WebSocket using the graphql-transport-ws protocol. On subscribe you get an immediate snapshot — the sale plus every lot — then a push whenever something changes.
āš ļø Alias the status fields. DutchSale.status and DutchSaleItem.status are different enums, so selecting both unaliased in the same union is a validation error.
graphql
subscription SaleActivity($saleId: ID!) { saleActivityV2(saleId: $saleId) { __typename ... on DutchSale { id saleStatus: status dates { openDate closingDate } } ... on DutchSaleItem { id itemStatus: status availableUnits unitsRemaining totalBids openTime endTime price { current nextDrop { price at } } } } }
Pass itemIdFilter: { itemIds: [...] } to narrow the snapshot to specific lots.
A minimal client. Any graphql-transport-ws library will do this for you; this is the raw version so the protocol chores are visible:
javascript
const WS_URL = "wss://client.api.basta.app/graphql"; export function openSaleActivity( saleId: string, onPush: (push: any) => void, onClose?: (reason: string) => void, ) { const socket = new WebSocket(WS_URL, "graphql-transport-ws"); let keepalive: ReturnType<typeof setInterval> | undefined; socket.onopen = () => { socket.send(JSON.stringify({ type: "connection_init", payload: {} })); }; socket.onmessage = (event) => { const msg = JSON.parse(String(event.data)); switch (msg.type) { case "connection_ack": socket.send( JSON.stringify({ id: "sale-activity", type: "subscribe", payload: { query: SALE_ACTIVITY, variables: { saleId } }, }), ); // The server drops silent connections, so ping while idle. keepalive = setInterval(() => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: "ping" })); } }, 25_000); break; case "ping": socket.send(JSON.stringify({ type: "pong" })); break; case "next": onPush(msg.payload.data.saleActivityV2); break; case "error": case "complete": socket.close(); break; } }; socket.onclose = (event) => { clearInterval(keepalive); onClose?.(`${event.code} ${event.reason}`.trim()); }; return { close: () => { clearInterval(keepalive); socket.close(); }, }; }
Reply pong to the server's ping. Connections that go quiet are dropped, usually surfacing as a 1006 close. Reconnect with backoff and re-read the snapshot on reopen; the snapshot-on-subscribe means you recover state without any extra call.

What the subscription sends

You get a DutchSaleItem frame for every change to a lot, including each scheduled price drop. When a lot crosses a drop boundary the push carries the new price.current and the following nextDrop, so a subscribed client stays on the ladder without polling.
Trigger
What to read from the payload
Scheduled price drop
price.current, price.nextDrop
A bid is accepted
unitsRemaining, totalBids
Lot opens or closes
itemStatus (NOT_OPEN → OPEN → CLOSED)
Schedule or window edited mid-sale
schedule, openTime, endTime
Sale status or dates change
on the DutchSale frame: saleStatus, dates
Two things worth knowing:
  • Render what was pushed. Because the price holds between drops, the pushed value is all you need: keep the latest price.current in state and run a 1 Hz timer purely for the countdown to price.nextDrop.at. Reconnect and re-read the snapshot if the socket drops, rather than extrapolating a price locally.

Putting it together

plain text
Server (Management API) Browser (Client API) ───────────────────────── ────────────────────────────── createDutchSale createDutchItemForSale saleV2 ─────────► snapshot publishSale saleActivityV2 ─► live price + units price.nextDrop.at ─► countdown createBidderToken ──── token ─────► placeDutchBid

Appendix — storing content in Basta

Titles, descriptions and images can live in Basta, so a client can render a lot from a single saleV2 call with no second content source. This is optional; if you keep your own catalogue, ignore this section and match records by id.

Titles and descriptions

Set them when you create the lot (as in step 2), or update them later. On updateDutchSale and updateDutchSaleItem, null leaves a field unchanged and an empty string clears it.
javascript
mutation UpdateLotContent($accountId: String!, $input: UpdateDutchSaleItemInput!) { updateDutchSaleItem(accountId: $accountId, input: $input) { id title description } }
javascript
{ "input": { "saleId": "1ec51eba19-665c3f000002000e", "itemId": "8f3c1d2e-...", "title": "Kaiju Club — Ember Variant", "description": "Cast resin, hand-painted, 18 cm. Numbered edition of 20." } }
The same mutation edits schedule, openTime, closingTime and availableUnits. Take care changing the schedule once a lot is open — bidders will already be looking at the published staircase.

Images

Uploading is two steps: ask for a signed URL, then PUT the bytes to it. The association is made at the point you request the URL, so there is nothing to link afterwards.
javascript
const CREATE_UPLOAD_URL = ` mutation CreateUploadUrl($accountId: String!, $input: CreateUploadUrlInput!) { createUploadUrl(accountId: $accountId, input: $input) { imageId uploadUrl imageUrl } } `; export async function uploadLotImage( saleId: string, itemId: string, file: Blob, order: number, ) { const { createUploadUrl } = await management<{ createUploadUrl: { imageId: string; uploadUrl: string; imageUrl: string }; }>(CREATE_UPLOAD_URL, { input: { imageTypes: ["SALE_ITEM"], contentType: file.type, // e.g. "image/jpeg" order, saleId, itemId, externalId: `kaiju-ember-${order}`, // optional, your own id }, }); const put = await fetch(createUploadUrl.uploadUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); if (!put.ok) throw new Error(`upload failed: HTTP ${put.status}`); return createUploadUrl; // imageUrl is renderable once the PUT completes }
Which imageTypes to use:
Type
Also set
Appears on
SALE
saleId
DutchSale.images — banner art for the drop
SALE_ITEM
saleId + itemId
DutchSaleItem.images — the lot's gallery
ACCOUNT
—
Account-level branding
order controls display order, lowest first. Values may tie, so sort by order then id if you need a stable sequence.
Read them back on any lot query:
javascript
images { id url order }
Already uploaded an image and want it on a second lot? linkImageById (or linkImageByExternalId) attaches an existing image without re-uploading. It is idempotent, so re-linking is a no-op.
javascript
mutation LinkImage($accountId: String!, $input: LinkImageByIdInput!) { linkImageById(accountId: $accountId, input: $input) { image { id url order } } }
javascript
{ "input": { "imageId": "3ac9b7e1-...", "imageTypes": ["SALE_ITEM"], "saleId": "1ec51eba19-665c3f000002000e", "itemId": "8f3c1d2e-...", "order": 1 } }
Use reorderImages to change display order, and deleteImage to remove an image or just one of its associations.

Powered by Notaku