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

# Webhook alerts

> Receive arb signals as soon as they fire — POST'd to your HTTPS URL with an HMAC-SHA256 signature for authenticity.

## What this is

Webhooks are a **Premium-tier-only** push channel. Instead of polling
`/v1/arb/live` or holding a WebSocket connection open, you give us an
HTTPS URL and we POST the same opportunity payload to you the moment
the engine detects it — typically within \~100 ms of the underlying
snapshot arriving.

Use this if you'd rather not run a persistent process to receive
signals. A serverless function (Cloudflare Worker, Vercel function,
Lambda) wakes up only when a real signal fires.

## Registering a webhook

1. Open [`/dashboard/webhooks`](https://www.polyquantlab.com/dashboard/webhooks).
2. Click **Create webhook**.
3. Fill in:
   * **URL** — `https://your-domain.com/webhook` (must be HTTPS)
   * **Label** — anything memorable, e.g. `prod-bot`
   * **Tier filter** — `logical`, `endgame`, or both
   * **Ticker filter** — subset of `BTC`, `ETH`, `SOL` (omit for all)
4. The success callout shows the **signing secret ONCE**
   (starts with `whsec_…`). Save it. You can't retrieve it later — if
   you lose it, delete the webhook and recreate.

## What each POST looks like

```http theme={null}
POST /webhook HTTP/1.1
Host: your-domain.com
Content-Type: application/json
X-PQL-Signature: 8b3c9d2e1a45f8c7d12a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5
X-PQL-Tier: logical
X-PQL-Ticker: BTC
User-Agent: PolyQuantLab-Webhook/1

{
  "type": "opportunity",
  "tier": "logical",
  "ticker": "BTC",
  "event_type": "5m",
  "market_id": "0x...",
  "polymarket_slug": "btc-up-or-down-at-2-15pm-jun-08",
  "direction": "BUY_NO",
  "fill_price": 0.61,
  "fill_spread": 0.02,
  "expected_pnl_per_share": 0.047,
  "est_fee_per_share": 0.017,
  "tau_minutes": 2.3,
  "confidence": "stable",
  "model_yes_prob": 0.42,
  "detected_at": "2026-06-08T07:42:11.205Z",
  "polymarket_url": "https://polymarket.com/event/..."
}
```

### Header reference

| Header            | Value                                                        | Why                                                                           |
| ----------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| `X-PQL-Signature` | HMAC-SHA256 hex digest of the raw body, keyed by your secret | The only proof the request is from us                                         |
| `X-PQL-Tier`      | `logical` or `endgame`                                       | Lets you route different tiers to different handlers without parsing the body |
| `X-PQL-Ticker`    | `BTC` / `ETH` / `SOL`                                        | Same — fast path for per-ticker routing                                       |
| `Content-Type`    | `application/json`                                           | Always JSON                                                                   |
| `User-Agent`      | `PolyQuantLab-Webhook/1`                                     | Identifies our delivery worker                                                |

### Body fields

| Field                    | Type   | Description                                                                                       |
| ------------------------ | ------ | ------------------------------------------------------------------------------------------------- |
| `type`                   | string | Always `"opportunity"` for actionable signals                                                     |
| `tier`                   | string | `"logical"` (math-guaranteed, yes+no \< \$1) or `"endgame"` (model-based, within τ of resolution) |
| `ticker`                 | string | `BTC`, `ETH`, or `SOL`                                                                            |
| `event_type`             | string | `5m`, `15m`, `1h`, `4h`, or `daily_up_down`                                                       |
| `market_id`              | string | Polymarket condition id                                                                           |
| `polymarket_slug`        | string | The slug part of the Polymarket URL                                                               |
| `direction`              | string | `BUY_YES`, `BUY_NO`, or `BUY_BOTH` (logical only)                                                 |
| `fill_price`             | float  | Best ask you'd actually pay walking the book                                                      |
| `fill_spread`            | float  | Best ask − best bid at the moment of detection                                                    |
| `expected_pnl_per_share` | float  | Model EV after Polymarket taker fee, per share                                                    |
| `est_fee_per_share`      | float  | The fee we already subtracted (audit transparency)                                                |
| `tau_minutes`            | float  | Minutes until market resolution                                                                   |
| `confidence`             | string | `stable` (book held for ≥30 s at this price) or `stale`                                           |
| `model_yes_prob`         | float  | Our model's probability that the YES token resolves true                                          |
| `detected_at`            | string | ISO timestamp the opportunity was first detected                                                  |
| `polymarket_url`         | string | Deep link to the market                                                                           |

## Verifying the signature

**Always verify.** Without the check, anyone with your URL could POST
fake opportunities. The signature is a hex-encoded HMAC-SHA256 over
the **raw request body bytes** (not the parsed JSON) using your
secret as the key.

<CodeGroup>
  ```js Node.js (Express) theme={null}
  import crypto from "crypto";
  import express from "express";

  const SECRET = process.env.PQL_WEBHOOK_SECRET; // whsec_...

  const app = express();
  app.post(
    "/webhook",
    // IMPORTANT: capture the raw body — express.json() parses it
    // and changes the bytes, breaking the signature check.
    express.raw({ type: "application/json" }),
    (req, res) => {
      const got = req.headers["x-pql-signature"];
      const expected = crypto
        .createHmac("sha256", SECRET)
        .update(req.body)
        .digest("hex");

      if (got !== expected) {
        return res.status(401).send("bad signature");
      }

      const opp = JSON.parse(req.body);
      console.log(opp.ticker, opp.direction, opp.fill_price);

      // Reply 2xx within 5 s, or we retry.
      res.send("ok");
    }
  );

  app.listen(3000);
  ```

  ```python Python (FastAPI) theme={null}
  import hmac, hashlib, os
  from fastapi import FastAPI, Request, HTTPException

  SECRET = os.environ["PQL_WEBHOOK_SECRET"].encode()
  app = FastAPI()

  @app.post("/webhook")
  async def webhook(request: Request):
      # IMPORTANT: read the raw body, not request.json(), so the
      # bytes match what we signed.
      body = await request.body()
      got  = request.headers.get("x-pql-signature", "")
      want = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(got, want):
          raise HTTPException(401, "bad signature")
      opp = await request.json()
      print(opp["ticker"], opp["direction"], opp["fill_price"])
      # Reply 2xx within 5 s, or we retry.
      return {"ok": True}
  ```

  ```go Go (net/http) theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "encoding/json"
      "fmt"
      "io"
      "net/http"
      "os"
  )

  var secret = []byte(os.Getenv("PQL_WEBHOOK_SECRET"))

  func handler(w http.ResponseWriter, r *http.Request) {
      body, _ := io.ReadAll(r.Body)
      mac := hmac.New(sha256.New, secret)
      mac.Write(body)
      want := hex.EncodeToString(mac.Sum(nil))
      if !hmac.Equal([]byte(r.Header.Get("X-PQL-Signature")), []byte(want)) {
          http.Error(w, "bad signature", 401)
          return
      }
      var opp map[string]interface{}
      json.Unmarshal(body, &opp)
      fmt.Println(opp["ticker"], opp["direction"], opp["fill_price"])
      w.Write([]byte("ok"))
  }

  func main() { http.HandleFunc("/webhook", handler); http.ListenAndServe(":3000", nil) }
  ```
</CodeGroup>

## Delivery semantics

* **Timeout**: 5 seconds. Reply 2xx within that or we treat the delivery as failed.
* **Auto-pause**: 10 consecutive failures → the webhook is paused. You'll see it in `/dashboard/webhooks` with a "Failing" badge; click *Resume* once your endpoint is back.
* **Idempotency**: Each opportunity carries a unique `(market_id, detected_at)` pair. We may re-fire the same opportunity if our worker restarts; deduplicate on that pair if your downstream isn't idempotent.
* **At-least-once**: Treat the delivery as at-least-once, not exactly-once. The Redis pub/sub channel + the retry loop means a single fire event can land twice in rare cases.
* **No backfill on register**: If signals fired before you created the webhook, you won't get them later. Use `GET /v1/arb/live` to catch up on currently-actionable signals.

## Quick local testing

The simplest test setup needs no servers:

1. Open [webhook.site](https://webhook.site) — you get a unique URL like `https://webhook.site/abc-…`.
2. Paste that URL into the create-webhook form.
3. Leave both tabs open. When an opportunity fires, you'll see the POST appear in real time on webhook.site.

For development against your own code, use **ngrok** to expose `localhost`:

```bash theme={null}
ngrok http 3000
# Use the https://*.ngrok.io URL it prints as your webhook target
```

## Common mistakes

* **Parsing the body before verifying the signature** — most web frameworks parse JSON middleware-style. The signature is over the *raw bytes*, so middleware that re-serializes the body breaks verification. Capture raw bytes first, verify, *then* parse.
* **HTTP, not HTTPS** — we reject `http://` URLs at registration time. Signatures don't replace TLS; both matter.
* **Replying slowly** — if your handler does heavy work synchronously, we may timeout before you ack. Acknowledge immediately, do the work in the background.
* **No tier check downstream** — we deliver to webhooks registered when the user was on Premium even if they later downgrade (we re-check tier at register time only). If you build a multi-tenant system on top, gate at your own layer too.

## Pricing

Webhook alerts are included with the **Premium** plan. Free / Pro / Plus users can register webhooks via the dashboard but registration returns `403`. See [pricing](https://www.polyquantlab.com/pricing).
