Webhooks
Receive signed HTTP callbacks when a document is ingested, a flow finishes or an approval is waiting, and manage endpoints, deliveries and replays through the API.
Webhooks let Subsidia call your server when something happens, so you do not have to poll. You register an HTTPS endpoint and a list of events; each time one of them occurs, Subsidia sends a signed POST with a small JSON body.
Payloads carry identifiers and status, never content. A document's text, an answer or the question an approval asks stay in Subsidia behind the person's session, because a webhook receiver is a third party. Use the identifier to fetch what you need through the API.
How a delivery looks
Each delivery is a POST to your URL with Content-Type: application/json. The body is the event payload itself, with no wrapper object: the event name travels in a header.
| Header | Value |
|---|---|
X-PulseLabs-Event | The event name, for example source.ingested. |
X-PulseLabs-Signature | sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the endpoint secret. |
X-PulseLabs-Delivery | A unique id for this delivery. It stays the same across retries and replays: use it to deduplicate. |
User-Agent | PulseLabs-Webhooks/1.0 |
Respond with any 2xx status within 10 seconds to acknowledge. Any other status, a timeout or a connection error counts as a failure and is retried. Redirects are not followed.
Event catalogue
| Event | Sent when | Payload |
|---|---|---|
source.ingested | A document finished ingestion and is searchable. | sourceId, name, chunkCount, factCount, contradictionCount, qaCount |
source.failed | A document could not be ingested. | sourceId, name, error |
knowledge.contradiction_detected | A new document contradicts a fact already in the knowledge base. | contradictionId, severity, newFact: { id, source }, existingFact: { id, source } |
knowledge.stale | Facts past their validity date were found. | count, facts: [{ id, source, validUntil }] (at most 20 facts) |
debate.synthesized | A multi-agent debate produced its synthesis. | conversationId, synthesis |
flow.run.finished | A Reflex run ended. | runId, flowId, flowName, status, actionable |
flow.approval.requested | A Reflex run is paused on an approval. | runId, flowId, flowName, nodeKey, approvalId |
webhook.test | You called the test endpoint. Delivered to the endpoint you test, whatever its subscriptions. | event, webhookId, message, timestamp |
Two further event names, simulation.tick.completed and simulation.report.ready, are accepted at registration. They belong to a module that is not documented here.
Who receives what. Knowledge and debate events go to the endpoints registered by the user whose action caused them. Reflex events (flow.run.finished, flow.approval.requested) have no single author, since a scheduled flow can finish at 4 am, so they go to every active endpoint of the workspace that subscribed.
{ "sourceId": "src_8f3a1c", "name": "Bail commercial 2024.pdf", "chunkCount": 42, "factCount": 17, "contradictionCount": 1, "qaCount": 8}Verify the signature
Anyone who knows your URL can post to it, so check the signature before acting on a payload. Compute the HMAC-SHA256 of the exact bytes received (not of a re-serialised JSON object) with the endpoint secret, prefix it with sha256=, and compare in constant time.
import crypto from 'node:crypto'import express from 'express'
const app = express()const seen = new Set<string>() // use a database or Redis in production
function isValid(rawBody: Buffer, header: string | undefined, secret: string): boolean { if (!header) return false const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex') const a = Buffer.from(expected) const b = Buffer.from(header) return a.length === b.length && crypto.timingSafeEqual(a, b)}
// express.raw keeps the body as received: do not use express.json() on this routeapp.post('/hooks/subsidia', express.raw({ type: '*/*' }), (req, res) => { if (!isValid(req.body, req.header('x-pulselabs-signature'), process.env.WEBHOOK_SECRET!)) { return res.status(401).send('bad signature') } const deliveryId = req.header('x-pulselabs-delivery')! if (seen.has(deliveryId)) return res.status(200).send('duplicate') seen.add(deliveryId)
const event = req.header('x-pulselabs-event') const payload = JSON.parse(req.body.toString('utf8')) console.log(event, payload) res.status(200).send('ok')})Retries, delivery log and idempotency
A delivery is first attempted three times in a row (immediately, after 1 second, after 5 seconds). If all three fail, Subsidia retries later on a widening schedule: after 5 minutes, 30 minutes, 2 hours and 12 hours. After the last retry the delivery stays in the log as failed. A retry sweep runs every minute, and only endpoints that are still active are retried.
Every attempt is recorded. GET /v1/webhooks/:id/deliveries returns the latest 50, with the status code your server answered, the beginning of its response body, the retry count and the time of the next retry. You can send any stored delivery again with the replay endpoint.
Delivery is at least once. A retry or a replay sends the same body with the same X-PulseLabs-Delivery id, so a receiver that stores the ids it has handled can drop repeats. Answer 2xx quickly and do the work afterwards; a slow handler that times out will be retried while it is still running.
Managing endpoints
Endpoints can be managed in the Subsidia console and through the API below. The API needs a key with the webhooks scope (keys created before scopes existed also work). A read-only key can list and read but not create, change, delete, test or replay. Endpoints are private to the user who created them within a workspace.
An endpoint URL must be a valid http or https URL with no credentials in it. In the cloud it must resolve to a public address (private ranges, loopback and internal names are refused). An on-premise installation also reaches its own private network, but never link-local addresses. Use HTTPS in production.
POST/v1/webhooks
Register a webhook endpoint.
webhooksCreates the endpoint and returns its signing secret once. Store it immediately: later reads only show a masked preview, and there is no route to read it again.
Request body
urlstringrequiredWhere deliveries are posted.eventsstring[]requiredAt least one event name from the catalogue above.descriptionstringA label for your own use.
Request examples
curl -X POST "https://api.subsidia.protypa.fr/v1/webhooks" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -H "Content-Type: application/json" \n -d '{ "url": "https://example.com/hooks/subsidia", "events": [ "source.ingested", "source.failed", "flow.approval.requested" ], "description": "Back office"}'Responses
The endpoint, with its secret (starts with whsec_).
{ "webhook": { "id": "whk_3e5b", "url": "https://example.com/hooks/subsidia", "events": ["source.ingested", "source.failed", "flow.approval.requested"], "description": "Back office", "isActive": true, "secret": "whsec_9d2c...e41f", "createdAt": "2026-10-09T08:20:00.000Z", "updatedAt": "2026-10-09T08:20:00.000Z" }}Errors
- 400
urloreventsmissing or empty, an unknown event name, an invalid URL, or a URL the server refuses to call. - 401Missing or invalid key.
- 403
scope_deniedThe key does not carry thewebhooksscope. - 403
read_only_keyThe key is read-only.
GET/v1/webhooks
List your webhook endpoints.
webhooksNewest first. The secret is never returned, only a masked secretPreview.
Request examples
curl "https://api.subsidia.protypa.fr/v1/webhooks" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY"Responses
The endpoints.
{ "webhooks": [ { "id": "whk_3e5b", "url": "https://example.com/hooks/subsidia", "events": ["source.ingested", "source.failed"], "description": "Back office", "isActive": true, "secretPreview": "whsec_9d2c...e41f", "createdAt": "2026-10-09T08:20:00.000Z", "updatedAt": "2026-10-09T08:20:00.000Z" } ]}GET/v1/webhooks/:id
Get one webhook endpoint.
webhooksPath parameters
idstringrequiredThe endpoint id.
Request examples
curl "https://api.subsidia.protypa.fr/v1/webhooks/$ID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY"Responses
The endpoint, in the shape of the list items.
PUT/v1/webhooks/:id
Change a webhook endpoint.
webhooksSend only the fields to change. Set isActive to false to pause deliveries without losing the endpoint or its secret. The secret cannot be rotated: to change it, create a new endpoint and delete the old one.
Path parameters
idstringrequiredThe endpoint id.
Request body
urlstringNew destination, checked like at creation.eventsstring[]Replaces the whole subscription list.descriptionstringNew label.isActivebooleanPause or resume deliveries.
Request examples
curl -X PUT "https://api.subsidia.protypa.fr/v1/webhooks/$ID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n -H "Content-Type: application/json" \n -d '{ "isActive": false}'Responses
The updated endpoint.
DELETE/v1/webhooks/:id
Delete a webhook endpoint and its delivery log.
webhooksPath parameters
idstringrequiredThe endpoint id.
Request examples
curl -X DELETE "https://api.subsidia.protypa.fr/v1/webhooks/$ID" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY"Responses
Deleted. The body is empty.
POST/v1/webhooks/:id/test
Send a test event to an endpoint.
webhooksPosts a signed webhook.test event to the endpoint, whatever its subscriptions, and records it in the delivery log. A single attempt, no retry. Use it to check your signature code end to end.
Path parameters
idstringrequiredThe endpoint id.
Request examples
curl -X POST "https://api.subsidia.protypa.fr/v1/webhooks/$ID/test" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY"Responses
success is true when your server answered 2xx. statusCode is null if it could not be reached.
{ "success": true, "statusCode": 200 }GET/v1/webhooks/:id/deliveries
Delivery log: the latest 50 attempts.
webhooksUse it to find out why your receiver is not getting events: the status code and the first part of your server's response body are kept.
Path parameters
idstringrequiredThe endpoint id.
Request examples
curl "https://api.subsidia.protypa.fr/v1/webhooks/$ID/deliveries" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY"Responses
Newest first. nextRetryAt is null once the delivery succeeded or all retries were used.
{ "deliveries": [ { "id": "d1f0c9a2b3", "event": "source.ingested", "statusCode": 503, "responseBody": "Service Unavailable", "success": false, "retryCount": 2, "deliveredAt": null, "nextRetryAt": "2026-10-09T08:35:07.000Z", "createdAt": "2026-10-09T08:30:02.000Z" } ]}POST/v1/webhooks/:id/deliveries/:deliveryId/replay
Send one stored delivery again, now.
webhooksRe-sends the stored payload with the same X-PulseLabs-Delivery id, so a receiver that deduplicates sees a repeat, and cancels any pending automatic retry for it.
Path parameters
idstringrequiredThe endpoint id.deliveryIdstringrequiredA deliveryidfrom the log.
Request examples
curl -X POST "https://api.subsidia.protypa.fr/v1/webhooks/$ID/deliveries/$DELIVERYID/replay" \n -H "Authorization: Bearer $SUBSIDIA_API_KEY"Responses
The outcome of the new attempt.
{ "success": true, "statusCode": 200 }Frequently asked
Why is there no event name in the body?
The body is the payload only. Read the event from the X-PulseLabs-Event header. The one exception is webhook.test, whose payload repeats it.
Are deliveries ordered?
No. Events are sent as they happen and retries arrive later, so an old event can land after a newer one. Use the identifiers in the payload and fetch the current state if order matters.
My receiver was down for a day. Did I lose events?
Possibly. Retries stop after about 14 hours (5 minutes, 30 minutes, 2 hours, 12 hours after the first round). Read the delivery log, then replay what is still marked failed, or reconcile through the API.
Can I send document content in a webhook?
No, by design. Fetch it with the identifier from the payload, using an API key that has the right scope.