# Webhooks

Webhooks send an HTTPS `POST` to your server when something happens in PROradius. Use them instead of polling.

## Set up an endpoint

1. Give your API key `webhooks:manage`, plus the read ability of every topic you want (see the
   [event catalog](#event-catalog)).

2. Create the endpoint:

   ```bash
   curl -X POST https://api.invoicing.proradius.co/v1/webhooks \
     -H "Authorization: Bearer $PRORADIUS_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "url": "https://hooks.example.com/proradius",
       "description": "CRM sync",
       "topics": [
         "ticket.created",
         "ticket.scheduled",
         "ticket.completed",
         "ticket.cancelled"
       ]
     }'
   ```

3. Store the `secret` from the answer. It is shown only here and when you roll it with
   `POST /v1/webhooks/{uuid}/roll-secret`.

4. Send a test event with `POST /v1/webhooks/{uuid}/test`. It delivers a `webhook.ping` through the same signed
   path as real events.

Endpoint rules:

- The URL must use `https` on port 443 and resolve to a public address.
- An organization can have up to 10 endpoints.
- Optional `branch_id` limits the endpoint to events of one branch. Without it the endpoint receives every event of
  the organization.
- `PUT /v1/webhooks/{uuid}` changes `url`, `description`, `topics`, `active` or `branch_id`.

### Endpoints created by an API key

- A key sees and manages only the endpoints it created. Other endpoints answer `404`.
- A key can subscribe only to topics it can read. Any other topic answers `422`.
- `GET /v1/webhooks/topics` called with a key lists only the topics that key may use.
- If the key is deleted, switched off, or loses the read ability for a topic, its endpoints stop receiving those
  events. They resume when the key is active and has the ability again.

## Event catalog

| Topic | Sent when | Key ability |
| --- | --- | --- |
| `ticket.created` | A service job is created, from any channel. | `tickets:read` |
| `ticket.status_changed` | A job moves to another column. Reordering inside a column sends nothing. | `tickets:read` |
| `ticket.assigned` | The assignees change. | `tickets:read` |
| `ticket.scheduled` | A visit is booked, moved, auto-assigned or cleared. | `tickets:read` |
| `ticket.completed` | A completion with outcome `done` or `partial` succeeds. Not sent for `revisit` or replays. | `tickets:read` |
| `ticket.cancelled` | A job is cancelled. | `tickets:read` |
| `ticket.archived` | A job is archived by a person, a key, or the daily auto-archive. | `tickets:read` |
| `client.created` | A client is created. | `clients:read` |
| `client.updated` | A client is updated. | `clients:read` |
| `invoice.created` | An invoice is created. | `invoices:read` |
| `invoice.paid` | An invoice is fully paid. | `invoices:read` |
| `invoice.voided` | An invoice is voided. | `invoices:read` |
| `payment.created` | A payment is recorded. | `client_payments:read` |
| `payment.deleted` | A payment is deleted. | `client_payments:read` |
| `webhook.ping` | You call the test endpoint. Not subscribable. | - |

Events are sent after the change is saved. A change that is rolled back sends nothing.

## Payload

Every delivery has the same envelope:

```json collapse={11-22}
{
  "id": "evt_1f0b6a3c-9d4e-4a1b-8c22-5f7e0d9a1234",
  "topic": "ticket.completed",
  "created_at": "2026-10-20T08:40:02Z",
  "api_version": "2026-09-01",
  "organization_id": "7c2d1e8a-3b45-4f9c-a0d1-6e8b2f4a9c11",
  "data": {
    "ticket": {
      "id": "9a7c3e1f-2b4d-4e6f-8a1c-3d5e7f9b1c2d",
      "ticket_number": "TKT-0123",
      "external_ref": "crm-job-48213",
      "status": "Done",
      "status_category": "done",
      "board_id": "4c6e8a0b-1d3f-4a5c-9e7b-2f4a6c8e0b1d",
      "client_id": "b8f2a1c4-7d39-4e52-9a06-1f4c8b3d5e27",
      "created_via": "api",
      "job_type": "installation",
      "technician_id": "5d1e2f3a-4b5c-4d6e-8f70-1a2b3c4d5e6f",
      "scheduled_at": "2026-10-20T06:00:00Z",
      "sla_due_at": "2026-10-21T08:00:00Z",
      "archived_at": null,
      "updated_at": "2026-10-20T08:40:00Z"
    },
    "outcome": "done",
    "invoice_id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
    "resolved_at": "2026-10-20T08:40:00Z"
  }
}
```

| Field | Meaning |
| --- | --- |
| `id` | The event id. The same across retries, resends and every endpoint that receives the event. Use it to skip duplicates. |
| `topic` | The topic. |
| `created_at` | When the payload was built, UTC. |
| `api_version` | The payload version stamped on the endpoint at creation. |
| `organization_id` | The organization's id (uuid). |
| `data` | The topic's data. |

Extra fields in `data` per ticket topic:

| Topic | Fields next to `ticket` |
| --- | --- |
| `ticket.status_changed` | `from` and `to`, each `{ "column", "category" }` |
| `ticket.assigned` | `assigned_user_ids`, `added_user_ids`, `removed_user_ids` |
| `ticket.scheduled` | `technician_id`, `scheduled_at`, `scheduled_until` (null when cleared), `auto`, `override` |
| `ticket.completed` | `outcome`, `invoice_id` (null when not billed), `resolved_at` |
| `ticket.cancelled` | `reason`, `closed_at` |
| `ticket.archived` | `auto` (true for the daily auto-archive) |

Ticket payloads carry ids and the main fields only. Fetch `GET /v1/tickets/{id}` for the rest. Examples for every
topic are in the [API reference](/api/) and in `GET /v1/webhooks/topics`.

## Headers

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `PROradius-Webhooks/1.0` |
| `X-PROradius-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |
| `X-PROradius-Topic` | The topic, for example `ticket.completed`. |
| `X-PROradius-Delivery` | The delivery id. A resend gets a new one. |
| `X-PROradius-Event` | The event id without the `evt_` prefix. |

## Verify the signature

Each delivery is signed with your endpoint's secret:

1. Read `t` and `v1` from the `X-PROradius-Signature` header.
2. Build the string `{t}.{raw request body}`. Use the body bytes exactly as received; do not parse and re-encode
   the JSON.
3. Compute HMAC-SHA256 of that string with the endpoint secret as the key, as lowercase hex.
4. Compare it with `v1` in constant time.
5. Reject the request if `t` is more than 300 seconds away from your clock.

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/proradius', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-PROradius-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=', 2)));
  const t = Number(parts.t);
  const expected = crypto
    .createHmac('sha256', process.env.PRORADIUS_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body.toString('utf8')}`)
    .digest('hex');

  const valid =
    Number.isInteger(t) &&
    Math.abs(Date.now() / 1000 - t) <= 300 &&
    typeof parts.v1 === 'string' &&
    parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));

  if (!valid) return res.status(400).send('invalid signature');

  const event = JSON.parse(req.body.toString('utf8'));
  // Queue the work, then answer quickly.
  res.sendStatus(200);
});
```

```php
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_PRORADIUS_SIGNATURE'] ?? '';
$secret = getenv('PRORADIUS_WEBHOOK_SECRET');

