Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,28 +94,48 @@ auth = client.authenticate_channel(socket_id, channel_name, channel_data)

## Webhook Verification

Each webhook endpoint you create in the console has its own signing secret
(`whsec_...`). Verify deliveries with that secret, not the app's API secret, and
pass the raw request body exactly as received.

```python
import json
import os

from apinator import Apinator

client = Apinator(
app_id="your-app-id",
key="your-app-key",
secret="your-webhook-secret",
secret="your-app-secret",
cluster="eu",
)
WEBHOOK_SECRET = os.environ["APINATOR_WEBHOOK_SECRET"] # whsec_...

# In your webhook route handler:
# In your webhook route handler (Flask shown):
headers = dict(request.headers)
body = request.get_data(as_text=True)

if client.verify_webhook(headers, body, max_age=300):
# Webhook is valid — process the payload
payload = json.loads(body)
else:
# Invalid webhook
if not client.verify_webhook(WEBHOOK_SECRET, headers, body, max_age=300):
abort(401)

payload = json.loads(body)
# {"id": "<event uuid>", "event": "member_added", "app_id": "...",
# "channel": "presence-room", "user_id": "42", "time_ms": 1760000000000}
# user_id is only present on member_added / member_removed.

# Deliveries are at-least-once: skip an id you have already processed.
if already_processed(payload["id"]):
return "", 200
```

Each delivery carries `X-Realtime-Signature` (`sha256=<hex>`),
`X-Realtime-Timestamp` (unix seconds), `X-Realtime-Event` and
`X-Realtime-Delivery-ID`. Events: `channel_occupied`, `channel_vacated`,
`member_added`, `member_removed`; the console's test button sends `webhook.test`.
With `max_age` set, timestamps older than `max_age` seconds or more than 300
seconds in the future are rejected.

## Channel Introspection

```python
Expand Down
28 changes: 22 additions & 6 deletions apinator/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
import time
import urllib.request
import urllib.error
from typing import Any
from typing import Any, Mapping
from urllib.parse import quote, urlencode

from .crypto import sign_request
from .auth import authenticate_channel as auth_channel
from .webhook import verify_webhook as verify_webhook_signature
from .webhook import HeaderValue, verify_webhook as verify_webhook_signature
from .errors import ValidationError, AuthenticationError, ApiError


Expand Down Expand Up @@ -154,22 +154,38 @@ def get_channel(self, channel_name: str) -> dict:

def verify_webhook(
self,
headers: dict[str, str],
webhook_secret: str,
headers: Mapping[str, HeaderValue],
body: str,
max_age: int | None = None
) -> bool:
"""
Verify a webhook signature.

Webhooks are signed with the webhook endpoint's own signing secret
(``whsec_...``, shown in the console per endpoint), not with the app's
API secret, so it must be passed explicitly.

Args:
webhook_secret: The webhook endpoint's signing secret (``whsec_...``)
headers: HTTP headers from webhook request
body: Webhook body as string
max_age: Optional max age in seconds
body: Raw webhook body as string, exactly as received
max_age: Optional max age in seconds (300 recommended). When set,
timestamps older than ``max_age`` or more than 300 seconds in
the future are rejected.

Returns:
True if signature is valid

Raises:
ValidationError: If ``webhook_secret`` is not a non-empty string
"""
return verify_webhook_signature(self.secret, headers, body, max_age)
if not isinstance(webhook_secret, str) or not webhook_secret:
raise ValidationError(
"verify_webhook requires the webhook signing secret (whsec_...) "
"as its first argument"
)
return verify_webhook_signature(webhook_secret, headers, body, max_age)

def _request(
self,
Expand Down
48 changes: 36 additions & 12 deletions apinator/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,55 @@

import hmac
import time
from typing import Any, Mapping, Union

from .crypto import sign_webhook_payload

#: How far in the future (seconds) a webhook timestamp may be before it is
#: rejected, when ``max_age`` is given. Allows for clock skew.
MAX_FUTURE_SKEW_SECONDS = 300

HeaderValue = Union[str, list[str], tuple[str, ...], None]


def _header_value(value: Any) -> str | None:
"""Return a header value as a string; list/tuple values use the first string entry."""
if isinstance(value, (list, tuple)):
value = next((v for v in value if isinstance(v, str)), None)
if isinstance(value, bytes):
value = value.decode("latin-1")
return value if isinstance(value, str) else None


def verify_webhook(
secret: str,
headers: dict[str, str],
headers: Mapping[str, HeaderValue],
body: str,
max_age: int | None = None
) -> bool:
"""
Verify a webhook signature.

Apinator signs each delivery with the webhook endpoint's own signing secret
(``whsec_...``, shown in the console per endpoint), not the app's API secret:
``X-Realtime-Signature: sha256=hex(hmac_sha256(secret, "{timestamp}.{raw_body}"))``.

Args:
secret: The webhook secret
headers: HTTP headers (case-insensitive lookup)
body: The webhook body as string
max_age: Optional max age in seconds to check timestamp freshness
secret: The webhook endpoint's signing secret (``whsec_...``)
headers: HTTP headers (case-insensitive lookup; list values use the first entry)
body: The raw webhook body as string, exactly as received
max_age: Optional max age in seconds (300 recommended). When set,
timestamps older than ``max_age`` or more than 300 seconds in the
future are rejected.

Returns:
True if signature is valid and timestamp is fresh (if max_age provided)
"""
# Case-insensitive header lookup
headers_lower = {k.lower(): v for k, v in headers.items()}
headers_lower = {str(k).lower(): v for k, v in headers.items()}

signature_header = headers_lower.get('x-realtime-signature')
timestamp_header = headers_lower.get('x-realtime-timestamp')
signature_header = _header_value(headers_lower.get('x-realtime-signature'))
timestamp_header = _header_value(headers_lower.get('x-realtime-timestamp'))

if not signature_header or not timestamp_header:
return False
Expand All @@ -47,14 +69,16 @@ def verify_webhook(
if max_age is not None:
try:
timestamp = int(timestamp_header)
current_time = int(time.time())
if current_time - timestamp > max_age:
return False
except ValueError:
return False
age = int(time.time()) - timestamp
if age > max_age or -age > MAX_FUTURE_SKEW_SECONDS:
return False

# Compute expected signature
expected_signature = sign_webhook_payload(secret, timestamp_header, body)

# Timing-safe comparison
# Timing-safe comparison (compare_digest rejects non-ASCII str input)
if not provided_signature.isascii():
return False
return hmac.compare_digest(expected_signature, provided_signature)
45 changes: 34 additions & 11 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,24 +135,45 @@ info = client.get_channel("presence-chat")

---

### `verify_webhook(headers, body, max_age=None)`
### `verify_webhook(webhook_secret, headers, body, max_age=None)`

Verify a webhook signature.
Verify a webhook signature. Webhooks are signed with the webhook endpoint's own
signing secret (`whsec_...`, shown in the console per endpoint), not the app's
API secret.

```python
is_valid = client.verify_webhook(headers, body, max_age=300)
is_valid = client.verify_webhook(
os.environ["APINATOR_WEBHOOK_SECRET"], headers, body, max_age=300
)
```

**Parameters:**

| Parameter | Type | Description |
|-----------|------|-------------|
| `headers` | `dict[str, str]` | HTTP headers from the webhook request |
| `body` | `str` | Webhook body as string |
| `max_age` | `int \| None` | Optional max age in seconds for timestamp freshness check |
| `webhook_secret` | `str` | The webhook endpoint's signing secret (`whsec_...`) |
| `headers` | `Mapping[str, str \| list[str]]` | HTTP headers (case-insensitive; list values use the first entry) |
| `body` | `str` | Raw webhook body, exactly as received |
| `max_age` | `int \| None` | Max age in seconds (300 recommended). When set, timestamps older than `max_age` or more than 300 seconds in the future are rejected |

**Returns:** `bool` — `True` if the signature is valid

**Raises:**
- `ValidationError` — if `webhook_secret` is empty or not a string

The signature header is
`X-Realtime-Signature: sha256=hex(hmac_sha256(webhook_secret, "{timestamp}.{raw_body}"))`,
with the timestamp from `X-Realtime-Timestamp`. Deliveries also carry
`X-Realtime-Event` and `X-Realtime-Delivery-ID`. Payload:

```json
{"id": "<event uuid>", "event": "member_added", "app_id": "...", "channel": "presence-room", "user_id": "42", "time_ms": 1760000000000}
```

`event` is one of `channel_occupied`, `channel_vacated`, `member_added`,
`member_removed` (`user_id` only on the member events); the console's test sends
`webhook.test`. Deliveries are at-least-once, so dedupe on `id`.

---

## Standalone Functions
Expand Down Expand Up @@ -189,10 +210,12 @@ auth = authenticate_channel(
### `verify_webhook(secret, headers, body, max_age=None)`

```python
import os

