Skip to content

Ask AI

Ask a question about Scribewren, such as uploading a file, editing a transcript, plans or the API.

Ctrl or Cmd+Enter to send

Answers are written by AI and can be wrong. For account or billing help, use the chat or contact us. Contact support (opens in a new tab)

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.

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-signature holds one or more signatures separated by spaces. Accept the delivery if any v1, entry matches.

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:

Terminal window
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.

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);

Both 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.

If there is no library for your language, follow these steps. Do them in this order.

  1. Read the raw request body as bytes, and read the three webhook-* headers. If one is missing, reject the request.
  2. Parse webhook-timestamp as 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.
  3. Take the secret, remove the whsec_ prefix and decode the rest as base64. The result is the key.
  4. 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.
  5. Split webhook-signature on spaces. For each entry that starts with v1,, compare the part after the comma with your result using a constant-time comparison. Ignore entries with any other version prefix.
  6. If at least one entry matches, the delivery is genuine. Only now parse the JSON.

In Python, with only the standard library:

import base64
import hashlib
import hmac
import 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 matched

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.

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.

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.

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