Webhooks
Webhooks send an HTTPS POST to your server when something happens in PROradius. Use them instead of polling.
Set up an endpoint
Section titled “Set up an endpoint”-
Give your API key
webhooks:manage, plus the read ability of every topic you want (see the event catalog). -
Create the endpoint:
Terminal window curl -X POST https://api.stg.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"]}' -
Store the
secretfrom the answer. It is shown only here and when you roll it withPOST /v1/webhooks/{uuid}/roll-secret. -
Send a test event with
POST /v1/webhooks/{uuid}/test. It delivers awebhook.pingthrough the same signed path as real events.
Endpoint rules:
- The URL must use
httpson port 443 and resolve to a public address. - An organization can have up to 10 endpoints.
- Optional
branch_idlimits the endpoint to events of one branch. Without it the endpoint receives every event of the organization. PUT /v1/webhooks/{uuid}changesurl,description,topics,activeorbranch_id.
Endpoints created by an API key
Section titled “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/topicscalled 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
Section titled “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
Section titled “Payload”Every delivery has the same envelope:
{ "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",12 collapsed lines
"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 and in GET /v1/webhooks/topics.
Headers
Section titled “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
Section titled “Verify the signature”Each delivery is signed with your endpoint’s secret:
- Read
tandv1from theX-PROradius-Signatureheader. - Build the string
{t}.{raw request body}. Use the body bytes exactly as received; do not parse and re-encode the JSON. - Compute HMAC-SHA256 of that string with the endpoint secret as the key, as lowercase hex.
- Compare it with
v1in constant time. - Reject the request if
tis more than 300 seconds away from your clock.
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);});$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);import hashlib, hmac, os, timefrom 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 "", 200Try it here
Section titled “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.
Retries
Section titled “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. |
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
Section titled “Inspect and resend”GET /v1/webhooks/{uuid}/deliverieslists deliveries with their status:pending,delivered,failedordropped. Filter bystatus,topic,date_fromanddate_to.GET /v1/webhooks/deliveries/{uuid}returns one delivery with its payload and your server’s response.POST /v1/webhooks/deliveries/{uuid}/resendsends the stored payload again, with the same eventid. The endpoint must be active.
Delivery history is kept for 30 days.
Thank you for the feedback.

