From c6c3729d95ecc0ab5005c72fc8d65b0f1494233f Mon Sep 17 00:00:00 2001 From: 0xFantomMenace <93462858+0xFantomMenace@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:31:50 +0000 Subject: [PATCH 1/3] Scope the sdist to the package The wheel has always been scoped to the oxarchive package, but the sdist had no scope at all, so the build backend included every non-ignored file in the working directory. Version 1.8.0's sdist is 1.67 MB against an 87 KB wheel because of it. List what ships instead of relying on the default, and ignore agent working directories so they cannot be swept in again. A rebuilt sdist is 84 KB and 30 files. --- .gitignore | 5 +++++ pyproject.toml | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/.gitignore b/.gitignore index 06fed4f..3793469 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,8 @@ dmypy.json # OS .DS_Store Thumbs.db + +# Agent working directories. These are not source and must never be packaged; +# an unscoped sdist published one to PyPI once already. +.claude/ +.codex/ diff --git a/pyproject.toml b/pyproject.toml index d2f4898..e194ad5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,19 @@ Issues = "https://github.com/0xArchiveIO/sdk-python/issues" [tool.hatch.build.targets.wheel] packages = ["oxarchive"] +# The wheel is scoped to the package, but the sdist had no scope at all, so +# hatchling swept in every non-gitignored file in the directory. That published +# an agent working directory, and with it a copy of the private backend, inside +# oxarchive 1.8.0's sdist. List what ships instead of trusting the default. +[tool.hatch.build.targets.sdist] +include = [ + "/oxarchive", + "/README.md", + "/CHANGELOG.md", + "/LICENSE", + "/pyproject.toml", +] + [tool.ruff] line-length = 100 target-version = "py39" From 3c804a156c8526dc6334f314ee3f89488f8ae742 Mon Sep 17 00:00:00 2001 From: 0xFantomMenace <93462858+0xFantomMenace@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:52:45 +0000 Subject: [PATCH 2/3] Fail the build if the sdist grows beyond the package The include list fixes today's build. This stops it regressing: the job plants a stray directory, builds the sdist, and fails if anything outside the package ends up inside it. --- .github/workflows/package-contents.yml | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/package-contents.yml diff --git a/.github/workflows/package-contents.yml b/.github/workflows/package-contents.yml new file mode 100644 index 0000000..8aa8ed9 --- /dev/null +++ b/.github/workflows/package-contents.yml @@ -0,0 +1,31 @@ +name: Package contents + +# The sdist once shipped an entire agent working directory because it had no +# include list and the build backend defaults to "everything not ignored". +# This fails the build if anything outside the package ever ships again. +on: + pull_request: + push: + branches: [master, main] + +jobs: + sdist-scope: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: pip install build + - name: Plant a stray directory the way an agent would + run: mkdir -p .claude/worktrees/probe && echo leak > .claude/worktrees/probe/leak.rs + - run: python -m build --sdist --outdir dist-check + - name: The sdist must contain only the package and its metadata + run: | + set -euo pipefail + tar tzf dist-check/*.tar.gz | sed 's|^[^/]*/||' | grep -v '^$' \ + | awk -F/ '{print $1}' | sort -u > /tmp/top.txt + echo "top-level entries in the sdist:"; cat /tmp/top.txt + if grep -qE '^\.claude|^\.codex|^backend|^crates|^migrations' /tmp/top.txt; then + echo "::error::the sdist contains files from outside the package"; exit 1 + fi From 774d13ccc69f7fc7de24355daf6ff4951a57db2d Mon Sep 17 00:00:00 2001 From: 0xFantomMenace <93462858+0xFantomMenace@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:49:54 +0000 Subject: [PATCH 3/3] Add the webhooks resource and a signature verifier Eighteen operations covering endpoints, subscriptions, watched addresses, the delivery log, and the two previews. Sync and async clients both. oxarchive/webhook_signature.py verifies a delivery. It takes the raw request bytes rather than a parsed object, because the body we sign is PostgreSQL's rendering of the payload and re-serialising it will not reproduce those bytes. It collects every v1 in the header, so a receiver holding both secrets keeps verifying through a rotation, and it compares in constant time. Free plans do not include webhooks. Estimates and dry-runs stay open on every plan for event types that are not scoped to a watched wallet. --- CHANGELOG.md | 31 ++ README.md | 325 +++++++++++++- oxarchive/__init__.py | 63 +++ oxarchive/client.py | 6 + oxarchive/http.py | 57 +++ oxarchive/resources/__init__.py | 2 + oxarchive/resources/webhooks.py | 740 ++++++++++++++++++++++++++++++++ oxarchive/types.py | 413 ++++++++++++++++++ oxarchive/webhook_signature.py | 577 +++++++++++++++++++++++++ 9 files changed, 2211 insertions(+), 3 deletions(-) create mode 100644 oxarchive/resources/webhooks.py create mode 100644 oxarchive/webhook_signature.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9998627..452b88e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to the `oxarchive` Python SDK are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Webhooks**: `client.webhooks`, covering every route under `/v1/webhooks` + (endpoints, subscriptions, watched wallets, deliveries, the event-type + catalog, and the dry-run and estimate previews), each with an async twin. +- **Signature verification**: `WebhookVerifier`, plus the functional + `verify_webhook()`, `verify_webhook_signature()`, and + `parse_signature_header()`. Takes raw bytes, compares with + `hmac.compare_digest`, enforces a configurable replay window (5 minutes by + default), and accepts every `v1=` in the header so a delivery keeps + verifying through a 24-hour secret rotation overlap. +- New pydantic models exported at package root: `WebhookEndpoint`, + `WebhookSecret`, `WebhookSubscription`, `WebhookDelivery`, + `WebhookTestResult`, `WebhookRedelivery`, `WebhookWatchedAddress`, + `WebhookEventTypeDeclaration`, `WebhookDryRun`, `WebhookEstimate`, and the + preview value types (`WebhookWindow`, `WebhookOccurrence`, + `WebhookDayCount`, `WebhookLadderRung`, `WebhookDistribution`, + `WebhookEstimateBasis`). +- `HttpClient` gained PATCH and DELETE (`patch`/`apatch`, `delete`/`adelete`). + The webhook routes are the first in the API to need either. + +### Notes +- Webhook delivery is a paid feature: Free plans hold no endpoints, + subscriptions, watched wallets, or deliveries. Free keeps `estimate()` and + `dry_run()`, so a rule can be designed and sized before upgrading. Per-plan + limits are in the README. +- Every webhook model allows unknown fields. Subscription pause state (set + when an account exceeds its deliveries-per-day allowance) passes through + untouched rather than being bound to field names that are still settling. + ## [1.8.0] - 2026-07-27 ### Added diff --git a/README.md b/README.md index ba33b50..c0caf79 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ hip3_ob = await client.hyperliquid.hip3.orderbook.aget("km:US500") The `depth` parameter controls how many price levels are returned per side. Full orderbook depth is available on every tier. -**Note:** Hyperliquid L2 source data contains ~20 levels. Full-depth L2 (derived from L4) and Lighter.xyz provide full depth. Depth limits apply to L2 snapshot endpoints only — L4 and L2 diff endpoints return full data. +**Note:** Hyperliquid L2 source data contains ~20 levels. Full-depth L2 (derived from L4) and Lighter.xyz provide full depth. Depth limits apply to L2 snapshot endpoints only. L4 and L2 diff endpoints return full data. #### Lighter Orderbook Granularity @@ -695,10 +695,10 @@ print(f"24h liquidation volume: ${summary.liquidation_volume_24h}") print(f" Long: ${summary.long_liquidation_volume_24h}") print(f" Short: ${summary.short_liquidation_volume_24h}") -# Lighter.xyz (price, funding, OI — no volume/liquidation data) +# Lighter.xyz (price, funding, OI; no volume/liquidation data) lighter_summary = client.lighter.get_summary("BTC") -# HIP-3 (includes mid_price — case-sensitive coins) +# HIP-3 (includes mid_price; case-sensitive coins) hip3_summary = client.hyperliquid.hip3.get_summary("km:US500") print(f"Mid price: {hip3_summary.mid_price}") @@ -1180,6 +1180,325 @@ keys = client.web3.list_keys(message=challenge.message, signature=signature) client.web3.revoke_key(message=challenge.message, signature=signature, key_id=keys.keys[0].id) ``` +### Webhooks + +Push delivery of market and account events. Instead of polling a route on a +timer, register a URL and 0xArchive posts to it when something happens. + +Three objects, in the order you create them: + +1. An **endpoint** is a URL 0xArchive posts to. Creating one returns a signing + secret, shown once. +2. A **subscription** is a rule: one event type, on one endpoint, with optional + filters, parameters, and conditions. +3. A **watched address** is a wallet you own or follow. Event types scoped to + `addresses` only fire on wallets you have added. + +```python +# 1. Somewhere to deliver +endpoint = client.webhooks.create_endpoint( + "https://example.com/hooks/0xarchive", + description="prod receiver", +) +store_secret(endpoint.secret) # shown ONCE, never returned again + +# 2. Size the rule before you turn it on +estimate = client.webhooks.estimate( + "market.liquidation", + config={"venue": "hyperliquid", "min_notional_usd": 250_000}, + lookback_days=7, +) +print(f"median {estimate.per_day_p50:.0f} deliveries a day, worst day {estimate.per_day_max}") + +# 3. Turn it on +sub = client.webhooks.create_subscription( + endpoint_id=endpoint.id, + event_type="market.liquidation", + config={"venue": "hyperliquid", "min_notional_usd": 250_000}, +) + +# 4. Fire a real signed test delivery at your receiver +client.webhooks.test_endpoint(endpoint.id) +``` + +#### Plan limits + +Webhook **delivery** is a paid feature. Free plans hold no endpoints, no +subscriptions, no watched wallets, and receive no deliveries. + +| Plan | Endpoints | Subscriptions | Watched wallets | Deliveries per day | +| --- | --- | --- | --- | --- | +| Free | 0 | 0 | 0 | 0 | +| Build | 1 | 8 | 2 | 5,000 | +| Pro | 4 | 40 | 15 | 50,000 | +| Scale | 12 | 200 | 50 | 500,000 | +| Enterprise | negotiated | negotiated | negotiated | negotiated | + +Free still gets `estimate()` and `dry_run()`. You can design a rule, see +exactly which historical occurrences it would have delivered and how often it +would have fired, and decide whether it is worth a plan, before there is +anywhere to deliver it to. Only delivery is gated. + +One consequence worth knowing before you try it: event types whose `scope` is +`addresses` preview against your watched wallets, so previewing one needs at +least one watched wallet. Plans that allow no watched wallets can preview the +`public` types but not the `addresses` types. + +If an account exceeds its deliveries-per-day allowance, the offending +subscription is **paused and says so**, rather than events being dropped +without a signal. A paused subscriber recovers the gap by querying the REST +archive over the paused interval for the same event type and filters; nothing +is queued up and replayed at you later. The pause state is reported on the +subscription record; those field names are still settling, so this SDK does not +bind types to them yet and passes them through untouched on +`list_subscriptions()`. + +#### Event types + +The catalog is the only place event types are declared. Read the filters, +parameters, metrics, and operators from it rather than hardcoding them. + +```python +for t in client.webhooks.event_types(): + if t.live: + print(t.type, t.scope, t.latency_class, t.description) +``` + +`scope` says what a type fires on: + +| Scope | Fires on | Needs | +| --- | --- | --- | +| `public` | Venue-wide activity | Nothing extra | +| `addresses` | Your watched wallets | At least one watched wallet | +| `user` | Your own account | Nothing extra | + +A type whose `live` is `False` is published but not yet subscribable. + +#### Conditions, filters, and parameters + +A subscription's `config` is validated against the event's declaration, and +nothing is silently ignored: an undeclared key, an operator that does not apply +to a metric, or a parameter outside its bounds is refused with the declaration +in the error message. Declared parameter defaults are filled in server-side, so +what comes back is more complete than what you sent. + +```python +# Watch the wallet first. Address-scoped rules can only filter on watched wallets. +client.webhooks.add_address("0x00000000000000000000000000000000000000a1", label="desk 1") + +sub = client.webhooks.create_subscription( + endpoint_id=endpoint.id, + event_type="account.fill", + config={ + "addresses": ["0x00000000000000000000000000000000000000a1"], + "conditions": [ + {"metric": "notional_usd", "op": ">=", "value": 25_000}, + {"metric": "side", "op": "in", "value": ["buy"]}, + ], + }, +) + +# Retune in place. config REPLACES the stored config, so send the whole object. +client.webhooks.update_subscription(sub.id, config={"addresses": [...], "conditions": [...]}) + +# Or just switch it off without losing the rule +client.webhooks.update_subscription(sub.id, enabled=False) +``` + +Operators are grouped by metric type and accept symbol spellings (`>=`, `<`, +`!=`) as well as canonical names (`greater_than_or_equal`, `less_than`, +`not_equal`). The catalog's `operators` map is authoritative. + +#### Previewing a rule + +`dry_run()` answers "which recent occurrences would this have delivered?". +`estimate()` answers "how often would it have fired?". Both validate `config` +exactly as `create_subscription()` does, so a config that previews cleanly will +subscribe cleanly. Neither writes anything. Both are available on every plan. + +```python +# Which ones, over a recent window (60s to 24h, default 1h) +preview = client.webhooks.dry_run( + "market.liquidation", + {"venue": "hyperliquid", "min_notional_usd": 500_000}, + lookback_s=86_400, +) +print(f"{preview.matched} matched between {preview.window.from_} and {preview.window.to}") +for occ in preview.occurrences[:5]: + print(occ.observed_at_estimate, occ.data) + +# How often, over 1 to 30 days (default 7) +est = client.webhooks.estimate("market.liquidation_burst", lookback_days=14) +print(f"{est.total} over {est.days} days, median {est.per_day_p50:.0f}/day") + +# And what a different threshold would have cost you +for rung in est.ladder: + print(f" at {rung.value:,.0f}: {rung.per_day:.1f}/day") +``` + +Dry-runs and estimates share a budget of 6 per minute per account. Not every +live event type is previewable yet; the error names the ones that are. + +#### Verifying deliveries + +Every delivery is signed with HMAC-SHA256. Verify it, or anyone who learns your +URL can post whatever they like to it. + +The one rule that matters: **verify the raw request body**, before any JSON +parser touches it. The body 0xArchive sends is rendered by PostgreSQL, so its +key order and spacing match neither the emitter's field order nor any JSON +library's default output. Re-serialising a parsed dict produces different bytes +and the signature will never match. + +```python +from oxarchive import WebhookVerifier, WebhookSignatureError + +verifier = WebhookVerifier(os.environ["OXARCHIVE_WEBHOOK_SECRET"]) + +# Flask +@app.post("/webhooks/0xarchive") +def receive(): + try: + event = verifier.verify(request.get_data(), request.headers) + except WebhookSignatureError as e: + # Never 5xx a bad signature: that replays the same bad delivery at + # you for 24 hours. + app.logger.warning("rejected webhook: %s", e.reason) + return "", 400 + + if already_seen(event.id): # delivery is at-least-once + return "", 200 + enqueue(event.payload) # do the real work out of band + return "", 200 +``` + +| Framework | Raw body | +| --- | --- | +| Flask | `request.get_data()` | +| FastAPI / Starlette | `await request.body()` | +| Django | `request.body` | +| Next.js route handlers | `await req.text()`, body parser disabled | + +Not `request.get_json()`, not a parsed Pydantic model, not `request.POST`. Any +proxy or gateway in front of the receiver that pretty-prints, minifies, or +re-encodes JSON also breaks verification: verify before that layer or turn it +off. + +Deliveries carry four headers: + +| Header | Meaning | +| --- | --- | +| `0xa-signature` | `t=,v1=` and, mid-rotation, a second `v1=` | +| `0xa-event-id` | Event UUID. **Deduplicate on this.** Stable across retries and manual redelivery | +| `0xa-event-type` | The event type, for example `account.fill` | +| `content-type` | Always `application/json` | + +Only the timestamp and the body are signed. The event id, the event type, the +destination URL, and every other header are not, so the replay window plus +deduplication on `0xa-event-id` is the whole of the defence. Serve the receiver +over HTTPS. + +The verifier enforces a 5-minute replay window by default. 0xArchive re-signs +with a fresh timestamp on every attempt, so a legitimate delivery is never stale +by more than network and clock skew: + +```python +verifier = WebhookVerifier(secret, tolerance_seconds=120) # tighten it if you like +``` + +A functional entry point is available if you do not want to hold state: + +```python +from oxarchive import verify_webhook + +event = verify_webhook(raw_body_bytes, headers, secret) +``` + +#### Rotating a secret + +`rotate_secret()` issues a new secret and keeps the previous one valid for 24 +hours. During the overlap every delivery carries **two** `v1=` signatures, one +under each secret, so a receiver holding either keeps verifying. That is what +makes a zero-downtime roll possible. + +```python +rotated = client.webhooks.rotate_secret(endpoint.id) + +verifier.add_secret(rotated.secret) # now accepts both +deploy() # ship the new secret to every replica +verifier.remove_secret(old_secret) # before the 24 hours are up +``` + +Two constraints the server imposes: + +- Only **one** previous secret is carried. Rotating twice inside the window + overwrites it, and the original stops verifying immediately. +- The window is measured server-side. A receiver cannot extend it. + +If you write your own verifier instead of using this one, parse **every** `v1=` +value in the header, not just the first. A verifier that reads only the first +works fine until the day someone rotates, then fails intermittently for 24 +hours and nowhere else. + +#### Retries and failures + +| Behaviour | Detail | +| --- | --- | +| Success | HTTP 200 to 299 only. A 301 or 302 counts as a failure; redirects are not followed | +| Timeout | 10 seconds. Acknowledge immediately and process asynchronously | +| Retry ladder | 5s, 30s, 2m, 10m, 1h, then hourly, capped at 24 hours | +| Auto-disable | After 10 consecutive failures spanning at least 6 hours | +| Recovery | `client.webhooks.enable_endpoint(endpoint_id)` | + +```python +# What actually happened +for d in client.webhooks.deliveries(endpoint.id, limit=100): + print(d.event_type, d.state, d.attempts, d.last_status_code, d.last_error) + +# Send one again after fixing the receiver. The event id is unchanged, so a +# receiver that already processed it will dedupe it away. +client.webhooks.redeliver(delivery_id) +``` + +#### Watched addresses + +```python +client.webhooks.add_address("0x00000000000000000000000000000000000000a1", label="desk 1") +watched = client.webhooks.list_addresses() +client.webhooks.delete_address(watched[0].id) +``` + +Re-adding a wallet already on the list is idempotent and does not consume +another slot. Bridge system addresses are refused: they are a counterparty to +every bridge move of their token, not an account. + +#### Every webhook method + +| Method | Route | +| --- | --- | +| `event_types()` | `GET /v1/webhooks/event-types` | +| `list_endpoints()` | `GET /v1/webhooks/endpoints` | +| `create_endpoint()` | `POST /v1/webhooks/endpoints` | +| `delete_endpoint()` | `DELETE /v1/webhooks/endpoints/{id}` | +| `rotate_secret()` | `POST /v1/webhooks/endpoints/{id}/rotate` | +| `enable_endpoint()` | `POST /v1/webhooks/endpoints/{id}/enable` | +| `test_endpoint()` | `POST /v1/webhooks/endpoints/{id}/test` | +| `deliveries()` | `GET /v1/webhooks/endpoints/{id}/deliveries` | +| `redeliver()` | `POST /v1/webhooks/deliveries/{id}/redeliver` | +| `list_subscriptions()` | `GET /v1/webhooks/subscriptions` | +| `create_subscription()` | `POST /v1/webhooks/subscriptions` | +| `update_subscription()` | `PATCH /v1/webhooks/subscriptions/{id}` | +| `delete_subscription()` | `DELETE /v1/webhooks/subscriptions/{id}` | +| `dry_run()` | `POST /v1/webhooks/subscriptions/dry-run` | +| `estimate()` | `POST /v1/webhooks/subscriptions/estimate` | +| `list_addresses()` | `GET /v1/webhooks/addresses` | +| `add_address()` | `POST /v1/webhooks/addresses` | +| `delete_address()` | `DELETE /v1/webhooks/addresses/{id}` | + +Every method has an async version prefixed with `a`: `await +client.webhooks.aestimate(...)`, `await client.webhooks.acreate_endpoint(...)`, +and so on. + ### Legacy API (Deprecated) The following legacy methods are deprecated and will be removed in v2.0. They default to Hyperliquid data: diff --git a/oxarchive/__init__.py b/oxarchive/__init__.py index 9322286..e69f951 100644 --- a/oxarchive/__init__.py +++ b/oxarchive/__init__.py @@ -28,6 +28,10 @@ >>> >>> # Get historical snapshots >>> history = client.hyperliquid.orderbook.history("ETH", start="2024-01-01", end="2024-01-02") + >>> + >>> # Webhooks: size a rule, then subscribe to it + >>> estimate = client.webhooks.estimate("market.liquidation") + >>> event_types = client.webhooks.event_types() """ from .client import Client @@ -43,6 +47,19 @@ reconstruct_final, ) from .l4_reconstructor import L4OrderBookReconstructor, L4Order, L2Level +from .webhook_signature import ( + DEFAULT_TOLERANCE_SECONDS, + EVENT_ID_HEADER, + EVENT_TYPE_HEADER, + SIGNATURE_HEADER, + WebhookEvent, + WebhookSignature, + WebhookSignatureError, + WebhookVerifier, + parse_signature_header, + verify_webhook, + verify_webhook_signature, +) from .types import ( OrderBook, Trade, @@ -74,6 +91,23 @@ Candle, CandleInterval, OxArchiveError, + # Webhook types + WebhookDayCount, + WebhookDelivery, + WebhookDistribution, + WebhookDryRun, + WebhookEndpoint, + WebhookEstimate, + WebhookEstimateBasis, + WebhookEventTypeDeclaration, + WebhookLadderRung, + WebhookOccurrence, + WebhookRedelivery, + WebhookSecret, + WebhookSubscription, + WebhookTestResult, + WebhookWatchedAddress, + WebhookWindow, # Web3 Auth types SiweChallenge, Web3SignupResult, @@ -176,6 +210,35 @@ "Candle", "CandleInterval", "OxArchiveError", + # Webhook Signature Verification + "WebhookVerifier", + "WebhookEvent", + "WebhookSignature", + "WebhookSignatureError", + "verify_webhook", + "verify_webhook_signature", + "parse_signature_header", + "SIGNATURE_HEADER", + "EVENT_ID_HEADER", + "EVENT_TYPE_HEADER", + "DEFAULT_TOLERANCE_SECONDS", + # Webhook Types + "WebhookEventTypeDeclaration", + "WebhookEndpoint", + "WebhookSecret", + "WebhookSubscription", + "WebhookDelivery", + "WebhookTestResult", + "WebhookRedelivery", + "WebhookWatchedAddress", + "WebhookWindow", + "WebhookOccurrence", + "WebhookDryRun", + "WebhookDayCount", + "WebhookLadderRung", + "WebhookDistribution", + "WebhookEstimateBasis", + "WebhookEstimate", # Web3 Auth Types "SiweChallenge", "Web3SignupResult", diff --git a/oxarchive/client.py b/oxarchive/client.py index 992ac94..6157b30 100644 --- a/oxarchive/client.py +++ b/oxarchive/client.py @@ -15,6 +15,7 @@ OpenInterestResource, DataQualityResource, Web3Resource, + WebhooksResource, ) DEFAULT_BASE_URL = "https://api.0xarchive.io" @@ -120,6 +121,11 @@ def __init__( self.web3 = Web3Resource(self._http) """Wallet-based auth: get API keys via SIWE signature""" + # Webhooks (push delivery). Paid plans only for delivery; the + # estimate and dry-run previews are open on every plan. + self.webhooks = WebhooksResource(self._http) + """Webhook endpoints, subscriptions, watched wallets, and deliveries""" + # Legacy resource namespaces (deprecated - use client.hyperliquid.* instead) # These will be removed in v2.0 # Note: Using /v1/hyperliquid base path for backward compatibility diff --git a/oxarchive/http.py b/oxarchive/http.py index 059b22a..69248ed 100644 --- a/oxarchive/http.py +++ b/oxarchive/http.py @@ -158,3 +158,60 @@ async def apost( except httpx.HTTPError as e: raise OxArchiveError(f"Network error: {e}", 0) from e return self._handle_response(response) + + def patch( + self, + path: str, + json: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """Make a synchronous PATCH request.""" + try: + response = self.client.patch(path, json=json) + except httpx.HTTPError as e: + raise OxArchiveError(f"Network error: {e}", 0) from e + return self._handle_response(response) + + async def apatch( + self, + path: str, + json: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """Make an asynchronous PATCH request.""" + try: + response = await self.async_client.patch(path, json=json) + except httpx.HTTPError as e: + raise OxArchiveError(f"Network error: {e}", 0) from e + return self._handle_response(response) + + def delete( + self, + path: str, + params: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """Make a synchronous DELETE request. + + No 0xarchive DELETE route takes a request body, so none is sent. + """ + if params: + params = {k: v for k, v in params.items() if v is not None} + + try: + response = self.client.delete(path, params=params) + except httpx.HTTPError as e: + raise OxArchiveError(f"Network error: {e}", 0) from e + return self._handle_response(response) + + async def adelete( + self, + path: str, + params: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """Make an asynchronous DELETE request.""" + if params: + params = {k: v for k, v in params.items() if v is not None} + + try: + response = await self.async_client.delete(path, params=params) + except httpx.HTTPError as e: + raise OxArchiveError(f"Network error: {e}", 0) from e + return self._handle_response(response) diff --git a/oxarchive/resources/__init__.py b/oxarchive/resources/__init__.py index 5957453..9a8fd5f 100644 --- a/oxarchive/resources/__init__.py +++ b/oxarchive/resources/__init__.py @@ -20,6 +20,7 @@ from .l2_orderbook import L2OrderBookResource from .l3_orderbook import L3OrderBookResource from .spot import SpotPairsResource, SpotTwapResource +from .webhooks import WebhooksResource __all__ = [ "OrderBookResource", @@ -41,4 +42,5 @@ "L3OrderBookResource", "SpotPairsResource", "SpotTwapResource", + "WebhooksResource", ] diff --git a/oxarchive/resources/webhooks.py b/oxarchive/resources/webhooks.py new file mode 100644 index 0000000..51c3843 --- /dev/null +++ b/oxarchive/resources/webhooks.py @@ -0,0 +1,740 @@ +"""Webhooks API resource: endpoints, subscriptions, watched wallets, deliveries.""" + +from __future__ import annotations + +from typing import Any, Optional + +from ..http import HttpClient +from ..types import ( + WebhookDelivery, + WebhookDryRun, + WebhookEndpoint, + WebhookEstimate, + WebhookEventTypeDeclaration, + WebhookRedelivery, + WebhookSecret, + WebhookSubscription, + WebhookTestResult, + WebhookWatchedAddress, +) + + +def _with_note(envelope: dict[str, Any]) -> dict[str, Any]: + """Fold the envelope's top-level advisory into the payload. + + The rotate route puts the new secret in ``data`` and its "previous secret + remains valid" advisory alongside it, outside ``data``. Merge the two so + one model carries both, without clobbering a ``note`` the payload may + grow of its own accord later. + """ + payload = dict(envelope.get("data") or {}) + note = envelope.get("note") + if note is not None and "note" not in payload: + payload["note"] = note + return payload + + +class WebhooksResource: + """ + Webhook management: push delivery of market and account events. + + Three objects, in the order you create them: + + 1. An **endpoint** is a URL 0xArchive posts to. Creating one returns a + signing secret, shown once. + 2. A **subscription** is a rule: one event type, on one endpoint, with + optional filters, parameters, and conditions. + 3. A **watched address** is a wallet you own or follow. The account-scoped + event types only fire on wallets you have added here. + + Webhook delivery is a paid feature. Free plans hold no endpoints, + subscriptions, watched wallets, or deliveries. Free does keep + :meth:`estimate` and :meth:`dry_run`, so a rule can be designed and sized + before upgrading. See the README for the per-plan grid. + + Example: + >>> # 1. Somewhere to deliver + >>> endpoint = client.webhooks.create_endpoint( + ... "https://example.com/hooks/0xarchive", + ... description="prod receiver", + ... ) + >>> store_secret(endpoint.secret) # shown once, never again + >>> + >>> # 2. Size the rule before you buy the deliveries + >>> estimate = client.webhooks.estimate( + ... "market.liquidation", + ... config={"venue": "hyperliquid", "min_notional_usd": 250000}, + ... lookback_days=7, + ... ) + >>> print(f"~{estimate.per_day_p50:.0f} deliveries a day") + >>> + >>> # 3. Turn it on + >>> sub = client.webhooks.create_subscription( + ... endpoint_id=endpoint.id, + ... event_type="market.liquidation", + ... config={"venue": "hyperliquid", "min_notional_usd": 250000}, + ... ) + >>> + >>> # 4. Prove the receiver verifies signatures, end to end + >>> client.webhooks.test_endpoint(endpoint.id) + + Verifying what arrives is :class:`~oxarchive.WebhookVerifier`'s job. + """ + + def __init__(self, http: HttpClient, base_path: str = "/v1/webhooks"): + self._http = http + self._base_path = base_path + + # ========================================================================= + # Catalog + # ========================================================================= + + def event_types(self) -> list[WebhookEventTypeDeclaration]: + """ + List every event type, with the filters, parameters, and metrics it accepts. + + This catalog is the only place event types are declared. Read the + available operators and thresholds from it rather than hardcoding + them: a type whose ``live`` is False is published but not yet + subscribable. + + Returns: + One declaration per event type. + + Example: + >>> live = [t for t in client.webhooks.event_types() if t.live] + >>> for t in live: + ... print(t.type, t.scope, t.description) + """ + data = self._http.get(f"{self._base_path}/event-types") + return [WebhookEventTypeDeclaration.model_validate(x) for x in data["data"]] + + async def aevent_types(self) -> list[WebhookEventTypeDeclaration]: + """Async version of :meth:`event_types`.""" + data = await self._http.aget(f"{self._base_path}/event-types") + return [WebhookEventTypeDeclaration.model_validate(x) for x in data["data"]] + + # ========================================================================= + # Endpoints + # ========================================================================= + + def list_endpoints(self) -> list[WebhookEndpoint]: + """ + List your delivery endpoints. + + Returns: + Every endpoint on the account. ``secret`` is always None here; it + is returned only at create and rotate time. + """ + data = self._http.get(f"{self._base_path}/endpoints") + return [WebhookEndpoint.model_validate(x) for x in data["data"]] + + async def alist_endpoints(self) -> list[WebhookEndpoint]: + """Async version of :meth:`list_endpoints`.""" + data = await self._http.aget(f"{self._base_path}/endpoints") + return [WebhookEndpoint.model_validate(x) for x in data["data"]] + + def create_endpoint(self, url: str, description: str = "") -> WebhookEndpoint: + """ + Register a delivery endpoint and get its signing secret. + + The returned ``secret`` is shown ONCE. Store it before you do + anything else: no later call returns it, and without it you cannot + verify a delivery. If you lose it, :meth:`rotate_secret` issues a new + one. + + The URL is checked at create time and again on every dispatch; + private and loopback destinations are refused. Redirects are not + followed, so give the final URL. + + Args: + url: HTTPS destination for deliveries. + description: Your own label for the endpoint. + + Returns: + The endpoint, with ``secret`` populated. + + Raises: + OxArchiveError: The plan allows no more endpoints, or the URL was + refused. + + Example: + >>> endpoint = client.webhooks.create_endpoint( + ... "https://example.com/hooks/0xarchive" + ... ) + >>> save_to_secret_store(endpoint.secret) + """ + data = self._http.post( + f"{self._base_path}/endpoints", + json={"url": url, "description": description}, + ) + return WebhookEndpoint.model_validate(data["data"]) + + async def acreate_endpoint(self, url: str, description: str = "") -> WebhookEndpoint: + """Async version of :meth:`create_endpoint`.""" + data = await self._http.apost( + f"{self._base_path}/endpoints", + json={"url": url, "description": description}, + ) + return WebhookEndpoint.model_validate(data["data"]) + + def delete_endpoint(self, endpoint_id: str) -> None: + """ + Delete an endpoint. + + Its subscriptions go with it. Deliveries already queued are not sent. + + Args: + endpoint_id: Endpoint UUID. + + Raises: + OxArchiveError: No such endpoint on this account. + """ + self._http.delete(f"{self._base_path}/endpoints/{endpoint_id}") + + async def adelete_endpoint(self, endpoint_id: str) -> None: + """Async version of :meth:`delete_endpoint`.""" + await self._http.adelete(f"{self._base_path}/endpoints/{endpoint_id}") + + def rotate_secret(self, endpoint_id: str) -> WebhookSecret: + """ + Issue a new signing secret, keeping the previous one valid for 24 hours. + + During the overlap every delivery carries two ``v1=`` signatures: one + under the new secret, one under the old. A receiver holding either + keeps verifying, which is what makes a zero-downtime roll possible. + + The roll is: rotate, add the new secret alongside the old one, deploy, + then drop the old one before the window closes. + + Two constraints the server imposes: + + - Only ONE previous secret is carried. Rotating twice inside the + window overwrites it, and the original stops verifying at once. + - The window is measured server-side. A receiver cannot extend it. + + Args: + endpoint_id: Endpoint UUID. + + Returns: + The new secret, shown once. + + Raises: + OxArchiveError: No such endpoint on this account. + + Example: + >>> rotated = client.webhooks.rotate_secret(endpoint.id) + >>> verifier.add_secret(rotated.secret) # accept both, then deploy + """ + data = self._http.post(f"{self._base_path}/endpoints/{endpoint_id}/rotate") + return WebhookSecret.model_validate(_with_note(data)) + + async def arotate_secret(self, endpoint_id: str) -> WebhookSecret: + """Async version of :meth:`rotate_secret`.""" + data = await self._http.apost(f"{self._base_path}/endpoints/{endpoint_id}/rotate") + return WebhookSecret.model_validate(_with_note(data)) + + def enable_endpoint(self, endpoint_id: str) -> None: + """ + Re-enable an endpoint that was disabled or auto-disabled. + + 0xArchive auto-disables an endpoint after 10 consecutive failures + spanning at least 6 hours. Fix the receiver first: re-enabling a + receiver that still fails just walks the same ladder again. + + Args: + endpoint_id: Endpoint UUID. + + Raises: + OxArchiveError: No such endpoint on this account. + """ + self._http.post(f"{self._base_path}/endpoints/{endpoint_id}/enable") + + async def aenable_endpoint(self, endpoint_id: str) -> None: + """Async version of :meth:`enable_endpoint`.""" + await self._http.apost(f"{self._base_path}/endpoints/{endpoint_id}/enable") + + def test_endpoint(self, endpoint_id: str) -> WebhookTestResult: + """ + Queue a real ``webhook.test`` delivery to an endpoint. + + This is not a simulation. The test event goes through the identical + dispatch path with the identical signing, so it is a genuine + end-to-end check of a receiver's verification code. + + Args: + endpoint_id: Endpoint UUID. + + Returns: + The queued delivery and event ids. + + Raises: + OxArchiveError: No such endpoint on this account. + + Example: + >>> fired = client.webhooks.test_endpoint(endpoint.id) + >>> log = client.webhooks.deliveries(endpoint.id, limit=1) + >>> print(log[0].state, log[0].last_status_code) + """ + data = self._http.post(f"{self._base_path}/endpoints/{endpoint_id}/test") + return WebhookTestResult.model_validate(data["data"]) + + async def atest_endpoint(self, endpoint_id: str) -> WebhookTestResult: + """Async version of :meth:`test_endpoint`.""" + data = await self._http.apost(f"{self._base_path}/endpoints/{endpoint_id}/test") + return WebhookTestResult.model_validate(data["data"]) + + def deliveries( + self, + endpoint_id: str, + *, + limit: Optional[int] = None, + ) -> list[WebhookDelivery]: + """ + Read an endpoint's delivery log, newest first. + + Args: + endpoint_id: Endpoint UUID. + limit: Deliveries to return (server default: 50). + + Returns: + Delivery records, including the attempt count, the last status + code, and the stored payload. + + Example: + >>> failed = [ + ... d for d in client.webhooks.deliveries(endpoint.id, limit=200) + ... if d.state != "delivered" + ... ] + """ + data = self._http.get( + f"{self._base_path}/endpoints/{endpoint_id}/deliveries", + params={"limit": limit}, + ) + return [WebhookDelivery.model_validate(x) for x in data["data"]] + + async def adeliveries( + self, + endpoint_id: str, + *, + limit: Optional[int] = None, + ) -> list[WebhookDelivery]: + """Async version of :meth:`deliveries`.""" + data = await self._http.aget( + f"{self._base_path}/endpoints/{endpoint_id}/deliveries", + params={"limit": limit}, + ) + return [WebhookDelivery.model_validate(x) for x in data["data"]] + + def redeliver(self, delivery_id: str) -> WebhookRedelivery: + """ + Send a past delivery again. + + The event id is deliberately unchanged, so a receiver that already + processed the event will dedupe it away. That is the point: use this + after fixing a receiver that rejected or dropped the event, not to + force a second processing of one that succeeded. + + Args: + delivery_id: Delivery UUID, from :meth:`deliveries`. + + Returns: + The requeued delivery. + + Raises: + OxArchiveError: No such delivery on this account. + + Note: + Requeueing succeeds even when the endpoint is disabled; the + dispatcher then retires the attempt unsent. Re-enable the endpoint + with :meth:`enable_endpoint` before redelivering into it. + """ + data = self._http.post(f"{self._base_path}/deliveries/{delivery_id}/redeliver") + return WebhookRedelivery.model_validate(data["data"]) + + async def aredeliver(self, delivery_id: str) -> WebhookRedelivery: + """Async version of :meth:`redeliver`.""" + data = await self._http.apost(f"{self._base_path}/deliveries/{delivery_id}/redeliver") + return WebhookRedelivery.model_validate(data["data"]) + + # ========================================================================= + # Subscriptions + # ========================================================================= + + def list_subscriptions(self) -> list[WebhookSubscription]: + """ + List your subscriptions across every endpoint. + + Returns: + Every rule on the account, with its stored configuration. + """ + data = self._http.get(f"{self._base_path}/subscriptions") + return [WebhookSubscription.model_validate(x) for x in data["data"]] + + async def alist_subscriptions(self) -> list[WebhookSubscription]: + """Async version of :meth:`list_subscriptions`.""" + data = await self._http.aget(f"{self._base_path}/subscriptions") + return [WebhookSubscription.model_validate(x) for x in data["data"]] + + def create_subscription( + self, + endpoint_id: str, + event_type: str, + config: Optional[dict[str, Any]] = None, + ) -> WebhookSubscription: + """ + Create a rule: deliver one event type to one endpoint. + + Everything in ``config`` is checked against what the event type + declares in :meth:`event_types`; nothing is silently ignored. An + undeclared key, an operator that does not apply to a metric, or a + parameter outside its bounds is refused with the declaration in the + error. Declared parameter defaults are filled in server-side, so the + stored configuration comes back more complete than what you sent. + + Run :meth:`dry_run` or :meth:`estimate` with the same ``config`` + first. Both validate it identically, and they tell you how much + traffic the rule will actually produce. + + Args: + endpoint_id: Endpoint UUID to deliver to. + event_type: Event type from the catalog, for example + ``account.fill``. + config: Filters, params, and conditions. Keys are event-specific + and declared in the catalog; commonly ``venue``, ``symbols``, + ``addresses``, ``params``, and ``conditions``. Sent on the + wire as ``filters``. + + Returns: + The created rule, with its normalised configuration. + + Raises: + OxArchiveError: The plan allows no more subscriptions, the event + type is unknown or not yet live, or the configuration was + refused. + + Example: + >>> sub = client.webhooks.create_subscription( + ... endpoint_id=endpoint.id, + ... event_type="account.fill", + ... config={ + ... "addresses": ["0x00000000000000000000000000000000000000a1"], + ... "conditions": [ + ... {"metric": "notional_usd", "op": ">=", "value": 25000} + ... ], + ... }, + ... ) + """ + data = self._http.post( + f"{self._base_path}/subscriptions", + json={ + "endpoint_id": endpoint_id, + "event_type": event_type, + "filters": config or {}, + }, + ) + return WebhookSubscription.model_validate(data["data"]) + + async def acreate_subscription( + self, + endpoint_id: str, + event_type: str, + config: Optional[dict[str, Any]] = None, + ) -> WebhookSubscription: + """Async version of :meth:`create_subscription`.""" + data = await self._http.apost( + f"{self._base_path}/subscriptions", + json={ + "endpoint_id": endpoint_id, + "event_type": event_type, + "filters": config or {}, + }, + ) + return WebhookSubscription.model_validate(data["data"]) + + def update_subscription( + self, + subscription_id: str, + *, + config: Optional[dict[str, Any]] = None, + enabled: Optional[bool] = None, + ) -> WebhookSubscription: + """ + Edit a rule in place: retune its configuration, or switch it on or off. + + ``config`` REPLACES the stored configuration rather than merging into + it, so send the whole object. Omit it to change only ``enabled``. + + Args: + subscription_id: Subscription UUID. + config: The complete replacement configuration, validated exactly + as at create time. + enabled: Your own on/off switch for the rule. + + Returns: + The updated rule. + + Raises: + OxArchiveError: No such subscription, or the configuration was + refused. + + Example: + >>> client.webhooks.update_subscription(sub.id, enabled=False) + """ + body: dict[str, Any] = {} + if config is not None: + body["filters"] = config + if enabled is not None: + body["enabled"] = enabled + data = self._http.patch(f"{self._base_path}/subscriptions/{subscription_id}", json=body) + return WebhookSubscription.model_validate(data["data"]) + + async def aupdate_subscription( + self, + subscription_id: str, + *, + config: Optional[dict[str, Any]] = None, + enabled: Optional[bool] = None, + ) -> WebhookSubscription: + """Async version of :meth:`update_subscription`.""" + body: dict[str, Any] = {} + if config is not None: + body["filters"] = config + if enabled is not None: + body["enabled"] = enabled + data = await self._http.apatch( + f"{self._base_path}/subscriptions/{subscription_id}", json=body + ) + return WebhookSubscription.model_validate(data["data"]) + + def delete_subscription(self, subscription_id: str) -> None: + """ + Delete a rule. Its endpoint is left alone. + + Args: + subscription_id: Subscription UUID. + + Raises: + OxArchiveError: No such subscription on this account. + """ + self._http.delete(f"{self._base_path}/subscriptions/{subscription_id}") + + async def adelete_subscription(self, subscription_id: str) -> None: + """Async version of :meth:`delete_subscription`.""" + await self._http.adelete(f"{self._base_path}/subscriptions/{subscription_id}") + + def dry_run( + self, + event_type: str, + config: Optional[dict[str, Any]] = None, + *, + lookback_s: Optional[int] = None, + limit: Optional[int] = None, + ) -> WebhookDryRun: + """ + Show which recent occurrences a rule WOULD have delivered. + + Nothing is created and nothing is delivered: the configuration is + validated and normalised exactly as :meth:`create_subscription` would, + then evaluated against history with the detectors' own matching. + + Available on every plan, Free included, so a rule can be checked + before there is an endpoint to deliver it to. Types whose scope is + ``addresses`` preview against your watched wallets, so previewing one + needs at least one wallet on the list. + + Args: + event_type: Event type from the catalog. Not every live type is + dry-runnable yet; the error names the ones that are. + config: The same configuration you would subscribe with. Sent on + the wire as ``config``. + lookback_s: Seconds of history to scan, ending now. 60 to 86400 + (24 hours). Server default: 3600. + limit: Occurrences to return, newest first. 1 to 200. Server + default: 100. + + Returns: + The matches and the window the answer vouches for. + + Raises: + OxArchiveError: The event type is unknown or not dry-runnable, + the window or page size is out of bounds, the configuration + was refused, or the per-minute preview budget is spent + (dry-runs and estimates share 6 per minute). + + Example: + >>> preview = client.webhooks.dry_run( + ... "market.liquidation", + ... {"venue": "hyperliquid", "min_notional_usd": 500000}, + ... lookback_s=86400, + ... ) + >>> print(f"{preview.matched} in the last day") + """ + body: dict[str, Any] = {"event_type": event_type, "config": config or {}} + if lookback_s is not None: + body["lookback_s"] = lookback_s + if limit is not None: + body["limit"] = limit + data = self._http.post(f"{self._base_path}/subscriptions/dry-run", json=body) + return WebhookDryRun.model_validate(data["data"]) + + async def adry_run( + self, + event_type: str, + config: Optional[dict[str, Any]] = None, + *, + lookback_s: Optional[int] = None, + limit: Optional[int] = None, + ) -> WebhookDryRun: + """Async version of :meth:`dry_run`.""" + body: dict[str, Any] = {"event_type": event_type, "config": config or {}} + if lookback_s is not None: + body["lookback_s"] = lookback_s + if limit is not None: + body["limit"] = limit + data = await self._http.apost(f"{self._base_path}/subscriptions/dry-run", json=body) + return WebhookDryRun.model_validate(data["data"]) + + def estimate( + self, + event_type: str, + config: Optional[dict[str, Any]] = None, + *, + lookback_days: Optional[int] = None, + ) -> WebhookEstimate: + """ + Show how often a rule WOULD have fired, per day, over a historical window. + + This is the call to make before turning a rule on. It returns the + daily counts, the median and busiest day, and a threshold ladder: the + rate the same rule would have had at other thresholds. Compare + ``per_day_p50`` against your plan's deliveries-per-day allowance. + + Available on every plan, Free included. Types whose scope is + ``addresses`` preview against your watched wallets, so previewing one + needs at least one wallet on the list. + + Args: + event_type: Event type from the catalog. Not every live type is + estimable yet; the error names the ones that are. + config: The same configuration you would subscribe with. Sent on + the wire as ``config``. + lookback_days: Days of history to evaluate, 1 to 30. Server + default: 7. Some types cap their own window lower, and the + response says which window it actually covered. + + Returns: + The daily rate, the threshold ladder, and a sample of matches. + + Raises: + OxArchiveError: The event type is unknown or not estimable, the + window is out of bounds, the configuration was refused, or + the per-minute preview budget is spent (dry-runs and + estimates share 6 per minute). + + Example: + >>> est = client.webhooks.estimate("market.liquidation_burst") + >>> print(f"median {est.per_day_p50:.0f}/day, worst {est.per_day_max}") + >>> for rung in est.ladder: + ... print(f" at {rung.value:,.0f}: {rung.per_day:.1f}/day") + """ + body: dict[str, Any] = {"event_type": event_type, "config": config or {}} + if lookback_days is not None: + body["lookback_days"] = lookback_days + data = self._http.post(f"{self._base_path}/subscriptions/estimate", json=body) + return WebhookEstimate.model_validate(data["data"]) + + async def aestimate( + self, + event_type: str, + config: Optional[dict[str, Any]] = None, + *, + lookback_days: Optional[int] = None, + ) -> WebhookEstimate: + """Async version of :meth:`estimate`.""" + body: dict[str, Any] = {"event_type": event_type, "config": config or {}} + if lookback_days is not None: + body["lookback_days"] = lookback_days + data = await self._http.apost(f"{self._base_path}/subscriptions/estimate", json=body) + return WebhookEstimate.model_validate(data["data"]) + + # ========================================================================= + # Watched addresses + # ========================================================================= + + def list_addresses(self) -> list[WebhookWatchedAddress]: + """ + List the wallets the account-scoped event types may fire on. + + Returns: + Every watched wallet on the account. + """ + data = self._http.get(f"{self._base_path}/addresses") + return [WebhookWatchedAddress.model_validate(x) for x in data["data"]] + + async def alist_addresses(self) -> list[WebhookWatchedAddress]: + """Async version of :meth:`list_addresses`.""" + data = await self._http.aget(f"{self._base_path}/addresses") + return [WebhookWatchedAddress.model_validate(x) for x in data["data"]] + + def add_address(self, address: str, label: str = "") -> WebhookWatchedAddress: + """ + Watch a wallet, so the account-scoped event types can fire on it. + + Event types whose scope is ``addresses`` only ever fire on wallets + added here, and a subscription cannot filter on a wallet that is not + on the list. Add the wallet first, then the rule. + + Re-adding a wallet already on the list is idempotent and does not + consume another slot. The address is normalised to lowercase. + + Args: + address: A 0x-prefixed 40-hex-character EVM address. + label: Your own label, truncated to 64 characters. + + Returns: + The watched wallet. + + Raises: + OxArchiveError: The plan allows no more watched wallets, the + address is malformed, or it is a bridge system address (those + are a counterparty to every bridge move of their token, not + an account). + + Example: + >>> client.webhooks.add_address( + ... "0x00000000000000000000000000000000000000a1", label="desk 1" + ... ) + """ + data = self._http.post( + f"{self._base_path}/addresses", + json={"address": address, "label": label}, + ) + return WebhookWatchedAddress.model_validate(data["data"]) + + async def aadd_address(self, address: str, label: str = "") -> WebhookWatchedAddress: + """Async version of :meth:`add_address`.""" + data = await self._http.apost( + f"{self._base_path}/addresses", + json={"address": address, "label": label}, + ) + return WebhookWatchedAddress.model_validate(data["data"]) + + def delete_address(self, address_id: str) -> None: + """ + Stop watching a wallet. + + Subscriptions that filtered on it keep their stored configuration; + they simply stop matching it. + + Args: + address_id: Watched-address UUID, from :meth:`list_addresses`. + + Raises: + OxArchiveError: No such watched address on this account. + """ + self._http.delete(f"{self._base_path}/addresses/{address_id}") + + async def adelete_address(self, address_id: str) -> None: + """Async version of :meth:`delete_address`.""" + await self._http.adelete(f"{self._base_path}/addresses/{address_id}") diff --git a/oxarchive/types.py b/oxarchive/types.py index 8521f50..bed689f 100644 --- a/oxarchive/types.py +++ b/oxarchive/types.py @@ -1832,3 +1832,416 @@ class SlaResponse(BaseModel): total_downtime_minutes: int """Total downtime in minutes.""" + + +# ============================================================================= +# Webhook Types +# ============================================================================= +# +# Webhook delivery is a paid feature. Free plans hold no endpoints, +# subscriptions, watched wallets, or deliveries, but they keep the two +# preview routes (estimate and dry-run) so a rule can be designed and costed +# before upgrading. See the README for the full per-plan grid. +# +# Every model here allows unknown fields. The webhook surface is the newest +# part of the API and it gains fields faster than the SDK ships; an extra key +# on the wire must never raise in a customer's receiver. + + +class WebhookEventTypeDeclaration(BaseModel): + """One entry in the served event-type catalog. + + The catalog is the single source the dashboard, the docs, and this SDK + render from. Nothing client-side should hardcode an event type, a + threshold, or an operator: read them from here. + """ + + model_config = {"extra": "allow"} + + type: str + """Event type identifier, for example 'account.fill' or 'market.liquidation'.""" + + live: bool + """True when the type accepts subscriptions. False means published but not yet available.""" + + scope: str + """'public' (venue-wide), 'addresses' (your watched wallets), or 'user' (your account).""" + + description: str + """What the event fires on.""" + + venues: list[str] = Field(default_factory=list) + """Venues this type covers, for example ['hyperliquid', 'hip3', 'lighter'].""" + + filters: list[str] = Field(default_factory=list) + """Filter keys the type accepts, for example ['venue', 'symbols', 'addresses'].""" + + params: dict[str, Any] = Field(default_factory=dict) + """Declared parameters, each with its type, bounds or enum, and default.""" + + metrics: dict[str, Any] = Field(default_factory=dict) + """Metrics that conditions can be written against, each with its type and unit.""" + + operators: dict[str, Any] = Field(default_factory=dict) + """Operator vocabulary, grouped by metric type.""" + + latency_class: Optional[str] = None + """Rough delivery latency after the underlying event: 'seconds' or 'minutes'.""" + + schema_version: Optional[int] = None + """Payload schema version for this event type.""" + + cost_floor: Optional[float] = None + """Minimum threshold the type enforces, when it has one.""" + + +class WebhookEndpoint(BaseModel): + """A registered delivery destination.""" + + model_config = {"extra": "allow"} + + id: str + """Endpoint UUID.""" + + url: str + """HTTPS destination. Redirects are not followed, so point this at the final URL.""" + + description: str = "" + """Your own label for the endpoint.""" + + status: str + """'active', 'disabled', or 'auto_disabled'. + + An endpoint auto-disables after 10 consecutive failures spanning at least + 6 hours. Recover it with ``enable_endpoint()``. + """ + + consecutive_failures: int = 0 + """Failures since the last success. Resets on any 2xx.""" + + created_at: datetime + """When the endpoint was registered (UTC).""" + + secret: Optional[str] = None + """The signing secret, shown ONCE. + + Populated only by ``create_endpoint()`` and ``rotate_secret()``. Every + list and get returns None. Store it when you get it: the API will not + show it again, and without it you cannot verify a delivery. + """ + + +class WebhookSecret(BaseModel): + """A newly issued signing secret, returned once by a rotation.""" + + model_config = {"extra": "allow"} + + secret: str + """The new signing secret ('whsec_' followed by 64 hex characters). + + Pass the whole string to :class:`~oxarchive.WebhookVerifier`; the prefix + is part of the HMAC key. + """ + + note: Optional[str] = None + """The API's own advisory, which states how long the previous secret stays valid.""" + + +class WebhookSubscription(BaseModel): + """A rule: which event type goes to which endpoint, under which conditions.""" + + model_config = {"extra": "allow"} + + id: str + """Subscription UUID.""" + + endpoint_id: str + """The endpoint this rule delivers to.""" + + event_type: str + """The subscribed event type.""" + + filters: dict[str, Any] = Field(default_factory=dict) + """The stored, normalised configuration: filters, params, and conditions. + + The API returns it under the name ``filters``; this SDK sends it as + ``config``. Same object. Declared parameter defaults are filled in + server-side, so what comes back is more complete than what you sent. + """ + + enabled: bool = True + """Your own on/off switch for the rule.""" + + created_at: datetime + """When the rule was created (UTC).""" + + @property + def config(self) -> dict[str, Any]: + """Alias for :attr:`filters`, matching the SDK's request vocabulary.""" + return self.filters + + +class WebhookDelivery(BaseModel): + """One delivery attempt record from the endpoint's delivery log.""" + + model_config = {"extra": "allow"} + + id: str + """Delivery UUID. Pass this to ``redeliver()``.""" + + event_id: str + """Event UUID, sent as the '0xa-event-id' header. + + Stable across retries and across manual redelivery. This is what a + receiver deduplicates on. + """ + + event_type: str + """The event type that was delivered.""" + + state: str + """'pending', 'delivered', or 'exhausted'.""" + + attempts: int = 0 + """Attempts made so far. The ladder is 5s, 30s, 2m, 10m, 1h, then hourly, capped at 24 hours.""" + + last_status_code: Optional[int] = None + """HTTP status from the most recent attempt. Only 2xx counts as success.""" + + last_error: Optional[str] = None + """Failure detail from the most recent attempt.""" + + last_latency_ms: Optional[int] = None + """Round-trip time of the most recent attempt. The request times out at 10 seconds.""" + + next_attempt_at: Optional[datetime] = None + """When the next attempt is due (UTC).""" + + delivered_at: Optional[datetime] = None + """When the delivery first succeeded (UTC), if it has.""" + + created_at: datetime + """When the delivery was queued (UTC).""" + + payload: dict[str, Any] = Field(default_factory=dict) + """The event body as stored. + + Useful for inspection. It is NOT byte-identical to what was signed and + sent, so never verify a signature against a re-serialised copy of this. + """ + + +class WebhookTestResult(BaseModel): + """The queued 'webhook.test' delivery from a test fire.""" + + model_config = {"extra": "allow"} + + delivery_id: str + """UUID of the queued delivery. Watch it in the delivery log.""" + + event_id: str + """Event UUID, which arrives as the '0xa-event-id' header.""" + + +class WebhookRedelivery(BaseModel): + """The outcome of asking for a past delivery to be sent again.""" + + model_config = {"extra": "allow"} + + delivery_id: str + """UUID of the queued delivery.""" + + event_id: Optional[str] = None + """Event UUID. Unchanged from the original, so receivers dedupe it away + unless they are meant to reprocess it.""" + + event_type: Optional[str] = None + """The event type being resent.""" + + state: Optional[str] = None + """Delivery state after requeueing.""" + + attempts: Optional[int] = None + """Attempts made so far.""" + + next_attempt_at: Optional[datetime] = None + """When the attempt is due (UTC).""" + + +class WebhookWatchedAddress(BaseModel): + """A wallet the account-scoped event types are allowed to fire on.""" + + model_config = {"extra": "allow"} + + id: str + """Watched-address UUID.""" + + address: str + """The wallet, normalised to lowercase 0x form.""" + + label: str = "" + """Your own label, up to 64 characters.""" + + created_at: datetime + """When the wallet was added (UTC).""" + + +class WebhookWindow(BaseModel): + """The time range a preview answer vouches for.""" + + model_config = {"extra": "allow", "populate_by_name": True} + + from_: str = Field(alias="from") + """Start of the window (RFC 3339). + + Named ``from_`` because ``from`` is a Python keyword. It moves later than + you asked when a capped scan could not reach back that far. + """ + + to: str + """End of the window (RFC 3339).""" + + +class WebhookOccurrence(BaseModel): + """One historical event a preview says would have been delivered.""" + + model_config = {"extra": "allow"} + + observed_at_estimate: str + """The occurrence's own timestamp (RFC 3339). + + A real delivery's ``observed_at`` would be this plus the detector's + ingest lag. + """ + + data: dict[str, Any] = Field(default_factory=dict) + """The event data, shaped as the delivered payload's ``data`` would be.""" + + +class WebhookDryRun(BaseModel): + """Which occurrences a rule would have delivered over a recent window. + + Available on every plan, Free included, so a rule can be checked before + there is anywhere to deliver it. + """ + + model_config = {"extra": "allow"} + + event_type: str + """The event type that was evaluated.""" + + window: WebhookWindow + """The window the answer vouches for.""" + + matched: int = 0 + """Occurrences that matched inside the window, before the page limit.""" + + truncated: bool = False + """True when fewer occurrences are listed than matched, or a scan hit its row cap.""" + + occurrences: list[WebhookOccurrence] = Field(default_factory=list) + """The matches, newest first.""" + + +class WebhookDayCount(BaseModel): + """One 24-hour bin of an estimate.""" + + model_config = {"extra": "allow"} + + date: str + """The UTC date the bin ends on.""" + + count: int + """Matches in the bin.""" + + +class WebhookLadderRung(BaseModel): + """The daily rate a rule would have had at a different threshold.""" + + model_config = {"extra": "allow"} + + value: float + """The threshold on the estimate's primary metric.""" + + per_day: float + """Deliveries per day at that threshold, everything else unchanged.""" + + +class WebhookDistribution(BaseModel): + """Quantiles of an estimate's primary metric over the matched occurrences.""" + + model_config = {"extra": "allow"} + + n: int + """Occurrences the quantiles are computed over.""" + + p50: float + """Median.""" + + p90: float + """90th percentile.""" + + p99: float + """99th percentile.""" + + max: float + """Largest observed value.""" + + +class WebhookEstimateBasis(BaseModel): + """How an estimate was computed.""" + + model_config = {"extra": "allow"} + + mode: str + """Which evaluation path answered, for example an exact scan or a replay.""" + + note: Optional[str] = None + """Any caveat attached to the answer.""" + + +class WebhookEstimate(BaseModel): + """How often a rule would have fired over a historical window. + + Available on every plan, Free included. Use it to size a rule before + paying for the deliveries: ``per_day_p50`` against the plan's + deliveries-per-day allowance is the number that matters. + """ + + model_config = {"extra": "allow"} + + event_type: str + """The event type that was evaluated.""" + + window: WebhookWindow + """The window the answer vouches for.""" + + days: int + """Days covered. Shorter than requested when the type caps its own window.""" + + total: int + """Matches across the whole window.""" + + per_day: list[WebhookDayCount] = Field(default_factory=list) + """One entry per day, oldest first, zero-filled.""" + + per_day_p50: float = 0.0 + """Median deliveries per day.""" + + per_day_max: int = 0 + """Busiest single day.""" + + primary_metric: Optional[str] = None + """The metric the ladder and the distribution describe.""" + + ladder: list[WebhookLadderRung] = Field(default_factory=list) + """Ascending what-if thresholds and the daily rate each would have produced.""" + + distribution: Optional[WebhookDistribution] = None + """Quantiles of the primary metric, when the type has one.""" + + sample: list[WebhookOccurrence] = Field(default_factory=list) + """A sample of matches, newest first.""" + + basis: Optional[WebhookEstimateBasis] = None + """How the answer was computed.""" diff --git a/oxarchive/webhook_signature.py b/oxarchive/webhook_signature.py new file mode 100644 index 0000000..7715c27 --- /dev/null +++ b/oxarchive/webhook_signature.py @@ -0,0 +1,577 @@ +"""Verification for inbound 0xArchive webhook deliveries. + +0xArchive signs every delivery with HMAC-SHA256 over the exact bytes it puts +on the wire. This module verifies that signature, enforces a replay window, +and hands back the parsed event. + +The one rule that matters: verify the RAW REQUEST BODY, before any JSON +parser touches it. The body 0xArchive sends is rendered by PostgreSQL, so its +key order and spacing match neither the emitter's struct order nor any JSON +library's default output. Re-serialising a parsed dict produces different +bytes and the signature will never match. + +Example (Flask):: + + from flask import Flask, request + from oxarchive import WebhookVerifier, WebhookSignatureError + + app = Flask(__name__) + verifier = WebhookVerifier(os.environ["OXARCHIVE_WEBHOOK_SECRET"]) + + @app.post("/webhooks/0xarchive") + def receive(): + try: + event = verifier.verify(request.get_data(), request.headers) + except WebhookSignatureError: + # A bad signature is never worth retrying. Answer 4xx so the + # delivery is not replayed at you for 24 hours. + return "", 400 + if already_processed(event.id): # delivery is at-least-once + return "", 200 + enqueue(event) # do the real work out of band + return "", 200 + +Example (FastAPI):: + + @app.post("/webhooks/0xarchive") + async def receive(request: Request): + event = verifier.verify(await request.body(), request.headers) + ... + +Both read the raw body. ``request.get_json()`` and a Pydantic model do not. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import re +import time +from dataclasses import dataclass, field +from typing import Any, Iterable, Mapping, Optional, Sequence, Union + +__all__ = [ + "DEFAULT_TOLERANCE_SECONDS", + "SIGNATURE_HEADER", + "EVENT_ID_HEADER", + "EVENT_TYPE_HEADER", + "WebhookSignature", + "WebhookEvent", + "WebhookSignatureError", + "WebhookVerifier", + "parse_signature_header", + "verify_webhook_signature", + "verify_webhook", +] + +SIGNATURE_HEADER = "0xa-signature" +"""Header carrying the timestamp and one or more HMAC signatures.""" + +EVENT_ID_HEADER = "0xa-event-id" +"""Header carrying the event UUID. Deduplicate on this value.""" + +EVENT_TYPE_HEADER = "0xa-event-type" +"""Header carrying the event type, for example ``account.fill``.""" + +DEFAULT_TOLERANCE_SECONDS = 300 +"""Default replay window, in seconds (5 minutes). + +0xArchive re-signs with a fresh timestamp on every attempt, including retries +and manual redeliveries, so a legitimate delivery is never stale by more than +network and clock skew. A tight window is safe here. +""" + +_SIGNATURE_SCHEME = "v1" + +# The server emits `t` as a plain decimal integer and each `v1` as 64 +# lowercase hex characters. Both patterns are enforced rather than assumed, +# because the header is attacker-controlled and Python is generous in ways +# that turn a rejection into a crash: `int()` happily parses Unicode digits, +# underscores, and surrounding whitespace, and `hmac.compare_digest` raises +# TypeError on a string with any non-ASCII character in it. A receiver that +# raises where it should have returned 4xx answers 5xx instead, and a 5xx is +# the one response that makes 0xArchive replay the delivery for 24 hours. +# +# The digit count is bounded for the same reason. Python 3.11 and later +# refuse `int()` on a string of more than 4300 digits, so an unbounded pattern +# waves `t=` followed by five thousand nines through the shape check and into +# a ValueError. Nineteen digits is the full width of the signed 64-bit integer +# the server puts there. A real timestamp is ten. +_DECIMAL_INTEGER = re.compile(r"\A-?[0-9]{1,19}\Z") +_LOWER_HEX = re.compile(r"\A[0-9a-f]+\Z") + + +class WebhookSignatureError(Exception): + """A delivery failed verification. + + Respond to the sender with a 4xx status when this is raised. A 5xx tells + 0xArchive to retry, which replays the same unverifiable delivery at you + for up to 24 hours. + + Attributes: + reason: Machine-readable cause. One of ``missing_header``, + ``malformed_header``, ``timestamp_out_of_tolerance``, + ``no_secrets``, or ``signature_mismatch``. + """ + + def __init__(self, message: str, reason: str): + super().__init__(message) + self.message = message + self.reason = reason + + +@dataclass(frozen=True) +class WebhookSignature: + """The parsed contents of the ``0xa-signature`` header.""" + + timestamp: str + """The timestamp exactly as it appeared in the header. + + This literal substring is what goes back into the signed string. Never + reformat it: parse a copy for the freshness check instead. + """ + + timestamp_seconds: int + """``timestamp`` as an integer. Unix SECONDS, not milliseconds.""" + + signatures: tuple[str, ...] + """Every ``v1=`` value in the header, lowercased. + + Normally one. Two during a secret rotation overlap, where the second is + the same payload signed with the previous secret. + """ + + +@dataclass(frozen=True) +class WebhookEvent: + """A delivery whose signature has been verified.""" + + id: str + """The event UUID, from ``0xa-event-id``. + + Stable across retries and across manual redelivery. This is the value to + deduplicate on: delivery is at-least-once. + """ + + type: str + """The event type, from ``0xa-event-type``, for example ``account.fill``.""" + + body: bytes + """The exact bytes that were signed and verified.""" + + signature: WebhookSignature + """The verified signature envelope.""" + + payload: dict[str, Any] = field(default_factory=dict) + """The body parsed as JSON. + + Read the event data from here. Do not re-serialise it and expect the + bytes to match ``body``. + """ + + +def _as_bytes(body: Union[bytes, bytearray, memoryview, str]) -> bytes: + """Coerce a body to the bytes that were signed. + + A ``str`` is encoded as UTF-8, which is what the server emitted. Passing + raw bytes straight through avoids the question entirely and is preferred. + """ + if isinstance(body, str): + return body.encode("utf-8") + if isinstance(body, (bytearray, memoryview)): + return bytes(body) + return body + + +def _lookup_header(headers: Mapping[str, Any], name: str) -> Optional[str]: + """Case-insensitive header lookup. + + HTTP header names are case-insensitive and 0xArchive emits them + lowercase, but proxies and frameworks re-case freely, so nothing here + depends on the emitted spelling. + """ + getter = getattr(headers, "get", None) + if getter is not None: + # Multidicts such as Werkzeug's and Starlette's are already + # case-insensitive; try the cheap path first. + found = getter(name) + if found is not None: + return str(found) + + wanted = name.lower() + for key, value in headers.items(): + if str(key).lower() == wanted: + return str(value) + return None + + +def _normalise_secrets(secret: Union[str, Iterable[str]]) -> tuple[str, ...]: + """Accept one secret or several, and drop empties. + + Anything that is not text is refused here, where it surfaces as a startup + error with a message that names the fix. A secret read as bytes is the + case worth naming: ``bytes`` is iterable, so it used to fall through as a + sequence of integers, build a verifier that looked healthy, and then raise + ``AttributeError`` inside the HMAC on every delivery, which is a 5xx and + therefore 24 hours of retries. + """ + if isinstance(secret, str): + candidates: Sequence[Any] = [secret] + elif isinstance(secret, (bytes, bytearray, memoryview)): + candidates = [secret] + else: + candidates = list(secret) + + for item in candidates: + if isinstance(item, (bytes, bytearray, memoryview)): + raise TypeError( + "A signing secret must be a string. Decode it first: " + 'secret.decode("utf-8").' + ) + if not isinstance(item, str): + raise TypeError( + f"A signing secret must be a string, not {type(item).__name__}." + ) + return tuple(s for s in candidates if s) + + +def parse_signature_header(header: str) -> WebhookSignature: + """Parse a ``0xa-signature`` header value. + + The grammar is ``t=,v1=<64 hex>[,v1=<64 hex>]``: comma + separated, no spaces. Every ``v1`` is collected, not just the first. A + header with only the first signature read is the classic rotation bug: + it works until the day someone rotates a secret, then fails intermittently + for 24 hours. + + Args: + header: The raw header value. + + Returns: + The parsed timestamp and every signature in the header. + + Raises: + WebhookSignatureError: The header is malformed, has no timestamp, has + a non-integer timestamp, or carries no ``v1`` signature. + """ + timestamp: Optional[str] = None + signatures: list[str] = [] + + for element in header.split(","): + key, sep, value = element.strip().partition("=") + if not sep: + continue + if key == "t" and timestamp is None: + timestamp = value + elif key == _SIGNATURE_SCHEME: + signatures.append(value.lower()) + + if timestamp is None: + raise WebhookSignatureError( + f"{SIGNATURE_HEADER} has no 't=' timestamp.", "malformed_header" + ) + if not signatures: + raise WebhookSignatureError( + f"{SIGNATURE_HEADER} has no 'v1=' signature.", "malformed_header" + ) + if not _DECIMAL_INTEGER.match(timestamp): + raise WebhookSignatureError( + f"{SIGNATURE_HEADER} timestamp '{timestamp}' is not a decimal integer.", + "malformed_header", + ) + timestamp_seconds = int(timestamp) + + return WebhookSignature( + timestamp=timestamp, + timestamp_seconds=timestamp_seconds, + signatures=tuple(signatures), + ) + + +def verify_webhook_signature( + body: Union[bytes, bytearray, memoryview, str], + signature_header: str, + secret: Union[str, Iterable[str]], + *, + tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS, + now: Optional[float] = None, +) -> WebhookSignature: + """Verify a signature header against a body and one or more secrets. + + The signed string is ``.``: the literal timestamp from + the header, one ASCII full stop, then the raw body bytes. Nothing else is + covered, so neither the event id, the event type, the destination URL, nor + any other header is authenticated. + + Args: + body: The RAW request body. Bytes are preferred; a ``str`` is encoded + as UTF-8. Never pass a re-serialised dict. + signature_header: The ``0xa-signature`` header value. + secret: The endpoint signing secret (``whsec_`` followed by 64 hex + characters), or several of them. Pass both the new and the + previous secret while a rotation window is open. The ``whsec_`` + prefix is part of the key: do not strip it, and do not decode the + hex. + tolerance_seconds: Replay window in seconds, compared as an absolute + difference so a receiver clock running behind the server still + verifies. Pass ``0`` to disable the check, which is only + reasonable when replaying a captured delivery in a test. + now: Unix seconds to treat as the current time. For tests. + + Returns: + The verified signature envelope. + + Raises: + WebhookSignatureError: No secrets were supplied, the header is + malformed, the timestamp is outside the tolerance, or no + signature matched. + """ + secrets = _normalise_secrets(secret) + if not secrets: + raise WebhookSignatureError("No signing secret was supplied.", "no_secrets") + + parsed = parse_signature_header(signature_header) + + if tolerance_seconds > 0: + current = time.time() if now is None else now + drift = abs(int(current) - parsed.timestamp_seconds) + if drift > tolerance_seconds: + raise WebhookSignatureError( + f"Delivery timestamp is {drift}s away from now, outside the " + f"{tolerance_seconds}s tolerance.", + "timestamp_out_of_tolerance", + ) + + signed_payload = parsed.timestamp.encode("ascii") + b"." + _as_bytes(body) + + matched = False + for key in secrets: + expected = hmac.new( + key.encode("utf-8"), signed_payload, hashlib.sha256 + ).hexdigest() + for candidate in parsed.signatures: + # A candidate that is not exactly as long as the digest, or is + # not lowercase hex, cannot match. Screen it out here rather than + # handing it to compare_digest, which raises TypeError on any + # non-ASCII string: a malformed signature must be a rejection, + # never an exception the receiver did not plan for. + if len(candidate) != len(expected) or not _LOWER_HEX.match(candidate): + continue + # compare_digest rather than ==, so a mismatch does not leak the + # position of the first differing character through timing. The + # loop does not break early: at most two secrets by two + # signatures, so finishing it costs nothing. + if hmac.compare_digest(candidate, expected): + matched = True + if not matched: + raise WebhookSignatureError( + "No signature in the header matched the supplied secrets.", + "signature_mismatch", + ) + + return parsed + + +def verify_webhook( + body: Union[bytes, bytearray, memoryview, str], + headers: Mapping[str, Any], + secret: Union[str, Iterable[str]], + *, + tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS, + now: Optional[float] = None, +) -> WebhookEvent: + """Verify a delivery and return the parsed event. + + Args: + body: The RAW request body, ideally as bytes. + headers: The request headers. Any mapping works; the lookup is + case-insensitive. + secret: One signing secret, or several during a rotation window. + tolerance_seconds: Replay window in seconds. Defaults to 5 minutes. + now: Unix seconds to treat as the current time. For tests. + + Returns: + The verified event, with its id, type, raw bytes, and parsed payload. + + Raises: + WebhookSignatureError: The ``0xa-signature`` header is missing or + malformed, the timestamp is stale, or no signature matched. + """ + signature_header = _lookup_header(headers, SIGNATURE_HEADER) + if not signature_header: + raise WebhookSignatureError( + f"Request has no {SIGNATURE_HEADER} header.", "missing_header" + ) + + raw = _as_bytes(body) + signature = verify_webhook_signature( + raw, + signature_header, + secret, + tolerance_seconds=tolerance_seconds, + now=now, + ) + + try: + payload = json.loads(raw.decode("utf-8")) if raw else {} + except (UnicodeDecodeError, ValueError): + payload = {} + if not isinstance(payload, dict): + payload = {} + + return WebhookEvent( + id=_lookup_header(headers, EVENT_ID_HEADER) or "", + type=_lookup_header(headers, EVENT_TYPE_HEADER) or "", + body=raw, + signature=signature, + payload=payload, + ) + + +class WebhookVerifier: + """Holds the signing secrets for one endpoint and verifies deliveries. + + Rotating a secret is a three-step move, and this class is built for the + middle step. ``POST /v1/webhooks/endpoints/{id}/rotate`` returns the new + secret and keeps the previous one valid for 24 hours, signing every + delivery with both. Hold both here, deploy, then drop the old one before + the window closes. + + Two constraints the server imposes, worth knowing before you script a + rotation: + + - Only ONE previous secret is carried. Rotating twice inside the window + overwrites it and the original stops verifying immediately. + - The window is measured on the server. A receiver cannot extend it. + + Example:: + + verifier = WebhookVerifier(current_secret) + + # Mid-rotation: accept both until the old one is retired. + verifier = WebhookVerifier([new_secret, previous_secret]) + + event = verifier.verify(raw_body_bytes, request.headers) + """ + + def __init__( + self, + secret: Union[str, Iterable[str]], + *, + tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS, + ): + """Create a verifier. + + Args: + secret: One signing secret, or several to accept during a + rotation window. + tolerance_seconds: Replay window in seconds. Defaults to 5 + minutes. + + Raises: + ValueError: No non-empty secret was supplied. + TypeError: A secret was supplied that is not a string. + """ + secrets = _normalise_secrets(secret) + if not secrets: + raise ValueError( + "WebhookVerifier needs at least one signing secret. Endpoint " + "secrets are returned once, by POST /v1/webhooks/endpoints and " + "by POST /v1/webhooks/endpoints/{id}/rotate." + ) + self._secrets = secrets + self.tolerance_seconds = tolerance_seconds + + @property + def secrets(self) -> tuple[str, ...]: + """The secrets this verifier will accept, in the order supplied.""" + return self._secrets + + def add_secret(self, secret: str) -> None: + """Start accepting another secret, newest first. + + Call this with the secret returned by a rotation, before deploying + the code that stops using the old one. + + Args: + secret: The additional signing secret. + + Raises: + ValueError: The secret is empty. + TypeError: The secret is not a string. + """ + normalised = _normalise_secrets([secret]) + if not normalised: + raise ValueError("Signing secret must not be empty.") + if normalised[0] not in self._secrets: + self._secrets = (normalised[0],) + self._secrets + + def remove_secret(self, secret: str) -> None: + """Stop accepting a secret, once its rotation window has closed. + + Args: + secret: The signing secret to drop. + + Raises: + ValueError: Dropping it would leave no secrets at all. + """ + remaining = tuple(s for s in self._secrets if s != secret) + if not remaining: + raise ValueError("A verifier must keep at least one signing secret.") + self._secrets = remaining + + def verify( + self, + body: Union[bytes, bytearray, memoryview, str], + headers: Mapping[str, Any], + *, + now: Optional[float] = None, + ) -> WebhookEvent: + """Verify a delivery and return the parsed event. + + Args: + body: The RAW request body, ideally as bytes. + headers: The request headers, looked up case-insensitively. + now: Unix seconds to treat as the current time. For tests. + + Returns: + The verified event. + + Raises: + WebhookSignatureError: The delivery did not verify. Answer 4xx. + """ + return verify_webhook( + body, + headers, + self._secrets, + tolerance_seconds=self.tolerance_seconds, + now=now, + ) + + def is_valid( + self, + body: Union[bytes, bytearray, memoryview, str], + headers: Mapping[str, Any], + *, + now: Optional[float] = None, + ) -> bool: + """Verify a delivery, returning a bool instead of raising. + + Prefer :meth:`verify`: the exception carries a ``reason`` that tells + a stale clock apart from a wrong secret, and you usually want that in + the log line. + + Args: + body: The RAW request body. + headers: The request headers. + now: Unix seconds to treat as the current time. For tests. + + Returns: + True when the delivery verified. + """ + try: + self.verify(body, headers, now=now) + except WebhookSignatureError: + return False + return True