$parts = [];
foreach (explode(',', $header) as $pair) {
    [$k, $v] = array_pad(explode('=', trim($pair), 2), 2, null);
    $parts[$k] = $v;
}

$t = isset($parts['t']) && ctype_digit($parts['t']) ? (int) $parts['t'] : null;
$expected = hash_hmac('sha256', $parts['t'].'.'.$body, $secret);

if ($t === null || abs(time() - $t) > 300 || ! hash_equals($expected, (string) ($parts['v1'] ?? ''))) {
    http_response_code(400);
    exit('invalid signature');
}

$event = json_decode($body, true);
http_response_code(200);
```

```python
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/proradius")
def proradius():
    body = request.get_data()  # raw bytes
    header = request.headers.get("X-PROradius-Signature", "")
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t = parts.get("t", "")
    expected = hmac.new(
        os.environ["PRORADIUS_WEBHOOK_SECRET"].encode(),
        f"{t}.".encode() + body,
        hashlib.sha256,
    ).hexdigest()

    if not t.isdigit() or abs(time.time() - int(t)) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(400)

    event = request.get_json()
    return "", 200
```

### Try it here

Paste the raw body and the `X-PROradius-Signature` header exactly as your server received them, or sign a test body
to call your own handler with.

<SignatureTester />

**Caution:** Use the secret as a plain string key. It looks like hex, but it is not decoded before signing.

## Retries

Answer with any `2xx` status within 10 seconds to acknowledge a delivery. The connection itself must open within 5
seconds. Redirects are not followed.

| Your answer | What happens |
| --- | --- |
| `2xx` | Delivered. |
| Timeout, connection or TLS error, `5xx`, `429` | Retried. |
| Any other status (`3xx`, `4xx`) | Failed, not retried. |

```mermaid
sequenceDiagram
    participant P as PROradius
    participant Y as Your endpoint
    P->>Y: POST event (attempt 1)
    Y-->>P: 503
    Note over P: wait 10 s
    P->>Y: POST same event (attempt 2)
    Y-->>P: timeout
    Note over P: wait 1 min
    P->>Y: POST same event (attempt 3)
    Y-->>P: 200 OK
    Note over P,Y: Delivered. The event id is the same on every attempt.
```

A delivery is tried up to **6 times**. The waits between attempts are 10 seconds, 1 minute, 5 minutes, 30 minutes
and 2 hours.

After **15 deliveries in a row** fail for good, the endpoint is switched off and the organization is notified by
email. A successful delivery resets the count. Switch the endpoint back on with `PUT /v1/webhooks/{uuid}` and
`{"active": true}`.

Deliveries can arrive more than once and in any order. Skip events whose `id` you have already processed, and use
`updated_at` or a fresh `GET` when order matters.

## Inspect and resend

- `GET /v1/webhooks/{uuid}/deliveries` lists deliveries with their status: `pending`, `delivered`, `failed` or
  `dropped`. Filter by `status`, `topic`, `date_from` and `date_to`.
- `GET /v1/webhooks/deliveries/{uuid}` returns one delivery with its payload and your server's response.
- `POST /v1/webhooks/deliveries/{uuid}/resend` sends the stored payload again, with the same event `id`. The endpoint
  must be active.

Delivery history is kept for 30 days.