# Tonnel Marketplace Event API

Machine-readable integration contract for developers and AI coding agents.

## Scope

The API publishes public marketplace activity in real time and supports replay
after a disconnect. It is read-only and does not change marketplace actions.

Included domains:

- Gift indexing
- Fixed-price and Dutch listings
- Sales
- Auctions and bids
- Direct buy offers
- Premarket listings and settlement
- Bundles
- Gift trades

## Privacy Contract

Events never expose Telegram user IDs, names, usernames, profile photos, wallet
addresses, auth data, chat IDs, message IDs, or referral identity. Public
`gift_id` and `gift_num` fields identify gifts, not users.

Consumers must not infer that an event contains seller, buyer, bidder, maker, or
taker identity. Those identities are deliberately unavailable through this API.

## Endpoints

| Purpose     | Endpoint                                               |
|-------------|--------------------------------------------------------|
| Live stream | `wss://gifts.coffin.meme/api/marketplace/ws`           |
| Replay      | `GET https://gifts.coffin.meme/api/marketplace/events` |

No authentication or client WebSocket messages are required.

## Event Envelope

Every marketplace event has this shape:

```ts
type MarketplaceEvent<T extends Record<string, unknown>> = {
	eventId: string
	version: 1
	type: MarketplaceEventType
	occurredAt: string // ISO 8601 UTC
	data: T
}
```

Example:

```json
{
  "eventId": "7d1de294-fc95-4ccb-a9ae-f7476366f997",
  "version": 1,
  "type": "listing.created",
  "occurredAt": "2026-08-13T10:00:00.000Z",
  "data": {
    "gift": {
      "gift_id": 123,
      "gift_num": 456,
      "gift_name": "Lol Pop",
      "model": "Blood Sucker (1%)",
      "backdrop": "Black (1.5%)",
      "symbol": "Bat (0.5%)"
    },
    "price": 2.5,
    "asset": "TON",
    "sale_type": "FIXED"
  }
}
```

The server sends this non-event message immediately after a WebSocket connects:

```json
{
  "type": "marketplace.connected",
  "version": 1,
  "serverTime": "2026-08-13T10:00:00.000Z",
  "replayEndpoint": "/api/marketplace/events"
}
```

It has no `eventId` and must not be stored as a marketplace event.

## Common Types

```ts
type PublicGift = {
	gift_id: number
	gift_num: number
	gift_name: string
	model: string
	backdrop: string
	symbol: string
}

type Asset = 'TON' | 'TONNEL' | 'USDT' | string
type SaleType = 'FIXED' | 'DUTCH'
```

Treat new object fields and new enum values as backward-compatible additions.
Ignore unknown fields. Use `version` for future incompatible envelope changes.

## Event Catalog

### Gifts and listings

| Event                     | `data` fields                                                                             |
|---------------------------|-------------------------------------------------------------------------------------------|
| `gift.indexed`            | `gift: PublicGift`, optional `market: "PREMARKET"`                                        |
| `listing.created`         | `gift`, `price`, `asset`, `sale_type`; Dutch listings also include `dutch`                |
| `listing.price_changed`   | `gift`, `previous_price`, `price`, `asset`                                                |
| `listing.cancelled`       | `gift`, `price`, `asset`, `sale_type`                                                     |
| `listing.promoted`        | `gift`, `target` (`LISTING` or `AUCTION`), optional `auction_id`, `promotion_started_at`  |
| `listing.promotion_ended` | `target`, `count`, `expired_before`                                                       |
| `sale.completed`          | `gift`, `price`, `asset`, `source` (one of `LISTING`, `DUTCH`, `BUY_OFFER`, or `AUCTION`) |

Dutch configuration:

```ts
type DutchListing = {
	start_price: number
	minimum_price: number
	drop_percent: number
	interval_minutes: number
	starts_at: string
}
```

### Auctions

| Event                | `data` fields                                                                                                                         |
|----------------------|---------------------------------------------------------------------------------------------------------------------------------------|
| `auction.created`    | `auction_id`, `gift`, `starting_bid`, `asset`, `starts_at`, `ends_at`                                                                 |
| `auction.bid_placed` | `auction_id`, `gift`, `amount`, `asset`, `ends_at`                                                                                    |
| `auction.extended`   | `auction_id`, `gift_id`, `previous_ends_at`, `ends_at`                                                                                |
| `auction.cancelled`  | `auction_id`, `gift_id`                                                                                                               |
| `auction.finished`   | `auction_id`, `status` (`SOLD` or `NO_BIDS`); sold auctions include `gift`, `winning_bid`, `asset`; no-bid auctions include `gift_id` |

An auction sale emits both `auction.finished` and `sale.completed`. Consumers
that count sales should use `sale.completed`; do not add both events together.

### Buy offers

These are direct offers on individual gifts, not collection-wide buy orders.

| Event                 | `data` fields                                                  |
|-----------------------|----------------------------------------------------------------|
| `buy_offer.created`   | `offer_id`, `gift`, `price`, `asset`                           |
| `buy_offer.countered` | `offer_id`, `gift`, `original_price`, `counter_price`, `asset` |
| `buy_offer.accepted`  | `offer_id`, `gift_id`, `price`, `asset`, `stage`               |
| `buy_offer.rejected`  | `offer_id`, `gift_id`, `price`, `asset`, `stage`               |
| `buy_offer.cancelled` | `offer_id`, `gift_id`, `price`, `asset`                        |

