Skip to content

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.

HeaderValue
X-PulseLabs-EventThe event name, for example source.ingested.
X-PulseLabs-Signaturesha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the endpoint secret.
X-PulseLabs-DeliveryA unique id for this delivery. It stays the same across retries and replays: use it to deduplicate.
User-AgentPulseLabs-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

EventSent whenPayload
source.ingestedA document finished ingestion and is searchable.sourceId, name, chunkCount, factCount, contradictionCount, qaCount
source.failedA document could not be ingested.sourceId, name, error
knowledge.contradiction_detectedA new document contradicts a fact already in the knowledge base.contradictionId, severity, newFact: { id, source }, existingFact: { id, source }
knowledge.staleFacts past their validity date were found.count, facts: [{ id, source, validUntil }] (at most 20 facts)
debate.synthesizedA multi-agent debate produced its synthesis.conversationId, synthesis
flow.run.finishedA Reflex run ended.runId, flowId, flowName, status, actionable
flow.approval.requestedA Reflex run is paused on an approval.runId, flowId, flowName, nodeKey, approvalId
webhook.testYou 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.

Example bodies
{
"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.

Receiver with signature check and deduplication
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 route
app.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.

API key (Bearer or x-api-key)webhooks

Creates 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

  • urlstringrequired
    Where deliveries are posted.
  • eventsstring[]required
    At least one event name from the catalogue above.
  • descriptionstring
    A 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_).

Example response
{
"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

  • 400url or events missing or empty, an unknown event name, an invalid URL, or a URL the server refuses to call.
  • 401Missing or invalid key.
  • 403scope_deniedThe key does not carry the webhooks scope.
  • 403read_only_keyThe key is read-only.

GET/v1/webhooks

List your webhook endpoints.

API keywebhooks

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

Example response
{
"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.

API keywebhooks

Path parameters

  • idstringrequired
    The 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.

API keywebhooks

Send 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

  • idstringrequired
    The endpoint id.

Request body

  • urlstring
    New destination, checked like at creation.
  • eventsstring[]
    Replaces the whole subscription list.
  • descriptionstring
    New label.
  • isActiveboolean
    Pause 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.

API keywebhooks

Path parameters

  • idstringrequired
    The 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.

API keywebhooks

Posts 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

  • idstringrequired
    The 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.

Example response
{ "success": true, "statusCode": 200 }

GET/v1/webhooks/:id/deliveries

Delivery log: the latest 50 attempts.

API keywebhooks

Use 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

  • idstringrequired
    The 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.

Example response
{
"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.

API keywebhooks

Re-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

  • idstringrequired
    The endpoint id.
  • deliveryIdstringrequired
    A delivery id from 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.

Example response
{ "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.

Related