> 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/internal-api/webhooks.md).

# Webhooks

Webhooks let your connector react to platform activity in near real time instead of polling. When an RFQ, quote, job or project belonging to your company changes state, Adaptria sends an HTTP `POST` to a URL your connector controls, carrying a small JSON payload describing what happened.

This is the recommended way to keep a connector in sync: rather than scanning for changes on a timer, you receive an event the moment a state transition occurs and advance your own workflow in response.

## Registering a webhook

Register your connector's 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://connector.example.com/adaptria/events" }'
```

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://connector.example.com/adaptria/events",
  "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. Persist it in your connector's secret store 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 inspect 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://connector.example.com/adaptria/events",
  "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.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.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. |

### Job events

| Event                        | Sent when                                  |
| ---------------------------- | ------------------------------------------ |
| `job.created`                | A job is created.                          |
| `job.started`                | Work on a job begins.                      |
| `job.talent_assigned`        | A talent is assigned to a job.             |
| `job.request_rejected`       | A talent declines a job request.           |
| `job.reassigned`             | A job is reassigned to a different talent. |
| `job.delivery_submitted`     | A delivery is submitted for a job.         |
| `job.delivery_updated`       | A submitted delivery is updated.           |
| `job.delivery_approved`      | A job delivery is approved.                |
| `job.delivery_rejected`      | A job delivery is rejected.                |
| `job.feedback_submitted`     | Feedback is submitted on a job.            |
| `job.approved_for_invoicing` | A job is approved for invoicing.           |
| `job.archived`               | A job is archived.                         |
| `job.cancelled`              | A job is cancelled.                        |

### 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, job or project), `companyId` and `timestamp` (ISO 8601, UTC) are present on every event. `entityNumber` (the human-readable number) is included where available. Use `entityId` to fetch the full entity from the relevant endpoint when you need more than the status transition. 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 connector's 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/events', (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);
  // ... advance your connector workflow from 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. Build your handler to be fast and idempotent: return `2xx` quickly (queue heavy work for asynchronous processing) and tolerate the same event arriving more than once.
{% endhint %}
