Webhooks
Registering a webhook
Visit the Webhooks page in the SavvyCal Dashboard and follow the prompts to register a new webhook.
For platforms, register webhooks on the platform settings page under the "Platform webhooks" section.
Listening for events
When an event occurs that triggers a webhook, we will send an HTTP POST to the URL you specified, with a JSON-encoded body of this shape:
POST /my-webhook-receiver HTTP/1.1
host: https://myapp.com
user-agent: SavvyCal Webhooks (https://savvycal.app)
x-savvycal-signature: sha256=6CAA4DEF5C3463B785E885FF19B8987B348E19399D2C5FB291274EDFA7128105
x-savvycal-webhook-id: wh_XXXXXXXXXX
content-type: application/json
{
"id": "evt_d025a96ac0c6",
"account_id": "acct_d025a96ac0c6",
"version": "1.0",
"created_at": "2025-03-12T12:34:55Z",
"data": {
"type": "appointment.created",
"object": {...}
}
}
Verifying signatures
Each webhook delivery includes an x-savvycal-signature header containing an HMAC-SHA256 signature of the raw request body. You can use this to verify that the payload was sent by SavvyCal and has not been tampered with.
To find your webhook's signing secret, open the webhook in the Webhooks page in the SavvyCal Dashboard. The signing secret is displayed in the edit panel.
To verify a signature:
- Read the raw request body (before any JSON parsing).
- Compute the HMAC-SHA256 digest of the body using your webhook's signing secret as the key.
- Hex-encode the digest in uppercase and prepend
sha256=. - Compare the result to the
x-savvycal-signatureheader value using a constant-time comparison function.
JavaScript
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, secret, signatureHeader) {
const digest = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex')
.toUpperCase();
const expected = `sha256=${digest}`;
return (
expected.length === signatureHeader.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader))
);
}
Ruby
require 'openssl'
def verify_webhook_signature(raw_body, secret, signature_header)
digest = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body).upcase
expected = "sha256=#{digest}"
Rack::Utils.secure_compare(expected, signature_header)
end
Event types
The following event types are available. The Schema definitions referenced in the table below represent the shape of the data field in the webhook payload.
| Event Type | Description |
|---|---|
account_user.created | Emitted when a new account user is created. (Schema) |
account_user.deleted | Emitted when an account user is removed. (Schema) |
account_user.updated | Emitted when an existing account user is updated. (Schema) |
appointment.created | Emitted when a new appointment is created. (Schema) |
appointment.rescheduled | Emitted when an existing appointment is rescheduled. (Schema) |
appointment.canceled | Emitted when an existing appointment is canceled. (Schema) |
appointment.confirmed | Emitted when an existing appointment is confirmed. (Schema) |
appointment.deleted | Emitted when an existing appointment is deleted. (Schema) |
appointment.meeting.canceled | Emitted after a meeting is canceled with the conferencing provider. (Schema) |
appointment.meeting.created | Emitted after a meeting is created with the conferencing provider. (Schema) |
appointment.meeting.failed | Emitted when a meeting creation or update terminally fails. (Schema) |
appointment.meeting.updated | Emitted after a meeting is updated with the conferencing provider. (Schema) |
booking_intent.abandoned | Emitted when a booking intent is abandoned. (Schema) |
booking_intent.completed | Emitted when a booking intent is completed. (Schema) |
booking_intent.created | Emitted when a new booking intent is created. (Schema) |
booking_intent.updated | Emitted when an existing booking intent is updated. (Schema) |
block.created | Emitted when a new block is created. (Schema) |
block.updated | Emitted when an existing block is updated. (Schema) |
block.deleted | Emitted when an existing block is deleted. (Schema) |
client.created | Emitted when a new client is created. (Schema) |
client.deleted | Emitted when a client is deleted. (Schema) |
client.updated | Emitted when an existing client is updated. (Schema) |
connected_account.created | Emitted when a new calendar integration is connected. (Schema) |
connected_account.deleted | Emitted when a calendar integration is disconnected. (Schema) |
connected_account.refresh_failed | Emitted when an OAuth token refresh permanently fails. (Schema) |
connected_account.reconnected | Emitted when a previously failed integration is reconnected. (Schema) |
provider.created | Emitted when a new provider is created. (Schema) |
provider.deactivated | Emitted when a provider is deactivated. (Schema) |
provider.reactivated | Emitted when a previously deactivated provider is reactivated. (Schema) |
provider.updated | Emitted when an existing provider is updated. (Schema) |
provider_schedule.created | Emitted when a new provider schedule is created. (Schema) |
provider_schedule.deleted | Emitted when a provider schedule is deleted. (Schema) |
provider_schedule.updated | Emitted when an existing provider schedule is updated. (Schema) |
service.created | Emitted when a new service is created. (Schema) |
service.deleted | Emitted when a service is deleted. (Schema) |
service.updated | Emitted when an existing service is updated. (Schema) |
service_provider.created | Emitted when a provider is added to a service. (Schema) |
service_provider.deleted | Emitted when a provider is removed from a service. (Schema) |
Sensitive fields
Webhook payloads err on the side of caution when it comes to personally identifiable information. Fields like first_name, last_name, email, and phone on client objects will be null in webhook payloads.
If you need access to these fields, fetch the full record via the API after receiving the webhook. For example, after receiving a client.created event, you can call GET /v1/clients/:id to retrieve the complete client data.