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

# Discord line-move alerts

> Post every sports line move past your threshold to a Discord channel, with the spread, the book depth and the large trades behind it.

By the end you have a small server that posts a line to a Discord channel each time a sports market's price moves past your threshold. It runs on the [`market_price_moved`](https://docs.0xinsider.com/api-reference/webhooks/market-price-moved) webhook, so 0xinsider measures the move and your server only formats and forwards it.

Each alert names the outcome, the move, the spread, the dollars resting within 2 cents of the price, and the large trades that landed in the same window.

You need a Pro or Max 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 what counts as a move

A subscription names one threshold and one window. 0xinsider sends at most one delivery per market per window, and a second one only when the move grows further in the same direction.

| Setting | What it does | Example |
| - | - | - |
| `min_move_cents` | The smallest move that fires, in whole cents from 2 to 50. | `5` |
| `window_seconds` | How far back the move is measured: `60`, `120`, `300` or `900`. | `300` |
| `league` | Only these leagues, by slug prefix. | `["nba"]` |
| `phase` | `any`, `pre_game` or `in_play`. A move before the scheduled start is `pre_game`. A move with no scheduled start, or whose game state we could not read, has phase `unknown`, which only `any` admits. | `any` |
| `min_book_usd_within_2c` | Only moves with at least this many dollars resting within 2 cents of the price on each side. A move whose depth we could not measure does not match. | `2000` |

`min_book_usd_within_2c` keeps out moves on thin books, where one small order can shift the price by several cents.

## 2. The server

The server verifies each delivery's signature, ignores a delivery it already posted, and forwards the rest to Discord. When Discord refuses a message, the server answers `503`, so 0xinsider retries the delivery later on its [retry schedule](https://docs.0xinsider.com/guides/webhooks.md).

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

```typescript discord-line-moves.ts theme={null}
import { createServer } from "node:http";
import { verifySignature } from "@0xinsider/sdk";

const SECRET = process.env.OXINSIDER_WEBHOOK_SECRET ?? "";
const DISCORD_URL = process.env.DISCORD_WEBHOOK_URL ?? "";
const PORT = Number(process.env.PORT ?? 8787);
if (!SECRET || !DISCORD_URL) {
  throw new Error("Set OXINSIDER_WEBHOOK_SECRET and DISCORD_WEBHOOK_URL");
}

// The fields of a market_price_moved delivery this server reads.
interface PriceMove {
  event_slug: string | null;
  label: string | null;
  direction: "up" | "down";
  from_price: string;
  to_price: string;
  move_cents: string;
  window_seconds: number;
  detected_at: string;
  spread_cents: string;
  phase: "pre_game" | "in_play" | "unknown";
  book_depth_within_2c: { bid_usd: string; ask_usd: string } | null;
  concurrent_sharp_flow: {
    detected: boolean;
    net_flow_usd: number;
    ranked_large_trades: number;
    large_trades_in_window: number;
  } | null;
}

// Event ids already posted. A retry of the same event carries the same id.
const posted = new Set<string>();
function remember(id: string): void {
  posted.add(id);
  if (posted.size > 1000) posted.delete(posted.values().next().value!);
}

function usd(value: string | number): string {
  const amount = Math.round(Number(value));
  return `${amount < 0 ? "-" : ""}$${Math.abs(amount).toLocaleString("en-US")}`;
}

function message(move: PriceMove): string {
  const name = move.label ?? move.event_slug ?? "Market";
  const cents = Math.abs(Number(move.move_cents)).toFixed(1);
  const minutes = move.window_seconds / 60;
  const phase = { pre_game: "before the game", in_play: "in play", unknown: "phase unknown" }[move.phase];
  const book = move.book_depth_within_2c;
  const flow = move.concurrent_sharp_flow;
  const lines = [
    `**${name}** ${move.direction} ${cents}c in ${minutes} min, ${move.from_price} to ${move.to_price} (${phase})`,
    `Spread ${move.spread_cents}c. Within 2c: ${book ? `${usd(book.bid_usd)} bid, ${usd(book.ask_usd)} ask` : "not measured"}.`,
  ];
  if (flow) {
    lines.push(
      `Large trades in the window: ${flow.large_trades_in_window}, ${flow.ranked_large_trades} of them by B-or-better wallets, net ${usd(flow.net_flow_usd)}${flow.detected ? ", with the move" : ""}.`,
    );
  }
  lines.push(`Detected <t:${Math.floor(Date.parse(move.detected_at) / 1000)}:R>`);
  if (move.event_slug) lines.push(`https://0xinsider.com/event/${move.event_slug}`);
  return lines.join("\n");
}

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, other event types and repeats are acknowledged and not posted.
  const event = JSON.parse(body.toString("utf8"));
  if (event.type !== "market_price_moved" || posted.has(event.id)) {
    res.writeHead(204).end();
    return;
  }

  const sent = await fetch(`${DISCORD_URL}?wait=true`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ content: message(event.data as PriceMove), allowed_mentions: { parse: [] } }),
  });
  if (sent.ok) {
    remember(event.id);
    res.writeHead(204).end();
    return;
  }
  // Discord refused or rate-limited the post. A 503 makes 0xinsider retry this delivery.
  const retryAfter = sent.headers.get("retry-after");
  res.writeHead(503, retryAfter ? { "retry-after": retryAfter } : {}).end();
}).listen(PORT, () => console.log(`listening on ${PORT}`));
```

Run it with the two secrets in the environment:

```bash theme={null}
OXINSIDER_WEBHOOK_SECRET=... DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... npx tsx discord-line-moves.ts
```

`<t:...:R>` is Discord's relative timestamp, so the channel shows how long ago the move happened. That matters on a retried delivery, which can arrive minutes after the move.

## 3. Test it before a live move

The `market_price_moved` page has an example delivery: a 6-cent move on the Heat moneyline before the Heat and Warriors game on November 5, 2026. Sign it with your secret and post it to the server. The Discord channel should show one alert, and a second post of the same body shows nothing, because the event id was already posted.

```bash theme={null}
curl -s https://docs.0xinsider.com/api-reference/openapi.json \
  | jq -c '.webhooks.market_price_moved.post.requestBody.content["application/json"].examples.nba_moneyline_before_tip.value' \
  > move.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("move.json")));
' "$TS")

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

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

## 4. Subscribe

Expose the server on a public HTTPS URL, then create the webhook. 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: discord-line-moves-1" \
  -d '{
    "name": "Discord line moves",
    "url": "https://alerts.example.com/",
    "event_types": ["market_price_moved"],
    "price_move_filters": {
      "min_move_cents": 5,
      "window_seconds": 300,
      "league": ["nba"],
      "min_book_usd_within_2c": 2000
    }
  }'
```

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

To change the threshold later, send a `PATCH` with a new `price_move_filters`. A `PATCH` without it keeps the stored settings.

## Limits

* The list of posted ids lives in memory. After a restart, a retry of an event posted just before the restart is posted a second time.
* The server posts before it answers, so a Discord outage longer than the 15-second delivery deadline turns into a retry. After 8 failed attempts the delivery is `dead_letter`, and the [delivery log](https://docs.0xinsider.com/api-reference/endpoint/list-webhook-deliveries.md) lists it.
* `book_depth_within_2c` is `null` when we had no recent full order book for the market, so a `min_book_usd_within_2c` subscription skips those moves. Leave the filter out to see every move.


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