BOOK A DEMO

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

TermMeaning
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.
UnitsavailableUnits is fixed at creation. unitsRemaining counts down as bids are accepted.
Pricing modeUNIFORM_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:

APIEndpointAuthUse for
Managementhttps://management.api.basta.app/graphqlx-account-id + x-api-keyCreating sales and lots, minting bidder tokens. Server-side only.
Clienthttps://client.api.basta.app/graphql, wss://client.api.basta.app/graphqlnone for reads, Authorization: Bearer <bidderToken> for biddingReading 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.

TypeScript
// 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.

TypeScript
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.

TypeScript
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.

TypeScript
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:

JSON
{
  "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.

TypeScript
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 after your own session check, and hand it to the browser.

TypeScript
// 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.

TypeScript
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:

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. 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.

TypeScript
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

errorCodeMeaningSuggested handling
PRICE_MISMATCHThe declared amount is no longer the clock price (price.current).Re-read and retry once.
NOT_OPENThe lot has not opened yet.Disable the accept button until status is OPEN.
ENDEDThe bidding window has closed.Show the closed state.
SOLD_OUTBidding is open but every unit is taken.Show sold out; refresh unitsRemaining.
CLOSEDThe lot has been closed.Show the closed state.
BIDDER_LIMIT_REACHEDThis 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.

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 library will do this for you; this is the raw version so the graphql-transport-ws protocol chores are visible.

TypeScript
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.

TriggerWhat to read from the payload
Scheduled price dropprice.current, price.nextDrop
A bid is acceptedunitsRemaining, totalBids
Lot opens or closesitemStatus (NOT_OPEN → OPEN → CLOSED)
Schedule or window edited mid-saleschedule, openTime, endTime
Sale status or dates changeon the DutchSale frame: saleStatus, dates
  • 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 countdown to price.nextDrop.at only. Render what was pushed.

Putting it together

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 — 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.

GraphQL
mutation UpdateLotContent($accountId: String!, $input: UpdateDutchSaleItemInput!) {
  updateDutchSaleItem(accountId: $accountId, input: $input) {
    id
    title
    description
  }
}
JSON
{
  "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.

TypeScript
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):

TypeAlso setAppears on
SALEsaleIdDutchSale.images — banner art for the drop
SALE_ITEMsaleId, itemIdDutchSaleItem.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:

GraphQL
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.

GraphQL
mutation LinkImage($accountId: String!, $input: LinkImageByIdInput!) {
  linkImageById(accountId: $accountId, input: $input) {
    image { id url order }
  }
}
JSON
{
  "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.