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

> **INFO: Header names**
> The headers still carry the former product prefix (`X-PulseLabs-...`). They are stable; do not look for `X-Subsidia-...`.

## 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](https://dev.subsidia.protypa.fr/docs/reflex.md) 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.

**Example bodies**

_source.ingested_

```json
{
  "sourceId": "src_8f3a1c",
  "name": "Bail commercial 2024.pdf",
  "chunkCount": 42,
  "factCount": 17,
  "contradictionCount": 1,
  "qaCount": 8
}
```

_knowledge.contradiction_detected_

```json
{
  "contradictionId": "ctr_41b9",
  "severity": "high",
  "newFact": { "id": "fct_203", "source": "Avenant 2026.pdf" },
  "existingFact": { "id": "fct_077", "source": "Bail commercial 2024.pdf" }
}
```

_flow.run.finished_

```json
{
  "runId": "run_5d20",
  "flowId": "flw_7a11",
  "flowName": "Relance des factures",
  "status": "succeeded",
  "actionable": true
}
```

_flow.approval.requested_

```json
{
  "runId": "run_5d20",
  "flowId": "flw_7a11",
  "flowName": "Relance des factures",
  "nodeKey": "send_mail",
  "approvalId": "apr_9c02"
}
```

_webhook.test_

```json
{
  "event": "webhook.test",
  "webhookId": "whk_3e5b",
  "message": "This is a test delivery from Subsidia",
  "timestamp": "2026-10-09T08:30:00.000Z"
}
```

## 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**

_Node.js (Express)_

```typescript
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')
})
```

_Python (Flask)_

```python
import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SECRET"].encode()
seen = set()  # use a database or Redis in production


def is_valid(raw_body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")


@app.post("/hooks/subsidia")
def receive():
    # request.get_data() is the raw body; do not rebuild it from request.json
    if not is_valid(request.get_data(), request.headers.get("X-PulseLabs-Signature")):
        abort(401)
    delivery_id = request.headers["X-PulseLabs-Delivery"]
    if delivery_id in seen:
        return "duplicate", 200
    seen.add(delivery_id)

    event = request.headers["X-PulseLabs-Event"]
    payload = request.get_json()
    print(event, payload)
    return "ok", 200
```

_Shell (debugging)_

```bash
# Recompute the signature of a body you saved to body.json
printf '%s' "$(cat body.json)" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET"
# Compare the hex digest with the part after "sha256=" in X-PulseLabs-Signature
```

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

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.

- **Authentication:** API key (Bearer or x-api-key)
- **Scopes:** `webhooks`

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `url` | `string` | yes |  | Where deliveries are posted. |
| `events` | `string[]` | yes |  | At least one event name from the catalogue above. |
| `description` | `string` | no |  | A label for your own use. |

#### Request examples

_curl_

```bash
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"
}'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "url": "https://example.com/hooks/subsidia",
    "events": [
      "source.ingested",
      "source.failed",
      "flow.approval.requested"
    ],
    "description": "Back office"
  }),
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr" + "/v1/webhooks",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={
      "url": "https://example.com/hooks/subsidia",
      "events": [
        "source.ingested",
        "source.failed",
        "flow.approval.requested"
      ],
      "description": "Back office"
    },
)
print(res.status_code, res.json())
```

#### Responses

**201**: The endpoint, with its `secret` (starts with `whsec_`).

```json
{
  "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"
  }
}
```

**400**: A validation error. For unknown events the body also lists `validEvents`.

