Receive and Verify a Delivery
Check the signature of each Risicare webhook delivery in your receiver, with the Python and JavaScript helpers.
Risicare signs each request that it sends to a webhook with the secret of the webhook. Your receiver checks the signature before it reads the body. A request with a wrong signature does not come from Risicare, or it is an old request sent again. You add a webhook and get its secret on the Webhooks page.
The headers
Each request carries two headers that the check reads.
| Header | Value |
|---|---|
X-Risicare-Signature | t=<unix seconds>,v1=<hex> |
X-Risicare-Timestamp | The same unix seconds as t |
The signature is an HMAC-SHA256 in hex. It is made over the text <timestamp>. followed by the raw bytes of the body. The key is the secret of the webhook. The only scheme is v1. A header may hold more name=value parts, and the helpers ignore them.
A check has three steps, and all must pass:
- Both headers are present and well formed, and the two timestamps are equal.
- The timestamp is within 300 seconds of the time now. This stops a captured request from being sent again later.
- The signature that you compute equals the signature in the header. The helpers compare them in constant time.
Pass the raw body, not parsed JSON
The signature covers the exact bytes that Risicare sent. If you parse the JSON and write it out again, the key order and the spaces can change, and the check fails. Read the raw body first, check it, then parse it.
What the body holds
The body of a prompt event is JSON. A prompt event carries names, hashes, numbers, links, and the note and reason written with the change. It never carries prompt text.
These keys are in the body of a prompt event.
| Key | Meaning |
|---|---|
event_id | The id of the event: the label event or the version that caused it. A test event gets a new id. Risicare may send an event more than once, so drop a repeat by this id |
is_test | true in a test event. A test event has the same keys as a real event, made-up values, no link and no prompt text |
This page does not list the other keys of a prompt event or the body of the other events.
Python
from risicare import verify_webhook_signature, WebhookVerificationError
verify_webhook_signature(payload, headers, secret, *, tolerance_s=300) # returns None| Argument | What it takes |
|---|---|
payload | The raw body. bytes, bytearray, memoryview or str. A str is encoded as UTF-8 |
headers | Any headers object with a case-insensitive .get(name), such as the headers of FastAPI, Flask, httpx or aiohttp. A plain dict works if its keys are in the case X-Risicare-Signature or all lower case |
secret | The signing secret of the webhook, as a str |
tolerance_s | The most seconds that the timestamp may differ from the time now. The default is 300 |
The function returns None when the delivery is good. It raises WebhookVerificationError when a header is missing, the signature is malformed, the two timestamps differ, the timestamp is outside the tolerance, the signature does not match, or payload has another type. The message is safe to write to a log. It never holds the secret or the expected signature. The error is a subclass of Exception, not of ValueError. The function uses only the Python standard library. The Python SDK exports it: from risicare import verify_webhook_signature. See the Python SDK reference.
JavaScript
import { verifyWebhookSignature, WebhookVerificationError } from 'risicare';
verifyWebhookSignature(payload, headers, secret, opts?): voidverifyWebhookSignature is an export of the package root. There is no risicare/webhooks subpath. The package also exports DEFAULT_TIMESTAMP_TOLERANCE_S (300).
| Argument | What it takes |
|---|---|
payload | The raw body. Uint8Array (a Buffer is one), ArrayBuffer or string. A string is encoded as UTF-8 |
headers | A Headers object, a Map<string, string>, or a plain object such as req.headers of Node. The name is tried as X-Risicare-Signature, then in lower case |
secret | The signing secret of the webhook, as a string |
opts.toleranceS | The most seconds that the timestamp may differ from the time now. The default is 300 |
The function returns nothing when the delivery is good. It throws WebhookVerificationError for the same causes as the Python function. Catch that class and answer 401, and let other errors pass. The function runs on Node 18 or later and uses node:crypto, so it does not run on edge runtimes. It ships in risicare 0.4.0 and later. See Webhook Verification in the JavaScript SDK reference.
import express from 'express';
import { verifyWebhookSignature, WebhookVerificationError } from 'risicare';
const app = express();
// express.raw gives the raw bytes. express.json would parse them first.
app.post('/risicare-webhook', express.raw({ type: '*/*' }), (req, res) => {
try {
verifyWebhookSignature(req.body, req.headers, process.env.WEBHOOK_SECRET);
} catch (e) {
if (e instanceof WebhookVerificationError) {
return res.status(401).send(e.message);
}
throw e;
}
const event = JSON.parse(req.body.toString('utf8'));
// For a prompt event: drop a repeat by skipping an event whose event.event_id you stored.
res.status(200).end();
});Test your receiver
- Add a webhook with the URL of your receiver and one of
prompt.label_moved,prompt.version_createdorprompt.promotion_requested. Copy the secret into your receiver. - Click Send a test event on the row of the webhook.
- The page reads "Test event sent to" the webhook and "The receiver took it." when your receiver takes the delivery.
A test event is signed like a real event, so it tests the check as well. A URL that points to a private or internal address is refused. See Webhooks.