ā ļø 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.javascriptconst 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.javascriptinterface 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.
javascriptconst 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.
javascriptconst 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 ā OPENEDat itsopenDate.
- Each lot moves
NOT_OPEN ā OPENat its ownopenTime, andCLOSEDatendTimeor 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.javascriptconst 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:graphqlquery 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.javascriptconst 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 thestatusfields.DutchSale.statusandDutchSaleItem.statusare different enums, so selecting both unaliased in the same union is a validation error.
graphqlsubscription 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:javascriptconst 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.currentin state and run a 1 Hz timer purely for the countdown toprice.nextDrop.at. Reconnect and re-read the snapshot if the socket drops, rather than extrapolating a price locally.
Putting it together
plain textServer (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.javascriptmutation 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.javascriptconst 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:
javascriptimages { 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.javascriptmutation 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.