Developers

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

  1. In Aphra, go to Settings → API → Webhooks.
  2. Enter your URL. It must start with https://.
  3. Tick the events you want.
  4. 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

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.