Watches and webhooks
Subscribe to company change events — push via signed webhooks, pull via server-held cursors. Free on every tier.
What a watch is
A watch subscription is a named list of companies (optionally filtered to specific dimensions) that you want change events for. One spine, two delivery surfaces:
- Push: register a webhook endpoint and route subscriptions to it. Bixel POSTs a signed, thin event to your URL when a watched company changes.
- Pull: read
GET /v1/watch/eventswith a server-held named cursor. Your agent resumes where it left off with zero client state.
Watches serve confirmed change events only — the debounced, cannot-flap class. Backfills and baselines stay inspectable in /v1/changes; they are never pushed at you.
Managing watches and receiving deliveries costs 0 credits on every tier, and there is no per-watched-company fee. A cursor page costs 1 credit (the raw /v1/changes firehose is 3 — a watch is pre-filtered). The follow-up data fetch prices as normal credits.
| Tier | Endpoints | Watched companies |
|---|---|---|
| Free | 1 | 25 |
| Pro | 5 | 500 |
| Scale | 25 | 5,000 |
Caps count distinct companies across your account, not rows: watching stripe.com in three subscriptions uses one slot.
Quickstart
Create an endpoint — the signing secret is shown once:
curl -X POST https://api.bixel.com/v1/watch/endpoints \
-H "Authorization: Bearer bx_your_key" -H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/bixel"}'
# → data.endpoint.id + data.secret ("whsec_...") — store the secret nowCreate a subscription routed to it:
curl -X POST https://api.bixel.com/v1/watch/subscriptions \
-H "Authorization: Bearer bx_your_key" -H "Content-Type: application/json" \
-d '{
"name": "Competitors",
"companies": ["pinecone.io", "weaviate.io"],
"dimensions": ["pricing", "features"],
"endpoint_ids": ["<endpoint-id>"]
}'Company resolution is all-or-nothing: if any identifier does not match a covered company, the request answers companies_unresolved and nothing is created — you are never silently half-watching a list.
Send yourself a signed sample:
curl -X POST https://api.bixel.com/v1/watch/endpoints/<endpoint-id>/test \
-H "Authorization: Bearer bx_your_key"
# → { "delivered": true, "response_status": 200, "duration_ms": 132 }Verifying deliveries
Deliveries follow the Standard Webhooks spec, so any off-the-shelf standard-webhooks library verifies them. Each POST carries:
| Header | Meaning |
|---|---|
webhook-id | Stable per delivery — retries and redelivers reuse it, so dedupe on it |
webhook-timestamp | Unix seconds at send time — reject stale timestamps to block replay |
webhook-signature | v1,<base64 HMAC-SHA256> over id.timestamp.body; space-delimited when a rotation grace window is open |
Manual verification, if you prefer no dependency:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, headers, rawBody) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = "v1," + createHmac("sha256", key)
.update(`${id}.${ts}.${rawBody}`).digest("base64");
return headers["webhook-signature"].split(" ").some((sig) => {
const a = Buffer.from(sig), b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
});
}Sign with the raw request body exactly as received — re-serializing the JSON breaks the signature.
The payload is thin on purpose
{
"type": "pricing_changed",
"timestamp": "2026-08-06T05:45:12.000Z",
"data": {
"event_id": "…",
"subscription_id": "…",
"company": { "domain": "pinecone.io", "name": "Pinecone" },
"dimension": "pricing",
"fact_key": "pricing.model",
"occurred_at": "2026-08-04T00:00:00.000Z",
"fetch_url": "https://api.bixel.com/v1/companies/pinecone.io/history?dimension=pricing&fact_key=pricing.model"
}
}No values, no receipts. You fetch the change (and its evidence) from fetch_url with your key — the notification is free, the data read prices as normal credits, and what you store came from an auditable read, not a pushed copy. The full event-type vocabulary is at GET /v1/watch/event-types — discover it there instead of hardcoding.
Delivery semantics
- Retries: first attempt + 3 retries at 5s → 2m → 15m (jittered). After that the delivery is
failed— visible in the log, redeliverable by hand. - Ordering is not guaranteed; use
data.occurred_at(semantic date) ortimestamp(commit time) to order, andwebhook-idto dedupe. - Auto-disable is duration-based: an endpoint failing continuously for 72 hours is disabled with an honest
disabled_reason; one successful delivery resets the window. Re-enable withPATCH … {"status": "active"}once fixed. - Delivery log:
GET /v1/watch/endpoints/{id}/deliveriesshows every attempt, the destination's answer, and what happens next.POST /v1/watch/deliveries/{id}/redeliverre-queues one. - Egress IPs: production deliveries originate from the prefixes published at
/.well-known/bixel-webhook-ips.json— allowlist those if you firewall inbound. Test-route sends come from cloud infrastructure instead. - Secret rotation:
POST /v1/watch/endpoints/{id}/rotatemints a new secret (shown once). For 24 hours deliveries carry both signatures, so you can roll the secret on your side without dropping an event.
Slack and Discord, no middleware
Set format on the endpoint and Bixel posts native chat messages instead of the JSON payload:
curl -X POST https://api.bixel.com/v1/watch/endpoints \
-H "Authorization: Bearer bx_your_key" -H "Content-Type: application/json" \
-d '{"url": "https://hooks.slack.com/services/T…/B…/…", "format": "slack"}'format: "discord" does the same for Discord webhook URLs. Chat-mode deliveries are still signed.
Polling without webhooks
The cursor surface needs no endpoint at all:
curl "https://api.bixel.com/v1/watch/events?subscription_id=<id>" \
-H "Authorization: Bearer bx_your_key"The default mode is a named server-held cursor (cursor_name=default): each call serves events since the last call and advances. A new named cursor anchors at now — watches are forward-looking; use /v1/changes for the archive. advance=false peeks without moving; cursor=<token> and after=<ISO> give stateless reads. 1 credit per page.
Agents get the same via MCP: list_watch_subscriptions, get_watch_events, and list_watch_event_types. Endpoint management stays REST-only — signing secrets never enter agent context.