For accepted and rejected buy offers, `stage` is either `OFFER` or `COUNTER`.

### Premarket

| Event                         | `data` fields                                    |
|-------------------------------|--------------------------------------------------|
| `premarket.listing_created`   | `gift`, `price`, `asset`                         |
| `premarket.sale_completed`    | `gift`, `price`, `price_with_fee`, `asset`       |
| `premarket.listing_cancelled` | `gift`, `previous_price`, `asset`                |
| `premarket.settled`           | `gift`, `status: "COMPLETED"`, `amount`, `asset` |

### Bundles

| Event              | `data` fields                     |
|--------------------|-----------------------------------|
| `bundle.created`   | `bundle_id`, `gift_ids: number[]` |
| `bundle.debundled` | `bundle_id`, `gift_ids: number[]` |

### Trades

| Event                 | `data` fields                                                                                                                              |
|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
| `trade.created`       | `trade_id`, `gift_ids`, `amount`, `asset`                                                                                                  |
| `trade.offer_created` | `trade_id`, `offer_id`, `gift_ids`, `amount`, `asset`                                                                                      |
| `trade.completed`     | `trade_id`, `offer_id`, `requested_gift_ids`, `offered_gift_ids`, `requested_amount`, `requested_asset`, `offered_amount`, `offered_asset` |
| `trade.cancelled`     | `trade_id`, `gift_ids`                                                                                                                     |

## Replay API

Events are retained for seven days. MongoDB removes expired records through a
TTL index, so physical deletion can happen shortly after the exact expiry time.

Initial page:

```http
GET https://gifts.coffin.meme/api/marketplace/events?limit=100
```

Continue from the last processed event:

```http
GET https://gifts.coffin.meme/api/marketplace/events?after=<eventId>&limit=100
```

Filter by one or more event types:

```http
GET https://gifts.coffin.meme/api/marketplace/events?types=listing.created,sale.completed&limit=100
```

Successful response:

```json
{
  "status": "success",
  "events": [],
  "nextAfter": "last-event-id-or-null"
}
```

Rules:

- `limit` is clamped to `1..500`; the default is `100`.
- `types` is a comma-separated allowlisted set.
- Replay is limited to 120 requests per minute per IP.
- HTTP 400 with `Invalid or expired after cursor` means the cursor is missing or
  older than retention. Start again without `after`.
- Continue paging while a response contains `limit` events.
- Persist `nextAfter` only after successfully processing its page.

## Delivery and Ordering

- An event is persisted before it is broadcast.
- Events from one backend process are persisted and broadcast in the same order.
- The live WebSocket is best effort; replay provides recovery for seven days.
- Consumers must deduplicate by `eventId` because reconnect recovery can deliver
  an event that was already seen live.
- A slow WebSocket client is disconnected with close code `1013`; reconnect and
  replay from the last committed `eventId`.
- The server uses WebSocket ping/pong frames every 30 seconds.

For race-free reconnect recovery:

1. Save the last fully processed `eventId` locally.
2. Open the WebSocket and temporarily buffer incoming marketplace events.
3. Request replay with `after=<savedEventId>` until all pages are consumed.
4. Deduplicate replay and buffered live messages by `eventId`.
5. Process replay first, then buffered live messages, in `occurredAt` order.
6. Continue processing live events and update the saved cursor transactionally
   with downstream work where possible.

## JavaScript Client Example

```js
const API = 'https://gifts.coffin.meme'
const WS = 'wss://gifts.coffin.meme/api/marketplace/ws'
let lastEventId = localStorage.getItem('tonnel-marketplace-cursor') || ''
const seen = new Set()

async function consume(event) {
    if (!event.eventId || seen.has(event.eventId)) return
    seen.add(event.eventId)

    // Apply the event to your index, database, cache, or notification system.
    console.log(event.type, event.data)

    lastEventId = event.eventId
    localStorage.setItem('tonnel-marketplace-cursor', lastEventId)
}

async function replay() {
    while (true) {
        const query = new URLSearchParams({limit: '500'})
        if (lastEventId) query.set('after', lastEventId)

        const response = await fetch(`${API}/api/marketplace/events?${query}`)
        if (response.status === 400) {
            lastEventId = ''
            localStorage.removeItem('tonnel-marketplace-cursor')
            continue
        }
        if (!response.ok) throw new Error(`Replay failed: ${response.status}`)

        const page = await response.json()
        for (const event of page.events) await consume(event)
        if (page.events.length < 500) return
    }
}

function connect() {
    const socket = new WebSocket(WS)

    socket.onopen = () => {
        replay().catch(console.error)
    }
    socket.onmessage = (message) => {
        const event = JSON.parse(message.data)
        if (event.eventId) consume(event).catch(console.error)
    }
    socket.onclose = () => {
        setTimeout(connect, 1000 + Math.random() * 2000)
    }
}

connect()
```

For strict ordering, implement the buffered reconnect procedure above instead of
processing replay and live messages concurrently as this compact example does.

## AI Agent Instructions

When generating an integration from this file:

1. Model `type` as a discriminated union but tolerate unknown future types.
2. Deduplicate all processing by `eventId`.
3. Persist a cursor only after downstream processing succeeds.
4. Reconnect with exponential backoff plus jitter.
5. Use replay after every disconnect and handle expired cursors.
6. Treat `sale.completed`, not `auction.finished`, as the canonical sales event.
7. Do not use this feed alone to authorize transfers, settle balances, or make
   other irreversible financial decisions.
