Webhooks for developers
Available onFreeProResearch
A webhook makes Readsira send an HTTP POST to your server when something happens in your account,
for example when you save a bookmark. This page is for the code that receives it: the events, the
payload, the signature and the retry rules. To add a webhook in the app, see
Webhooks.
Before you start
- A public
httporhttpsaddress that acceptsPOST. Readsira refuseslocalhostand private network addresses, and it does not follow redirects. - Your server must read the raw request body. You need the exact bytes to check the signature.
What a delivery looks like
Every delivery is a POST with a JSON body and three headers:
| Header | Value |
|---|---|
content-type | application/json |
x-readsira-event | The event name, for example bookmark.created |
x-readsira-signature | sha256= followed by the HMAC of the body, in hex |
The body is an envelope:
{
"event": "bookmark.created",
"timestamp": "2026-11-04T09:15:02.481Z",
"data": {
"bookmark_id": "c71d0e3a-5b1c-4c58-9d6f-0f6c0d2a9e11",
"user_id": "7b0e1f42-2a43-4f0b-8f0e-1d2c3b4a5f60",
"url": "https://example.com/grid-lab",
"title": "Grid Lab: long-duration storage",
"tags": ["energy"],
"collection_id": "a2c4e6f8-1b3d-4f5a-8c7e-9d0b1a2c3d4e"
}
}
timestamp is when Readsira built the delivery. The values above are an example.
Events
You choose the events for each webhook. A new webhook gets bookmark.created.
| Event | Sent when | data |
|---|---|---|
bookmark.created | You save a bookmark | bookmark_id, user_id, url, title, tags, collection_id |
bookmark.updated | You change a bookmark | bookmarkId, userId, bookmark, changes, notesChanged |
bookmark.deleted | A bookmark is deleted | bookmarkId, userId, bookmark |
bookmark.visited | You open a bookmark | bookmarkId, userId, visitedAt |
output.created | A project output finishes | id, projectId, kind, title, version, sourceCount, completedAt |
The app also lists collection.created, collection.updated and collection.deleted. Readsira does
not send these yet, so a webhook that only listens to them receives nothing.
In bookmark.updated and bookmark.deleted, bookmark is a snapshot with id, url, title,
description, collectionId, tags, isStarred, isPublic, domain, notes, triageStatus
and captureIntent. changes lists the names of the fields the request set. For output.created
the payload carries ids and metadata only, never the text of the output; read it through the app.
The Test button in the app sends one more event, webhook.test, with
data: { "message": "Test webhook from Readsira", "userId": "..." }. It is signed like the others.
Webhooks pause while your account is scheduled for deletion.
Check the signature
The signature proves the request came from Readsira and that nobody changed the body. Readsira
computes HMAC-SHA256 over the raw body, with the webhook's secret as the key, and sends the result
as sha256= plus 64 hex characters.
The secret is the 64 character hex string the app shows when you create the webhook or rotate its secret. Use that string as it is, as text. Do not decode it from hex. It is shown once.
A receiver in Node, with no packages:
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const SECRET = process.env.READSIRA_WEBHOOK_SECRET;
function isFromReadsira(rawBody, signatureHeader) {
const expected = 'sha256=' + createHmac('sha256', SECRET).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}
createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (!isFromReadsira(rawBody, req.headers['x-readsira-signature'])) {
res.writeHead(401).end();
return;
}
const { event, timestamp, data } = JSON.parse(rawBody.toString('utf8'));
console.log(event, timestamp, data);
res.writeHead(204).end();
});
}).listen(3000);
Three details that cause most failures:
- Hash the raw bytes. If your framework parses the JSON first and you serialize it again, the bytes
differ and the check fails. In Express, use
express.raw({ type: 'application/json' })for the webhook route. - Compare in constant time, as
timingSafeEqualdoes, not with===. - After you rotate the secret in the app, requests signed with the old secret fail the check. Update
READSIRA_WEBHOOK_SECRETfirst.
To reject replays, compare timestamp with the clock and drop deliveries that are older than a few
minutes. The timestamp is inside the signed body, so it cannot be changed without breaking the
signature. A retry (below) carries the original event again, so make your handler safe to run twice.
Reply quickly
Answer with any 2xx status as soon as you have stored the event. Readsira waits 5 seconds and
reads at most 64 KB of your reply.
| Your answer | What Readsira does |
|---|---|
2xx | The delivery counts as delivered. |
5xx or no answer within 5 seconds | The delivery is tried again, up to 4 attempts in all, with a wait between them that starts at 30 seconds and doubles. |
4xx | The delivery counts as failed and is not tried again. |
The app shows for each webhook how many times it fired and when it fired last.
Tips
- Keep the handler short. Put the work on a queue and answer first.
- Use the Test button while you build, before you save a real bookmark.
- Store the secret like a password. Rotate it if it leaks.
Related
- Webhooks (set up in the app)
- Examples
- Rate limits and errors