Gareth Wilson Gareth Wilson

Guide to Acuity Scheduling Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationStatic: Integrations page. Dynamic: POST /api/v1/webhooks
Eventsscheduled, rescheduled, canceled, changed, order.completed
Body formatapplication/x-www-form-urlencoded
Body fieldsaction, id, and for appointments calendarID and appointmentTypeID
Signature headerx-acuity-signature
SchemeHMAC-SHA256, base64
Signed contentThe raw request body only
KeyThe main admin's API key (static) or the creating user's API key (dynamic)
URL rulesPort 443 (HTTPS) or 80 (HTTP). Static URLs up to 256 characters
Limit25 API webhooks per account
RetriesOnly on a 500 or a connection error: 9 retries over 24 hours
Auto-disableAfter 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.

actionFires when
scheduledAn appointment is first booked (sent once)
rescheduledAn appointment is moved to a new time
canceledAn appointment is canceled
changedAny change to an appointment, including booking, rescheduling, canceling, and updates to details such as the email address or intake forms
order.completedA 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:

{
  "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:

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:

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.

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:

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 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 500s 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 and our guide to 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 sits in front of your receiver, verifies every Acuity signature, and holds each notification until your service has processed it.

Get started with Hookdeck for free and handle Acuity Scheduling webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.