Because your webhook endpoint is a public URL, anyone could send requests to it.
To confirm that a request genuinely came from Openlayer and was not tampered
with, every delivery is signed. Your endpoint should verify the signature
before processing the payload.
Openlayer follows the Standard Webhooks
specification, so you can verify signatures with any compatible library.
Every webhook request includes the following headers:
| Header | Description |
|---|
webhook-id | A unique identifier for the message. It stays constant across retries of the same event. |
webhook-timestamp | The Unix timestamp (in seconds) of the delivery attempt. |
webhook-signature | A space-delimited list of signatures, each prefixed with its version (for example, v1,…). |
Requests are also sent with Content-Type: application/json and a
User-Agent of Openlayer-Webhooks/1.0.
How the signature is computed
The signature is an HMAC-SHA256 over the webhook id, timestamp, and the raw
request body, joined with periods:
signed_content = "{webhook-id}.{webhook-timestamp}.{raw_body}"
The key is your subscription’s signing secret with the whsec_ prefix removed
and the remainder Base64-decoded. The result is Base64-encoded and prefixed with
v1, to form the value sent in the webhook-signature header:
signature = "v1," + base64(HMAC_SHA256(base64decode(secret_without_prefix), signed_content))
Verify against the raw request body exactly as received. Parsing the JSON
and re-serializing it can change the bytes (key order, whitespace) and cause
verification to fail.
Verify with a library (recommended)
The Standard Webhooks libraries handle
signature construction, Base64 decoding, constant-time comparison, and timestamp
checks for you. Pass the signing secret returned when you created the
subscription.
# pip install standardwebhooks
from standardwebhooks import Webhook
# The secret returned when the subscription was created, e.g. "whsec_..."
secret = "YOUR_WEBHOOK_SIGNING_SECRET"
def handle_request(raw_body: bytes, headers: dict):
wh = Webhook(secret)
# Raises an error if the signature or timestamp is invalid.
payload = wh.verify(raw_body, {
"webhook-id": headers["webhook-id"],
"webhook-timestamp": headers["webhook-timestamp"],
"webhook-signature": headers["webhook-signature"],
})
# payload is the verified, parsed event.
return payload
Verify manually
If you prefer not to add a dependency, you can reproduce the signature yourself
and compare it to the header using a constant-time comparison.
import base64
import hashlib
import hmac
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
webhook_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
signed_content = f"{webhook_id}.{timestamp}.{raw_body.decode('utf-8')}"
key = base64.b64decode(secret.removeprefix("whsec_"))
digest = hmac.new(key, signed_content.encode("utf-8"), hashlib.sha256).digest()
expected = base64.b64encode(digest).decode("utf-8")
# The header may contain multiple space-delimited signatures.
for part in headers["webhook-signature"].split(" "):
version, _, value = part.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return True
return False
Replay protection
The webhook-timestamp header lets you reject stale requests. Compare it to the
current time and discard requests whose timestamp is outside a tolerance window
(for example, more than 5 minutes old). The Standard Webhooks libraries perform
this check for you.
Idempotency
Because deliveries are retried, your
endpoint may receive the same event more than once. The webhook-id header is
constant across retries of the same event, so you can use it as an idempotency
key — record the IDs you’ve already processed and skip duplicates.