> ## Documentation Index
> Fetch the complete documentation index at: https://docs.0xinsider.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> The Markdown of an API reference page leaves out the OpenAPI schema.
> Every object an endpoint returns or a webhook carries, with each field's type, presence and meaning, is on https://docs.0xinsider.com/api-reference/objects.md.
> The full OpenAPI 3.1 spec names each operation's and each webhook's exact parameters, request body and response schema: https://docs.0xinsider.com/api-reference/openapi.json.

# Settlement notifier

> Post every game market settlement in your leagues to a Discord channel, and catch up on the ones you missed while your server was down.

By the end you have a small server that posts a line to a Discord channel each time a sports or esports game market settles in the leagues you pick. Each line names the game, the market type, the winning outcome, and what one share pays.

The server gets settlements two ways. It reads [List settled markets](https://docs.0xinsider.com/api-reference/endpoint/list-settled-markets.md) every minute, which works on every plan and fills any gap after a restart. On Pro and Max, it also receives the [`market_settled`](https://docs.0xinsider.com/api-reference/webhooks/market-settled) webhook, so a settlement reaches Discord within seconds of the delivery instead of at the next read.

You need an API key, a Discord channel webhook URL (channel settings, then Integrations, then Webhooks), and Node.js 20 or later. See [Authentication](https://docs.0xinsider.com/authentication.md).

## 1. Pick the markets

Both ways take the same two filters. Use the same values for both, so the read and the webhook send the same rows.

| Setting | List settled markets | Webhook `settlement_filters` | Example |
| - | - | - | - |
| Leagues | `league`, comma-separated | `leagues`, an array | `nba,epl` |
| Market types | `sports_market_type`, comma-separated; `*` is every type | `sports_market_types`, an array | `moneyline` |

Both default to `moneyline`. In golf and other field events, Polymarket labels every player market `moneyline`, so one tournament can settle hundreds of them. A golf event slug that starts with a year has a `null` league, so a league filter leaves it out.

## 2. The server

The server keeps one cursor and the ids it has posted in `settlement-notifier.json`, so a restart picks up where it stopped. A settlement's id is its `condition_id` and `settlement_id`. The webhook and the read can return the same settlement, and the server posts it once.

```bash theme={null}
npm install @0xinsider/sdk
```

```typescript settlement-notifier.ts theme={null}
import { createServer } from "node:http";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { verifySignature } from "@0xinsider/sdk";

const API_KEY = process.env.OXINSIDER_API_KEY ?? "";
const DISCORD_URL = process.env.DISCORD_WEBHOOK_URL ?? "";
const SECRET = process.env.OXINSIDER_WEBHOOK_SECRET ?? ""; // optional: Pro and Max
const LEAGUES = process.env.LEAGUES ?? "nba,epl";
const TYPES = process.env.MARKET_TYPES ?? "moneyline";
const PORT = Number(process.env.PORT ?? 8787);
const STATE_FILE = "settlement-notifier.json";
if (!API_KEY || !DISCORD_URL) throw new Error("Set OXINSIDER_API_KEY and DISCORD_WEBHOOK_URL");

// The fields of a settled market this server reads: a webhook's data, or one row of the list.
interface Settled {
  condition_id: string;
  settlement_id: number;
  event_slug: string | null;
  league: string | null;
  sports_market_type: string | null;
  outcomes: string[];
  settlement: {
    payouts: number[] | null;
    result: "outcome" | "split" | null;
    winning_outcome_index: number | null;
    observed_at: string;
  };
}

// The list's cursor and the settlements already posted, kept across restarts.
const state: { cursor: string | null; posted: string[] } = existsSync(STATE_FILE)
  ? JSON.parse(readFileSync(STATE_FILE, "utf8"))
  : { cursor: null, posted: [] };
const posted = new Set(state.posted);
function save(): void {
  state.posted = [...posted].slice(-5000);
  writeFileSync(STATE_FILE, JSON.stringify(state));
}

function message(row: Settled): string {
  const { payouts, result, winning_outcome_index: winner } = row.settlement;
  const game = row.event_slug ?? row.condition_id;
  const type = row.sports_market_type ?? "market";
  let outcome = "resolved, payouts not published yet";
  if (result === "split") {
    outcome = "settled 50-50";
  } else if (winner !== null) {
    const pays = payouts ? `, pays $${(payouts[winner] / 1_000_000).toFixed(2)} a share` : "";
    outcome = `**${row.outcomes[winner] || `outcome ${winner}`}** won${pays}`;
  }
  const link = row.event_slug ? `\nhttps://0xinsider.com/event/${row.event_slug}` : "";
  return `${game} ${type}: ${outcome}${link}`;
}

// Posts a settlement once. Returns false when Discord refused it, so the caller can retry.
async function post(row: Settled): Promise<boolean> {
  const id = `${row.condition_id}_${row.settlement_id}`;
  if (posted.has(id)) return true;
  posted.add(id); // claimed before the await, so the webhook and the read never both post it
  const sent = await fetch(`${DISCORD_URL}?wait=true`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ content: message(row), allowed_mentions: { parse: [] } }),
  });
  if (!sent.ok) {
    posted.delete(id);
    return false;
  }
  save();
  return true;
}

