Skip to main content

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 http or https address that accepts POST. Readsira refuses localhost and 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:

HeaderValue
content-typeapplication/json
x-readsira-eventThe event name, for example bookmark.created
x-readsira-signaturesha256= 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.

EventSent whendata
bookmark.createdYou save a bookmarkbookmark_id, user_id, url, title, tags, collection_id
bookmark.updatedYou change a bookmarkbookmarkId, userId, bookmark, changes, notesChanged
bookmark.deletedA bookmark is deletedbookmarkId, userId, bookmark
bookmark.visitedYou open a bookmarkbookmarkId, userId, visitedAt
output.createdA project output finishesid, 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 timingSafeEqual does, not with ===.
  • After you rotate the secret in the app, requests signed with the old secret fail the check. Update READSIRA_WEBHOOK_SECRET first.

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 answerWhat Readsira does
2xxThe delivery counts as delivered.
5xx or no answer within 5 secondsThe delivery is tried again, up to 4 attempts in all, with a wait between them that starts at 30 seconds and doubles.
4xxThe 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.