mallow docs

Quickstart

Read an artwork, buy a fixed-price listing and draw from a Gumball with the mallow API, with TypeScript you can copy.

This page takes you through one flow from start to finish: read a listing, build a purchase, sign it, send it and confirm it. It then covers Gumball and Jellybean draws, which are sent a different way.

You need:

  • An API key. See Authentication
  • A Solana RPC endpoint. You send /v2 transactions yourself
  • Node.js 20 or newer, and @solana/web3.js or @solana/kit

The API runs against Solana mainnet

Building a transaction is free and changes nothing. A transaction you sign and send is real: it moves real funds and artworks.

1. Set Up a Client

Every request carries your key in the x-api-key header. This helper adds it, sends a JSON body as a POST, and turns a failed response into an error you can inspect. The rest of the page uses it.

mallow.ts
const API = "https://api.mallow.art";

export class MallowApiError extends Error {
  constructor(
    readonly status: number,
    message: string,
    readonly code?: string,
    readonly retryAfterSeconds?: number,
  ) {
    super(message);
  }
}

/** Calls the API and returns the parsed body. A `body` makes it a POST. */
export async function mallow<T>(path: string, body?: unknown): Promise<T> {
  const res = await fetch(API + path, {
    method: body === undefined ? "GET" : "POST",
    headers: {
      "x-api-key": process.env.MALLOW_API_KEY!,
      ...(body === undefined ? {} : { "content-type": "application/json" }),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const text = await res.text();
  if (res.ok) return JSON.parse(text) as T;

  // Usually { error: { message, code? } }, but a few failures are plain text.
  let error: { message?: string; code?: string } | undefined;
  try {
    error = JSON.parse(text).error;
  } catch {}
  const retryAfter = Number(res.headers.get("retry-after"));
  throw new MallowApiError(
    res.status,
    error?.message ?? text,
    error?.code,
    retryAfter > 0 ? retryAfter : undefined,
  );
}

Keep the key on a server. A key in browser code is public.

2. Read

/v1 returns data mallow has indexed. /v2 reads the chain directly. Both wrap a successful response in result.

read.ts
import { PublicKey } from "@solana/web3.js";
import { mallow } from "./mallow";

const mint = new PublicKey("<artwork-mint>");

// v1: indexed data.
const artwork = await mallow<{
  result: { mintAccount: string; name?: string; image?: string };
}>(`/v1/artworks/${mint.toBase58()}`);
console.log(artwork.result.name);

// v2: live chain state. A listing lives at the PDA ["listing", mint] of the market program.
const MARKET_PROGRAM = new PublicKey("MMA7VebX8Pi5JrrvaTBBm7nW81sfCww7ZtLBBT1YCy8");
const [listingAddress] = PublicKey.findProgramAddressSync(
  [Buffer.from("listing"), mint.toBuffer()],
  MARKET_PROGRAM,
);

const listing = await mallow<{
  result: {
    viewSlot: number;
    account: { seller: string; price: string; currencyMint: string } | null;
  };
}>(`/v2/accounts/market/listing/${listingAddress.toBase58()}`);

if (listing.result.account === null) {
  console.log(`not listed as of slot ${listing.result.viewSlot}`);
} else {
  console.log(`listed for ${listing.result.account.price} base units`);
}

What comes back:

  • Artwork by mint returns mintAccount, name and image, and may add fields over time. Ignore fields you don't know. An unknown mint is a 404
  • Listing account returns the decoded account beside viewSlot, the slot the read was served at. price is a string in the currency's base units (lamports for SOL)
  • A listing that doesn't exist is a 200 with account: null, not a 404. A sold listing, a cancelled listing and a mint that was never listed all read the same way
  • List endpoints take page (from 0) and pageSize, and return nextPage while there are more results. See Pagination

For auctions, editions and raffles, use Auction state and the other reads under Marketplace. They take the mint or the raffle address, so there's no PDA to derive.

3. Build, Sign, Send

The Fixed-price buy builder returns a transaction with no signatures. Decode it, sign it with the buyer's wallet, send it to your RPC and wait for confirmation.

import { Connection, Keypair, VersionedTransaction } from "@solana/web3.js";
import { mallow } from "./mallow";

const connection = new Connection(process.env.RPC_URL!, "confirmed");
const buyer = Keypair.fromSecretKey(/* your key */ new Uint8Array(64));

export async function buy(asset: string, maxPrice: number): Promise<string> {
  // Your own blockhash gives you the lastValidBlockHeight to confirm against.
  const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();

  const { result } = await mallow<{
    result: { tx?: string; swapTx?: string; setupTx?: string };
  }>("/v2/tx/fixed-price/buy", {
    buyer: buyer.publicKey.toBase58(),
    asset,
    maxPrice,
    blockhash,
  });
  if (result.setupTx || !result.tx) {
    throw new Error("two-step response: see the list of routes below");
  }

  const tx = VersionedTransaction.deserialize(Buffer.from(result.tx, "base64"));
  tx.sign([buyer]);

  const signature = await connection.sendRawTransaction(tx.serialize());
  const { value } = await connection.confirmTransaction(
    { signature, blockhash, lastValidBlockHeight },
    "confirmed",
  );
  if (value.err) throw new Error(`transaction failed: ${JSON.stringify(value.err)}`);
  return signature;
}

Call it with the listing you read in step 2:

const { account } = listing.result;
if (account) await buy(mint.toBase58(), Number(account.price));

maxPrice is optional. With it, the build fails if the listing now costs more than the price you read. A build that can't succeed returns a 400 with the reason, such as Listing not found. See Errors.

Who Signs

  • The wallet named in the request body signs and pays the network fee. Here that is buyer. Other routes name seller, bidder, authority, creator or payer. Your API key never stands in for a wallet
  • Sign the transaction exactly as returned. Changing an instruction, the fee or the blockhash invalidates any signature it already carries
  • In a browser, pass the decoded transaction to the wallet's signTransaction and send it yourself. Both sign calls above add your signature and keep any that are already there

Priority Fee

Every /v2/tx/* builder except the Magic Eden buy accepts targetPriorityFeeLamports.

You sendmallow pays
NothingIts estimate from current network conditions, up to 50,000 lamports
targetPriorityFeeLamportsIts estimate, up to your value
targetPriorityFeeLamports and exactPriorityFee: trueYour value
  • The fee never goes below a floor of 15,000 lamports, or up to 21,000 for the largest transactions. A lower value pays the floor
  • On a route that returns several transactions, the value applies to each one
  • It's ignored when mallow pays the fee, and on an edition buy that swaps currency in the same transaction

Blockhash Expiry

A transaction is valid for about 60 seconds after its blockhash was issued. Build, sign and send without a long pause in between.

  • If it expires, build again. confirmTransaction throws TransactionExpiredBlockheightExceededError, and Kit throws SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED. Request a new transaction and sign that one
  • Check before you rebuild. If a send timed out and you never saw a result, look up the first signature. A second transaction is a second purchase if the first one landed
  • Without blockhash, mallow supplies one that can be up to 10 seconds old. Two identical requests inside that window return the same transaction, with the same signature once signed, and the network rejects the second as already processed. Send your own blockhash when you need two distinct transactions
  • The Magic Eden buy and the six Gumball compressed-artwork routes don't accept blockhash. The Magic Eden buy returns lastValidBlockHeight. For the others, confirm against the height from a getLatestBlockhash call you make after the build

Routes That Return More Than One Transaction

Most builders return a single tx. These don't:

ResponseRoutesWhat to do
setupTx beside tx, in some casesCreate listing, Cancel listing, Fixed-price buy, Accept offer, Edit metadata, Create auction, Cancel auction, Settle auction and the six Gumball routes for compressed artworks, from Add to Settle saleSign, send and confirm setupTx first. Then send tx
swapTx instead of txFixed-price buy, only when you pass a swapQuoteSend swapTx and wait for confirmed. Then call the builder again without swapQuote
A list of { mintAccount, tx }, with an optional setupTx beside resultBuy editionOne transaction per copy. Each is already signed by a one-time key for its copy, so add the buyer's signature and send every one. Confirm setupTx first when present
A txs listEdit collection artworksSign and send every entry, in order
tx or evmTransferEthereum standards return an unsigned EVM transaction in evm
tx, quote and lastValidBlockHeightBuy on Magic EdenGet orderId and revision from the quote first

And these arrive needing or carrying a second signature:

RouteSignatures
MintSign with authority and with the new asset keypair. The transaction may already carry a signature from mallow. Send it as returned
Schedule a bid, Cancel a scheduled bidAlready signed by mallow. Add the bidder's signature and send
Draw a Gumball, Draw a JellybeanNeed mallow's signature, which is added only when you submit them. See the next section

4. Draws Go Through mallow

A draw's result depends on the slot it lands in. Anyone holding a fully signed draw could simulate it slot by slot and send it only when it wins something good. So every draw needs mallow's signature as well as the buyer's, and mallow adds it only at send time, after a short random delay. The co-signed transaction is never returned.

So a draw takes four steps:

  1. Build with Draw a Gumball. You get up to 10 transactions in txs
  2. Sign each one with the buyer's wallet. Don't send them
  3. Submit them to Send signed gumball draws. mallow checks, co-signs and sends each one, and keeps rebroadcasting it until it confirms or expires
  4. Poll Gumball draw status for any draw that came back with a pendingId
draw.ts
import { Keypair, VersionedTransaction } from "@solana/web3.js";
import { mallow } from "./mallow";

const buyer = Keypair.fromSecretKey(/* your key */ new Uint8Array(64));
const machine = "<gumball-address>";

// 1. Build.
const built = await mallow<{
  result: { txs: string[]; swapTx?: string };
}>(`/v1/getDrawGumballTxs/${machine}`, {
  buyer: buyer.publicKey.toBase58(),
  quantity: 2,
});

// 2. Sign with the buyer only. Change nothing else.
const signed = built.result.txs.map((encoded) => {
  const tx = VersionedTransaction.deserialize(Buffer.from(encoded, "base64"));
  tx.sign([buyer]);
  return Buffer.from(tx.serialize()).toString("base64");
});

// 3. Submit through mallow, which co-signs and sends.
type SendResult = {
  txId?: string;
  pendingId?: string;
  error?: { message: string; logs?: string[] };
};
const sent = await mallow<{ result: { results: SendResult[] } }>(
  `/v1/sendDrawGumballTxs/${machine}`,
  { txs: signed },
);

let pendingIds: string[] = [];
for (const draw of sent.result.results) {
  if (draw.txId) console.log("sent, confirm on chain:", draw.txId);
  else if (draw.pendingId) pendingIds.push(draw.pendingId);
  else console.log("not sent:", draw.error?.message);
}

// 4. Poll the draws mallow paid for.
type StatusResult = { pending?: boolean; txId?: string; error?: { message: string } };
while (pendingIds.length > 0) {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  const status = await mallow<{ result: { results: StatusResult[] } }>(
    `/v1/sendDrawGumballTxs/${machine}/status`,
    { pendingIds },
  );
  pendingIds = pendingIds.filter((id, i) => {
    const draw = status.result.results[i];
    if (draw.pending) return true;
    console.log(id, draw.txId ? `confirmed: ${draw.txId}` : `failed: ${draw.error?.message}`);
    return false;
  });
}

The submit call returns one result per transaction, in the order you sent them. The response is a 200 even when a draw was refused, so read each result.

ResultMeaningWhat to do
txIdThe draw was sent. It isn't confirmed yetConfirm the signature with your RPC
pendingIdmallow pays the fee for this draw. It was sent, and its signature is held back until it settlesPoll the status endpoint
errorThe draw wasn't sent. message says why, and logs holds the simulation logs when simulation failedBuild new draw transactions to try again

The status call works the same way, with one result per id:

ResultMeaningWhat to do
pending: trueNot settled yetPoll again in about 2 seconds
txIdConfirmed on chainDone
errorThe draw failed on chain, didn't land in time, or the id is unknownCheck the buyer's wallet before you draw again. An id can be read for 5 minutes, and only for the machine it was issued for

Because mallow co-signs every draw, a few rules apply:

  • Submit promptly. A draw is refused once its blockhash is close to expiry
  • Submit each transaction once. A second submission of the same transaction is refused
  • Don't let a wallet rewrite it. mallow co-signs only the exact transaction it built. A wallet that adds a fee instruction, or one that signs and sends in one step, breaks the draw. Use sign-only
  • If you pass a swapQuote and the swap doesn't fit beside the draw, txs is empty and swapTx holds the swap. Send that yourself, wait for confirmed, then build again without swapQuote

Jellybean draws work the same way through Draw a Jellybean, Send signed jellybean draws and Jellybean draw status.

5. Rate Limits and Retries

A key gets 100 requests per minute, counted in fixed 60-second windows. Past that, the API returns 429 until the window ends. If you need more, ask us for a higher limit on your key.

  • /v1 responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers, and a 429 adds Retry-After in seconds. /v2 responses carry none of them
  • A browser can't read those headers on a cross-origin request. Wait a full minute instead
  • Polling a draw status every 2 seconds uses 30 requests a minute

Reads and builders change nothing, so it's safe to repeat them. Add this to mallow.ts:

mallow.ts
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/** Retries rate limits and server-side failures. Only for calls that are safe to repeat. */
export async function withRetry<T>(call: () => Promise<T>, attempts = 3): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    try {
      return await call();
    } catch (e) {
      if (!(e instanceof MallowApiError) || attempt === attempts) throw e;
      if (e.status === 429) {
        await sleep((e.retryAfterSeconds ?? 60) * 1000); // the window is 60 s
      } else if (e.status === 408 || e.status >= 500) {
        await sleep(1000 * 2 ** (attempt - 1));
      } else {
        throw e; // any other 4xx: the request itself needs fixing
      }
    }
  }
}
const trending = await withRetry(() => mallow("/v1/artworks/trending"));

Don't wrap the draw submit call in it. Each draw transaction can be submitted once, so a repeat only tells you it was already submitted.

Next

  • Errors: every status the API returns, the error codes, and what to retry
  • SDKs and programs: the program clients, program addresses and token mints

On this page