# Authentication (/authentication) The SavvyCal REST API is authenticated via bearer tokens. To make authenticated requests, include the API token in the `Authorization` header: ```http Authorization: Bearer ``` There are a few different types of tokens you can use, depending on the context of your request. ## Account API tokens [#account-api-tokens] Account API tokens are long-lived and intended for server-side requests to the SavvyCal API for a single account. To create one, go to [Settings → API tokens](https://savvycal.app/tokens) in the SavvyCal Dashboard and follow the prompts. You can only view the token once, so store it securely. ## Platform API tokens [#platform-api-tokens] Platform API tokens are long-lived and intended for server-side requests to the SavvyCal API on behalf of multiple accounts. To create one, open your platform’s settings in the SavvyCal Dashboard (via the top-left menu) and follow the prompts. You’ll only be able to view the token once, so store it securely. When using a platform token, most endpoints require you to include the account ID in the request headers. ```http Authorization: Bearer X-SavvyCal-Account: ``` See the [Platforms](/platforms) guide for a detailed walkthrough of managing accounts and operating on behalf of them with a platform token. ## JSON Web Tokens [#json-web-tokens] JSON Web Tokens (JWTs) are short-lived tokens used for client-side requests to the SavvyCal API on behalf of a specific user. How it works: 1. Your server generates a JWT, signed with a signing key you create in the SavvyCal Dashboard. 2. Your server sends the JWT to your client. 3. The client includes the JWT in the `Authorization` header for API requests. To create a signing key, go to [Settings → Signing keys](https://savvycal.app/signing_keys) in the SavvyCal Dashboard. You can only view the Private Key once, so store it securely. A Private Key (PEM format) looks like this: ``` -----BEGIN PRIVATE KEY----- MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3znxFBcKjgwaN4wF Ucef2XQOkJCCcAonUwDwQQbXpm2hRANCAATJ4WS1J2k+ZrvbkUFfYwUc2ucFANMf iVmGsbSVWKSXqDMOv7Sjd4tcznUROsO4j3EEpm66kkez7iMJ2gOhHmjK -----END PRIVATE KEY----- ``` Your JWT must: * Use the `ES256` algorithm. * Include the `kid` header (e.g. `kid_f05b815d1852`). * Include the `sub` claim with the target user ID (e.g. `user_0987654321`). * Include the `exp` claim with a short expiration (e.g. one hour). Most languages have JWT libraries available (see [jwt.io/libraries](https://jwt.io/libraries)). We've included a few examples below to get you started. ### JavaScript [#javascript] Here's an example of how to create and sign a JWT using the `jsonwebtoken` library in Node.js: ```javascript const jwt = require("jsonwebtoken"); const fs = require("fs"); // Read your private key from a secure location const privateKey = fs.readFileSync("path/to/private-key.pem", "utf8"); // Your signing key ID from the SavvyCal Dashboard const kid = "kid_f05b815d1852"; // Create the JWT payload with a reasonbly short expiration time (e.g., 1 hour) const payload = { sub: "user_0987654321", exp: Math.floor(Date.now() / 1000) + 60 * 60, }; // Sign the JWT const token = jwt.sign(payload, privateKey, { algorithm: "ES256", header: { kid: kid, }, }); console.log(token); ``` Install the required package: ```bash npm install jsonwebtoken ``` ### Ruby [#ruby] Here's an example of how to create and sign a JWT using the `jwt` gem in Ruby: ```ruby require 'jwt' require 'openssl' # Read your private key from a secure location private_key = OpenSSL::PKey::EC.new(File.read('path/to/private-key.pem')) # Your signing key ID from the SavvyCal Dashboard kid = 'kid_f05b815d1852' # Create the JWT payload with a reasonbly short expiration time (e.g., 1 hour) payload = { sub: 'user_0987654321', exp: Time.now.to_i + 3600 } # Sign the JWT token = JWT.encode(payload, private_key, 'ES256', { kid: kid }) puts token ``` Install the required gem: ```bash gem install jwt ``` # API Changelog (/changelog) A log of notable changes to the SavvyCal Appointments API. ## September 14, 2026 [#september-14-2026] * **Fixed:** -- The privileged [Create Booking Intent](/api/booking-intents/createBookingIntent) endpoint now inherits `enabled` from the service's hold policy when `booking_policy.hold` is supplied without `enabled` (e.g. `{"hold": {}}` or `{"hold": {"duration": "PT30M"}}`). Previously the omitted `enabled` silently defaulted to `false`, disabling the hold on a hold-enabled service and leaving `hold_until` null so the intent reserved no slot while it sat in `slot_selected`. Explicit `enabled: true` or `enabled: false` continues to be honored, an omitted `hold` still inherits the full service policy, and `hold_until` is now set whenever the effective hold is enabled with a duration. See [BookingIntent](/api/schemas/bookingintent). * **Fixed:** -- The `until` field on [RecurrenceRule](/api/schemas/recurrencerule) (and the [Block](/api/schemas/block), [CreateBlockRequest](/api/schemas/createblockrequest), [UpdateBlockRequest](/api/schemas/updateblockrequest), [SlotRule](/api/schemas/slotrule), [Service](/api/schemas/service), [CreateServiceRequest](/api/schemas/createservicerequest), and [UpdateServiceRequest](/api/schemas/updateservicerequest) schemas that embed it) is now typed as `string | null` in the generated TypeScript SDK (`@savvycal/appointments-core`). It previously resolved to `Record | null` because the OpenAPI schema declared `until` with `type: :date` — `date` is a JSON-Schema *format*, not a *type* — so `openapi-typescript` could not render the field and `OpenApiSpex.Plug.CastAndValidate` rejected every authenticated request carrying `recurrence_rule.until` at the cast phase with `"Invalid schema.type. Got: :date"` (HTTP 422) before the controller action ran. The schema now declares `type: :string, format: :date`, so an ISO-8601 date for `recurrence_rule.until` is accepted by [Create Block](/api/blocks/createBlock), [Update Block](/api/blocks/updateBlock), [Create Service](/api/services/createService), and [Update Service](/api/services/updateService), matching the schema's `example: "2024-01-02"` and the encoder serialization (`Date.to_iso8601/1`). ## September 6, 2026 [#september-6-2026] * **New: Schedule Groups** -- Added endpoints for managing schedule groups — named schedule lineages at the account level that providers tag schedules and openings into, and that a service binds a provider to: [List Schedule Groups](/api/schedule-groups/listScheduleGroups), [Create Schedule Group](/api/schedule-groups/createScheduleGroup), [Get Schedule Group](/api/schedule-groups/getScheduleGroup), [Update Schedule Group](/api/schedule-groups/updateScheduleGroup), and [Delete Schedule Group](/api/schedule-groups/deleteScheduleGroup). Delete returns a 409 when a live schedule, opening, or service provider still references the group. See [ScheduleGroup](/api/schemas/schedulegroup). * **Fixed:** -- The `data` field on [CancellationReasonResponse](/api/schemas/cancellationreasonresponse) and [ClientFieldResponse](/api/schemas/clientfieldresponse) is now typed as the resource in the generated TypeScript SDK (`@savvycal/appointments-core`). It previously resolved to `Record` because the OpenAPI schema declared `data` with `type: object, items: ` (the `items` keyword is only valid under `type: array` and was silently dropped during codegen). The runtime wire response was always correct. This restores type safety for [Get Cancellation Reason](/api/cancellation-reasons/getCancellationReason), [Create Cancellation Reason](/api/cancellation-reasons/createCancellationReason), [Update Cancellation Reason](/api/cancellation-reasons/updateCancellationReason), [Get Client Field](/api/client-fields/getClientField), [Create Client Field](/api/client-fields/createClientField), and [Update Client Field](/api/client-fields/updateClientField). ## August 11, 2026 [#august-11-2026] * **New: Role permissions** -- Added endpoints for retrieving and updating roles: [Get Role](/api/roles/getRole) and [Update Role](/api/roles/updateRole). Use `denied_permissions` on [Update Role](/api/roles/updateRole) to narrow a built-in role (denials win over baseline grants, including wildcards); superuser roles cannot have denials. See [UpdateRoleRequest](/api/schemas/updaterolerequest) and [RoleResponse](/api/schemas/roleresponse). * **New:** -- [Role](/api/schemas/role) responses now include `permissions` (granted permissions for the role) and `denied_permissions` (explicit denials that always win over grants, including wildcards). ## July 16, 2026 [#july-16-2026] * **New:** -- The [Create Provider](/api/providers/createProvider) endpoint now accepts an optional `initial_schedule` field for creating the provider's first schedule atomically with the provider. It's ignored when the request reactivates or affiliates an existing provider, and an empty object `{}` still creates a schedule with `effective_from` defaulted to yesterday and no weekly rules. See [InitialProviderSchedule](/api/schemas/initialproviderschedule). * **Changed:** -- Clarified in the [Create Account User](/api/account-users/createAccountUser) docs that providers created via the `create_provider` flag start with no schedules — create schedules separately via [Create Provider Schedule](/api/provider-schedules/createProviderSchedule) or inline via `initial_schedule` on [Create Provider](/api/providers/createProvider). * **Changed:** -- Removed a misleading `default: true` annotation from `public_bookings.enabled` on [CreateProviderScheduleRequest](/api/schemas/createproviderschedulerequest) and [UpdateProviderScheduleRequest](/api/schemas/updateproviderschedulerequest); the field is required, so no default applies. ## June 22, 2026 [#june-22-2026] * **New: Service Conferencing** -- Added endpoints for managing a service's default conferencing across all of its providers: [Set Service Conferencing](/api/services/setServiceConferencing) configures the default from a connected conferencing account (e.g. Zoom) and applies it to every provider from their default user mappings, [Clear Service Conferencing](/api/services/clearServiceConferencing) removes it from the service and all its providers, and [Sync Service Conferencing](/api/services/syncServiceConferencing) re-applies the default to all providers. See [SetServiceConferencingRequest](/api/schemas/setserviceconferencingrequest), [ServiceConferencingResponse](/api/schemas/serviceconferencingresponse), and [ServiceConferencingResult](/api/schemas/serviceconferencingresult). * **New: Service Provider Conferencing** -- Added endpoints for managing the conferencing configuration of an individual provider on a service: [Get Service Provider Conferencing](/api/services/getServiceProviderConferencing), [Set Service Provider Conferencing](/api/services/setServiceProviderConferencing) (identifying the hosting external user by `external_user_id` or `email`), and [Clear Service Provider Conferencing](/api/services/clearServiceProviderConferencing). See [ServiceProviderConferencing](/api/schemas/serviceproviderconferencing), [ServiceProviderConferencingResponse](/api/schemas/serviceproviderconferencingresponse), and [SetServiceProviderConferencingRequest](/api/schemas/setserviceproviderconferencingrequest). * **New:** -- Added webhook events for booking links: `booking_link.created`, `booking_link.updated`, `booking_link.deleted`, `booking_link.activated`, and `booking_link.deactivated`. See [BookingLinkCreatedEventData](/api/schemas/bookinglinkcreatedeventdata), [BookingLinkUpdatedEventData](/api/schemas/bookinglinkupdatedeventdata), [BookingLinkDeletedEventData](/api/schemas/bookinglinkdeletedeventdata), [BookingLinkActivatedEventData](/api/schemas/bookinglinkactivatedeventdata), and [BookingLinkDeactivatedEventData](/api/schemas/bookinglinkdeactivatedeventdata). * **Changed:** -- The [Remove Service Provider](/api/services/deleteServiceProvider) endpoint has moved from `/v1/service_providers/{service_provider_id}` to `/v1/services/{service_id}/providers/{provider_id}`, now taking the service and provider IDs as separate path parameters. ## June 16, 2026 [#june-16-2026] * **New: Booking Links** -- Added endpoints for managing booking links — reusable, shareable configurations for booking a service that can restrict the eligible providers, pre-assign a client, lock fields, and enforce an expiration and usage limits: [Create Booking Link](/api/booking-links/createBookingLink), [List Booking Links](/api/booking-links/listBookingLinks), [Get Booking Link](/api/booking-links/getBookingLink), [Update Booking Link](/api/booking-links/updateBookingLink), and [Delete Booking Link](/api/booking-links/deleteBookingLink). See [BookingLink](/api/schemas/bookinglink). * **New:** -- Added a [Sync External Users](/api/connected-accounts/syncConnectedAccountExternalUsers) endpoint for refreshing a connected account's external users from the provider on demand (e.g. pulling the current list of Zoom users). New users are added, existing ones updated, and removed ones deleted; it returns the full refreshed list. See [SyncExternalUsersResponse](/api/schemas/syncexternalusersresponse) and [ExternalUser](/api/schemas/externaluser). * **Changed:** -- [Set User Mapping](/api/connected-accounts/setUserMapping) now refreshes a connected account's external users from the provider and retries once when an `email` matches no existing external user, so a freshly-added user can be mapped without a manual sync. A 502 is now returned (instead of a 422) when the refresh cannot reach the provider (e.g. the connection needs to be re-authorized). ## June 12, 2026 [#june-12-2026] * **New: Openings** -- Added endpoints for managing openings — one-off ranges of available time for a provider that sit alongside their recurring schedules: [Create Opening](/api/openings/createOpening), [List Openings](/api/openings/listOpenings), [Get Opening](/api/openings/getOpening), [Update Opening](/api/openings/updateOpening), and [Delete Opening](/api/openings/deleteOpening). See [Opening](/api/schemas/opening). * **New:** -- Added webhook events for openings: `opening.created`, `opening.updated`, and `opening.deleted`. See [OpeningCreatedEventData](/api/schemas/openingcreatedeventdata), [OpeningUpdatedEventData](/api/schemas/openingupdatedeventdata), and [OpeningDeletedEventData](/api/schemas/openingdeletedeventdata). * **New: Buffer Policy** -- Services now support a `buffer_policy` for shielding time before and/or after appointments from being booked (e.g. prep time before, charting time after). Configurable via [Create Service](/api/services/createService) and [Update Service](/api/services/updateService). See [BufferPolicy](/api/schemas/bufferpolicy) and [Service](/api/schemas/service). * **New:** -- [Appointment](/api/schemas/appointment) responses now include `buffer_policy`, `buffered_start_at`, and `buffered_end_at` fields. The buffer policy is captured at booking time and continues to shield the surrounding time even if the service configuration changes later; `buffered_start_at` and `buffered_end_at` equal `start_at` and `end_at` when no buffer applies. * **New: User Mappings** -- Added endpoints for managing default mappings between providers and external users on connected accounts (e.g., the Zoom user whose account hosts meetings for a provider): [List User Mappings](/api/connected-accounts/listUserMappings), [Get User Mapping](/api/connected-accounts/getUserMapping), [Set User Mapping](/api/connected-accounts/setUserMapping), and [Delete User Mapping](/api/connected-accounts/deleteUserMapping). Mappings can be set by external user ID or by email. See [UserMapping](/api/schemas/usermapping). * **New:** -- Added a [List External Users](/api/connected-accounts/listConnectedAccountExternalUsers) endpoint for listing users synced from a connected external account (e.g., Zoom users). See [ExternalUser](/api/schemas/externaluser). ## May 18, 2026 [#may-18-2026] * **Changed:** -- The `X-SavvyCal-Account` header is no longer listed as a parameter on individual operations in the OpenAPI spec or API reference; it's now documented once on the [Platforms](/platforms) and [Authentication](/authentication) pages. The header is unchanged at the protocol level — it is still required when authenticating with a platform token to specify the target account. * **New:** -- The [Create Provider](/api/providers/createProvider) and [Update Provider](/api/providers/updateProvider) endpoints now accept an optional `time_zone` field for setting the provider's preferred IANA time zone. When omitted on create, it defaults to the account location's time zone. See [CreateProviderRequest](/api/schemas/createproviderrequest) and [UpdateProviderRequest](/api/schemas/updateproviderrequest). ## May 16, 2026 [#may-16-2026] * **Changed:** -- The `start_date` and `end_date` fields on [Block](/api/schemas/block), [CreateBlockRequest](/api/schemas/createblockrequest), and [UpdateBlockRequest](/api/schemas/updateblockrequest) have been consolidated into a single `date` field. The [List Blocks](/api/blocks/listBlocks) endpoint's `start_date`, `end_date`, `start_date_min`, `start_date_max`, `end_date_min`, and `end_date_max` query parameters have likewise been replaced with `date`, `date_min`, and `date_max`, and results are now sorted by `date`. ## May 15, 2026 [#may-15-2026] * **New:** -- [Provider](/api/schemas/provider) and [AppointmentProvider](/api/schemas/appointmentprovider) responses now include a `time_zone` field — the provider's preferred IANA time zone, used as the default when authoring schedules. * **New:** -- Provider schedules now have a `time_zone` field. [ProviderSchedule](/api/schemas/providerschedule) responses include the IANA time zone the schedule's weekly rules are authored in, and the [Create Provider Schedule](/api/provider-schedules/createProviderSchedule) and [Update Provider Schedule](/api/provider-schedules/updateProviderSchedule) endpoints accept an optional `time_zone` to declare it; when omitted, it defaults to the provider's time zone. ## May 12, 2026 [#may-12-2026] * **New:** -- [AppointmentMeeting](/api/schemas/appointmentmeeting) now includes a `host_url` field — a proxy URL on the account's booking domain that the meeting host follows to start the meeting. SavvyCal refreshes the underlying conferencing-provider URL behind the proxy transparently, so the proxy URL is generally stable across `appointment.meeting.updated` events, though it may change if SavvyCal rotates the underlying token. It always changes when the meeting is replaced (e.g. a reschedule routes the appointment to a different host on the account, producing a brand-new meeting with a new `id`). **Treat as a credential** — anyone with this URL can start the meeting as host. Valid through the appointment's end time + a 24h grace window, then returns 410 Gone. Listen for `appointment.meeting.updated` and `appointment.meeting.replaced` to keep stored copies fresh. `null` when the meeting is not yet `created` or when the conferencing provider has no distinct host start URL (e.g., Google Meet). * **New:** -- `client_data` on [Appointment](/api/schemas/appointment) and [BookingIntent](/api/schemas/bookingintent) now includes a `fields` map containing custom client field values, keyed by field slug. Values for fields flagged as sensitive are redacted unless `include_sensitive` is `true`. * **Changed:** -- The `client_data` input on appointment and booking intent endpoints is now exposed as named, reusable schemas: [AppointmentClientDataInput](/api/schemas/appointmentclientdatainput) (used by [CreateAppointmentRequest](/api/schemas/createappointmentrequest) and [CreatePublicAppointmentRequest](/api/schemas/createpublicappointmentrequest)) and [ClientDataInput](/api/schemas/clientdatainput) (used by booking intent create/update requests). Shapes are unchanged; the `fields` description has been tightened to clarify that each key must correspond to a Client Field defined in the account — arbitrary key-value pairs are not accepted. ## May 7, 2026 [#may-7-2026] * **New:** -- [Appointment](/api/schemas/appointment) and [PublicAppointment](/api/schemas/publicappointment) now expose individual top-level URL fields. [Appointment](/api/schemas/appointment) exposes `add_to_calendar_url`, `cancel_url`, `confirm_url`, `ics_url`, `join_url`, and `reschedule_url`. [PublicAppointment](/api/schemas/publicappointment) exposes `add_to_calendar_url`, `cancel_url`, `ics_url`, and `reschedule_url`. Each is a single canonical URL string that resolves to the account's active custom domain when configured. There is no top-level replacement for `urls.add_to_google_calendar`; use `add_to_calendar_url` instead. * **Deprecated:** -- The `urls` object on [Appointment](/api/schemas/appointment) and [PublicAppointment](/api/schemas/publicappointment) is deprecated in favor of the new top-level URL fields and will be removed in a future release. ## April 30, 2026 [#april-30-2026] * **New:** -- Added two endpoints for listing time slots based on a booking intent's configuration: [List available slots for a booking intent](/api/time-slots/listBookingIntentSlots) (authenticated) and [List available slots for a booking intent](/api/public-booking/listPublicBookingIntentSlots) (public). Both return the new [SlotListResponse](/api/schemas/slotlistresponse) schema. * **New:** -- [Appointment](/api/schemas/appointment) responses now include a `meeting` field with conferencing details (provider, join URL, password, etc.) and an asynchronous lifecycle status. See [AppointmentMeeting](/api/schemas/appointmentmeeting). * **New:** -- Added webhook events for the appointment meeting lifecycle: `appointment.meeting.created`, `appointment.meeting.updated`, `appointment.meeting.canceled`, and `appointment.meeting.replaced`, plus terminal-failure events `appointment.meeting.create_failed`, `appointment.meeting.update_failed`, `appointment.meeting.cancel_failed`, and `appointment.meeting.replace_failed`. See [AppointmentMeetingCreatedEventData](/api/schemas/appointmentmeetingcreatedeventdata), [AppointmentMeetingUpdatedEventData](/api/schemas/appointmentmeetingupdatedeventdata), [AppointmentMeetingCanceledEventData](/api/schemas/appointmentmeetingcanceledeventdata), [AppointmentMeetingReplacedEventData](/api/schemas/appointmentmeetingreplacedeventdata), [AppointmentMeetingCreateFailedEventData](/api/schemas/appointmentmeetingcreatefailedeventdata), [AppointmentMeetingUpdateFailedEventData](/api/schemas/appointmentmeetingupdatefailedeventdata), [AppointmentMeetingCancelFailedEventData](/api/schemas/appointmentmeetingcancelfailedeventdata), and [AppointmentMeetingReplaceFailedEventData](/api/schemas/appointmentmeetingreplacefailedeventdata). * **New:** -- Booking intents now support secondary providers via a `secondary_providers` field on [CreateBookingIntentRequest](/api/schemas/createbookingintentrequest), [UpdateBookingIntentRequest](/api/schemas/updatebookingintentrequest), and [BookingIntent](/api/schemas/bookingintent). * **New:** -- [Appointment](/api/schemas/appointment) responses now include a `providers` array containing each assigned provider with their role (`primary` or `secondary`). See [AppointmentProvider](/api/schemas/appointmentprovider). * **New:** -- [Dashboard sessions](/api/schemas/dashboardsession) now support an `idle_timeout_minutes` field for configuring the inactivity period before re-authentication is required. See [Create Dashboard Session](/api/dashboard-sessions/createDashboardSession). * **Changed:** -- The `source` field on [ConfirmationEvent](/api/schemas/confirmationevent) and [RescheduleEvent](/api/schemas/rescheduleevent) now includes a `public_api` value to distinguish events initiated through the public API. * **Changed:** -- The `PublicServiceSlotsResponse` schema has been renamed to [SlotListResponse](/api/schemas/slotlistresponse) and is now used by both authenticated and public slot listing endpoints. The response shape is unchanged. ## March 25, 2026 [#march-25-2026] * **New:** -- Booking intents now include `requirements` and `workflow` fields. Requirements provides per-section completeness status for booking, info, and form submission steps. Workflow provides server-computed step routing, completability, and defunct state. See [BookingIntentRequirements](/api/schemas/bookingintentrequirements), [BookingIntentWorkflow](/api/schemas/bookingintentworkflow), and [SubmissionRequirement](/api/schemas/submissionrequirement). * **Changed:** -- The `locked_fields` field on [BookingIntent](/api/schemas/bookingintent), [PublicBookingIntent](/api/schemas/publicbookingintent), and related request schemas is now a typed enum array instead of a freeform string array. * **Changed:** -- The `locked_fields` enum values `start_at`, `end_at`, and `time_zone` have been consolidated into a single `slot` value on [BookingIntent](/api/schemas/bookingintent), [PublicBookingIntent](/api/schemas/publicbookingintent), and related request schemas. ## March 23, 2026 [#march-23-2026] * **New:** -- Added a [BookingIntentBookingPolicy](/api/schemas/bookingintentbookingpolicy) schema representing hold policy overrides applied to a booking intent. * **Changed:** -- The `hold_duration` and `hold_enabled` fields on [BookingIntent](/api/schemas/bookingintent), [CreateBookingIntentRequest](/api/schemas/createbookingintentrequest), and [UpdateBookingIntentRequest](/api/schemas/updatebookingintentrequest) have been replaced with a nested `booking_policy` object. Hold settings are now at `booking_policy.hold.duration` and `booking_policy.hold.enabled`. ## March 21, 2026 [#march-21-2026] * **New:** -- Booking intents now include a `submissions` field for attaching form responses during the booking flow. See [BookingIntent](/api/schemas/bookingintent) and [IntentSubmissionInput](/api/schemas/intentsubmissioninput). * **New:** -- The [Get Client](/api/clients/getClient) and [List Clients](/api/clients/listClients) endpoints now support an `include_sensitive` query parameter for controlling whether sensitive fields (name, email, phone) are included in the response. * **New:** -- Booking intents now include an `errors` field containing validation errors in JSON:API format. See [BookingIntent](/api/schemas/bookingintent) and [JsonError](/api/schemas/jsonerror). * **New:** -- Added a [JsonError](/api/schemas/jsonerror) schema representing a single validation error object. ## March 20, 2026 [#march-20-2026] * **New: Forms** -- Added endpoints for managing intake forms with typed fields: [Create Form](/api/forms/createForm), [Get Form](/api/forms/getForm), [List Forms](/api/forms/listForms), [Update Form](/api/forms/updateForm), and [Delete Form](/api/forms/deleteForm). See [Form](/api/schemas/form). * **New: Client Fields** -- Added endpoints for managing custom client fields: [Create Client Field](/api/client-fields/createClientField), [Get Client Field](/api/client-fields/getClientField), [List Client Fields](/api/client-fields/listClientFields), [Update Client Field](/api/client-fields/updateClientField), and [Delete Client Field](/api/client-fields/deleteClientField). See [ClientField](/api/schemas/clientfield). * **New: Service Forms** -- Added endpoints for attaching forms to services: [Attach Service Form](/api/services/attachServiceForm), [List Service Forms](/api/services/listServiceForms), [Sort Service Forms](/api/services/sortServiceForms), and [Detach Service Form](/api/services/detachServiceForm). See [ServiceForm](/api/schemas/serviceform). * **New:** -- Services now include a `forms` field containing attached intake forms. See [Service](/api/schemas/service). ## March 10, 2026 [#march-10-2026] * **Removed:** -- The deprecated `client_locale`, `client_reference_id`, `client_time_zone`, and `fields` properties have been removed from the [Appointment](/api/schemas/appointment) schema. Use `client_data` instead. ## March 5, 2026 [#march-5-2026] * **New:** -- Clients and services now support a `metadata` field for storing custom key-value pairs (maximum 16 KB). See [Client](/api/schemas/client) and [Service](/api/schemas/service). * **New:** -- The [List Clients](/api/clients/listClients) and [List Services](/api/services/listServices) endpoints now support filtering by `metadata` using bracket notation. * **New:** -- Added a [ForbiddenResponse](/api/schemas/forbiddenresponse) schema and 403 status code to the [List Appointments](/api/appointments/listAppointments) endpoint. ## March 2, 2026 [#march-2-2026] * **New:** -- Services now include an optional `internal_name` field for setting an internal admin label. When set, this name is displayed in the admin UI instead of the public name. See [Service](/api/schemas/service). ## March 1, 2026 [#march-1-2026] * **New:** -- Services now include a `provider_notifications` field for configuring staff email notification settings, including which notification types are enabled and additional recipients. See [ProviderNotificationConfig](/api/schemas/providernotificationconfig) and [ProviderNotificationType](/api/schemas/providernotificationtype). ## February 27, 2026 [#february-27-2026] * **New:** -- [PublicAppointment](/api/schemas/publicappointment) now includes a `urls` field containing client-facing action URLs for rescheduling, canceling, adding to calendar, downloading ICS files, and adding to Google Calendar. ## February 26, 2026 [#february-26-2026] * **New:** -- Error responses now include an optional `code` field containing a machine-readable error code (e.g. `slot_unavailable`, `no_available_provider`). See [GenericErrorResponse](/api/schemas/genericerrorresponse) and [JsonErrorResponse](/api/schemas/jsonerrorresponse). ## February 25, 2026 [#february-25-2026] * **Changed:** -- The `from` and `until` query parameters on the [List available time slots](/api/time-slots/listServiceSlots) and [List public time slots](/api/public-booking/listPublicServiceSlots) endpoints are now optional. `from` defaults to today if not provided. Either `until` or `limit` must be provided. * **New:** -- Added a `limit` query parameter to the [List available time slots](/api/time-slots/listServiceSlots) and [List public time slots](/api/public-booking/listPublicServiceSlots) endpoints as an alternative to `until` for controlling the number of aggregated slots returned. * **New:** -- Added webhook events for booking intents: `booking_intent.created`, `booking_intent.updated`, `booking_intent.completed`, and `booking_intent.abandoned`. See [Webhooks](/webhooks). * **New:** -- Booking intent endpoints now accept an `auto_assign_provider` flag. When `true`, an available provider is automatically assigned for the selected time slot if no `provider_id` is specified. Supported on create, update, and complete operations for both [authenticated](/api/booking-intents/createBookingIntent) and [public](/api/public-booking/createPublicBookingIntent) booking intents. ## February 24, 2026 [#february-24-2026] * **New: Booking Intents** -- Added 6 authenticated endpoints under `/v1/booking_intents` and 5 public endpoints under `/v1/public/booking_intents` for managing multi-step booking flows with time slot holding. See [Booking Intents](/api/booking-intents/createBookingIntent) and [Public Booking Intents](/api/public-booking/createPublicBookingIntent). * **New: Hold Policy** -- Services now include a `hold` field within `booking_policy` for configuring time slot holds during the booking flow. See [HoldPolicy](/api/schemas/holdpolicy) and [BookingPolicy](/api/schemas/bookingpolicy). * **New: Booking Intent schemas** -- Added [BookingIntent](/api/schemas/bookingintent), [PublicBookingIntent](/api/schemas/publicbookingintent), [HoldPolicy](/api/schemas/holdpolicy), and related request/response types. # Idempotency (/idempotency) Network blips and timeouts make it easy to accidentally retry the same request twice — and on unsafe operations like creating a booking intent or completing an appointment, that retry can produce duplicate side effects (a double-booked slot, a duplicate charge). To make retries safe, the SavvyCal API accepts an `Idempotency-Key` header on unsafe (`POST`, `PATCH`, `DELETE`) endpoints. ## How it works [#how-it-works] Include an `Idempotency-Key` header on any unsafe request with a value unique to that logical operation: ```http POST /v1/booking_intents Authorization: Bearer Content-Type: application/json Idempotency-Key: 4f0e0e2d-9b6a-4d7a-8b9f-2f6c1f0b1f6c ``` The first request with a given key executes normally and the response is stored. Any subsequent request with the same key returns the original response without re-executing the action. A good key is opaque, unique, and reused only for retries of the **same** logical operation. UUIDs are a fine default. ## Behaviors [#behaviors] | Scenario | HTTP status | Notes | | --------------------------------------------------- | ----------- | ---------------------------------------------------------------------- | | First request with a key | normal | Response is stored under the key for 24h. | | Retry with the same key and the same body | normal | The stored response is returned byte-for-byte. | | Retry with the same key but a different body | `422` | The key is considered reused with a different request. Pick a new key. | | Retry while the original request is still in flight | `409` | Another request has claimed the key. Wait and retry. | | Retry after the 24h retention window | normal | The stored response has expired; the action runs again. | Replayed responses include two extra headers: ```http Idempotency-Replayed: true Idempotency-Replayed-At: 2026-05-11T18:42:09Z ``` The absence of `Idempotency-Replayed` on a response means the request executed for the first time under that key. ## Scope [#scope] Keys are scoped **per account**. Two different accounts can use the same key string with no collision. Within an account, every unsafe endpoint shares the same keyspace, so you can use the same key across different routes without interference — though we recommend a fresh key per logical operation. ## Body matching [#body-matching] The body comparison is **raw-byte** based — two semantically-equivalent JSON requests with different key ordering or whitespace will be treated as different requests and return `422`. Send byte-identical retries. ## Retention and error responses [#retention-and-error-responses] * Stored responses expire after **24 hours**. * Both successful (`2xx`) and client error (`4xx`) responses are stored and replayed. * Server error (`5xx`) responses are also stored. This protects you from duplicate side effects when an action has committed work but failed during response rendering. If you receive a stored `5xx` on retry and want to attempt the action again, use a new `Idempotency-Key`. ## Supported endpoints [#supported-endpoints] `Idempotency-Key` is supported on every unsafe endpoint under: * `POST /v1/{resource}`, `PATCH /v1/{resource}/:id`, `DELETE /v1/{resource}/:id` — authenticated routes with an account scope (i.e. all of `/v1` except account- and platform-level admin endpoints). * `POST`, `PATCH`, and the cancel/reschedule/complete/abandon variants under `/v1/public/booking_intents` and `/v1/public/appointments`. Safe methods (`GET`, `HEAD`) ignore the header; sending one has no effect. ## Header validation [#header-validation] The header value must be: * 1 to 255 characters long after trimming whitespace * Printable ASCII (no spaces, no control characters) A malformed header — or more than one `Idempotency-Key` header on the same request — returns `400 Bad Request`. ## Best practices [#best-practices] * Generate a new key for each logical operation. Reusing a key across different requests forces you to send byte-identical bodies. * Retry on network errors and `5xx` responses with the **same** key. The server will replay the original response if the action already succeeded, or run it for the first time if it hadn't. * If a retry returns `409`, the original request is still running. Wait briefly and retry the same key; once the server finishes, you'll get the replay. * If a retry returns `422` with a "different request" message, you've reused a key with a different body. Pick a new key. # SavvyCal Appointments Developer Docs (/) ## Getting started [#getting-started] ## Concepts [#concepts] ## REST API [#rest-api] The SavvyCal Appointments API adheres to [REST](http://en.wikipedia.org/wiki/Representational_State_Transfer) conventions. It communicates in JSON and authenticates with [bearer tokens](/authentication). New fields may be added to existing payload types as the API expands. The full OpenAPI specification is at [https://api.savvycal.app/v1/spec](https://api.savvycal.app/v1/spec). ## More [#more] # Rate Limiting (/rate-limiting) The SavvyCal API enforces rate limits to protect against abuse and ensure fair usage across all consumers. ## Limits [#limits] | API | Limit | Key | | --------------------------- | ----------------------- | ------------------------ | | Authenticated API (`/v1/*`) | 300 requests per minute | Per bearer token or user | | Public API (`/v1/public/*`) | 120 requests per minute | Per IP address | ## Rate limit responses [#rate-limit-responses] When you exceed the rate limit, the API returns a `429 Too Many Requests` response with a `retry-after` header indicating how many seconds to wait before retrying. ```http HTTP/1.1 429 Too Many Requests retry-after: 30 content-type: application/json { "errors": [ { "detail": "Rate limit exceeded. Please try again later.", "status": "429" } ] } ``` ## Best practices [#best-practices] * **Check the `retry-after` header** when you receive a 429 response and wait the specified number of seconds before retrying. * **Use exponential backoff** for retry logic to avoid immediately hitting the limit again. * **Cache responses** where possible to reduce the number of requests you need to make. # Webhooks (/webhooks) ## Registering a webhook [#registering-a-webhook] Visit the [Webhooks](https://savvycal.app/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 [#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 [#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](https://savvycal.app/webhooks) page in the SavvyCal Dashboard. The signing secret is displayed in the edit panel. To verify a signature: 1. Read the raw request body (before any JSON parsing). 2. Compute the HMAC-SHA256 digest of the body using your webhook's signing secret as the key. 3. Hex-encode the digest in uppercase and prepend `sha256=`. 4. Compare the result to the `x-savvycal-signature` header value using a constant-time comparison function. ### JavaScript [#javascript] ```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 [#ruby] ```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 [#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](/api/schemas/accountusercreatedeventdata)) | | `account_user.deleted` | Emitted when an account user is removed. ([Schema](/api/schemas/accountuserdeletedeventdata)) | | `account_user.updated` | Emitted when an existing account user is updated. ([Schema](/api/schemas/accountuserupdatedeventdata)) | | `appointment.created` | Emitted when a new appointment is created. ([Schema](/api/schemas/appointmentcreatedeventdata)) | | `appointment.rescheduled` | Emitted when an existing appointment is rescheduled. ([Schema](/api/schemas/appointmentrescheduledeventdata)) | | `appointment.canceled` | Emitted when an existing appointment is canceled. ([Schema](/api/schemas/appointmentcanceledeventdata)) | | `appointment.confirmed` | Emitted when an existing appointment is confirmed. ([Schema](/api/schemas/appointmentconfirmedeventdata)) | | `appointment.deleted` | Emitted when an existing appointment is deleted. ([Schema](/api/schemas/appointmentdeletedeventdata)) | | `appointment.meeting.canceled` | Emitted after a meeting is canceled with the conferencing provider. ([Schema](/api/schemas/appointmentmeetingcanceledeventdata)) | | `appointment.meeting.cancel_failed` | Emitted when meeting cancellation with the conferencing provider terminally fails. ([Schema](/api/schemas/appointmentmeetingcancelfailedeventdata)) | | `appointment.meeting.created` | Emitted after a meeting is created with the conferencing provider. ([Schema](/api/schemas/appointmentmeetingcreatedeventdata)) | | `appointment.meeting.create_failed` | Emitted when initial meeting creation with the conferencing provider terminally fails. ([Schema](/api/schemas/appointmentmeetingcreatefailedeventdata)) | | `appointment.meeting.replaced` | Emitted after a meeting is replaced with a brand-new meeting at the conferencing provider, typically after a reschedule changes the primary provider. ([Schema](/api/schemas/appointmentmeetingreplacedeventdata)) | | `appointment.meeting.replace_failed` | Emitted when a meeting replacement with the conferencing provider terminally fails. ([Schema](/api/schemas/appointmentmeetingreplacefailedeventdata)) | | `appointment.meeting.updated` | Emitted after a meeting is updated with the conferencing provider. ([Schema](/api/schemas/appointmentmeetingupdatedeventdata)) | | `appointment.meeting.update_failed` | Emitted when a meeting update with the conferencing provider terminally fails. ([Schema](/api/schemas/appointmentmeetingupdatefailedeventdata)) | | `booking_intent.abandoned` | Emitted when a booking intent is abandoned. ([Schema](/api/schemas/bookingintentabandonedeventdata)) | | `booking_intent.completed` | Emitted when a booking intent is completed. ([Schema](/api/schemas/bookingintentcompletedeventdata)) | | `booking_intent.created` | Emitted when a new booking intent is created. ([Schema](/api/schemas/bookingintentcreatedeventdata)) | | `booking_intent.updated` | Emitted when an existing booking intent is updated. ([Schema](/api/schemas/bookingintentupdatedeventdata)) | | `block.created` | Emitted when a new block is created. ([Schema](/api/schemas/blockcreatedeventdata)) | | `block.updated` | Emitted when an existing block is updated. ([Schema](/api/schemas/blockupdatedeventdata)) | | `block.deleted` | Emitted when an existing block is deleted. ([Schema](/api/schemas/blockdeletedeventdata)) | | `client.created` | Emitted when a new client is created. ([Schema](/api/schemas/clientcreatedeventdata)) | | `client.deleted` | Emitted when a client is deleted. ([Schema](/api/schemas/clientdeletedeventdata)) | | `client.updated` | Emitted when an existing client is updated. ([Schema](/api/schemas/clientupdatedeventdata)) | | `connected_account.created` | Emitted when a new calendar integration is connected. ([Schema](/api/schemas/connectedaccountcreatedeventdata)) | | `connected_account.deleted` | Emitted when a calendar integration is disconnected. ([Schema](/api/schemas/connectedaccountdeletedeventdata)) | | `connected_account.refresh_failed` | Emitted when an OAuth token refresh permanently fails. ([Schema](/api/schemas/connectedaccountrefreshfailedeventdata)) | | `connected_account.reconnected` | Emitted when a previously failed integration is reconnected. ([Schema](/api/schemas/connectedaccountreconnectedeventdata)) | | `provider.created` | Emitted when a new provider is created. ([Schema](/api/schemas/providercreatedeventdata)) | | `provider.deactivated` | Emitted when a provider is deactivated. ([Schema](/api/schemas/providerdeactivatedeventdata)) | | `provider.reactivated` | Emitted when a previously deactivated provider is reactivated. ([Schema](/api/schemas/providerreactivatedeventdata)) | | `provider.updated` | Emitted when an existing provider is updated. ([Schema](/api/schemas/providerupdatedeventdata)) | | `provider_schedule.created` | Emitted when a new provider schedule is created. ([Schema](/api/schemas/providerschedulecreatedeventdata)) | | `provider_schedule.deleted` | Emitted when a provider schedule is deleted. ([Schema](/api/schemas/providerscheduledeletedeventdata)) | | `provider_schedule.updated` | Emitted when an existing provider schedule is updated. ([Schema](/api/schemas/providerscheduleupdatedeventdata)) | | `service.created` | Emitted when a new service is created. ([Schema](/api/schemas/servicecreatedeventdata)) | | `service.deleted` | Emitted when a service is deleted. ([Schema](/api/schemas/servicedeletedeventdata)) | | `service.updated` | Emitted when an existing service is updated. ([Schema](/api/schemas/serviceupdatedeventdata)) | | `service_provider.created` | Emitted when a provider is added to a service. ([Schema](/api/schemas/serviceprovidercreatedeventdata)) | | `service_provider.deleted` | Emitted when a provider is removed from a service. ([Schema](/api/schemas/serviceproviderdeletedeventdata)) | ## Sensitive fields [#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. # Help (/help) # Integrating with Zoom (/help/integrating-with-zoom) Our first-class Zoom integration allows you to automatically create a scheduled Zoom meeting when someone books an appointment via SavvyCal. ## Adding the App [#adding-the-app] To connect your Zoom account to SavvyCal, click **Settings → Conferencing**. Then, click **Connect** on the Zoom app: The Conferencing settings page with a Connect button on the Zoom app ## Usage [#usage] Once you've connected your Zoom account, you can choose it as the conferencing provider when editing the settings on a Service. A service's conferencing settings with Zoom selected as the provider When someone books an appointment for the service, SavvyCal will automatically create a scheduled Zoom meeting with a unique meeting ID for that appointment. We'll include instructions for joining the call in the follow-up communication with the client. ## Removing the App [#removing-the-app] Disconnecting Zoom will mean that new appointments will no longer have a Zoom meeting created. Your Zoom account information will be deleted from our systems upon disconnection. If you'd like to disconnect your Zoom account from SavvyCal, follow these steps: 1. Click on **Settings** → **Conferencing**, then click **Disconnect** on the Zoom app in SavvyCal. 2. Login to your Zoom account and navigate to the Zoom App Marketplace. 3. Click **Manage** → **Installed Apps** or search for the **SavvyCal Appointments** app. 4. Click the **SavvyCal Appointments** app. 5. Click **Remove**. The Zoom App Marketplace page for SavvyCal Appointments with a Remove button **Need a hand?** Reach out to our friendly support team at [support@savvycal.app](mailto:support@savvycal.app) (available Monday - Friday, 9:00 am - 5:00 pm US Central Time). You can expect to hear back from us within 24 hours. # User roles (/help/user-roles) There are several predefined roles you can assign to users when adding them to a SavvyCal Appointments account. These roles determine what permissions the user will have on the account. | Role | Description | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Superuser | Full access to the entire account (including billing). This is the highest level of permissions available. | | Admin | Manage appointments, services, staff members, and clients. Limited access to account settings. People with this role can add/invite other users to join the account in the Admin or Staff role. | | Staff | Limited access to manage their own appointments and schedules. People with this role cannot modify service settings, cannot add/invite other users, and cannot modify core account settings. | To manage member access, navigate to **Settings** → **Members & permissions** from the top navigation. # Booking Intents (/booking-intents) A [`BookingIntent`](/api/schemas/bookingintent) tracks the lifecycle of a multi-step booking attempt, enabling slot holding and progressive data collection before creating an appointment. Booking intents are useful when your booking flow spans multiple steps or screens. Rather than collecting all information upfront and creating an appointment in a single call, you can create a booking intent early in the flow, hold the selected time slot so it isn't taken by someone else, and gradually attach data as the user progresses. When the user confirms, you complete the intent and an [`Appointment`](/api/schemas/appointment) is created. ## Lifecycle [#lifecycle] A booking intent moves through these statuses: ``` pending ──▶ slot_selected ──▶ completed │ └──▶ abandoned ``` | Status | Description | | --------------- | ------------------------------------------------------------------------------------------------------------------- | | `pending` | The initial status. No time slot has been selected yet. | | `slot_selected` | A time slot has been selected (`start_at` and `end_at` are set). The slot may be held depending on the hold policy. | | `completed` | The booking intent has been completed and an [`Appointment`](/api/schemas/appointment) has been created. | | `abandoned` | The booking intent has been explicitly abandoned. Any active slot hold is released. | Status transitions happen automatically. When you update a booking intent to include `start_at` and `end_at`, the status transitions from `pending` to `slot_selected`. You don't set the status directly — it is derived from the state of the intent. ## Slot holding [#slot-holding] Slot holding prevents double-booking by temporarily blocking a time slot while the user completes the booking flow. When enabled, the selected slot is blocked from availability for other callers until the hold expires or the intent is completed or abandoned. Hold settings are configured via the `booking_policy.hold` object (see [BookingIntentBookingPolicy](/api/schemas/bookingintentbookingpolicy)): | Field | Type | Description | | ------------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------- | | `booking_policy.hold.enabled` | boolean | Whether slot holding is enabled. When omitted at creation, falls back to the service's hold policy. | | `booking_policy.hold.duration` | string | The hold duration as an ISO 8601 duration string (e.g., `PT10M` for 10 minutes). Falls back to the service's hold policy. | | `hold_until` | string | Read-only. The UTC datetime when the current hold expires. Set automatically when a slot is selected with holding enabled. | When a service is set on the booking intent, the service's default hold policy (the "Hold selected slots" setting under Booking Policy) is automatically applied unless `booking_policy.hold` is explicitly provided when creating or updating the booking intent. When a hold expires, the slot becomes available to other callers again. The booking intent itself remains in `slot_selected` status — an expired hold does not abandon the intent. ## Progressive client data [#progressive-client-data] The `client_data` field lets you collect client information incrementally across multiple updates. Each update **merges** the provided fields with existing values rather than replacing them entirely. For example, you might collect the client's name on one screen and their email on the next: ```bash # Step 1: Collect name curl -X PATCH /v1/booking_intents/:id \ -d '{"client_data": {"first_name": "Jane", "last_name": "Doe"}}' # Step 2: Collect email curl -X PATCH /v1/booking_intents/:id \ -d '{"client_data": {"email": "jane@example.com"}}' ``` After both updates, `client_data` contains all three fields. When the booking intent is completed, this data is used to create or match a [`Client`](/api/schemas/client) and populate the resulting appointment. The available `client_data` fields are: `first_name`, `last_name`, `email`, `phone`, `locale`, `time_zone`, and `reference_id`. ## Form submissions [#form-submissions] The `submissions` field lets you accumulate form responses during the booking flow. Each entry contains a `form_id` (the form's object ID) and a `responses` map of field answers. When a service has forms attached, these submissions are collected progressively and validated at completion time. Each update **merges** by `form_id` — providing a submission for an existing form replaces that entry, while new form IDs are appended: ```bash # Step 1: Submit responses for the first form curl -X PATCH /v1/booking_intents/:id \ -d '{ "submissions": [ {"form_id": "frm_abc123def456", "responses": {"field_1": "Answer A"}} ] }' # Step 2: Submit responses for a second form curl -X PATCH /v1/booking_intents/:id \ -d '{ "submissions": [ {"form_id": "frm_def456abc123", "responses": {"field_2": "Answer B"}} ] }' ``` After both updates, the intent's `submissions` array contains entries for both forms. ## Workflow [#workflow] The `workflow` property is a server-computed object that provides step routing and completability signals for the booking flow. It appears on single-intent responses (get, create, update, complete) but is `null` on list responses. Rather than inferring flow state from raw fields like `status`, `locked_fields`, and `client_data`, the frontend can use `workflow` to determine which step to show, which steps are available, and whether the intent can be completed. | Field | Type | Description | | ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `resume_step` | string | The step the UI should show on load or resume. One of: `booking`, `info`, `form:`, `confirmed`, `defunct`. | | `available_steps` | string\[] | Ordered list of steps the user can visit. Derived from the service's forms and `locked_fields`. | | `can_complete` | boolean | Whether the intent has enough valid data to complete right now. | | `can_change_slot` | boolean | Whether slot fields (`start_at`, `end_at`, `time_zone`) are unlocked and can be modified. | | `is_defunct` | boolean | Whether the intent is in an unrecoverable state and should render a terminal UI. | | `defunct_reason` | string \| null | Why the intent is defunct. One of: `abandoned`, `no_available_steps`, `slot_expired`, `status_invalid`. Null when not defunct. | When `is_defunct` is `true`, `available_steps` is cleared to an empty array and `resume_step` is set to `defunct`. Locked fields affect which steps appear in `available_steps`: * Locking `slot` removes the `booking` step. * Locking `client_data` removes the `info` step. * Locking `submissions` removes all `form:` steps. ## Requirements [#requirements] The `requirements` property provides per-section completeness status, enabling the frontend to show progress indicators or prevent navigation to the complete step without parsing error details. Like `workflow`, it appears on single-intent responses but is `null` on list responses. | Field | Type | Description | | ------------- | ------ | ------------------------------------------------------------------------------------------------------------------------ | | `booking` | object | `{ complete: boolean }` — whether the booking step is complete (provider and slot are set). | | `info` | object | `{ complete: boolean }` — whether the info step is complete (required client fields are present, or a client is linked). | | `submissions` | array | Per-form completeness: `[{ form_id: string, complete: boolean }]`, in the same order as the service's forms. | A booking intent is ready to complete when all applicable requirements are satisfied, which is also reflected by `workflow.can_complete`. ## Completing a booking intent [#completing-a-booking-intent] To complete a booking intent, call the [Complete booking intent](/api/booking-intents/completeBookingIntent) endpoint. Completion requires: * A time slot must be set (`start_at` and `end_at`). * A service must be assigned. * All required client data must be provided (or a client must be linked). * All required form submissions must be present (if the service has forms). You can check `workflow.can_complete` to determine whether the intent is ready to complete, and inspect `requirements` to see which sections still need data. The complete endpoint accepts the same update fields as the update endpoint (`service_id`, `provider_id`, `start_at`, `end_at`, `client_data`, `submissions`, etc.). These fields are applied atomically before the intent is completed, so you can finalize data and complete in a single request. On success, the booking intent transitions to `completed` status and the resulting [`Appointment`](/api/schemas/appointment) is available in the `appointment` field of the response. ## Abandoning a booking intent [#abandoning-a-booking-intent] Call the [Abandon booking intent](/api/booking-intents/abandonBookingIntent) endpoint to explicitly abandon a booking intent. This releases any active slot hold and transitions the status to `abandoned`. Abandon a booking intent when the user exits the booking flow without completing it, so the held slot is freed up for other callers. ## Authenticated vs. public API [#authenticated-vs-public-api] Booking intents are available through two API surfaces: * **Authenticated API** — Uses your API key. Full control over all fields. Returns the complete [`BookingIntent`](/api/schemas/bookingintent) object. * **Public API** — No API key required. Intended for client-side use. Restricted capabilities and returns a simplified [`PublicBookingIntent`](/api/schemas/publicbookingintent) object that omits sensitive and admin-only fields. A common pattern is to use the authenticated API on your server to create a booking intent with pre-configured settings and locked fields, then hand the intent ID to the client to complete the flow via the public API. | Capability | Authenticated API | Public API | | -------------------------- | :---------------: | :--------: | | Create / update / complete | Yes | Yes | | Abandon | Yes | Yes | | List / get | Yes | Get only | | Set `submissions` | Yes | Yes | | Set `booking_policy` | Yes | No | | Set `locked_fields` | Yes | No | | Set `metadata` | Yes | No | | Set `slot_prevalidated` | Yes | No | | Link `client_id` | Yes | No | | Returns full object | Yes | No | ## Locked fields [#locked-fields] The authenticated API can lock specific fields so that the public API cannot modify them. This is useful when your server pre-selects values (like a service or provider) that the end user should not be able to change. Set `locked_fields` when creating or updating a booking intent via the authenticated API: ```bash curl -X POST /v1/booking_intents \ -d '{ "service_id": "srv_1234567890", "locked_fields": ["service_id", "provider_id"] }' ``` The lockable fields are: `service_id`, `provider_id`, `slot`, `client_data`, and `submissions`. The `slot` value locks all time-related fields (`start_at`, `end_at`, `time_zone`) as a group. When updating via the authenticated API, `locked_fields` replaces the existing set entirely. ## Auto-assigning a provider [#auto-assigning-a-provider] Set `auto_assign_provider` to `true` when you want the system to automatically select an available provider for the chosen time slot. This is useful when your flow lets the user pick a time without choosing a specific provider. Auto-assignment is evaluated when a time slot is selected, so the held slot is reserved for a specific provider's schedule and remains bookable at completion. If a `provider_id` is also specified, the explicit provider takes precedence. ## Webhook events [#webhook-events] Four webhook events are emitted during the booking intent lifecycle: | Event | Description | | -------------------------- | --------------------------------- | | `booking_intent.created` | A new booking intent was created. | | `booking_intent.updated` | A booking intent was updated. | | `booking_intent.completed` | A booking intent was completed. | | `booking_intent.abandoned` | A booking intent was abandoned. | See the [Webhooks](/webhooks) guide for details on registering webhooks and handling events. ## Key properties [#key-properties] | Property | Type | Description | | --------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `id` | string | The unique identifier (e.g., `bi_d025a96ac0c6`). | | `status` | enum | The current status: `pending`, `slot_selected`, `completed`, or `abandoned`. | | `start_at` | [ZonedDateTime](/api/schemas/zoneddatetime) | The start time of the selected slot. | | `end_at` | [ZonedDateTime](/api/schemas/zoneddatetime) | The end time of the selected slot. | | `hold_until` | string | When the current slot hold expires (UTC). | | `client_data` | object | Progressive client data collected during the booking flow. | | `submissions` | array | Form responses accumulated during the booking flow. | | `workflow` | [BookingIntentWorkflow](/api/schemas/bookingintentworkflow) | Server-computed step routing and completability state. Present on single-intent responses only. | | `requirements` | [BookingIntentRequirements](/api/schemas/bookingintentrequirements) | Per-section completeness status. Present on single-intent responses only. | | `locked_fields` | array | Fields locked from update via the public API. | | `appointment` | [Appointment](/api/schemas/appointment) | The resulting appointment (set when completed). | See the [BookingIntent](/api/schemas/bookingintent) schema for complete details. # Connected Accounts (/connected-accounts) This guide explains how connected accounts integrate external calendar and conferencing providers with SavvyCal, how users connect them, and how to monitor and respond to connection health changes. ## What connected accounts do [#what-connected-accounts-do] A [`ConnectedAccount`](/api/schemas/connectedaccount) is an OAuth-authorized link between a SavvyCal user and an external provider such as Google Calendar, Microsoft 365, or Zoom. Each connected account represents a single authenticated session with the provider. Connected accounts enable different capabilities depending on the provider type. Calendar providers (Google, Microsoft) enable: * **Availability conflict checking** — reads the external calendar to identify busy times and remove them from slot availability. See [Slot Availability](/slot-availability) for how this feeds into the availability pipeline. * **Appointment syncing** — writes confirmed appointments back to the external calendar so the provider's schedule stays in sync. Conferencing providers (Zoom) enable: * **Meeting room creation** — automatically creates a video meeting and attaches the join link to appointments. ``` User ──▶ ConnectedAccount ──▶ External Provider (Google / Microsoft / Zoom) │ │ └── Provider ◀── reads availability ─┘ ``` A user can have multiple connected accounts — for example, both a Google Calendar and a Microsoft 365 account. Each is tracked independently. See [Users and Accounts](/users-and-accounts) for how users, accounts, and providers relate. ## Supported providers [#supported-providers] | Provider | Description | | ------------ | ---------------------------------------------------------------------------------------- | | `google` | Google Calendar. Enables availability conflict checking and appointment syncing. | | `microsoft` | Microsoft 365 / Outlook. Enables availability conflict checking and appointment syncing. | | `zoom_admin` | Zoom (admin-level). Enables automatic Zoom meeting creation for appointments. | ## How users connect accounts [#how-users-connect-accounts] Connected accounts **cannot** be created through the API. Users connect by authenticating with the external provider through the SavvyCal Dashboard. The API provides read, update, and delete operations, but the initial OAuth connection must happen interactively. Non-passive users can sign in to their SavvyCal account directly and manage their connected accounts from the Dashboard. For passive users (or when you want to streamline the experience), you can generate a dashboard session link to redirect them. ### Directing users to the Dashboard [#directing-users-to-the-dashboard] Use the Dashboard Sessions API to programmatically redirect users to the Dashboard where they can connect their calendar accounts. This is especially useful for passive users who don't have login credentials. ```bash curl -X POST "https://api.savvycal.app/v1/dashboard_sessions" \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "user_id": "user_d025a96ac0c6", "return_url": "https://yourapp.com/settings/calendars" }' ``` The flow works as follows: 1. Call [Create dashboard session](/api/dashboard-sessions/createDashboardSession) with the target `user_id` and an optional `return_url`. 2. Redirect the user's browser to the returned `url` to sign them in automatically. 3. The user connects their calendar or conferencing account in the Dashboard. 4. A `connected_account.created` webhook fires to notify your system. 5. When the user signs out, they are redirected to the `return_url`. ### Prerequisites [#prerequisites] * The user must already exist as an account member. See [Create account user](/api/account-users/createAccountUser). * To use a connected calendar account for availability and syncing, the user must have a provider (called "Staff" in the Dashboard) associated with their user. This can be set up via the Members & permissions page in the Dashboard, or through the API — see [Providers and users](/users-and-accounts#providers-and-users). ## Connection scope [#connection-scope] | Scope | Description | | --------- | ------------------------------------------------------------------------------------------------------- | | `user` | Owned by a specific user (`user_id` is set). Most common for calendar integrations. | | `account` | Shared across the account (`user_id` is `null`). Used for account-level integrations like `zoom_admin`. | ## Connection status [#connection-status] A connected account has one of three statuses representing its health: ``` ┌───────────────────────────┐ │ │ v │ active ──▶ reconnect_required ──▶ active (reconnected) │ └──▶ insufficient_permissions ──▶ active (reconnected) ``` | Status | Meaning | Action required | | -------------------------- | --------------------------------------------------- | --------------------------------------------------------------- | | `active` | OAuth token is valid, integration working normally. | None. | | `reconnect_required` | OAuth token refresh permanently failed. | Direct user to Dashboard to reconnect. | | `insufficient_permissions` | User didn't grant all required OAuth scopes. | Direct user to Dashboard to reconnect with correct permissions. | Both failure states resolve the same way — the user reauthenticates through the Dashboard and the status returns to `active`. ## Monitoring connection health [#monitoring-connection-health] ### Webhook events [#webhook-events] Four webhook events track the connected account lifecycle: | Event | Trigger | Typical response | | ---------------------------------- | --------------------------------------------- | ------------------------------------------------------------ | | `connected_account.created` | User connected a new integration. | Update records, confirm provider ready. | | `connected_account.deleted` | Integration disconnected. | Update records, external availability no longer checked. | | `connected_account.refresh_failed` | OAuth token refresh permanently failed. | Notify user to reconnect; generate a dashboard session link. | | `connected_account.reconnected` | Previously failed connection reauthenticated. | Clear reconnection alerts. | See the [Webhooks](/webhooks) guide for details on registering webhooks and handling events. ### Polling for status [#polling-for-status] You can poll the list endpoint to check connection statuses, though webhooks are preferred: ```bash curl -G "https://api.savvycal.app/v1/connected_accounts" \ -H "Authorization: Bearer sk_live_..." \ -d user_id=user_d025a96ac0c6 ``` ## Responding to connection failures [#responding-to-connection-failures] A recommended integration pattern for handling OAuth failures: 1. Register a webhook for `connected_account.refresh_failed`. 2. When the event fires, look up the `user_id` on the connected account. 3. Create a dashboard session for that user. 4. Notify the user with the dashboard session URL so they can reconnect. 5. Listen for `connected_account.reconnected` to confirm resolution. ```bash # Create a dashboard session for reconnection curl -X POST "https://api.savvycal.app/v1/dashboard_sessions" \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "user_id": "user_d025a96ac0c6", "return_url": "https://yourapp.com/settings/calendars" }' ``` ## Managing connected accounts via the API [#managing-connected-accounts-via-the-api] ### Listing connected accounts [#listing-connected-accounts] Use query parameters to filter the list: | Parameter | Type | Description | | ----------- | ------- | ----------------------------------------------------------- | | `user_id` | string | Filter by owning user. | | `provider` | enum | Filter by provider: `google`, `microsoft`, or `zoom_admin`. | | `page` | integer | Page number for pagination. | | `page_size` | integer | Number of results per page. | ```bash curl -G "https://api.savvycal.app/v1/connected_accounts" \ -H "Authorization: Bearer sk_live_..." \ -d provider=google ``` ### Updating a connected account [#updating-a-connected-account] Only `display_name` is mutable via the API: ```bash curl -X PATCH "https://api.savvycal.app/v1/connected_accounts/cact_d025a96ac0c6" \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{"display_name": "Work Calendar"}' ``` ### Disconnecting a connected account [#disconnecting-a-connected-account] Deleting a connected account disconnects the integration and revokes the OAuth token: ```bash curl -X DELETE "https://api.savvycal.app/v1/connected_accounts/cact_d025a96ac0c6" \ -H "Authorization: Bearer sk_live_..." ``` ## Key properties [#key-properties] | Property | Type | Description | | --------------------- | -------------- | -------------------------------------------------------------- | | `id` | string | Unique identifier (e.g., `cact_d025a96ac0c6`). | | `provider` | enum | `google`, `microsoft`, or `zoom_admin`. | | `email` | string | Email associated with this connected account. | | `display_name` | string \| null | Settable via the update endpoint. | | `connection_scope` | enum | `user` or `account`. | | `status` | enum | `active`, `reconnect_required`, or `insufficient_permissions`. | | `user_id` | string \| null | Owning user's ID, or `null` for account-scoped connections. | | `external_subject` | string | Unique identifier at the external provider. | | `external_account_id` | string \| null | Org identifier at the provider. | See the [ConnectedAccount](/api/schemas/connectedaccount) schema for complete details. # Data Model (/data-model) This guide introduces the core resource types available in SavvyCal Appointments to help you plan your integration. ## Accounts [#accounts] An [`Account`](/api/schemas/account) represents an organization in SavvyCal and serves as the core tenancy boundary for all resources in the system. ## Account Users [#account-users] An [`AccountUser`](/api/schemas/accountuser) represents a user's membership in an account, including their assigned roles. ``` AccountUser ─┬─ has one ───▶ User └─ has many ──▶ Roles ``` Key properties of an account user include: | Property | Type | Description | | --------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `user` | [User](/api/schemas/accountuser) | The underlying user, with `id`, `first_name`, `last_name`, and `email`. | | `roles` | array of [Role](/api/schemas/accountuser) | The roles assigned to the user. Each role has a `name` and a `type`. | | `passive` | boolean | Whether this user is passive. Passive users don't receive direct email notifications (e.g., OAuth reconnection notices). | Role types include: `admin`, `staff`, `developer`, `superuser`, and `custom`. See the [AccountUser](/api/schemas/accountuser) schema for complete details. ## Providers [#providers] A [`Provider`](/api/schemas/provider) represents a staff member who provides services to clients. Providers are managed in the [Staff](https://savvycal.app/providers) section of the SavvyCal Dashboard. Key properties of a provider include: | Property | Type | Description | | -------------- | ------ | -------------------------------------------------------------------------------------------------- | | `id` | string | The unique identifier for the provider (e.g., `prov_d025a96ac0c6`). | | `first_name` | string | The first name of the provider. | | `last_name` | string | The last name of the provider. | | `display_name` | string | The display name of the provider, including any title or designations (e.g., "Dr. Evelyn Brooks"). | See the [Provider](/api/schemas/provider) schema for complete details. ## Provider Schedules [#provider-schedules] A [`ProviderSchedule`](/api/schemas/providerschedule) defines working hours for a provider over a date range. Key properties of a provider schedule include: | Property | Type | Description | | ----------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `id` | string | The unique identifier for the schedule (e.g., `psch_d025a96ac0c6`). | | `effective_from` | string | The start date of the schedule (ISO date format). | | `effective_to` | string \| null | The end date of the schedule, or `null` if open-ended. | | `public_bookings` | object | Public booking settings. Contains `enabled` (boolean) indicating whether public bookings are allowed. | | `weekly_rules` | array of [WeeklyRule](/api/schemas/providerschedule) | Rules defining availability windows per day of the week. Each rule has a `day` (`mo`–`su`), `start_time`, and `end_time`. | The slot availability logic described under [Services](#services) uses provider schedules to determine a provider's working hours for a given date range. See the [ProviderSchedule](/api/schemas/providerschedule) schema for complete details. ## Openings [#openings] An [`Opening`](/api/schemas/opening) is a one-off range of available time for a provider on a specific date. Openings sit alongside a provider's recurring [`ProviderSchedule`](/api/schemas/providerschedule), adding availability without modifying the weekly rules. Key properties of an opening include: | Property | Type | Description | | ------------- | -------------- | ----------------------------------------------------------------- | | `id` | string | The unique identifier for the opening (e.g., `opn_d025a96ac0c6`). | | `title` | string \| null | An optional display title for the opening. | | `date` | string | The date of the opening (ISO date format). | | `start_time` | string | The start time of the opening (24-hour clock, e.g., `09:00`). | | `end_time` | string | The end time of the opening (24-hour clock, e.g., `17:00`). | | `time_zone` | string | The IANA time zone the opening's times are authored in. | | `provider_id` | string | The ID of the provider the opening belongs to. | Unlike [`Blocks`](#blocks), which remove time from availability, openings add it. The slot availability logic uses openings to extend a provider's working hours for the dates they cover. See the [Opening](/api/schemas/opening) schema for complete details, and the [Slot Availability](/slot-availability) guide for how openings factor into availability. ## Services [#services] A [`Service`](/api/schemas/service) represents a service offered by one or more [`Providers`](/api/schemas/provider). ``` Service ─┬─ has many ──▶ Providers └─ has many ──▶ Slot Rules ``` You can add one or more providers to a service (via the SavvyCal Dashboard or the [Add provider to service API](/api/services/createServiceProvider)). Key properties of a service include: | Property | Type | Description | | ------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | The unique identifier for the service (e.g., `srv_d025a96ac0c6`). | | `name` | string | The name of the service. | | `duration` | string | The duration of the service in ISO-8601 duration format (e.g., `PT30M`, `PT1H30M`). | | `slot_rules` | array of [SlotRule](/api/schemas/slotrule) | The rules that define the time slots available for the service (e.g., Monday through Friday, 9:00 AM - 5:00 PM, at the top of each hour). | See the [Service](/api/schemas/service) schema for complete details. When calculating available time slots for a service provider, the system uses this logic: 1. Generate all time slots defined by the slot rules for the service. 2. Remove any time slots that are already booked, are marked as unavailable with a block, or fall outside the provider's configured working hours. See the [Slot Availability](/slot-availability) guide for a detailed explanation of how availability is calculated and how to query available time slots. ## Blocks [#blocks] A [`Block`](/api/schemas/block) represents a range of blocked time that prevents bookings. Blocks can be attached to a `provider`, `service`, `service_provider`, or `location`. Key properties of a block include: | Property | Type | Description | | ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `id` | string | The unique identifier for the block (e.g., `blk_d025a96ac0c6`). | | `title` | string \| null | The title of the block. | | `attachment_type` | enum | The type of resource the block is attached to: `provider`, `service`, `service_provider`, or `location`. | | `attachments` | array | IDs of the attached resources (e.g., provider IDs, service IDs). | | `start_date` | string | The start date of the block (ISO date format). | | `end_date` | string | The end date of the block (ISO date format). | | `start_time` | string \| null | The start time of the block (`HH:MM` format), or `null` if all-day. | | `end_time` | string \| null | The end time of the block (`HH:MM` format), or `null` if all-day. | | `time_zone` | string \| null | The time zone of the block (IANA format). | | `all_day` | boolean | Whether the block spans the entire day. | | `recurrence_rule` | object \| null | An optional recurrence rule. Supports `daily` and `weekly` frequencies with `byday`, `interval`, `count`, and `until` options. | See the [Block](/api/schemas/block) schema for complete details. ## Clients [#clients] A [`Client`](/api/schemas/client) represents a person who has made (or will make) appointments with a [`Provider`](/api/schemas/provider) for a particular [`Service`](/api/schemas/service). SavvyCal uses the generic term "client" to remain industry-agnostic. For example, in healthcare contexts, a client is typically called a patient. Key properties of a client include: | Property | Type | Description | | -------------- | ------ | --------------------------------------------------------------------------------------------------------------------------- | | `id` | string | The unique identifier for the client (e.g., `clnt_d025a96ac0c6`). | | `reference_id` | string | The external reference identifier for the client—typically a primary key from your database that corresponds to the client. | | `first_name` | string | The first name of the client. | | `last_name` | string | The last name of the client. | | `email` | string | The email address of the client. | | `phone` | string | The phone number of the client. | | `time_zone` | string | The time zone of the client (IANA format). | See the [Client](/api/schemas/client) schema for complete details. ## Appointments [#appointments] An [`Appointment`](/api/schemas/appointment) represents a booking that a [`Client`](/api/schemas/client) has made with a [`Provider`](/api/schemas/provider) for a particular [`Service`](/api/schemas/service). ``` Appointment ─┬─ has one ──▶ Service ├─ has one ──▶ Provider └─ has one ──▶ Client ``` Key properties of an appointment include: | Property | Type | Description | | --------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | The unique identifier for the appointment (e.g., `appt_d025a96ac0c6`). | | `status` | enum | The status of the appointment—either `scheduled` or `cancelled`. | | `client_reference_id` | string | The external reference identifier for the client—typically a primary key from your database that corresponds to the client who made the appointment. | | `client_time_zone` | string | The client's time zone (IANA format). This field represents the appointment time slot in the client's local time zone when it differs from the appointment's local time zone. | | `start_at` | [ZonedDateTime](/api/schemas/zoneddatetime) | The start time of the appointment. | | `end_at` | [ZonedDateTime](/api/schemas/zoneddatetime) | The end time of the appointment. | | `fields` | object | A map of client data provided when the appointment was created (`email`, `first_name`, `last_name`, `phone`). | See the [Appointment](/api/schemas/appointment) schema for complete details. Appointments can be created via the [Create appointment API](/api/appointments/createAppointment) or the [SavvyCal Booking Embed](/embedding/booking-embed). ## Booking Intents [#booking-intents] A [`BookingIntent`](/api/schemas/bookingintent) tracks the lifecycle of a multi-step booking attempt, enabling slot holding and progressive data collection before creating an appointment. See the [Booking Intents](/booking-intents) guide for a detailed walkthrough of the booking intent lifecycle, slot holding, and progressive client data collection. See the [BookingIntent](/api/schemas/bookingintent) schema for complete details. # Working with Dates (/dates) The SavvyCal API uses string-based date and time values in several formats. Understanding these formats — and the differences between how dates appear in responses versus requests — will help you integrate correctly. ## Date Formats [#date-formats] | Format | Example | Where Used | | --------------------- | --------------------------- | ------------------------------------------------- | | ISO date | `2025-10-27` | Calendar dates (e.g., block `start_date`) | | ISO datetime | `2025-03-01T03:00:00Z` | UTC timestamps (e.g., `created_at`, `updated_at`) | | Offset-aware datetime | `2025-03-01T10:00:00-05:00` | Local time with offset (used in request bodies) | | Time | `09:00` | Time of day (e.g., schedule rule `start_time`) | ## ZonedDateTime [#zoneddatetime] Several response fields (such as appointment `start_at` and `end_at`) return a **ZonedDateTime** object instead of a plain string. This object provides the same moment in multiple representations: ```json { "object": "zoned_date_time", "local": "2025-03-01T10:00:00-05:00", "utc": "2025-03-01T15:00:00Z", "unix_ts": 1740841200, "time_zone": "America/New_York" } ``` | Field | Description | | ----------- | ---------------------------------------------------- | | `local` | Offset-aware datetime in the event's local time zone | | `utc` | ISO datetime in UTC | | `unix_ts` | Unix timestamp in seconds | | `time_zone` | IANA time zone identifier | | `object` | Always `"zoned_date_time"` | ## Slots [#slots] The [public slots endpoint](/api/public-booking/listPublicServiceSlots) returns **Slot** objects representing bookable time windows: ```json { "object": "slot", "start_at": "2025-03-10T09:00:00-04:00", "start_at_ts": 1741612800, "end_at": "2025-03-10T10:00:00-04:00", "end_at_ts": 1741616400, "time_zone": "America/New_York" } ``` | Field | Description | | --------------------------- | ------------------------------------------------------------- | | `start_at` / `end_at` | ISO datetime with time zone offset in the requested time zone | | `start_at_ts` / `end_at_ts` | Unix timestamps in seconds | | `time_zone` | IANA time zone identifier | ## Creating Appointments [#creating-appointments] When creating or rescheduling an appointment, the API expects **offset-aware ISO-8601 datetime** strings paired with a separate `time_zone` field. Both UTC (`Z` suffix) and explicit offsets (`+/-HH:MM`) are accepted. ```json { "service_id": "svc_01JN5EQ4G6FXR5DEWCB1BNXGYQ", "start_at": "2025-03-01T10:00:00-05:00", "end_at": "2025-03-01T11:00:00-05:00", "time_zone": "America/New_York" } ``` The offset in `start_at` and `end_at` tells the API the exact moment in time. The `time_zone` field identifies the IANA time zone for the appointment (used for display and DST-aware operations). You can also use UTC: ```json { "start_at": "2025-03-01T15:00:00Z", "end_at": "2025-03-01T16:00:00Z", "time_zone": "America/New_York" } ``` Naive datetime strings without an offset (e.g., `"2025-03-01T10:00:00"`) are **rejected** with a 422 error. Always include an offset or `Z` suffix. ## JavaScript SDK [#javascript-sdk] The `@savvycal/appointments` SDK passes date values through as strings and numbers, matching the wire format exactly. For convenience helpers, install `@savvycal/appointments-utils`: ```bash npm install @savvycal/appointments-utils ``` Common operations: ```ts import { fromUnixTime, isSameDay } from "@savvycal/appointments-utils"; // Convert a slot's unix timestamp to a Date const date = fromUnixTime(slot.start_at_ts); // Slot start_at/end_at values are already offset-aware strings // (e.g., "2025-03-10T09:00:00-04:00") — pass them directly to create requests // Compare two dates in a specific time zone isSameDay(date1, date2, "America/New_York"); // => true ``` # Metadata Filtering (/metadata-filtering) Several API endpoints support filtering by metadata using query parameters with bracket notation. ## Query Format [#query-format] Filter resources by metadata using bracket notation in query parameters: * `metadata[key]=value` - equality match (implicit eq operator) * `metadata[key][operator]=value` - explicit operator ## Operators [#operators] | Operator | Description | Example | | ---------- | ------------------------------------------------ | ------------------------------------------ | | `eq` | Exact match (default when no operator specified) | `metadata[location][eq]=NYC` | | `not_eq` | Not equal (excludes matching resources) | `metadata[status][not_eq]=inactive` | | `in` | Value is one of comma-delimited values | `metadata[department][in]=sales,marketing` | | `contains` | Array metadata value contains the given value | `metadata[tags][contains]=priority` | | `gte` | Greater than or equal (numeric comparison) | `metadata[age][gte]=30` | | `lte` | Less than or equal (numeric comparison) | `metadata[score][lte]=100` | ## Examples [#examples] Filter by exact match: ```http GET /v1/providers?metadata[location]=NYC ``` Filter by numeric range: ```http GET /v1/providers?metadata[age][gte]=30&metadata[age][lte]=50 ``` Filter by multiple values: ```http GET /v1/providers?metadata[department][in]=sales,marketing,support ``` Exclude specific values: ```http GET /v1/providers?metadata[status][not_eq]=inactive ``` Filter array metadata: ```http GET /v1/providers?metadata[skills][contains]=javascript ``` # Platforms (/platforms) This guide explains the platform model in SavvyCal Appointments: how platforms manage multiple accounts, authenticate with the API, and operate on behalf of individual accounts. ## What a platform is [#what-a-platform-is] A [`Platform`](/api/schemas/platform) represents a multi-tenant application — such as a practice management system or a marketplace — that provisions and manages multiple SavvyCal accounts. Each managed account is a full tenancy boundary with its own providers, services, appointments, and other resources. ``` Platform ├── Account A (Clinic Alpha) │ ├── Providers │ ├── Services │ └── Appointments ├── Account B (Clinic Beta) │ ├── Providers │ ├── Services │ └── Appointments └── ... ``` Not all accounts are platform-managed. If you are a single-account customer using an [account API token](/authentication#account-api-tokens), the platform model does not apply to you. See [Users and Accounts](/users-and-accounts) for how users and accounts relate, and [Data Model](/data-model) for a detailed overview of account-level resources. ## Authentication [#authentication] Platform tokens are long-lived, created in the Dashboard, and authenticate server-side requests across multiple accounts. See [Authentication](/authentication) for full details on token types. ### Platform-level requests [#platform-level-requests] Some endpoints operate at the platform level and don't require an account context. Examples include `GET /v1/platform`, `GET /v1/accounts`, and `POST /v1/accounts`. ```bash curl "https://api.savvycal.app/v1/platform" \ -H "Authorization: Bearer sk_..." ``` ### Account-scoped requests [#account-scoped-requests] Most endpoints require an account context. When using a platform token, include the `X-SavvyCal-Account` header to specify which account you're operating on behalf of. ```bash curl "https://api.savvycal.app/v1/services" \ -H "Authorization: Bearer sk_..." \ -H "X-SavvyCal-Account: acct_d025a96ac0c6" ``` ## Managing accounts [#managing-accounts] ### Creating an account [#creating-an-account] Use `POST /v1/accounts` with a platform token to provision a new account. This endpoint is only available to platform tokens. ```bash curl -X POST "https://api.savvycal.app/v1/accounts" \ -H "Authorization: Bearer sk_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Clinic Alpha", "time_zone": "America/Chicago" }' ``` ### Listing accounts [#listing-accounts] Use `GET /v1/accounts` to retrieve all accounts managed by the platform. Supports `page` and `page_size` query parameters for pagination. ```bash curl -G "https://api.savvycal.app/v1/accounts" \ -H "Authorization: Bearer sk_..." ``` ### Retrieving an account [#retrieving-an-account] ```bash curl "https://api.savvycal.app/v1/accounts/acct_d025a96ac0c6" \ -H "Authorization: Bearer sk_..." ``` ### Updating an account [#updating-an-account] ```bash curl -X PATCH "https://api.savvycal.app/v1/accounts/acct_d025a96ac0c6" \ -H "Authorization: Bearer sk_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Clinic Alpha (Renamed)" }' ``` ## Operating on behalf of an account [#operating-on-behalf-of-an-account] Once an account exists, you can use the platform token with the `X-SavvyCal-Account` header to call any account-scoped endpoint. A typical setup sequence looks like this: 1. Create an account (`POST /v1/accounts`). 2. Add a user to the account ([Create account user](/api/account-users/createAccountUser) with `X-SavvyCal-Account`). 3. Create a provider ([Create provider](/api/providers/createProvider) with `X-SavvyCal-Account`). 4. Set up services, schedules, and other resources. 5. Generate a [dashboard session](/api/dashboard-sessions/createDashboardSession) to let users manage their own settings. See [Connected Accounts](/connected-accounts) for how users connect external calendars, and [Dashboard Sessions](/api/dashboard-sessions/createDashboardSession) for signing users into the Dashboard. ## Webhooks [#webhooks] Platforms can register webhooks at the platform level from the platform settings page in the Dashboard. Platform webhooks receive events from **all** managed accounts, so a single webhook endpoint can handle events across your entire fleet. Each webhook payload includes the account context, so you can route events to the correct account in your system. See the [Webhooks](/webhooks) guide for details on registering webhooks and handling events. ## Key properties [#key-properties] | Property | Type | Description | | -------- | ------ | ---------------------------------------------- | | `id` | string | Unique identifier (e.g., `plat_d025a96ac0c6`). | | `name` | string | The platform's name. | See the [Platform](/api/schemas/platform) schema for complete details. # Slot Availability (/slot-availability) This guide explains how SavvyCal calculates available time slots for a service and how to query them through the API. ## How availability is calculated [#how-availability-is-calculated] When you query for available time slots, SavvyCal runs a pipeline that starts with candidate slots and progressively removes unavailable times: ``` Slot Rules (Service) ──▶ Generate candidate slots ──▶ Remove unavailable slots ──▶ Available slots ``` Each stage of the pipeline works as follows: 1. **Slot generation** — The service's `slot_rules` define when slots begin (day of week, start/end time, recurrence pattern). The service `duration` determines the length of each slot. Together, these produce a set of candidate time slots for the requested date range. 2. **Provider working hours** — Each provider's [`ProviderSchedule`](/data-model#provider-schedules) defines `weekly_rules` with working hours per day of the week. An [`Opening`](/data-model#openings) adds a one-off range of available time on a specific date, extending the provider's working hours alongside their recurring schedule. Candidate slots that fall outside both a provider's recurring working hours and any opening for the effective date range are removed. 3. **Existing appointments** — Any scheduled [`Appointment`](/data-model#appointments) that overlaps a candidate slot removes that slot from availability. 4. **Blocks** — Any [`Block`](/data-model#blocks) that overlaps a candidate slot removes it, regardless of the block's `attachment_type` (provider, service, service\_provider, or location). 5. **Connected calendar conflicts** — When a provider has a [connected calendar account](/connected-accounts) (Google Calendar, Microsoft 365), busy times from the external calendar are removed from availability. 6. **Booking intent slot holds** — Active holds from [`BookingIntents`](/booking-intents#slot-holding) temporarily remove held slots until the hold expires or the intent is completed or abandoned. ## Querying available slots [#querying-available-slots] Three endpoints return slot availability data: | Endpoint | Path | Auth | Use case | | -------------------------------------------------------------------------------- | ---------------------------------------------------- | ------- | ------------------------------------------- | | [List available time slots](/api/time-slots/listServiceSlots) | `GET /v1/services/{service_id}/slots` | API key | Server-side UI with per-provider breakdowns | | [List available time slots (public)](/api/public-booking/listPublicServiceSlots) | `GET /v1/public/services/{service_id}/slots` | None | Client-facing booking UI | | [Get earliest available slot](/api/public-booking/getEarliestPublicServiceSlot) | `GET /v1/public/services/{service_id}/earliest_slot` | None | "Next available" display | The public endpoints only return slots for providers whose [`ProviderSchedule`](/data-model#provider-schedules) has `public_bookings.enabled` set to `true`. ## Request parameters [#request-parameters] The authenticated and public list endpoints share these query parameters: | Parameter | Type | Required | Description | | -------------- | -------- | ----------- | ------------------------------------------------------------------------------------ | | `service_id` | string | Yes (path) | The service to query slots for. | | `from` | ISO date | No | Start of the date range. Defaults to today. | | `until` | ISO date | Conditional | End of the date range. Either `until` or `limit` is required. | | `limit` | integer | Conditional | Maximum number of aggregated slots to return. Either `limit` or `until` is required. | | `time_zone` | string | No | IANA time zone (e.g., `America/New_York`). Defaults to `Etc/UTC`. | | `provider_ids` | string | No | Comma-separated provider IDs to filter by. | The earliest-slot endpoint accepts only `service_id` (path), `time_zone`, and `provider_ids`. ### Authenticated request [#authenticated-request] ```bash curl -G "https://api.savvycal.app/v1/services/srv_d025a96ac0c6/slots" \ -H "Authorization: Bearer sk_live_..." \ -d from=2025-03-10 \ -d until=2025-03-14 \ -d time_zone=America/New_York ``` ### Public request [#public-request] ```bash curl -G "https://api.savvycal.app/v1/public/services/srv_d025a96ac0c6/slots" \ -H "X-SavvyCal-Account: acct_d025a96ac0c6" \ -d from=2025-03-10 \ -d limit=10 \ -d time_zone=America/New_York ``` ### Earliest slot request [#earliest-slot-request] ```bash curl -G "https://api.savvycal.app/v1/public/services/srv_d025a96ac0c6/earliest_slot" \ -H "X-SavvyCal-Account: acct_d025a96ac0c6" \ -d time_zone=America/New_York ``` ## Response structure [#response-structure] ### Authenticated response [#authenticated-response] The authenticated endpoint returns a [`ServiceSlotResponse`](/api/schemas/serviceslotresponse) with two arrays inside `data`: `aggregated_slots` (deduplicated by time, with a `count` of available providers) and `provider_slots` (per-provider breakdowns). ```json { "data": { "aggregated_slots": [ { "object": "aggregated_slot", "start_at": "2025-03-10T09:00:00-04:00", "start_at_ts": 1741615200, "end_at": "2025-03-10T10:00:00-04:00", "end_at_ts": 1741618800, "time_zone": "America/New_York", "count": 2 } ], "provider_slots": [ { "service_provider": { "object": "service_provider", "id": "sp_d025a96ac0c6", "service_id": "srv_d025a96ac0c6", "provider": { "object": "provider", "id": "prov_d025a96ac0c6", "display_name": "Dr. Evelyn Brooks" } }, "open": [ { "object": "slot", "start_at": "2025-03-10T09:00:00-04:00", "start_at_ts": 1741615200, "end_at": "2025-03-10T10:00:00-04:00", "end_at_ts": 1741618800, "time_zone": "America/New_York" } ] } ] } } ``` [`AggregatedSlot`](/api/schemas/aggregatedslot) properties: | Property | Type | Description | | ------------- | ------- | ----------------------------------------------------- | | `object` | string | Always `"aggregated_slot"`. | | `start_at` | string | Offset-aware ISO datetime in the requested time zone. | | `start_at_ts` | integer | Unix timestamp in seconds. | | `end_at` | string | Offset-aware ISO datetime in the requested time zone. | | `end_at_ts` | integer | Unix timestamp in seconds. | | `time_zone` | string | IANA time zone name. | | `count` | integer | Number of providers available for this slot. | ### Public response [#public-response] The public list endpoint returns a [`SlotListResponse`](/api/schemas/slotlistresponse) with a flat `data` array of [`Slot`](/api/schemas/slot) objects. ```json { "data": [ { "object": "slot", "start_at": "2025-03-10T09:00:00-04:00", "start_at_ts": 1741615200, "end_at": "2025-03-10T10:00:00-04:00", "end_at_ts": 1741618800, "time_zone": "America/New_York" } ] } ``` ### Earliest slot response [#earliest-slot-response] The earliest-slot endpoint returns a [`PublicServiceEarliestSlotResponse`](/api/schemas/publicserviceearliestslotresponse) where `data` is a single [`Slot`](/api/schemas/slot) object, or `null` if no slots are available. ```json { "data": { "object": "slot", "start_at": "2025-03-10T09:00:00-04:00", "start_at_ts": 1741615200, "end_at": "2025-03-10T10:00:00-04:00", "end_at_ts": 1741618800, "time_zone": "America/New_York" } } ``` [`Slot`](/api/schemas/slot) properties (used by both public responses): | Property | Type | Description | | ------------- | ------- | ----------------------------------------------------- | | `object` | string | Always `"slot"`. | | `start_at` | string | Offset-aware ISO datetime in the requested time zone. | | `start_at_ts` | integer | Unix timestamp in seconds. | | `end_at` | string | Offset-aware ISO datetime in the requested time zone. | | `end_at_ts` | integer | Unix timestamp in seconds. | | `time_zone` | string | IANA time zone name. | ## Time zone handling [#time-zone-handling] The `time_zone` query parameter controls two things: 1. **Offset in returned datetime strings** — The `start_at` and `end_at` values include the UTC offset for the requested time zone (e.g., `-04:00` for `America/New_York` during EDT). 2. **Date boundary interpretation** — The `from` and `until` date parameters are interpreted as calendar dates in the requested time zone, or in the service's time zone when `time_zone` is omitted. When `time_zone` is omitted, returned `start_at`/`end_at` use `Etc/UTC`. Unix timestamps (`start_at_ts`, `end_at_ts`) are absolute and unaffected by the time zone parameter. See [Working with Dates — Slots](/dates#slots) for more on slot date formats. ## What affects availability [#what-affects-availability] This table summarizes the resources that influence slot availability: | Resource | Effect | Learn more | | ------------------------------------ | ---------------------------------------------- | ----------------------------------------------------------------- | | `SlotRule` | Defines candidate start times and recurrence | [Data Model — Services](/data-model#services) | | Service `duration` | Determines slot length | [Service schema](/api/schemas/service) | | `ProviderSchedule` `weekly_rules` | Filters to working hours | [Data Model — Provider Schedules](/data-model#provider-schedules) | | `Opening` | Adds one-off available time on a specific date | [Data Model — Openings](/data-model#openings) | | `ProviderSchedule` `public_bookings` | Controls public endpoint visibility | [ProviderSchedule schema](/api/schemas/providerschedule) | | `Appointment` | Removes booked slots | [Data Model — Appointments](/data-model#appointments) | | `Block` | Removes blocked time ranges | [Data Model — Blocks](/data-model#blocks) | | `ConnectedAccount` (calendar) | Removes externally busy times | [Connected Accounts](/connected-accounts) | | `BookingIntent` hold | Temporarily removes held slots | [Booking Intents — Slot holding](/booking-intents#slot-holding) | # Users and Accounts (/users-and-accounts) This guide explains the identity and access model in SavvyCal Appointments: how users authenticate, how accounts provide tenancy boundaries, and how these concepts relate to providers and connected calendar accounts. ## Users [#users] A [`User`](/api/schemas/user) represents a person's identity for the purpose of authenticating to the application. Users log in with their email and password and can belong to one or more accounts. Key properties of a user include: | Property | Type | Description | | ------------ | ------ | --------------------------------------------------------------- | | `id` | string | The unique identifier for the user (e.g., `user_d025a96ac0c6`). | | `email` | string | The user's email address. | | `first_name` | string | The user's first name. | | `last_name` | string | The user's last name. | See the [User](/api/schemas/user) schema for complete details. ## Accounts [#accounts] An [`Account`](/api/schemas/account) represents a tenancy boundary, generally an organization or a department within an organization. All resources — providers, services, clients, appointments, and more — belong to an account. See the [Data Model](/data-model) page for a detailed overview of account-level resources. ## Account membership and roles [#account-membership-and-roles] Users can belong to one or more accounts and are assigned roles within each account. The [`AccountUser`](/api/schemas/accountuser) object represents this membership. Key properties of an account user include: | Property | Type | Description | | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------- | | `user` | object | The [User](/api/schemas/user) object for this membership. | | `roles` | array | The roles assigned to the user in this account (e.g., `admin`, `staff`, `developer`, `custom`). | | `passive` | boolean | Whether this user is passive. Passive users do not receive direct email notifications (e.g., OAuth reconnection notices). | See the [AccountUser](/api/schemas/accountuser) schema for complete details. ### Relevant API endpoints [#relevant-api-endpoints] * [List account users](/api/account-users/listAccountUsers) — retrieve all users in an account. * [Create account user](/api/account-users/createAccountUser) — add a new user to an account. * [Update account user](/api/account-users/updateAccountUser) — modify a user's roles or passive status. ## Providers and users [#providers-and-users] [Providers](/data-model#providers) (referred to as "Staff" in the Dashboard UI) represent people who provide services. A provider may or may not be affiliated with a user: * **Provider without a user** — for staff whose schedules are fully managed by admins and who never need to log in. * **User without a provider** — for administrative team members who manage the account but don't provide services themselves. When creating resources via the API, you can link these together: * The [Create provider](/api/providers/createProvider) API has an `add_user` option to create (or affiliate) a user for the provider. * The [Create account user](/api/account-users/createAccountUser) API has an `add_provider` option to create (or affiliate) a provider for the user. ## Connected calendar accounts [#connected-calendar-accounts] Connecting external calendar accounts (Google Calendar, Microsoft 365) to a provider enables appointment syncing and availability conflict checking. This requires the provider to be affiliated with a user, because the user logs in and authenticates their calendar accounts via OAuth. See the [ConnectedAccount](/api/schemas/connectedaccount) schema for complete details. See the [Connected Accounts](/connected-accounts) guide for a detailed walkthrough of providers, connection statuses, and monitoring connection health. # API Reference (/api) Generated from the [OpenAPI specification](https://api.savvycal.app/v1/spec). # Components (/components) SavvyCal Components are designed to be copied and pasted directly into your application, similar to modern UI kits like [Catalyst](https://catalyst.tailwindui.com/) and [shadcn](https://ui.shadcn.com/). This allows you to weave SavvyCal-powered functionality seamlessly into your existing UI, without resorting to messy CSS overrides to force third-party elements to look like your own. Because the code lives in your codebase, components are meant to be starting points rather than fixed building blocks. Adapt the markup, styling, and behavior to fit your application's needs. Components are the most advanced way to build a highly-customized booking experience, giving you full control over the UI. If you just want a clean, ready-made booking experience with minimal setup, use the [Booking Embed](/embedding/booking-embed) instead. ## Prerequisites [#prerequisites] We've build the components to require minimal external dependencies. * **Tailwind CSS**: Components are styled with Tailwind CSS. * **React**: Components are built with React. We may support other frameworks in the future. * **TanStack Query**: Components use [TanStack Query](https://tanstack.com/query/docs) for data fetching. ## Prepare your app [#prepare-your-app] ### Install dependencies [#install-dependencies] Add the following dependencies to your project: ```bash npm install clsx @tanstack/react-query \ @savvycal/appointments-core @savvycal/appointments-react-query \ @savvycal/appointments-utils ``` ### Set up your app root [#set-up-your-app-root] Ensure the `` and `` components are present in your app's root component. If you are making requests to API endpoints that require authentication, you'll need to pass the `fetchAccessToken` prop to the `` component. This should be a function that returns a JSON Web Token (JWT) generated by your backend for the user who is currently authenticated ([see docs on how to construct JWTs](/authentication#json-web-tokens)). Since JWTs are short-lived, `fetchAccessToken` will be called any time the token expires. If you're only making requests to public endpoints, you can omit the `fetchAccessToken` prop. ```tsx // src/App.tsx import { useMemo } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { SavvyCalProvider } from "@savvycal/appointments-react-query"; const queryClient = new QueryClient(); function App() { const fetchAccessToken = useMemo(async () => { const response = await fetch("/access_token"); if (!response.ok) { const { error } = await response.json(); console.error("An error occurred: ", error); return undefined; } else { const { accessToken } = await response.json(); return accessToken; } }, []); return ( {/* ... */} ); } ``` ## Start using the components [#start-using-the-components] You're now ready to use the components. Browse the docs below to learn how to use each component. # Public Booking Form (/components/public-booking-form) A basic public booking form component. ## Live Demo [#live-demo] ## Installation [#installation] Follow the instructions to [prepare your app](/components#prerequisites). This component requires a few additional dependencies: * `lucide-react` (icons) * `@base-ui/react` (radio group) * `react-day-picker` (calendar) Run the following command to install the dependencies: ```bash npm install lucide-react @base-ui/react react-day-picker ``` Add the base styles for React Day Picker to your app styles: ```css @import "react-day-picker/style.css" layer(components); ``` Since this component only uses public API endpoints, the `fetchAccessToken` prop is not required on `SavvyCalProvider`. # Booking Embed (v1) (/embedding/booking-embed-v1) A newer version of the booking embed is available. See [Booking Embed](/embedding/booking-embed) for the current component. This page documents the previous `` component, which remains supported. The booking embed component allows you to display the appointment booking UI seamlessly on your website. Under the covers, the booking embed uses a custom web component that is defined by a small, SavvyCal-hosted JavaScript library. ## Live Demo [#live-demo]
## Installation [#installation] Before adding the component to your website, add your domain to your *Allowed Origins* list on the [Settings → Allowed origins](https://savvycal.app/allowed_origins) page (just the hostname, e.g. `myapp.com` or `subdomain.myapp.com`). If you're using `localhost`, be sure to include the port if it differs from port 80 (e.g. `localhost:3000`). To add the booking embed component to your website, you'll need to include the `booking-embed.js` script on your page and add the `` element where you want to show the component. ```html ``` ## Attributes [#attributes] The `` element accepts various attributes to customize the behavior of the component. We strongly recommend setting the `client-reference-id` attribute to a unique identifier for the person booking the appointment, to ensure that the appointment is associated with the correct client in your system. | Attribute | Type | Description | | ---------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `service` \* | string (Required) | The ID of the service you want to embed. | | `client-first-name` \* | string (Required) | The first name of the person booking the appointment. | | `client-last-name` \* | string (Required) | The last name of the person booking the appointment. | | `client-email` \* | string (Required) | The email address of the person booking the appointment. | | `client-reference-id` | string | A unique identifier for the person booking the appointment (strongly recommended). | | `client-phone` | string | The phone number of the person booking the appointment (in [E.164 format](https://www.twilio.com/glossary/what-e164), e.g. `+12125551212`). | | `client-time-zone` | string | The time zone of the person booking the appointment (defaults to the browser's time zone). | | `theme` | `"light"` or `"dark"` | The theme to use for the booking embed (defaults to `"light"`). | | `locale` | string | The locale to use for the booking embed, one of `"auto"`, `"en"` (English), `"es"` (Spanish). Defaults to `"auto"`. | | `show-confirmation` | boolean | Whether to show a confirmation page after the appointment is booked (defaults to `true`). If set to `false`, the booking interface will stay in a "submitting" state, so you can perform the next action via the `appointment-created` event handler. | ## Styling [#styling] The component comes with a transparent background and zero padding, so that you can easily position it. You'll want to set the `theme` attribute to `"dark"` if you are placing the embed over a dark background, so that the text will be legible. You can also customize the color of the buttons, border radius, and other attributes by using CSS variables. The component will look for the following variables and forward them to the embedded component: | Variable | Description | | ----------------------- | ------------------------------------------- | | `--sc-btn-primary-bg` | The background color of the primary button. | | `--sc-btn-primary-text` | The text color of the primary button. | | `--sc-btn-radius` | The border radius of the buttons. | ### Example [#example] ```css :root { --sc-btn-primary-bg: #00c65a; --sc-btn-primary-text: #ffffff; --sc-btn-radius: 8px; } ``` ## Events [#events] The component emits the following events: | Event | Description | | --------------------- | ---------------------------------------- | | `appointment-created` | Emitted when the appointment is created. | ### Example [#example-1] ```javascript // Listen for appointment created events window.addEventListener("appointment-created", (e) => { const { appointmentId } = e.detail; // Example: Redirect to a thank you page // window.location.href = "/thank-you?appointment=" + appointmentId; }); ``` # Booking Embed (/embedding/booking-embed) The booking embed lets you display the SavvyCal appointment booking experience directly on your website. It's a small, SavvyCal-hosted web component that wraps an `