# Vollector Listings API

Everything collectors list for sale on Vollector, for any app, aggregator or
bot that wants to show it. Read-only, free, no key, open to
any origin (CORS `*`). Each listing carries a link that opens the purchase on
Vollector: buyers check out here, so you never hold an order or a signature.

Docs home: https://vollector.id/docs/public-api

Base URL: `https://api.vollector.id/v1`

## Endpoints

| Method | Path | What it answers |
|---|---|---|
| GET | `/listings` | Every live listing, a page at a time |
| GET | `/listings/{id}` | One listing: 200 while for sale, 410 with its status after, 404 if unknown |
| GET | `/changes` | What changed since a cursor: upserts and removals, oldest first |

## Quick start

```sh
# Everything for sale, first page
curl "https://api.vollector.id/v1/listings?ref=yourapp"

# Courtyard cards under $500, 500 per page
curl "https://api.vollector.id/v1/listings?issuer=courtyard&max_price=500&limit=500&ref=yourapp"

# Everything for sale on Solana
curl "https://api.vollector.id/v1/listings?chain=solana&ref=yourapp"

# One listing
curl "https://api.vollector.id/v1/listings/7b0c1e52-3f4a-4c8e-9d21-5a6f0b8e2c13"

# A starting cursor for the changes feed, then what changed since
curl "https://api.vollector.id/v1/changes"
curl "https://api.vollector.id/v1/changes?since=MjAyNi0xMC0wMlQxODowNDo0NS4wMDBafGZmZmZmZmZmLWZmZmYtZmZmZi1mZmZmLWZmZmZmZmZmZmZmZg"
```

## GET /listings

Every live listing, in id order. Walk it with `after` until `has_more` is
false. Unknown parameters are refused with a 400, and so is a parameter given
twice.

| Parameter | Type | Meaning |
|---|---|---|
| `limit` | `integer` | Listings per page, 1 to 500. Default 100. |
| `after` | `listing id` | The next_after of the previous page. Leave it out for the first page. |
| `chain` | `string` | Only this chain: solana, ethereum, polygon, base, abstract, flow. |
| `issuer` | `string` | Only this issuer's collectibles: courtyard, collector-crypt, phygitals, beezie, rip-fun, rip-cars, dyli. |
| `ref` | `string` | Your app's name, 1 to 32 letters, digits, - or _. It rides on every listing's url, so we can see the buyers you send. A Connect API partner uses its partner id. |
| `min_price` | `number` | Lowest price, in USD. |
| `max_price` | `number` | Highest price, in USD. |

```json
{
  "listings": [
    {
      "id": "7b0c1e52-3f4a-4c8e-9d21-5a6f0b8e2c13",
      "url": "https://vollector.id/buy/7b0c1e52-3f4a-4c8e-9d21-5a6f0b8e2c13?ref=yourapp",
      "price": {
        "amount": "1250.00",
        "currency": "USDC"
      },
      "item": {
        "chain": "polygon",
        "contract": "0x251BE3A17Af4892035C37ebf5890F4a4D889dcAD",
        "token_id": "48211393570123994812385730016622931807",
        "standard": "erc721",
        "quantity": 1,
        "name": "2023 Pokemon Scarlet & Violet 151 Charizard ex #199 PSA 10",
        "image": "https://images.example.com/charizard-151-psa10.png",
        "issuer": {
          "slug": "courtyard",
          "name": "Courtyard"
        },
        "grader": "PSA",
        "grade": "10",
        "tier": null,
        "cert": "84512039"
      },
      "seller": {
        "wallet": "0x9f3a2b8c41d5e6f70812a3b4c5d6e7f8091a2b3c"
      },
      "listed_at": "2026-10-02T18:00:00.124518+00:00",
      "updated_at": "2026-10-02T18:00:00.124518+00:00",
      "expires_at": "2026-11-01T18:00:00+00:00"
    }
  ],
  "next_after": "7b0c1e52-3f4a-4c8e-9d21-5a6f0b8e2c13",
  "has_more": true
}
```

## GET /changes

What changed after a cursor, oldest first, as far as 20 to
35 seconds ago. An `upsert` carries
the listing as it is now (new, or its price or expiry moved); a `remove` says
why it left. Follow `next_cursor` while `has_more` is true; once it is
false you are up to date, poll again in 30 to 60 seconds.

Without `since`, the answer is empty and its `next_cursor` is where to start:
15 minutes back, so a snapshot read just after it misses
nothing. Pass the same filters you used for the snapshot.

| Parameter | Type | Meaning |
|---|---|---|
| `since` | `cursor` | The next_cursor of the previous answer. Leave it out to get a starting cursor. |
| `limit` | `integer` | Changes per page, 1 to 500. Default 100. |
| `chain` | `string` | Only this chain: solana, ethereum, polygon, base, abstract, flow. |
| `issuer` | `string` | Only this issuer's collectibles: courtyard, collector-crypt, phygitals, beezie, rip-fun, rip-cars, dyli. |
| `ref` | `string` | Your app's name, 1 to 32 letters, digits, - or _. It rides on every listing's url, so we can see the buyers you send. A Connect API partner uses its partner id. |

