Webhooks
Register an endpoint instead of polling or holding a WebSocket open. Two event types, each a catalog or price observation.
A webhook endpoint receives a signed POSTwhenever one of two things happens: a new matched pair becomes catalog-visible, or a pair's cross-venue price gap crosses a threshold you set. Both are observations of the current catalog and price state. Neither is a trade signal, and neither carries a settlement outcome or a per-venue winner.
Webhooks are available on Basic, Premium, and Pro. A Free key gets 403. An organization can hold up to 3 endpoints; registering a fourth returns 409.
Registering an endpoint
| Field | Type | Meaning |
|---|---|---|
url | string | Must be https://. Localhost and private-network hosts are rejected. |
events | array | One or both of pair.created, spread.threshold. |
min_spread_pts | number | Required when events includes spread.threshold; 0.5 to 50. Omit it otherwise. |
curl -s -X POST "https://api.dino.markets/v1/webhooks" \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://yourapp.com/hook", "events": ["pair.created", "spread.threshold"], "min_spread_pts": 5 }'{
"id": "8f3a1c92-47e8-4c3f-b9d2-f1a8e6c4d5f2",
"url": "https://yourapp.com/hook",
"events": ["pair.created", "spread.threshold"],
"min_spread_pts": 5,
"secret": "whsec_9f2c...",
"created_at": "2026-07-31T12:00:00+00:00"
}secret comes back once, in this response only. Store it: it signs every delivery to this endpoint. GET /v1/webhooks lists your endpoints with a secret_preview (the first few characters) in place of the full value, plus status, failure_count, last_success_at, and last_failure_at. DELETE /v1/webhooks/{id} removes an endpoint, scoped to your organization; another organization's endpoint id returns 404.
Events
pair.created fires the first time a match becomes catalog-visible, once per pair per endpoint:
{
"event": "pair.created",
"endpoint_id": "8f3a1c92-47e8-4c3f-b9d2-f1a8e6c4d5f2",
"pair_id": "dino_9b1e2c34-5f6a-4d7e-8b9c-0a1b2c3d4e5f",
"slug": "nyy-vs-bos-2026-08-01",
"title": "Nyy vs Bos",
"sport": "baseball",
"category": "sports",
"status": "open",
"created_at": "2026-07-31T12:00:00+00:00"
}spread.threshold fires when a pair's cross-venue spread_pts crosses your min_spread_pts from below to at or above it. It fires again only after the gap drops back below your threshold, at most once every 30 minutes for the same pair on the same endpoint, and it never fires while a pair has no price:
{
"event": "spread.threshold",
"endpoint_id": "8f3a1c92-47e8-4c3f-b9d2-f1a8e6c4d5f2",
"pair_id": "dino_9b1e2c34-5f6a-4d7e-8b9c-0a1b2c3d4e5f",
"slug": "nyy-vs-bos-2026-08-01",
"title": "Nyy vs Bos",
"spread_pts": 6.1,
"threshold_pts": 5,
"observed_at": "2026-07-31T12:00:03+00:00"
}spread_pts is a snapshot of the observed gap at observed_at. It says what the gap measured at that moment, and it stops there. Confirm settlement disclosure through the settlement endpoint before treating any gap as tradeable.
Verifying a delivery
Each delivery is a POST with a 5-second timeout and three headers:
X-Dino-Event: pair.created
X-Dino-Delivery: 3f1e2a4b-...
X-Dino-Signature: t=1785600000,v1=9c3a7e...The v1 value is a hex HMAC-SHA256 of {t}.{raw_body}, keyed with your endpoint's secret. Compute the same hash over the literal request body, using the raw bytes rather than a re-serialized copy, and compare.
Python:
import hashlib
import hmac
import time
def verify_signature(secret: str, raw_body: bytes, header: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > tolerance_seconds:
return False
signed_payload = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)TypeScript:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifySignature(secret: string, rawBody: string, header: string, toleranceSeconds = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((pair) => pair.split("=") as [string, string]));
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const expectedBuffer = Buffer.from(expected, "utf8");
const receivedBuffer = Buffer.from(parts.v1, "utf8");
return expectedBuffer.length === receivedBuffer.length && timingSafeEqual(expectedBuffer, receivedBuffer);
}Retries and auto-disable
A failed delivery retries up to 3 more times, at 1, 5, and 25 seconds. Each final failure increments failure_count and stamps last_failure_at. An endpoint that reaches 20 consecutive failures is disabled (status: "disabled", disabled_reason: "delivery_failures"). A success resets failure_count to 0 and stamps last_success_at. There is no reactivation call for a disabled endpoint; register a fresh one with POST /v1/webhooks.