from apinator import verify_webhook

is_valid = verify_webhook(
secret="your-webhook-secret",
secret=os.environ["APINATOR_WEBHOOK_SECRET"], # whsec_...
headers=headers,
body=body,
max_age=300,
Expand All @@ -203,10 +226,10 @@ is_valid = verify_webhook(

| Parameter | Type | Description |
|-----------|------|-------------|
| `secret` | `str` | Webhook secret |
| `headers` | `dict[str, str]` | HTTP headers (case-insensitive lookup) |
| `body` | `str` | Webhook body as string |
| `max_age` | `int \| None` | Optional max age in seconds |
| `secret` | `str` | The webhook endpoint's signing secret (`whsec_...`), not the API secret |
| `headers` | `Mapping[str, str \| list[str]]` | HTTP headers (case-insensitive; list values use the first entry) |
| `body` | `str` | Raw webhook body, exactly as received |
| `max_age` | `int \| None` | Max age in seconds (300 recommended); also rejects timestamps more than 300s in the future |

**Returns:** `bool` — `True` if the signature is valid and timestamp is fresh

Expand Down
21 changes: 17 additions & 4 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,30 +96,43 @@ def auth_channel():

Verify incoming webhooks to ensure they are authentic:

Use the webhook endpoint's signing secret (`whsec_...`, shown in the console per
endpoint), not the app's API secret:

```python
import json
import os
from flask import Flask, request, abort
from apinator import Apinator

app = Flask(__name__)
client = Apinator(
app_id="your-app-id",
key="your-app-key",
secret="your-webhook-secret",
secret="your-app-secret",
cluster="eu",
)
WEBHOOK_SECRET = os.environ["APINATOR_WEBHOOK_SECRET"]
processed_ids: set[str] = set() # use a persistent store in production

@app.route("/webhooks/apinator", methods=["POST"])
def handle_webhook():
headers = dict(request.headers)
body = request.get_data(as_text=True)

if not client.verify_webhook(headers, body, max_age=300):
if not client.verify_webhook(WEBHOOK_SECRET, headers, body, max_age=300):
abort(401)

payload = json.loads(body)
# Process the webhook payload
print(f"Received event: {payload}")
# {"id": "...", "event": "member_added", "app_id": "...", "channel": "presence-room",
# "user_id": "42", "time_ms": 1760000000000}

# Deliveries are at-least-once: dedupe on id.
if payload["id"] in processed_ids:
return "", 200
processed_ids.add(payload["id"])

print(f"Received {payload['event']} on {payload.get('channel')}")
return "", 200
```

Expand Down
Loading
Loading