Removal reasons:

| Reason | Meaning |
|---|---|
| `sold` | Somebody bought it. |
| `cancelled` | The seller took it down. |
| `expired` | It reached expires_at. |
| `failed` | A purchase reverted on-chain, which usually means the seller's approval is gone; it stays off until they relist. |

```json
{
  "changes": [
    {
      "type": "upsert",
      "id": "7b0c1e52-3f4a-4c8e-9d21-5a6f0b8e2c13",
      "updated_at": "2026-10-02T18:00:00.124518+00:00",
      "listing": {
        "id": "7b0c1e52-3f4a-4c8e-9d21-5a6f0b8e2c13",
        "url": "https://vollector.id/buy/7b0c1e52-3f4a-4c8e-9d21-5a6f0b8e2c13?ref=yourapp",
        "price": {
          "amount": "1250.00",
          "currency": "USDC"
        },
        "item": {
          "chain": "polygon",
          "contract": "0x251BE3A17Af4892035C37ebf5890F4a4D889dcAD",
          "token_id": "48211393570123994812385730016622931807",
          "standard": "erc721",
          "quantity": 1,
          "name": "2023 Pokemon Scarlet & Violet 151 Charizard ex #199 PSA 10",
          "image": "https://images.example.com/charizard-151-psa10.png",
          "issuer": {
            "slug": "courtyard",
            "name": "Courtyard"
          },
          "grader": "PSA",
          "grade": "10",
          "tier": null,
          "cert": "84512039"
        },
        "seller": {
          "wallet": "0x9f3a2b8c41d5e6f70812a3b4c5d6e7f8091a2b3c"
        },
        "listed_at": "2026-10-02T18:00:00.124518+00:00",
        "updated_at": "2026-10-02T18:00:00.124518+00:00",
        "expires_at": "2026-11-01T18:00:00+00:00"
      }
    },
    {
      "type": "remove",
      "id": "c41d9a07-6e2b-4f13-8a55-0b7e9c3d1f28",
      "updated_at": "2026-10-02T18:03:12.551902+00:00",
      "reason": "sold"
    }
  ],
  "next_cursor": "MjAyNi0xMC0wMlQxODowNDo0NS4wMDBafGZmZmZmZmZmLWZmZmYtZmZmZi1mZmZmLWZmZmZmZmZmZmZmZg",
  "has_more": false
}
```

## Keeping a full copy

1. Call `/changes` without `since` and keep its `next_cursor`.
2. Walk `/listings` to the end. That is the book.
3. Poll `/changes?since=<cursor>`: apply each upsert and removal, keep the new
   `next_cursor`, and fetch again at once while `has_more` is true.

Replays are harmless: an upsert always carries the current listing, and a
removal of something you do not have is a no-op. Store the cursor and a restart
resumes where it stopped. Cursors do not expire.

```js
const API = "https://api.vollector.id/v1";
const REF = "yourapp";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function get(path) {
  for (;;) {
    const res = await fetch(API + path);
    if (res.ok) return res.json();
    if (res.status !== 503) throw new Error(res.status + " " + (await res.text()));
    await sleep(5000); // our side hiccuped: retry shortly
  }
}

// 1. A starting cursor, taken BEFORE the snapshot so nothing slips between them.
let cursor = (await get("/changes?ref=" + REF)).next_cursor;

// 2. The snapshot: every live listing.
const book = new Map();
let after = null;
do {
  const page = await get("/listings?limit=500&ref=" + REF + (after ? "&after=" + after : ""));
  for (const l of page.listings) book.set(l.id, l);
  after = page.has_more ? page.next_after : null;
} while (after);

// 3. Keep up, for as long as you run.
for (;;) {
  const page = await get("/changes?limit=500&ref=" + REF + "&since=" + cursor);
  for (const c of page.changes) {
    if (c.type === "upsert") book.set(c.id, c.listing);
    else book.delete(c.id);
  }
  cursor = page.next_cursor; // store it: a restart resumes from here
  if (!page.has_more) await sleep(30_000);
}
```

## GET /listings/{id}

```json
{
  "listing": {
    "id": "7b0c1e52-3f4a-4c8e-9d21-5a6f0b8e2c13",
    "url": "https://vollector.id/buy/7b0c1e52-3f4a-4c8e-9d21-5a6f0b8e2c13?ref=yourapp",
    "price": {
      "amount": "1250.00",
      "currency": "USDC"
    },
    "item": {
      "chain": "polygon",
      "contract": "0x251BE3A17Af4892035C37ebf5890F4a4D889dcAD",
      "token_id": "48211393570123994812385730016622931807",
      "standard": "erc721",
      "quantity": 1,
      "name": "2023 Pokemon Scarlet & Violet 151 Charizard ex #199 PSA 10",
      "image": "https://images.example.com/charizard-151-psa10.png",
      "issuer": {
        "slug": "courtyard",
        "name": "Courtyard"
      },
      "grader": "PSA",
      "grade": "10",
      "tier": null,
      "cert": "84512039"
    },
    "seller": {
      "wallet": "0x9f3a2b8c41d5e6f70812a3b4c5d6e7f8091a2b3c"
    },
    "listed_at": "2026-10-02T18:00:00.124518+00:00",
    "updated_at": "2026-10-02T18:00:00.124518+00:00",
    "expires_at": "2026-11-01T18:00:00+00:00"
  }
}
```

