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

# Webhooks

> Subscribe to Harly domain events, verify signatures, handle retries, and replay failed deliveries.

Webhooks notify external systems after Harly records a domain change. Delivery
is durable and at-least-once: your receiver must be idempotent.

## Create an endpoint

1. Open **Settings → Developers → Webhooks**.
2. Enter an HTTPS endpoint and select event types.
3. Store the signing secret — it begins with `whsec_` — in your secret manager.
4. Send a test delivery and confirm a `2xx` response.

Harly blocks loopback, private, link-local, and metadata destinations by default.
For a controlled internal deployment, an owner may set
`HARLY_ALLOW_PRIVATE_WEBHOOKS=true`.

## Verify the signature

Every delivery includes an `x-harly-signature-256` header with the format:

```text theme={null}
t=<unix-seconds>,v1=<hex-hmac>
```

The HMAC-SHA256 is computed over `${t}.${rawBody}` keyed by the endpoint
secret. The timestamp lets receivers reject replayed deliveries (Harly's
default tolerance window is 5 minutes).

Always verify the raw request body **before** parsing JSON.

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

function verifyHarlyWebhook(
  rawBody: string,
  signatureHeader: string | null,
  secret: string,
  toleranceSeconds = 300,
): boolean {
  if (!signatureHeader) return false;

  // Parse t=<unix>,v1=<hex>
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((kv) => kv.split("=") as [string, string]),
  );
  const t = Number.parseInt(parts.t ?? "", 10);
  const v1 = parts.v1;
  if (Number.isNaN(t) || !v1) return false;

  // Reject stale timestamps
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - t) > toleranceSeconds) return false;

  // Constant-time HMAC comparison
  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}
```

```ts theme={null}
// Express example
import express from "express";

const app = express();

app.post(
  "/harly/events",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const valid = verifyHarlyWebhook(
      req.body.toString(),
      req.headers["x-harly-signature-256"] as string ?? null,
      process.env.HARLY_WEBHOOK_SECRET!,
    );
    if (!valid) return res.status(400).json({ error: "invalid_signature" });

    const event = JSON.parse(req.body.toString());
    res.status(200).json({ received: true });

    // Deduplicate and process asynchronously
    processEvent(event).catch(console.error);
  },
);
```

## Receiver contract

* Return `2xx` after durable acceptance, not after completing a slow workflow.
* Deduplicate by `event.id` — delivery is at-least-once.
* Persist the event before enqueueing downstream work.
* Treat unknown event types as accepted and observable.
* Return `4xx` for an invalid signature, `5xx` for a transient failure.

## Event types

| Event                       | When it fires                          |
| --------------------------- | -------------------------------------- |
| `application.created`       | Application submitted                  |
| `application.stage_changed` | Application moved to a different stage |
| `application.hired`         | Application marked as hired            |
| `application.rejected`      | Application rejected                   |
| `candidate.created`         | Candidate record created               |
| `candidate.updated`         | Candidate profile updated              |
| `interview.scheduled`       | Interview created                      |
| `interview.rescheduled`     | Interview time or details changed      |
| `interview.canceled`        | Interview canceled                     |
| `interview.completed`       | Interview marked complete              |
| `job.published`             | Job published to the career page       |

## Delivery and retries

Failed deliveries are retried by the scheduler with exponential backoff:

| Attempt | Delay      |
| ------: | ---------- |
|       1 | 1 minute   |
|       2 | 5 minutes  |
|       3 | 30 minutes |
|       4 | 2 hours    |
|       5 | 6 hours    |
|       6 | 24 hours   |

After 6 failed attempts, the delivery is marked permanently failed. Owners can
inspect delivery attempts and use **Replay** from the delivery history after
correcting the receiver.

## Secret format

Webhook secrets generated by Harly begin with `whsec_` and contain 32 bytes
of cryptographically random data encoded as base64url. Rotate the secret by
deleting and recreating the endpoint.

<Note>
  Webhooks are for durable external reactions. For browser UI updates inside a
  Harly workspace, use [realtime events](/developers/realtime).
</Note>
