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

# Webhooks

> Register an endpoint, verify the HMAC signature, and handle retries, deduplication and replay.

OnTime posts to an HTTPS endpoint you control when certain things happen. It is the only push mechanism — there is no email, SMS or notification fan-out to external systems.

## Registering

In the admin console: **API & webhooks → Register webhook**. Requires `api:manage`.

Give the endpoint URL and choose which of the [nine events](/developers/webhooks/events) to subscribe to.

<Warning>
  **The signing secret is shown exactly once.** Store it before you close the dialog. Losing it means deleting the webhook and registering a new one.
</Warning>

<Note>
  Webhook registration is an **admin-console action**. There is no public endpoint for a third party to self-register a webhook.
</Note>

## What a delivery looks like

```http theme={null}
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Ontime-Event: leave.approved
X-Ontime-Delivery: 7c4f2b1e-9a83-4d05-b6e1-3f8c2d5a9014
X-Ontime-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

{
  "event": "leave.approved",
  "data": {
    "recipientId": "3f1c9b2a-5d84-4e7a-9c11-2b6f8d0a4e73",
    "ref": { "kind": "leave", "id": "b81d3e40-2c67-4a19-8f53-7d0e6b4c1a92" },
    "title": "Leave approved"
  },
  "timestamp": "2026-07-25T09:14:22.481Z"
}
```

### Headers

<ResponseField name="X-Ontime-Event" type="string">
  The event type, e.g. `leave.approved`.
</ResponseField>

<ResponseField name="X-Ontime-Delivery" type="uuid">
  The delivery ID. **Stable across every retry** of the same delivery — this is your deduplication key.
</ResponseField>

<ResponseField name="X-Ontime-Signature" type="string">
  `sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your signing secret.
</ResponseField>

### Payload

<Warning>
  **The payload is a notification, not the domain object.** It tells you *that* something happened and gives you a reference to it — it does not embed the leave request, the payslip or the message.

  Treat a webhook as a trigger: receive it, then fetch what you need. The `ref.id` is the affected record's ID and `data.recipientId` is the employee it concerns.
</Warning>

## Verifying the signature

Compute HMAC-SHA256 over the **raw request body** — the exact bytes, before any JSON parsing — and compare in constant time.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();

  // Raw body is essential: re-serialising parsed JSON will not match the signature.
  app.post(
    "/webhooks/ontime",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const header = req.get("X-Ontime-Signature") ?? "";
      const expected =
        "sha256=" +
        crypto
          .createHmac("sha256", process.env.ONTIME_WEBHOOK_SECRET)
          .update(req.body)
          .digest("hex");

      const a = Buffer.from(header);
      const b = Buffer.from(expected);
      if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
        return res.sendStatus(401);
      }

      // Acknowledge immediately, then process out of band.
      res.sendStatus(200);

      const deliveryId = req.get("X-Ontime-Delivery");
      void enqueue(deliveryId, JSON.parse(req.body.toString("utf8")));
    },
  );
  ```

  ```python Python theme={null}
  import hashlib, hmac, os
  from flask import Flask, request, abort

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

  @app.post("/webhooks/ontime")
  def ontime_webhook():
      raw = request.get_data()  # raw bytes, not request.json
      expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()

      if not hmac.compare_digest(request.headers.get("X-Ontime-Signature", ""), expected):
          abort(401)

      enqueue(request.headers["X-Ontime-Delivery"], request.get_json())
      return "", 200
  ```
</CodeGroup>

<Warning>
  **Sign the raw bytes.** Parsing JSON and re-serialising it changes key order and whitespace, and the signature will never match. Most frameworks need explicit configuration to hand you the raw body.
</Warning>

## Deduplicate

Deliveries are **at least once**. Record the `X-Ontime-Delivery` value and ignore a repeat.

```typescript theme={null}
async function handle(deliveryId: string, payload: unknown) {
  const inserted = await db.deliveries.insertIfAbsent(deliveryId);
  if (!inserted) return; // already processed
  await process(payload);
}
```

## Retries, failure and replay

|                |                                                                                    |
| -------------- | ---------------------------------------------------------------------------------- |
| **Success**    | Any `2xx`.                                                                         |
| **Failure**    | Any non-2xx, connection error, or exceeding the 30-second timeout.                 |
| **Retries**    | 8 attempts, quadrupling backoff from \~1 minute to \~8 hours (\~22 hours total).   |
| **After that** | The delivery lands in the dead-letter queue and `api:manage` holders are notified. |
| **Recovery**   | **Replay** from the delivery log re-sends the original payload unchanged.          |

<Tip>
  Return `2xx` before doing any work. A slow handler risks the 30-second timeout, which OnTime reads as a failure — so slow processing manufactures the duplicates you are trying to avoid.
</Tip>

## Operating a consumer

<Steps>
  <Step title="Monitor it yourself">
    The webhook's status flips to `Failing` on the first failed attempt, but the OnTime alert only fires after all eight retries — about 22 hours later. Do not rely on it to tell you your endpoint is down.
  </Step>

  <Step title="Handle unknown event types gracefully">
    Return `2xx` for an event you do not recognise rather than erroring. An unrecognised event that you reject will be retried eight times.
  </Step>

  <Step title="Rotate the secret by re-registering">
    There is no rotate action. Register a second webhook, cut over, then delete the first.
  </Step>
</Steps>

See [The outbox and delivery](/developers/concepts/outbox-and-delivery) for how the guarantee is implemented.
