# Webhooks (https://docs.pulsarml.com/webhooks)

Get notified the moment a machine stop is classified, reclassified or unclassified in Pulsar.



Instead of polling the API, you can have Pulsar call your application when something happens. A webhook is an HTTPS endpoint you host. Pulsar sends it a `POST` request with a JSON body every time an event you subscribed to occurs.

Webhooks currently cover [machine stop classifications](/webhooks/events): Pulsar notifies you when a stop is classified, when a classification changes, and when one is deleted.

## Set up a webhook [#set-up-a-webhook]

<Steps>
  <Step>
    ### Build an endpoint [#build-an-endpoint]

    Add a route to your application that accepts a `POST` with a JSON body and answers with a `2XX` status. It has to meet the [endpoint requirements](#endpoint-requirements) below.
  </Step>

  <Step>
    ### Ask Pulsar to register it [#ask-pulsar-to-register-it]

    Send your Pulsar contact:

    * **Name**: a label for the endpoint, such as `ERP`.
    * **URL**: the full HTTPS URL of your endpoint.
    * **Event type**: the [event](/webhooks/events) it should receive.

    Each registration receives one event type. To receive several, register the same URL once per event type, and branch on `eventType` in your handler.
  </Step>

  <Step>
    ### Store your signing secret [#store-your-signing-secret]

    Pulsar gives you a **signing secret**. Every request Pulsar sends is signed with it, so you can [verify it really came from Pulsar](/webhooks/signatures). Keep it out of source control, like any other credential. There is one secret per company, shared by all your endpoints.
  </Step>

  <Step>
    ### Send a test ping [#send-a-test-ping]

    Ask Pulsar to ping your endpoint. You'll receive a [`Ping` event](#ping-events), which confirms that your endpoint is reachable and your signature check passes, before real events start arriving.
  </Step>
</Steps>

## Endpoint requirements [#endpoint-requirements]

Pulsar only delivers to endpoints that are:

* **Public HTTPS.** The URL must use `https://`, be at most 255 characters, and contain no credentials (`user:pass@`) or `#fragment`. The hostname must resolve to public IP addresses. Private, loopback and link-local addresses are refused.
* **Fast.** Each request has a **10-second** limit, including DNS lookup and connection setup.
* **Final.** Redirects are **not followed**. A `3XX` response counts as a failed delivery, so register the final URL.

## The request [#the-request]

Every notification is a `POST` with a JSON body and these headers:

```http
POST /webhooks/pulsar HTTP/1.1
Host: example.com
Content-Type: application/json
X-Pulsar-Signature: 5c0e2f4a1b9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f
X-Pulsar-Event-Type: MachineStopClassificationCreated
X-Pulsar-Event-Id: 8d2f6c1e-4b7a-4f0e-9a3c-5e1d7b9f2a64
X-Pulsar-Timestamp: 2026-09-25T14:03:12.482Z
X-Pulsar-Request-Id: 3f9b2c7d-1e4a-4c8b-b6d2-9a0e5f7c1b38

{
  "eventId": "8d2f6c1e-4b7a-4f0e-9a3c-5e1d7b9f2a64",
  "eventTimestamp": "2026-09-25T14:03:12.482Z",
  "eventType": "MachineStopClassificationCreated",
  "event": { ... }
}
```

### Headers [#headers]

| Header                | Description                                                                                           |
| --------------------- | ----------------------------------------------------------------------------------------------------- |
| `Content-Type`        | Always `application/json`.                                                                            |
| `X-Pulsar-Signature`  | HMAC-SHA256 signature of the raw body. [Verify it](/webhooks/signatures) before trusting the request. |
| `X-Pulsar-Event-Type` | The event type, same as `eventType` in the body.                                                      |
| `X-Pulsar-Event-Id`   | The event's ID, same as `eventId` in the body.                                                        |
| `X-Pulsar-Timestamp`  | Same as `eventTimestamp` in the body.                                                                 |
| `X-Pulsar-Request-Id` | The ID of this delivery attempt. Include it when you contact Pulsar support about a delivery.         |

Header names are case-insensitive, so your framework may show them lowercased.

### Body [#body]

Every notification has the same envelope:

| Field            | Type   | Description                                                                                                     |
| ---------------- | ------ | --------------------------------------------------------------------------------------------------------------- |
| `eventId`        | string | Unique ID of the event. It stays **the same across retries**, so use it to detect duplicates.                   |
| `eventTimestamp` | string | When Pulsar queued the event, as an ISO 8601 UTC timestamp. This is not the time the change happened in Pulsar. |
| `eventType`      | string | The [event type](/webhooks/events), for example `MachineStopClassificationCreated`.                             |
| `event`          | object | The event's data. Its shape depends on `eventType`; see [Events](/webhooks/events).                             |

## Respond with a 2XX, quickly [#respond-with-a-2xx-quickly]

Return a `2XX` status as soon as you've [verified the signature](/webhooks/signatures). Pulsar reads only the status code and ignores the response body.

* Return `200` if you've already handled the event.
* Return `202` if you've queued it for later.

Do anything slow, like calling other systems or heavy database work, in a background job after responding. A response that takes longer than 10 seconds counts as a failure and is retried, even if your code eventually finishes.

## Retries [#retries]

What Pulsar does with a failed delivery depends on why it failed:

| Your endpoint...                                              | Pulsar...                                                                                                                                                      |
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Returns `2XX`                                                 | Marks the delivery as done.                                                                                                                                    |
| Times out, can't be reached, or returns `408`, `429` or `5XX` | Makes up to 3 attempts in quick succession, with exponential backoff, honoring a short `Retry-After` header. If all 3 fail, it tries the delivery again later. |
| Returns any other `4XX`, or a `3XX` redirect                  | **Does not retry.** The delivery is recorded as failed, and Pulsar can replay it once your endpoint is fixed.                                                  |

<Callout type="warn" title="A 4XX response stops retries">
  Pulsar treats `400`, `401`, `403`, `404` and most other `4XX` codes as your endpoint deliberately rejecting the event, so it doesn't retry them. If a bug in your handler returns a `4XX` for valid events, those events wait until Pulsar replays them. Use `5XX` for temporary problems on your side.
</Callout>

A failing endpoint doesn't hold up deliveries to your other endpoints.

## Duplicates and ordering [#duplicates-and-ordering]

Delivery is **at least once**. Occasionally the same event reaches your endpoint more than once, for example when your endpoint processed a request but Pulsar didn't record the response in time. A retried event also arrives later than events that went through on the first try, so order isn't guaranteed.

Make your handler safe to run twice: store each `eventId` you've processed, and skip one you've already seen. A retry or a replay always carries the original `eventId`.

## Ping events [#ping-events]

A ping tests your endpoint without waiting for a real event. It uses the same headers and signature as any other notification, has `eventType` set to `Ping`, and its `event` is always `{ "text": "ping" }`:

```json
{
  "eventId": "0b6f3d2a-9c1e-4a7b-8f5d-2e4c6a8b0d1f",
  "eventTimestamp": "2026-09-25T14:00:00.000Z",
  "eventType": "Ping",
  "event": {
    "text": "ping"
  }
}
```

A ping can reach an endpoint whatever event type it's registered for, so reply `2XX` to `Ping` rather than rejecting event types you didn't expect.

## Next steps [#next-steps]

<Cards>
  <Card title="Verify signatures" href="/webhooks/signatures" description="Check every request really came from Pulsar." />

  <Card title="Events" href="/webhooks/events" description="Every event type and the fields it carries." />
</Cards>
