# Guide to Acuity Scheduling Webhooks: Features and Best Practices

Acuity Scheduling webhooks tell your systems when bookings change: a client schedules an appointment, reschedules it, cancels it, or updates their intake form, or a package, gift certificate or subscription order is completed. If you take bookings with Acuity Scheduling, webhooks let your CRM, billing or calendar sync react straight away instead of polling the appointments API.

This guide covers the two ways to register Acuity webhooks, how they are signed, what the short form-encoded notifications contain, the retry rules that only cover some failures, and the practices that keep an Acuity receiver reliable.

## What are Acuity Scheduling webhooks?

Acuity sends a `POST` to your URL when an appointment or order changes. There are two ways to set them up, and they share one delivery format:

* **Static webhooks** are set in your account under **Integrations**, with one URL per action.
* **Dynamic webhooks** are created through Acuity's Webhooks API, one subscription per event and target URL.

Two things set Acuity apart from most webhook senders. First, the body is `application/x-www-form-urlencoded`, not JSON, and it contains only ids: `action`, `id`, and for appointments `calendarID` and `appointmentTypeID`. You fetch the details from the API.

Second, there is no separate webhook secret. Acuity signs each notification with an **API key**: the main admin's key for static webhooks, or the key of the user who created a dynamic webhook. The `x-acuity-signature` header carries a base64 HMAC-SHA256 of the raw body under that key.

## Acuity Scheduling webhook features

| Feature | Details |
| --- | --- |
| Configuration | Static: **Integrations** page. Dynamic: `POST /api/v1/webhooks` |
| Events | `scheduled`, `rescheduled`, `canceled`, `changed`, `order.completed` |
| Body format | `application/x-www-form-urlencoded` |
| Body fields | `action`, `id`, and for appointments `calendarID` and `appointmentTypeID` |
| Signature header | `x-acuity-signature` |
| Scheme | HMAC-SHA256, base64 |
| Signed content | The raw request body only |
| Key | The main admin's API key (static) or the creating user's API key (dynamic) |
| URL rules | Port 443 (HTTPS) or 80 (HTTP). Static URLs up to 256 characters |
| Limit | 25 API webhooks per account |
| Retries | Only on a `500` or a connection error: 9 retries over 24 hours |
| Auto-disable | After 5 days of failing with `500` or connection errors |

Acuity does not publish source IPs, a response timeout or a timestamp, so there is no replay window to check.

## Common events

The event type is the `action` field.

| `action` | Fires when |
| --- | --- |
| `scheduled` | An appointment is first booked (sent once) |
| `rescheduled` | An appointment is moved to a new time |
| `canceled` | An appointment is canceled |
| `changed` | Any change to an appointment, including booking, rescheduling, canceling, and updates to details such as the email address or intake forms |
| `order.completed` | A package, gift certificate or subscription order is completed |

Dynamic webhooks subscribe with slightly different names: `appointment.scheduled`, `appointment.rescheduled`, `appointment.canceled`, `appointment.changed` and `order.completed`. Acuity's docs show a delivery only for static webhooks, so read the `action` value you receive rather than assuming it matches the subscription name.

`changed` overlaps the others. Acuity warns that using `changed` alongside `scheduled`, `rescheduled` or `canceled` "may lead to duplicate requests". Choose `changed` alone if you want every update, or the specific actions if you only care about those.

This is Acuity's example request:

```
POST https://example.com/webhook-callback
Content-Type: application/x-www-form-urlencoded

action=changed&id=13&calendarID=1&appointmentTypeID=13

```

Decoded, that is:

```json
{
  "action": "changed",
  "id": "13",
  "calendarID": "1",
  "appointmentTypeID": "13"
}

```

Every value arrives as a string. `id` is the appointment id, or the order id for `order.completed`. To get the client's name, time, price or intake answers, call `GET /appointments/{id}` (or the orders endpoint) with the id.

## Setting up Acuity Scheduling webhooks

First find the API key that will sign your webhooks. Acuity shows API credentials on the **Integrations** page under API. For static webhooks you need the main admin's key, and for dynamic webhooks the key of the user who creates them. Store it server-side, for example as `ACUITY_API_KEY`.

For a static webhook:

1. Open **Integrations** in Acuity and find the webhook settings.
2. Enter your endpoint URL for each action you want. The URL must be reachable on port 443 or 80 and be at most 256 characters.
3. Save.

For a dynamic webhook, authenticate with your user ID and API key and create one subscription per event:

```bash
curl -u "$ACUITY_USER_ID:$ACUITY_API_KEY" \
  -X POST https://acuityscheduling.com/api/v1/webhooks \
  -H 'Content-Type: application/json' \
  -d '{"event":"appointment.scheduled","target":"https://example.com/webhooks/acuity"}'

```

The response returns the subscription's `id` and a `status` of `active`. `GET /api/v1/webhooks` lists your subscriptions and `DELETE /api/v1/webhooks/:id` removes one. An account can have at most 25, and the API returns a `400` at that limit.

Acuity suggests simulating a webhook with `curl -d "action=changed&id=1&calendarID=1&appointmentTypeID=1" <url>`. A verifying endpoint will reject that, because it has no signature. Sign the body yourself to test:

```bash
BODY='action=scheduled&id=1&calendarID=1&appointmentTypeID=1'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$ACUITY_API_KEY" -binary | base64)
curl -d "$BODY" -H "x-acuity-signature: $SIG" https://example.com/webhooks/acuity

```

## Securing Acuity Scheduling webhooks