// Reads every settlement recorded since the last read, page by page, and posts it.
async function catchUp(): Promise<void> {
  for (;;) {
    const query = state.cursor
      ? new URLSearchParams({ cursor: state.cursor, limit: "100" })
      : new URLSearchParams({ since: new Date().toISOString(), league: LEAGUES, sports_market_type: TYPES, limit: "100" });
    const res = await fetch(`https://api.0xinsider.com/api/v1/markets/settled?${query}`, {
      headers: { authorization: `Bearer ${API_KEY}` },
    });
    if (!res.ok) throw new Error(`List settled markets answered ${res.status}: ${await res.text()}`);
    const page = await res.json();
    for (const row of page.data as Settled[]) {
      // Stop before the cursor moves past a settlement Discord refused; the next read retries it.
      if (!(await post(row))) return;
    }
    state.cursor = page.next_cursor;
    save();
    if (!page.has_more) return;
  }
}

async function readEveryMinute(): Promise<void> {
  try {
    await catchUp();
  } catch (error) {
    console.error(error);
  }
  setTimeout(readEveryMinute, 60_000);
}
void readEveryMinute();

// The webhook receiver, on Pro and Max. Without a secret, the server only reads.
if (SECRET) {
  createServer(async (req, res) => {
    if (req.method !== "POST") {
      res.writeHead(405).end();
      return;
    }
    const chunks: Buffer[] = [];
    for await (const chunk of req) chunks.push(chunk as Buffer);
    const body = Buffer.concat(chunks);

    // Verify the raw bytes before parsing: re-serialized JSON no longer matches the signature.
    const signed = verifySignature({
      secret: SECRET,
      timestamp: String(req.headers["x-0xinsider-timestamp"] ?? ""),
      signature: String(req.headers["x-0xinsider-signature"] ?? ""),
      body,
    });
    if (!signed) {
      res.writeHead(401).end();
      return;
    }

    // The verification challenge and other event types are acknowledged and not posted.
    const event = JSON.parse(body.toString("utf8"));
    if (event.type !== "market_settled") {
      res.writeHead(204).end();
      return;
    }
    // A 503 makes 0xinsider retry the delivery later; the minute read may post it first.
    res.writeHead((await post(event.data as Settled)) ? 204 : 503).end();
  }).listen(PORT, () => console.log(`listening on ${PORT}`));
}
```

Run it with the secrets in the environment. Leave out `OXINSIDER_WEBHOOK_SECRET` to run on reads alone:

```bash theme={null}
OXINSIDER_API_KEY=... DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... \
  OXINSIDER_WEBHOOK_SECRET=... LEAGUES=nba,epl npx tsx settlement-notifier.ts
```

The first read starts at the current time, so the channel shows only settlements from then on. To post earlier ones once, put a `since` time in the past in the first query, for example `new Date(Date.now() - 3_600_000).toISOString()` for the last hour.

## 3. Test it before a live settlement

The `market_settled` page has an example delivery: a Premier League moneyline that settled on November 7, 2026. Sign it with your secret and post it to the server. The Discord channel should show one line, and a second post of the same body shows nothing, because that settlement was already posted.

```bash theme={null}
curl -s https://docs.0xinsider.com/api-reference/openapi.json \
  | jq -c '.webhooks.market_settled.post.requestBody.content["application/json"].examples.epl_moneyline_settled.value' \
  > settled.json

TS=$(date +%s)
SIG=$(node --input-type=module -e '
  import { computeSignature } from "@0xinsider/sdk";
  import { readFileSync } from "node:fs";
  process.stdout.write(computeSignature(process.env.OXINSIDER_WEBHOOK_SECRET, process.argv[1], readFileSync("settled.json")));
' "$TS")

curl -i http://localhost:8787 \
  -H "content-type: application/json" \
  -H "x-0xinsider-timestamp: $TS" \
  -H "x-0xinsider-signature: $SIG" \
  --data-binary @settled.json
```

Expect `204`. A `401` means the secret differs from the one the server reads.

## 4. Subscribe the webhook (Pro and Max)

Expose the server on a public HTTPS URL, then create the webhook with the same filters the read uses. The response carries `signing_secret` once; put it in `OXINSIDER_WEBHOOK_SECRET` and restart the server before you verify.

```bash theme={null}
curl -s https://api.0xinsider.com/api/v1/webhooks \
  -H "Authorization: Bearer $OXINSIDER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: settlement-notifier-1" \
  -d '{
    "name": "Settlement notifier",
    "url": "https://alerts.example.com/",
    "event_types": ["market_settled"],
    "settlement_filters": {
      "leagues": ["nba", "epl"],
      "sports_market_types": ["moneyline"]
    }
  }'
```

Then verify the endpoint with the returned `verification.token`, as the [Webhooks guide](https://docs.0xinsider.com/guides/webhooks.md) shows. The server answers the signed challenge with `204`, and the webhook becomes `active`.

## Limits

* A market can settle in up to three states: a resolved question with no winner yet, the same question with the winner Polymarket's ConditionalTokens contract paid, and Polymarket's row with payouts. The server posts each state it receives, so a first line can say the payouts are not published yet. The webhook skips a state that names a winner it already sent, but the read lists it, so the server can post a winner twice, the second time with what it pays.
* A Yes or No market names its winner `Yes` or `No`. The row carries no question, so the line links the event page, where the market's label shows which team or player it is about.
* The read holds back the last 2 minutes, so on reads alone a settlement reaches Discord 2 to 3 minutes after we observe it.
* The cursor keeps the filters of the first read. To change `LEAGUES` or `MARKET_TYPES`, delete `settlement-notifier.json` and restart.
* If Discord refuses a post, the read stops at that settlement and tries it again a minute later. The webhook answers `503`, so 0xinsider retries the delivery on its [retry schedule](https://docs.0xinsider.com/guides/webhooks.md).
* The webhook does not send a state we first see more than 6 hours after its market resolved. The read lists it, so the server still posts it.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.