Verifying webhook signatures
Anyone who knows your endpoint URL can send it a request. The signature is how you know that a delivery came from Scribewren and that nobody changed it on the way. Verify it before you parse the body or act on the event.
Scribewren signs webhooks with the Standard Webhooks scheme, so you can use a ready-made library. For the events and payloads themselves, see Webhooks.
What is signed
Section titled “What is signed”Each delivery carries three headers: webhook-id, webhook-timestamp and webhook-signature. The signature covers the exact bytes of the request, in this form:
<webhook-id>.<webhook-timestamp>.<raw request body>- The key is the signing secret of the endpoint. A secret looks like
whsec_followed by a base64 string. Decode the base64 part and use the resulting bytes as the HMAC key. Do not use the text of the secret as the key. - The algorithm is HMAC-SHA256. The result is base64-encoded and prefixed with
v1,. webhook-signatureholds one or more signatures separated by spaces. Accept the delivery if anyv1,entry matches.
Keep the secret out of your code
Section titled “Keep the secret out of your code”Copy the secret from the app when you create the endpoint or rotate its secret. It is shown once. Store it in your secret manager or an environment variable, and never commit it. The samples below read it from SCRIBEWREN_WEBHOOK_SECRET:
export SCRIBEWREN_WEBHOOK_SECRET="whsec_EXAMPLEonlyEXAMPLEonlyEXAMPLEonlyEXAMPLEfak="That value is a placeholder, as long as a real secret so you can see the shape. Use your own secret.
Use a library
Section titled “Use a library”The Standard Webhooks project publishes libraries that do the whole check: the signature, the timestamp window and the case where a header holds several signatures. They throw an error when verification fails. See standardwebhooks on npm and standardwebhooks on PyPI.
These samples follow the documented API of those libraries. We have not run them against a live Scribewren delivery, so test them with the test event described below before you rely on them.
import express from "express";import { Webhook } from "standardwebhooks";
const app = express();const webhook = new Webhook(process.env.SCRIBEWREN_WEBHOOK_SECRET);
// The raw body is needed for the signature, so no express.json() on this route.app.post("/scribewren", express.raw({ type: "application/json" }), (req, res) => { let event; try { event = webhook.verify(req.body.toString("utf8"), req.headers); } catch { console.warn("webhook rejected: invalid signature"); return res.status(400).send("invalid signature"); }
const deliveryId = req.headers["webhook-id"]; // Queue the work and remember deliveryId so a retry does not repeat it. console.log("accepted", deliveryId, event.type); return res.status(204).end();});
app.listen(3000);import os
from flask import Flask, requestfrom standardwebhooks.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)webhook = Webhook(os.environ["SCRIBEWREN_WEBHOOK_SECRET"])
@app.post("/scribewren")def scribewren(): headers = {name.lower(): value for name, value in request.headers.items()} try: event = webhook.verify(request.get_data(), headers) except WebhookVerificationError: app.logger.warning("webhook rejected: invalid signature") return "invalid signature", 400
delivery_id = headers["webhook-id"] # Queue the work and remember delivery_id so a retry does not repeat it. app.logger.info("accepted %s %s", delivery_id, event["type"]) return "", 204Both libraries return the parsed JSON body when the check passes. Answer with a 2xx status quickly and do the real work afterwards, as described in Webhooks.
Verify by hand
Section titled “Verify by hand”If there is no library for your language, follow these steps. Do them in this order.
- Read the raw request body as bytes, and read the three
webhook-*headers. If one is missing, reject the request. - Parse
webhook-timestampas an integer number of seconds. Reject the request when it is more than 5 min away from your clock, in either direction. This blocks replayed deliveries. Keep your server clock synchronized. - Take the secret, remove the
whsec_prefix and decode the rest as base64. The result is the key. - Compute HMAC-SHA256 with that key over the bytes
webhook-id, a dot,webhook-timestamp, a dot and the raw body. Encode the result as base64. - Split
webhook-signatureon spaces. For each entry that starts withv1,, compare the part after the comma with your result using a constant-time comparison. Ignore entries with any other version prefix. - If at least one entry matches, the delivery is genuine. Only now parse the JSON.
In Python, with only the standard library:
import base64import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def is_valid(secret: str, headers: dict, body: bytes) -> bool: try: msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] signatures = headers["webhook-signature"] sent_at = int(timestamp) except (KeyError, ValueError): return False if abs(time.time() - sent_at) > TOLERANCE_SECONDS: return False
key = base64.b64decode(secret.removeprefix("whsec_")) signed = msg_id.encode() + b"." + timestamp.encode() + b"." + body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
matched = False for entry in signatures.split(): version, _, candidate = entry.partition(",") if version == "v1" and hmac.compare_digest(candidate.encode(), expected.encode()): matched = True return matchedRotating the secret
Section titled “Rotating the secret”When you rotate a secret in the app, the old one keeps signing for 24 hours, and every delivery then carries two signatures. A library or the steps above accept the request if either one matches, so you can deploy the new secret at your own pace inside that window. After the window, only the new secret signs. The rotation rules are on the Webhooks page.
Handle duplicates
Section titled “Handle duplicates”Delivery is at least once. webhook-id identifies one delivery: it is the same on every retry of that delivery, and it differs between endpoints, even for the same event. Use it as an idempotency key: claim it when you accept a delivery, and skip a delivery whose ID you already hold. Claim it atomically, with one statement that inserts the ID and tells you whether the row was new; the example is in Webhooks. Keep the IDs for longer than the retry span of about 28 hours.
Test your endpoint
Section titled “Test your endpoint”Send a test event from the app. It is a normal signed delivery with the type webhook.test, so it exercises the same verification code. A 2xx answer marks it as delivered. If it fails, look at the last error in the delivery log.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
| Every delivery fails verification | The body was parsed and serialized again before the check, or a proxy changed it. Verify the raw bytes. |
| Every delivery fails verification | The secret is wrong or belongs to another endpoint. Check for stray spaces or line breaks in the stored value, and make sure you kept the whole string. |
| Deliveries fail only after a while | The old secret expired 24 hours after a rotation. Deploy the new secret. |
| The timestamp check fails | Your server clock is off by more than 5 min. Synchronize it. |
| The library throws on startup | The secret value is not valid base64 after the whsec_ prefix. Copy it again from the app. |
Never write the secret, the payload or the full headers to your logs. The webhook-id is enough to trace a delivery.
Can't find what you need? Contact support, email [email protected], or open Help and then Contact in the app. scribewren.com