> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runpulse.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Have Pulse tell your server when a job reaches a status, with signed deliveries.

A webhook tells your server when a job in your organization reaches a status, so you don't have to poll. Pulse sends your endpoint a signed `POST` that names the job, and your server then reads the job with its API key.

Each delivery follows this pattern:

```mermaid theme={null}
%%{init: {"themeVariables": {"fontSize": "14px"}, "sequence": {"mirrorActors": false, "messageMargin": 28, "height": 44, "boxMargin": 6}}}%%
sequenceDiagram
    participant Pulse
    participant Server as Your server
    Pulse->>Server: parse.completed, signed
    Note over Server: verify it, skip a seen webhook-id
    Server-->>Pulse: 2xx within 5 s, or it's retried
    Server->>Pulse: GET /parse/{parse_id}/result
```

***

## Create a webhook

This request creates a webhook for completed and failed parses. It sends a static header with every delivery, such as a token your gateway checks.

```python Python theme={null}
webhook = client.webhook.create(
    url="https://example.com/pulse/webhook",
    events=["parse.completed", "parse.failed"],
    headers={"X-Gateway-Token": "your-gateway-token"},
)
print(webhook.secret)  # store it now
```

```typescript TypeScript theme={null}
const webhook = await client.webhook.create({
  url: "https://example.com/pulse/webhook",
  events: ["parse.completed", "parse.failed"],
  headers: { "X-Gateway-Token": "your-gateway-token" },
});
console.log(webhook.secret); // store it now
```

```bash cURL theme={null}
curl -X POST https://api.runpulse.com/webhook \
  -H "x-api-key: $PULSE_API_KEY" \
  -H "Pulse-Version: 2" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/pulse/webhook",
    "events": ["parse.completed", "parse.failed"],
    "headers": {"X-Gateway-Token": "your-gateway-token"}
  }'
```

