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