> For the complete documentation index, see [llms.txt](https://docs.adaptria.locaria.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.adaptria.locaria.com/client-api/webhooks.md).

# Webhooks

Webhooks let Adaptria notify your systems the moment something changes, instead of polling the API on a schedule. When an RFQ, quote or project belonging to your company changes state, Adaptria sends an HTTP `POST` to a URL you control, carrying a small JSON payload describing what happened.

Use webhooks when you want to react to platform activity in near real time, for example to update an internal dashboard when a quote is created, or to advance your own workflow when a project status changes.

## Registering a webhook

Register your endpoint with a single request. The `url` must be a valid HTTPS URL; plain `http` is rejected.

```bash
curl -X POST https://api.adaptria.locaria.com/api/public/v1/webhooks \
  -H "Api-key: lla_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://hooks.example.com/adaptria" }'
```

On staging, use the base URL `https://staging.api.locate.studiographene.co.uk` instead.

A successful registration returns `201` with the stored registration and a signing `secret`:

```json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "url": "https://hooks.example.com/adaptria",
  "secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "isActive": true,
  "createdAt": "2026-06-03T10:15:00.000Z"
}
```

{% hint style="warning" %}
The `secret` is shown **only** in this registration response and is never returned again. Store it securely as soon as you receive it. You will need it to verify the authenticity of every delivery (see [Verifying authenticity](#verifying-authenticity)).
{% endhint %}

### One webhook per key

There is exactly one webhook registration per API key. Posting to the endpoint again **overwrites** the previous registration and issues a **new** secret, which invalidates the old one. There is no separate rotation endpoint: to rotate the secret, re-register, then update your stored secret to the newly returned value.

## Retrieving your webhook

To see your current registration, send a `GET`. The response omits the secret.

```bash
curl https://api.adaptria.locaria.com/api/public/v1/webhooks \
  -H "Api-key: lla_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "url": "https://hooks.example.com/adaptria",
  "isActive": true,
  "createdAt": "2026-06-03T10:15:00.000Z",
  "updatedAt": "2026-06-03T10:15:00.000Z"
}
```

## Removing your webhook

To stop receiving events, delete your registration:

```bash
curl -X DELETE https://api.adaptria.locaria.com/api/public/v1/webhooks \
  -H "Api-key: lla_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

## Event types

A registered webhook receives every event type listed below for entities belonging to your company. There is no per-event subscription or filtering; if you only care about some events, branch on the `event` field (and the matching `X-Webhook-Event` header) in your handler.

### RFQ events

| Event                 | Sent when                                  |
| --------------------- | ------------------------------------------ |
| `rfq.status_changed`  | An RFQ moves to a new status.              |
| `rfq.info_submitted`  | The initial RFQ information is submitted.  |
| `rfq.assets_uploaded` | Source assets are added to an RFQ.         |
| `rfq.submitted`       | An RFQ is submitted for quoting.           |
| `rfq.copied`          | An RFQ is duplicated from an existing one. |
| `rfq.cancelled`       | An RFQ is cancelled.                       |

### Quote events

| Event                        | Sent when                                      |
| ---------------------------- | ---------------------------------------------- |
| `quote.created`              | A quote is created.                            |
| `quote.status_changed`       | A quote moves to a new status.                 |
| `quote.sent_to_client`       | A quote is sent to the client for review.      |
| `quote.accepted`             | A quote is accepted.                           |
| `quote.rejected`             | A quote is rejected.                           |
| `quote.revised_and_sent`     | A revised quote is issued and sent.            |
| `quote.cancelled`            | A quote is cancelled.                          |
| `quote.converted_to_project` | An accepted quote is converted into a project. |

### Project events

| Event                      | Sent when                                     |
| -------------------------- | --------------------------------------------- |
| `project.status_changed`   | A project moves to a new status.              |
| `project.created`          | A project is created.                         |
| `project.team_updated`     | A project's team is updated.                  |
| `project.sent_for_billing` | A project is sent for billing.                |
| `project.delivered`        | A project is delivered.                       |
| `project.copied`           | A project is duplicated from an existing one. |
| `project.cancelled`        | A project is cancelled.                       |

## Payload

Each delivery is a `POST` to your registered URL with a JSON envelope: a top-level `event` field and a `data` object describing the entity that changed.

```json
{
  "event": "project.status_changed",
  "data": {
    "entityId": "9c8f2b1e-1234-4a5b-9c8f-2b1e12344a5b",
    "entityNumber": "PRJ-1042",
    "entityName": "Spring campaign localisation",
    "oldStatus": "in_progress",
    "newStatus": "completed",
    "companyId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "timestamp": "2026-06-03T10:15:00.000Z"
  }
}
```

The fields present in `data` vary by event:

| Event                    | `oldStatus` | `newStatus` | `entityName` |
| ------------------------ | ----------- | ----------- | ------------ |
| `rfq.status_changed`     | Yes         | Yes         | No           |
| `quote.created`          | No          | Yes         | No           |
| `quote.status_changed`   | Yes         | Yes         | No           |
| `project.status_changed` | Yes         | Yes         | Yes          |

`entityId` (the UUID of the RFQ, quote or project), `companyId` and `timestamp` (ISO 8601, UTC) are present on every event. `entityNumber` (the human-readable number) is included where available. Every event uses this same envelope: `entityId`, `companyId` and `timestamp` are always present, with status fields (`oldStatus`/`newStatus`), `entityName` or `entityNumber` included where they apply to that entity.

## Verifying authenticity

Because your endpoint is publicly reachable, you must confirm that each request genuinely came from Adaptria before acting on it. Every delivery includes two headers:

* `X-Webhook-Event` carries the event type (the same value as the `event` field in the body).
* `X-Webhook-Signature` carries a signature over the request body.

The signature is the **HMAC-SHA256 of the exact raw request body**, hex-encoded, keyed with your webhook `secret` (the value returned at registration). To verify a delivery, recompute the HMAC over the raw body and compare it, using a constant-time comparison, against the `X-Webhook-Signature` header. Reject any request whose signature does not match.

{% hint style="warning" %}
Verify against the **raw body bytes**, exactly as received, **before** JSON parsing. Re-serialising the parsed JSON can change byte-for-byte content (key order, whitespace) and break the comparison. Always store the secret securely and never expose it client-side.
{% endhint %}

```javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

// `rawBody` is the exact request body as a Buffer or string, before JSON.parse.
function isValidSignature(rawBody, signatureHeader, secret) {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader ?? '');
  return a.length === b.length && timingSafeEqual(a, b);
}

// Example (Express, with the raw body preserved):
app.post('/adaptria', (req, res) => {
  const signature = req.header('X-Webhook-Signature');
  if (!isValidSignature(req.rawBody, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
  const payload = JSON.parse(req.rawBody);
  // ... act on payload.event / payload.data ...
  return res.sendStatus(200);
});
```

## Delivery, timeouts and retries

Delivery is asynchronous and does not block the platform action that triggered the event. Each attempt has a **10-second** timeout.

Adaptria treats any HTTP `2xx` response from your endpoint as success. A non-`2xx` response, a timeout or a network error triggers a retry. Adaptria makes up to **3 attempts**, with a backoff of **1 second**, then **5 seconds**, then **25 seconds** between attempts.

{% hint style="info" %}
If all attempts fail, the event is dropped and the failure is logged on our side. Your webhook stays active for future events; it is **not** automatically disabled. Make your handler fast and idempotent: return `2xx` quickly (do heavy work asynchronously) and tolerate the same event arriving more than once.
{% endhint %}
