Webhooks
A webhook is a URL on your server that Aphra calls when something happens to a booking. You get a POST with a JSON body, signed so you can check it came from us.
Set one up
- In Aphra, go to Settings → API → Webhooks.
- Enter your URL. It must start with
https://. - Tick the events you want.
- Save. Aphra generates a signing secret and shows it with the webhook. Copy it to your server’s configuration.
You can register several webhooks, each with its own URL and events. To change one, delete it and add it again; it then gets a new secret.
Events
| Event | Sent when |
|---|---|
booking.created |
A booking is made: online by a customer, through the API, or when the business confirms a request. |
booking.cancelled |
The customer cancels, the business cancels, or the business declines a request. |
booking.completed |
The business marks the treatment as done or checks it out at the desk. |
booking.no_show |
The business marks the customer as a no-show. |
A request goes through two steps. When the customer books, you receive booking.created with status requested. When the business accepts, you receive booking.created again for the same reference, now with status confirmed. When it declines, you receive booking.cancelled.
A booking the customer pays online is announced once the payment is in: you receive booking.created with status confirmed at that moment, not when the slot was picked. If the customer never pays, the booking lapses and you receive nothing.
The request
POST https://your-server.example.com/hooks/aphra
Content-Type: application/json
X-Webhook-Event: booking.created
X-Webhook-Signature: 5d41402abc4b2a76b9719d911017c592...
{
"event": "booking.created",
"created_at": "2026-11-14T09:12:45.002Z",
"data": {
"reference": "K7Q2MX",
"status": "confirmed",
"starts_at": "2026-11-20T14:00:00.000Z",
"ends_at": "2026-11-20T15:00:00.000Z",
"total": 65,
"currency": "USD",
"service": "Deep tissue massage",
"customer": {
"first_name": "Maria",
"last_name": "Janga",
"email": "maria@example.com"
}
}
}
Fields in data
Always present:
| Field | Description |
|---|---|
reference |
The booking’s reference code. Use it to match events to the same booking. |
status |
The booking’s status after the event. |
starts_at |
Start time, UTC. |
total |
Price of the booking. For booking.completed, the amount settled at checkout when the treatment was checked out. |
currency |
ISO currency code. |
Present depending on where the event came from:
| Field | When |
|---|---|
ends_at |
booking.created from an online or API booking. |
service |
booking.created from an online or API booking, and booking.cancelled by the customer. The treatment’s name. |
customer |
booking.created from an online or API booking: first_name, last_name, email. |
changed_by |
"staff" when someone at the business caused the event. |
cancelled_by |
"customer" when the customer cancelled through their confirmation link. |
Treat every field outside the first table as optional, and ignore fields you do not know. We add fields without notice.
Check the signature
The X-Webhook-Signature header holds an HMAC-SHA256 of the raw request body, keyed with your webhook’s secret, written as lowercase hex. Compute the same on your side and compare. Use the raw bytes you received: if you parse the JSON and serialize it again, the signature will not match.
Node.js (Express):
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/hooks/aphra', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto
.createHmac('sha256', process.env.APHRA_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const received = req.get('X-Webhook-Signature') ?? '';
const ok =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const { event, data } = JSON.parse(req.body);
// ... handle the event
res.sendStatus(200);
});
PHP:
$body = file_get_contents('php://input');
$expected = hash_hmac('sha256', $body, getenv('APHRA_WEBHOOK_SECRET'));
$received = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$payload = json_decode($body, true);
// ... handle $payload['event'] and $payload['data']
http_response_code(200);
Python (Flask):
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/hooks/aphra")
def aphra_hook():
body = request.get_data()
expected = hmac.new(os.environ["APHRA_WEBHOOK_SECRET"].encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Webhook-Signature", "")):
abort(401)
payload = request.get_json()
# ... handle payload["event"] and payload["data"]
return "", 200
The signature covers only the body and contains no timestamp. If replayed requests are a concern for you, reject events whose created_at is more than a few minutes old.
Delivery
- Answer with any
2xxstatus within 5 seconds. Do the slow work afterwards, for example in a queue. - Each event is sent once. A failed or slow delivery is not retried.
- The settings page shows, per webhook, the time and status code of the last delivery. A status of
-1means your server could not be reached or took longer than 5 seconds. - Events can arrive out of order. Compare
created_atif order matters to you. - The same event can, rarely, arrive twice. Make your handler safe to run twice for the same
eventandreference.
Because there are no retries, do not rely on webhooks alone for anything that must be complete, such as your bookkeeping. Run a periodic sync with GET /api/v1/bookings as a safety net.