`client` is the one from [SDKs](https://docs.runpulse.com/v2/sdks.md). The response is `201` with the new webhook:

```json theme={null}
{
  "webhook_id": "webhook-5b2a8c1e-7d3f-4a96-b0e4-2c9d1f6a8e37",
  "url": "https://example.com/pulse/webhook",
  "events": ["parse.completed", "parse.failed"],
  "events_from": {
    "parse.completed": "2026-10-16T09:30:15Z",
    "parse.failed": "2026-10-16T09:30:15Z"
  },
  "enabled": true,
  "disabled_reason": null,
  "header_names": ["X-Gateway-Token"],
  "created_at": "2026-10-16T09:30:00Z",
  "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
  "ping": { "delivered": true }
}
```

* **`secret`** signs every delivery. Store it now: only this response and a rotate return it.
* **`url`** must be a public `https` URL on port 443, and it can't change.
* **`events_from`** gives, for each event, the time it started counting: 15 seconds after you added it. Only jobs created after that time send the event. It appears only in responses.
* **`ping`** says whether a test `webhook.ping` reached your endpoint. The first ping waits only 5 seconds, so a `timeout` may only mean your endpoint was slow to start. Retest with `POST /webhook/{webhook_id}/ping`, which waits 15.

***

## Events

An event's name is `<job type>.<status>`, such as `extract.completed`:

| Part | Values |
| - | - |
| Job type | `parse`, `extract`, `split`, `tables`, `charts`, `classify`, `pipeline` |
| Status | `queued`, `processing`, `completed`, `failed`, `canceled` |

A webhook receives only the events in its `events` list, and none if you leave the list out. Sync jobs send events too, but never `queued`. Each job in a [batch](https://docs.runpulse.com/v2/batch.md) or [pipeline](https://docs.runpulse.com/v2/pipelines.md) sends its own endpoint's events, and a pipeline also sends `pipeline` events. There are no batch events.

Each delivery's body names the job, not its result:

```json theme={null}
{
  "event_id": "evt-0f8e2b6c-1a4d-4e7f-9c3b-5d2a8f6e1b90",
  "type": "parse.completed",
  "created_at": "2026-10-16T09:31:12Z",
  "data": { "id": "parse-6f1c3e9a-2b7d-4c58-a1e6-9d4b2f7c0a35", "endpoint": "parse" }
}
```

`created_at` is when the job reached that status. A test ping has the type `webhook.ping`, and its `data` names the webhook.

Each delivery carries these [Standard Webhooks](https://www.standardwebhooks.com/) headers, plus your static headers:

| Header | Value |
| - | - |
| `webhook-id` | The event's `event_id`. It's the same on every retry. |
| `webhook-timestamp` | When this attempt was sent, in Unix seconds. |
| `webhook-signature` | One or more space-separated signatures, each `v1,<base64>`. |
| `pulse-webhook-id` | The webhook's id. |

***

## Verify the signature

Each signature is `v1,` followed by the base64 HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<body>`, keyed with the base64-decoded part of the secret after `whsec_`. Verify the raw body before you parse it, and reject old timestamps. Any [Standard Webhooks library](https://github.com/standard-webhooks/standard-webhooks) does this for you.

This function returns `True` when any of a delivery's signatures matches:

```python Python theme={null}
import base64, hashlib, hmac, time

def verify(secret: str, headers, body: bytes, tolerance: int = 300) -> bool:
    msg_id, timestamp = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = base64.b64encode(hmac.new(key, f"{msg_id}.{timestamp}.".encode() + body, hashlib.sha256).digest()).decode()
    return any(
        hmac.compare_digest(expected, sig.split(",", 1)[1])
        for sig in headers["webhook-signature"].split()
        if sig.startswith("v1,")
    )
```

```typescript TypeScript theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, headers: Record<string, string>, body: Buffer, tolerance = 300): boolean {
  const id = headers["webhook-id"], timestamp = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(body).digest();
  return headers["webhook-signature"].split(" ").some((sig) => {
    const [version, value] = sig.split(",");
    const given = Buffer.from(value ?? "", "base64");
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

***

## Receive deliveries

Return a `2xx` within 5 seconds, and do the work after you respond. This Flask endpoint verifies each delivery, drops retries it has already seen, and queues the rest:

```python theme={null}
import json, os
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["PULSE_WEBHOOK_SECRET"]

@app.post("/pulse/webhook")
def pulse_webhook():
    body = request.get_data()
    if not verify(SECRET, request.headers, body):
        return "", 401
    if already_seen(request.headers["webhook-id"]):
        return "", 204
    event = json.loads(body)
    if event["type"] != "webhook.ping":
        enqueue(event)
    return "", 204
```

`already_seen` and `enqueue` are yours to write. When you handle an event, read the job's current [status](https://docs.runpulse.com/v2/jobs.md) with `client.<endpoint>.get(data.id)` before acting on it. Events can arrive out of order, and if a job's status changes before an event is delivered, Pulse may send only the newer status. If your organization deletes results on first read, read each result once, in one place.

***

## Retries and switch-off

Pulse doesn't follow redirects, so a `3xx` counts as a failure. A failed delivery is retried, with growing delays of up to a day, for about 35 hours.

Pulse turns a webhook off when:

* 10 events in a row can't be delivered after all their retries;
* its deliveries have been failing for 3 days; or
* your endpoint returns `410 Gone`.

The webhook's `enabled` then reads `false`, and its `disabled_reason` says why. To turn it back on, send `PATCH /webhook/{webhook_id}` with `{"enabled": true}`. If it was off for 30 days or more, it then counts only jobs created from 15 seconds after that request.

***

## Rotate the secret

`POST /webhook/{webhook_id}/rotate` returns a new secret, or you can send your own as `secret` in a `PATCH`. For the next 24 hours, each delivery is signed with both the old and the new secret, so you can deploy the new one without missing an event.

***

## Manage webhooks

You can also list, read, change and delete your organization's webhooks. `PATCH` changes a webhook's events, headers, description or secret, or turns it on or off. The [Webhook API reference](https://docs.runpulse.com/api-reference/v2/webhook/create-webhook.md) covers every route, with its hourly limits.

## Limits

| Limit | Value |
| - | - |
| Webhooks per organization | 10 |
| Time to answer a delivery | 5 seconds to respond, 15 seconds in all |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.