```json
{ "error": "Invalid event types: invoice.paid", "validEvents": ["debate.synthesized", "source.ingested"] }
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 400 |  | `url` or `events` missing or empty, an unknown event name, an invalid URL, or a URL the server refuses to call. |
| 401 |  | Missing or invalid key. |
| 403 | `scope_denied` | The key does not carry the `webhooks` scope. |
| 403 | `read_only_key` | The key is read-only. |

### GET /v1/webhooks

List your webhook endpoints.

Newest first. The secret is never returned, only a masked `secretPreview`.

- **Authentication:** API key
- **Scopes:** `webhooks`

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/v1/webhooks" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/v1/webhooks",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The endpoints.

```json
{
  "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.

- **Authentication:** API key
- **Scopes:** `webhooks`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The endpoint id. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/v1/webhooks/$ID" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks/' + id, {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/v1/webhooks/" + id,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The endpoint, in the shape of the list items.

**404**: Unknown id, or the endpoint belongs to another user.

```json
{ "error": "Webhook not found" }
```

### PUT /v1/webhooks/:id

Change a webhook endpoint.

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.

- **Authentication:** API key
- **Scopes:** `webhooks`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The endpoint id. |

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `url` | `string` | no |  | New destination, checked like at creation. |
| `events` | `string[]` | no |  | Replaces the whole subscription list. |
| `description` | `string` | no |  | New label. |
| `isActive` | `boolean` | no |  | Pause or resume deliveries. |

#### Request examples

_curl_

```bash
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
}'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks/' + id, {
  method: 'PUT',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "isActive": false
  }),
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.put(
    "https://api.subsidia.protypa.fr" + "/v1/webhooks/" + id,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={
      "isActive": False
    },
)
print(res.status_code, res.json())
```

#### Responses

**200**: The updated endpoint.

**400**: Invalid URL or unknown event name.

**404**: Unknown id.

### DELETE /v1/webhooks/:id

Delete a webhook endpoint and its delivery log.

- **Authentication:** API key
- **Scopes:** `webhooks`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The endpoint id. |

#### Request examples

_curl_

```bash
curl -X DELETE "https://api.subsidia.protypa.fr/v1/webhooks/$ID" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks/' + id, {
  method: 'DELETE',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
console.log(res.status)
```

_Python_

```python
import os, requests

res = requests.delete(
    "https://api.subsidia.protypa.fr" + "/v1/webhooks/" + id,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code)
```

#### Responses

**204**: Deleted. The body is empty.

**404**: Unknown id.

### POST /v1/webhooks/:id/test

Send a test event to an endpoint.

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.

- **Authentication:** API key
- **Scopes:** `webhooks`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The endpoint id. |

#### Request examples

_curl_

```bash
curl -X POST "https://api.subsidia.protypa.fr/v1/webhooks/$ID/test" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks/' + id + '/test', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr" + "/v1/webhooks/" + id + "/test",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: `success` is true when your server answered `2xx`. `statusCode` is `null` if it could not be reached.

```json
{ "success": true, "statusCode": 200 }
```

**404**: Unknown id.

### GET /v1/webhooks/:id/deliveries

Delivery log: the latest 50 attempts.

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.

- **Authentication:** API key
- **Scopes:** `webhooks`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The endpoint id. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/v1/webhooks/$ID/deliveries" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks/' + id + '/deliveries', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/v1/webhooks/" + id + "/deliveries",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: Newest first. `nextRetryAt` is `null` once the delivery succeeded or all retries were used.

```json
{
  "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"
    }
  ]
}
```

**404**: Unknown endpoint id.

### POST /v1/webhooks/:id/deliveries/:deliveryId/replay

Send one stored delivery again, now.

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.

- **Authentication:** API key
- **Scopes:** `webhooks`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The endpoint id. |
| `deliveryId` | `string` | yes |  | A delivery `id` from the log. |

#### Request examples

_curl_

```bash
curl -X POST "https://api.subsidia.protypa.fr/v1/webhooks/$ID/deliveries/$DELIVERYID/replay" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/webhooks/' + id + '/deliveries/' + deliveryId + '/replay', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr" + "/v1/webhooks/" + id + "/deliveries/" + deliveryId + "/replay",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The outcome of the new attempt.

```json
{ "success": true, "statusCode": 200 }
```

**404**: Unknown endpoint (`Webhook not found`) or delivery (`Delivery not found`).

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