Compute an HMAC-SHA256 of the raw request body with the right API key, base64-encode it, and compare it with `x-acuity-signature` in constant time. Do this before you parse the form fields.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyAcuitySignature(rawBody, signatureHeader, apiKey) {
  if (!signatureHeader || !apiKey) return false;
  const expected = Buffer.from(
    crypto.createHmac('sha256', apiKey).update(rawBody).digest('base64'),
    'utf8'
  );
  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/acuity', express.raw({ type: '*/*' }), (req, res) => {
  const apiKey = process.env.ACUITY_API_KEY;
  if (!apiKey) return res.sendStatus(500); // fail closed

  if (!verifyAcuitySignature(req.body, req.get('x-acuity-signature'), apiKey)) {
    return res.sendStatus(401);
  }

  const fields = new URLSearchParams(req.body.toString('utf8'));
  const action = fields.get('action');
  const id = fields.get('id');
  // Fetch the appointment or order by id, then handle the action.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_acuity_signature(raw_body: bytes, signature_header, api_key: str) -> bool:
    if not signature_header or not api_key:
        return False
    expected = base64.b64encode(
        hmac.new(api_key.encode("utf-8"), raw_body, hashlib.sha256).digest()
    )
    return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)

# In FastAPI: raw_body = await request.body()
# verify_acuity_signature(raw_body, request.headers.get("x-acuity-signature"),
#                         os.environ["ACUITY_API_KEY"])
# Then parse with urllib.parse.parse_qs(raw_body.decode())

```

Acuity's own samples compare with `!==` or `!=`, which is not constant-time. Use `crypto.timingSafeEqual` or `hmac.compare_digest` instead. Base64 is case-sensitive, so don't lowercase either side.

> **Make Acuity Scheduling webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `x-acuity-signature` header with your API key, answers Acuity with an immediate `200 OK`, and durably queues every appointment and order notification before your service sees it.

## Acuity Scheduling webhook limitations and pain points

### An API key, not a webhook secret

**The Problem:** Verification fails, or a key rotation silently breaks webhooks.

**Why It Happens:** Acuity signs with an API key, and which key depends on how the webhook was created: the main admin's for static webhooks, the creating user's for dynamic ones. If you mix the two, or someone regenerates the API key, signatures stop matching.

**Workarounds:**

* Record which key signs each webhook, and keep static and dynamic webhooks on separate endpoints if they use different keys.
* Treat an API key regeneration as a webhook change and update your receiver at the same time.
* Store the key as a secret. It authenticates API calls too, not only webhooks.

**How Hookdeck Can Help:** Hookdeck's Acuity Scheduling source takes the API key in its verification settings, so the key is stored and checked in one place. Use one source per key if static and dynamic webhooks are signed differently.

### Only some failures are retried

**The Problem:** A notification is lost after a single failed attempt.

**Why It Happens:** Acuity retries only when your endpoint returns a `500` or the connection fails. A `502`, `503`, `404` or `401` is final. A validation error in your code that returns `400` drops the event for good.

**Workarounds:**

* Return `500` for temporary problems you want retried.
* Make sure deploys, gateways and load balancers don't answer with other `5xx` codes while your app restarts.
* Reconcile appointments through the API after any incident.

**How Hookdeck Can Help:** Hookdeck acknowledges Acuity as soon as it receives the notification, then retries delivery to your service on any failure code you choose, so a `503` during a deploy no longer loses a booking.

### Duplicate notifications from `changed`

**The Problem:** One booking triggers two notifications.

**Why It Happens:** `changed` fires on every change, including the ones `scheduled`, `rescheduled` and `canceled` cover. Subscribing to both produces duplicates.

**Workarounds:**

* Subscribe to `changed` alone, or to the specific actions alone.
* Deduplicate on `action` and `id` over a short window if you need both.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated notifications for the same `id` within a time window you set.

### Ids only, and no replay protection

**The Problem:** Every notification needs an API call, and a captured request can be replayed.

**Why It Happens:** The body carries only ids, and only the body is signed, with no timestamp.

**Workarounds:**

* Fetch the appointment or order by `id` before acting. That also guards against a replayed request acting on stale data.
* Keep the endpoint on HTTPS.

**How Hookdeck Can Help:** Hookdeck keeps a log of every notification with its headers, so you can trace which ids arrived and when.

## Best practices

### Verify the raw body before parsing the form

The signature covers the exact form-encoded bytes. Read the raw body (`express.raw()`, `await request.text()` or `await request.body()`), verify it, and only then parse the fields.

### Treat every value as a string

Form fields arrive as strings, so `id` is `"13"`, not `13`. Convert explicitly before comparing with your own records.

### Fetch details from the API

Use `GET /appointments/{id}` for appointments and the orders endpoint for `order.completed`. The webhook tells you what changed, not what it changed to.

### Return `500` for retryable errors

Since Acuity only retries `500`s and connection errors, reserve `500` for temporary failures and `200` for everything you have accepted, including actions you ignore.

### Acknowledge fast and process asynchronously

Acuity publishes no timeout, so respond quickly anyway. Verify, store the notification, return `200`, then fetch and process. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Acuity Scheduling sends short form-encoded notifications signed with `x-acuity-signature`, a base64 HMAC-SHA256 of the raw body keyed with an API key. Know which key signs each webhook, verify before parsing, avoid combining `changed` with the specific actions, fetch details by id, and return `500` for anything you want retried, because other failure codes end delivery.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Acuity signature, and holds each notification until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Acuity Scheduling webhooks reliably in minutes.