# Subscribe to webhooks

## 1. Register an endpoint

```bash
curl -X POST https://api.exayard.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $EXAYARD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/hooks/exayard",
    "events": ["assessment.completed", "estimate.generated", "bid.generated"]
  }'
```

Or on the Developer page: **Webhooks → New webhook**. The URL must be public; `https://` is best, and `http://` is
accepted. Pick event types from the [event catalog](https://developers.exayard.com/concepts/webhooks.md), or `*` for every event.

The response includes the signing `secret` (`whsec_...`). It is shown once, so store it. If you lose it, **Rotate
secret** on the Developer page makes a new one, and the old one stops signing at once.

Two kinds of caller can manage a company's endpoints: an admin of the company, or a key set to work in **This
company** with `write:webhooks`. Everyone else is refused. That includes members who are not admins, keys set to work
in every company that installed the app, and apps signed in for a person. An app working in a company that installed
it has [its own endpoint](#webhooks-for-an-app) instead.

## 2. Read a delivery

Each delivery is a `POST` with a JSON body:

```json
{
  "id": "evt_8fk2q9x4mw1p8c3n6d5rs0tuv2bjh4ye",
  "type": "assessment.completed",
  "created": 1759651200000,
  "organizationId": "js79m7b3kfqd3vqzwx0kpcnryx7h2k4e",
  "data": { "assessmentId": "as2k7m4p9x1d6rs3tuvw8abc", "projectId": "kn7d9x2p4m1q8rs6tuvw3xyz" }
}
```

`organizationId` is the company the event came from. It is also sent in the `Exayard-Organization-Id` header, so you
can route a delivery before you parse it. One receiver address can serve many companies: register the same URL in each
company and route on `organizationId`. See [Webhook events](https://developers.exayard.com/concepts/webhooks.md) for every type and its fields.

## 3. Verify signatures

Every delivery carries these headers:

- `Exayard-Signature: t=<unix seconds>,v1=<base64 HMAC-SHA256>`
- `Exayard-Event-Id` and `Exayard-Event-Type`
- `Exayard-Organization-Id`
- `User-Agent: Exayard-Webhooks/1.0`

Compute HMAC-SHA256 over `${t}.${rawBody}` with the endpoint's secret, base64-encode it and compare it with `v1`.
Reject a delivery whose `t` is more than 5 minutes from now. Each endpoint has its own secret, so when one address
serves many companies, pick the secret by `Exayard-Organization-Id` before you verify. The signature covers the whole
body, `organizationId` included.

The SDK does this for you. `constructWebhookEvent` is async, so `await` it, and pass the raw body exactly as received:

```typescript
import express from 'express'
import { constructWebhookEvent, WebhookSignatureError } from '@exayard/sdk'

const app = express()

app.post('/hooks/exayard', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.header('exayard-signature') ?? ''
  try {
    const event = await constructWebhookEvent(
      req.body.toString('utf8'),
      signature,
      process.env.EXAYARD_WEBHOOK_SECRET!
    )
    switch (event.type) {
      case 'assessment.completed':
        // ...
        break
    }
    res.sendStatus(200)
  } catch (err) {
    if (err instanceof WebhookSignatureError) {
      res.status(400).send(err.code)
      return
    }
    throw err
  }
})
```

`err.code` is `invalid_signature`, `replay_window_exceeded` or `malformed_header`. `exa.webhooks.constructEvent` is
the same function. A fourth argument, `{ toleranceSeconds }`, changes the 5-minute window.

## 4. Handle retries

Answer with any 2xx. Anything else, a redirect, a timeout or a network error counts as a failure. Redirects are not
followed.

A failed delivery is tried again. There are **10 attempts in all**: at once, then after 15 seconds, 1 minute, 5
minutes, 30 minutes, 2 hours, 6 hours, 24 hours, 24 hours and 24 hours, each with some jitter. That is about 80 hours.
After the 10th attempt the delivery is marked `failed`.

Every attempt sends the same body and the same event id. Use `Exayard-Event-Id` to ignore a delivery you already
handled.

## Send a test event

To try your receiver, send an event of any type with sample values:

```bash
curl -X POST https://api.exayard.com/v1/webhook_endpoints/<endpoint id>/test \
  -H "Authorization: Bearer $EXAYARD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "type": "assessment.completed" }'
```

The answer is `{ "deliveryId": "...", "eventId": "evt_test_..." }`. A test event is signed like any other, has
`"test": true` at the top level of its body, and is sent once with no retries. The endpoint must be active. On the
Developer page, use **Send test event** in the endpoint's menu.

## Deliveries

`GET /v1/webhook_endpoints/{id}/deliveries` lists an endpoint's deliveries, newest first, 50 by default (`limit` 1 to
200). On the Developer page, use **Deliveries** in the endpoint's menu. Each delivery has:

- `status`: `pending`, `retrying`, `success` or `failed`.
- `attempts`, `responseStatus`, and `responseBody` (cut to 2,048 characters).
- `failureReason` when it failed: `max_attempts_exhausted`, `endpoint_missing_or_paused`, `sent_once` (a test or a
  resend that failed), and for an app's endpoint `install_removed`, `app_inactive` or `scope_not_granted`.
- `test`: true for a test event. `resendOf`: the delivery it resends, for a resend.

## Resend a delivery

```bash
curl -X POST https://api.exayard.com/v1/webhook_deliveries/<delivery id>/resend \
  -H "Authorization: Bearer $EXAYARD_API_KEY"
```

A resend is a new delivery with the same event id and body. It is sent once, with no retries, and names the earlier
delivery in `resendOf`. The endpoint must be active. On the Developer page, use **Resend** on a delivery.

Test, deliveries' resend and the listener calls below are on the REST API only, not the MCP server.

## Forward events to your laptop: `exayard listen`

Your laptop has no public address, so the CLI (0.5.0 or later) fetches your company's events and forwards them:

```bash
exayard listen --forward-to http://localhost:3000/hooks/exayard --events "bid.won,project.created"
```

- It prints a `whsec_...` secret at the start. Deliveries it forwards are signed with it, so your verification code
  runs unchanged.
- Without `--forward-to` it prints each event instead.
- `--events` picks event types; leave it out for every event. `--name` labels the session.
- Ctrl-C stops it.

The same callers can listen: an admin of the company, or a key set to work in **This company** with
`write:webhooks`. A key set to work in every company that installed the app cannot.
A company can have 10 listeners at once. A listener that nobody reads for an hour is removed.

The SDK can do the same: `exa.webhooks.listeners.start({ events })`, then `for await` over
`exa.webhooks.listeners.events(id)`, and `exa.webhooks.listeners.stop(id)` at the end.

## Webhooks for an app

An [app](https://developers.exayard.com/apps-and-keys.md) can have one endpoint of its own that serves every company that installed it. You don't
register an endpoint in each company.

### Set it up

An admin of your company opens **Settings → Developer → Apps**, then the app's **More actions → Webhook**, and enters
a public address. The signing secret (`whsec_...`) is shown once. The same dialog pauses and resumes the endpoint,
changes its address, rotates the secret (the old one stops signing at once) and removes it.

### What it receives

- **Events from each company that installed the app**, but only those the install's scopes can read. See
  [Events for an app](https://developers.exayard.com/concepts/webhooks.md#events-for-an-app).
- **Events about your app's installs:**
  - `app.installed`: a company installed the app, or installed it again.
  - `app.scopes_approved`: a company's admin approved new access.
  - `app.uninstalled`: a company removed the app, or it was removed for another reason (`reason`: `admin_removed`,
    `company_deleted` or `partner_released`).

`organizationId` and the `Exayard-Organization-Id` header name the company the event is about, and the signature covers
them, so route on `organizationId` after you verify. One secret signs deliveries for every company.

An install event's `data`:

```json
{
  "installationId": "k57abc...",
  "app": { "id": "app_k3k3k3k3k3k3k3k3k3k3", "name": "Nightly Sync" },
  "grantedScopes": ["read:projects"],
  "scopesVersion": 2,
  "origin": "consent"
}
```

`origin` is `consent` (an admin installed it), `owner_company` (your own company) or `partner_owned` (an
[account for your customer](https://developers.exayard.com/concepts/customer-accounts.md)). `app.uninstalled` sends `grantedScopes: []` and adds
`reason`.

### When deliveries stop

Before every attempt, including retries, Exayard checks again. A delivery is marked `failed` without being sent when
the company removed the app (`install_removed`), the install no longer grants the event's scope
(`scope_not_granted`), or the app is suspended or deleted (`app_inactive`).
