Receiving webhooks
Patternode signs every delivery to the Standard Webhooks specification. The event types are on Events.
- Set your endpoint:
PATCH /v1/account {"webhook_url": "https://..."}. HTTPS only. - Get the secret:
POST /v1/account/webhook_secret. Shown once; put it in your secret store. - On each delivery:
- Verify the signature over
webhook-id.webhook-timestamp.body, using the raw body bytes, before parsing. - Reject a timestamp more than 5 minutes from your clock.
- Drop a
webhook-idyou have seen. Retries carry the same id. - Answer 2xx within 10 seconds, then do the work from a queue.
- Ignore event types you do not know.
- Verify the signature over
During a secret rotation a delivery carries two signatures, space separated. Accept either.
- Python
- TypeScript
- AWS Lambda
examples/python/webhook_receiver.py
"""Pattern 4, the webhook receiver: verify, deduplicate, answer 2xx at once, then do the work.
PATTERNODE_WEBHOOK_SECRET=whsec_... python examples/python/webhook_receiver.py # :8787
Deliveries are signed to the Standard Webhooks specification. `verify` is the part to copy;
it is also what terraform/aws runs in its Lambda.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
TOLERANCE_S = 300
class InvalidSignature(Exception):
pass
def verify(secret: str, headers: dict[str, str], body: bytes, now: float | None = None) -> dict:
"""Return the event if the delivery is genuine and fresh, else raise InvalidSignature.
`headers` keys are lower case. During a secret rotation Patternode sends two signatures,
space separated; either may match.
"""
msg_id, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
if not (msg_id and ts and sigs):
raise InvalidSignature("missing webhook headers")
try:
ts_int = int(ts)
except ValueError:
raise InvalidSignature("bad timestamp") from None
if abs((now if now is not None else time.time()) - ts_int) > TOLERANCE_S:
raise InvalidSignature("timestamp outside tolerance")
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
for sig in sigs.split(" "):
version, _, value = sig.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return json.loads(body)
raise InvalidSignature("no signature matched")
SEEN: set[str] = set() # in production: a table with a TTL, keyed on webhook-id
def handle(event: dict) -> None:
"""Your work, after the 2xx. Here it only prints."""
data = event.get("data", {})
if event["type"] == "segmentation.job.succeeded":
print(f"job {data['id']} succeeded: fetch {data['result_url']}")
elif event["type"] == "segmentation.job.failed":
print(f"job {data['id']} failed, not charged: {data.get('error')}")
else:
print(f"ignored {event['type']}") # unknown types are expected: new ones are added without notice
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("content-length") or 0))
headers = {k.lower(): v for k, v in self.headers.items()}
try:
event = verify(os.environ["PATTERNODE_WEBHOOK_SECRET"], headers, body)
except InvalidSignature as e:
self.send_response(400)
self.end_headers()
self.wfile.write(str(e).encode())
return
self.send_response(204)
self.end_headers()
if headers["webhook-id"] in SEEN:
return
SEEN.add(headers["webhook-id"])
handle(event)
if __name__ == "__main__":
port = int(os.environ.get("PORT", "8787"))
print(f"listening on :{port}")
HTTPServer(("127.0.0.1", port), Receiver).serve_forever()
examples/typescript/src/webhooks.ts
// Verify a Patternode webhook delivery (Standard Webhooks). Copy this function.
import { createHmac, timingSafeEqual } from "node:crypto";
export const TOLERANCE_S = 300;
export function verify(secret: string, headers: Record<string, string | undefined>, body: string | Buffer, now = Date.now() / 1000): any {
const id = headers["webhook-id"], ts = headers["webhook-timestamp"], sigs = headers["webhook-signature"];
if (!id || !ts || !sigs) throw new Error("missing webhook headers");
if (!/^\d+$/.test(ts) || Math.abs(now - Number(ts)) > TOLERANCE_S) throw new Error("timestamp outside tolerance");
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${ts}.`).update(body).digest();
for (const sig of sigs.split(" ")) {
const [version, value] = sig.split(",", 2);
if (version !== "v1" || !value) continue;
const got = Buffer.from(value, "base64");
if (got.length === expected.length && timingSafeEqual(got, expected)) return JSON.parse(body.toString());
}
throw new Error("no signature matched");
}
terraform/aws/delve-partner/lambda/receiver.py
"""Patternode webhook receiver for AWS Lambda (Function URL). Standard library and boto3 only.
Verifies the Standard Webhooks signature against the current and previous secrets, drops a
delivery already seen, puts the event on SQS for your workers, and answers 204 at once.
"""
import base64
import hashlib
import hmac
import json
import os
import time
import boto3
TOLERANCE_S = 300
_sm, _ddb, _sqs = boto3.client("secretsmanager"), boto3.client("dynamodb"), boto3.client("sqs")
_cache = {"at": 0.0, "secrets": []}
def _secrets() -> list[str]:
if time.time() - _cache["at"] > 300:
value = json.loads(_sm.get_secret_value(SecretId=os.environ["WEBHOOK_SECRET_ARN"])["SecretString"])
_cache.update(at=time.time(), secrets=[s for s in (value.get("current"), value.get("previous")) if s])
return _cache["secrets"]
def _valid(headers: dict, body: bytes) -> bool:
msg_id, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature", "")
if not (msg_id and ts and ts.isdigit()) or abs(time.time() - int(ts)) > TOLERANCE_S:
return False
given = [s.partition(",")[2] for s in sigs.split(" ") if s.startswith("v1,")]
for secret in _secrets():
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
if any(hmac.compare_digest(g, expected) for g in given):
return True
return False
def handler(event, _context):
body = event.get("body") or ""
body = base64.b64decode(body) if event.get("isBase64Encoded") else body.encode()
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
if event.get("requestContext", {}).get("http", {}).get("method") != "POST" or not _valid(headers, body):
return {"statusCode": 400, "body": "invalid"}
ttl = int(time.time()) + int(os.environ.get("DEDUPE_TTL_HOURS", "48")) * 3600
try:
_ddb.put_item(TableName=os.environ["SEEN_TABLE"],
Item={"webhook_id": {"S": headers["webhook-id"]}, "expires_at": {"N": str(ttl)}},
ConditionExpression="attribute_not_exists(webhook_id)")
except _ddb.exceptions.ConditionalCheckFailedException:
return {"statusCode": 204}
_sqs.send_message(QueueUrl=os.environ["EVENTS_QUEUE_URL"], MessageBody=body.decode(),
MessageAttributes={"webhook_id": {"DataType": "String", "StringValue": headers["webhook-id"]}})
return {"statusCode": 204}