Once the listing is gone: `410 {"id": "...", "status": "sold"}`. The status is
one of the removal reasons above.

## The listing object

| Field | Type | Meaning |
|---|---|---|
| `id` | `string` | The listing's id. Stable for its whole life; a relist is a new id. |
| `url` | `string` | Where to send a buyer. Opens the card on Vollector with the purchase ready. Link to this, not to a page you build. |
| `price.amount` | `string` | The price as a decimal string, at least two decimals ("1250.00"). A string so no float rounds it. |
| `price.currency` | `string` | Always USDC today. |
| `item.chain` | `string` | The chain the collectible lives on. |
| `item.contract` | `string` | Its contract (EVM) or collection (Solana) address. EVM addresses may come in either case; compare them lowercased. |
| `item.token_id` | `string` | Its token id on EVM; its asset (mint) address on Solana. |
| `item.standard` | `string` | erc721, erc1155 or solana. |
| `item.quantity` | `integer` | Units for sale in this listing, 1 for a single card. |
| `item.name` | `string | null` | The collectible's name as the issuer gives it. |
| `item.image` | `string | null` | An image URL. Hotlinking is fine. |
| `item.issuer` | `object | null` | { slug, name } of the platform that tokenized it; the slug is what the issuer filter takes. |
| `item.grader` | `string | null` | PSA, BGS, CGC, SGC and so on, when graded. |
| `item.grade` | `string | null` | The grade, as a string ("10", "9.5"). |
| `item.tier` | `string | null` | The tier above a plain 10, when the card has it: black_label (Beckett, all four subgrades 10) or pristine (CGC). Its grade reads 10. |
| `item.cert` | `string | null` | The grading certificate number, when known. |
| `seller.wallet` | `string` | The wallet the listing is signed by. |
| `listed_at` | `timestamp` | When it was listed. |
| `updated_at` | `timestamp` | When something on this object last changed. |
| `expires_at` | `timestamp | null` | When the listing lapses on its own. Hide it from then on, even before the feed says so. |

There is no fair-market value in this API. The price is the seller's.

## Linking and attribution

Send buyers to `url` as it is. It goes through https://vollector.id/buy/{id}, which opens
the card's current page with the purchase ready; once the listing is gone it
opens the card alone, so a stale link never dead-ends. Add `ref=<your app>` to
your calls and every `url` carries it, so we can credit the buyers you send.
Without it we go by the site the click came from, which apps and links with
`rel="noreferrer"` do not reveal. If you also integrate the Connect API, use your
partner id as `ref`: one identifier across both.

Show the price as `price.amount` `price.currency`, and hide a listing once its
`expires_at` has passed, even before the feed sends its removal.

## Logo and icon

To show where a listing is sold: the mark next to the price or the buy button,
with "Vollector" or "Buy on Vollector", or the icon wherever you list your
sources.

- Mark: SVG, takes the color of the text around it: next to a price, in a button. [SVG](https://vollector.id/vollector-mark.svg)
- Icon, dark: White mark on black, square: avatars, source lists, app grids on dark interfaces. [PNG 512](https://vollector.id/brand/vollector-icon-dark-512.png), [PNG 1024](https://vollector.id/brand/vollector-icon-dark-1024.png)
- Icon, light: Black mark on white, square: the same, on light interfaces. [PNG 512](https://vollector.id/brand/vollector-icon-light-512.png), [PNG 1024](https://vollector.id/brand/vollector-icon-light-1024.png)

- Use the files as they are: not stretched, recolored or redrawn.
- Next to a listing, the mark with "Vollector" or "Buy on Vollector" says where it is sold.

## Caching and limits

Every answer is the same for every caller and is cached at our CDN, so polling
costs us next to nothing and answers fast. There is no key and no hard rate
limit; be reasonable (a poll every 30 seconds is plenty) and we will keep it
that way.

| Answer | CDN cache (s) | Served stale while refreshing (s) |
|---|---|---|
| /listings | 60 | 300 |
| /listings/{id} | 15 | 60 |
| /changes | 15 | 30 |
| A 400 (bad parameters) | 300 | 0 |

Errors are JSON, `{"error": "..."}`. A 400 names the bad parameter. A 503 means
our side hiccuped: retry after the `Retry-After` seconds.

## Versioning

This is v1. Fields may be added to any object; nothing is removed or changes
meaning within v1. Anything breaking ships as v2, with v1 kept running.
