Selling
Dutch Auctions (Beta)
A complete integration walkthrough: create a sale with a single multi-unit lot, publish it, and take live bids as the price ticks down.
Beta. Dutch auctions are enabled per account on request. Get in touch at hi@basta.app before you go live.
A Dutch auction opens at a high price and steps down on a published schedule until the lot is claimed. Bidders accept the current price; when a multi-unit lot sells out or reaches its floor, every winner pays the same clearing price.
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 doesn't sell out). |
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.
Setup
Two small transport helpers, used by every example below.
// 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.
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 (the number of objects available), its bidding window, and a schedule — the opening price plus every dated drop.
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.
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 whole descent up front:
{
"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 the whole run.
3. Publish the sale
Nothing is visible to bidders until you publish.
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 → 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 after your own session check, and hand it to the browser.
// 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.
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 the floor. The ladder is a staircase, not a slide — the price holds steady between drops, so there is nothing to interpolate; run a countdown to price.nextDrop.at. The schedule is there for drawing the descent rather than for deriving the price: the whole staircase is public.
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 charges another. Display server values only.
To list a bidder's available drops instead of a single sale, salesV2(accountId:, filter:) returns the same polymorphic nodes:
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. If the clock ticked past a drop boundary between render and submit, amount must equal the lot's current clock price exactly, or you get PRICE_MISMATCH.
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; retry against the server's price.current.
Error codes
| errorCode | Meaning | Suggested handling |
|---|---|---|
PRICE_MISMATCH | The declared amount is no longer the clock price (price.current). | Re-read 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, then a push for every change.
DutchSale.status and DutchSaleItem.status are different enums, so selecting both unaliased in the same union is a validation error. Alias the status fields.
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 library will do this for you; this is the raw version so the graphql-transport-ws protocol chores are visible.
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 reconnect rather than extrapolating locally.
What the subscription sends
You get a frame for every change to a DutchSaleItem, including each scheduled price drop. When a lot crosses a drop boundary the push carries the new price.current and nextDrop.
| 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 |
- Because the price holds between drops, the pushed value is all you need: keep the latest
price.currentin state and run a 1 Hz countdown toprice.nextDrop.atonly. Render what was pushed.
Putting it together
Server (Management API) Browser (Client API)
───────────────────────── ──────────────────────────────
createDutchSale
createDutchItemForSale saleV2 ─────────► snapshot
publishSale saleActivityV2 ─► live price + units
price.nextDrop.at ─► countdown
createBidderToken ──── token ─────► placeDutchBidAppendix — storing content in Basta
Titles, descriptions and images can live in Basta, so a client can render a lot from a single saleV2 call — or in your own system, if you'd rather Basta stayed a pure bidding engine.
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.
mutation UpdateLotContent($accountId: String!, $input: UpdateDutchSaleItemInput!) {
updateDutchSaleItem(accountId: $accountId, input: $input) {
id
title
description
}
}{
"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 have seen the old one.
Images
Uploading is two steps: ask for a signed URL, then PUT the bytes to it. The association is made at the createUploadUrl step.
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 to use (imageTypes):
| 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:
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.
mutation LinkImage($accountId: String!, $input: LinkImageByIdInput!) {
linkImageById(accountId: $accountId, input: $input) {
image { id url order }
}
}{
"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.