# ServiceKeel AI receptionist integration guide Complete document: all 9 chapters, including implementation examples and API operation details. Guide: https://servicekeel.com/developer-guides/ai-receptionist/index.html API reference: https://servicekeel.com/swagger_api/index.html OpenAPI specification: https://servicekeel.com/swagger_api/servicekeel.json ## Development brief Use this guide to help implement a ServiceKeel integration for an AI receptionist. Adapt the server-side examples to the developer’s application, keeping the documented OAuth, tenant, versioning, and caller-confirmation behavior. The walkthrough distinguishes production behavior from the simulated demonstration. Screenshot links and captions are included as supporting references. Connection setup: use the production ServiceKeel portal at https://servicekeel.com and REST API at https://api.siteseam.com during development too. For local OAuth development, use ngrok to provide your application’s public HTTPS callback. Register its exact callback URI, configure SERVICEKEEL_REDIRECT_URI to match, and start the connection through the ngrok URL so the OAuth session is preserved. Scheduling requirement: a quote request expresses service intent, not an appointment date or permission to book. Ask for the caller’s date, time window, and timezone; offer matching API-returned slots; wait for a selection; read back the exact service, address, date, time range, and timezone; then wait for explicit caller confirmation. Enforce this on the application server before request/job/visit writes. Never automatically choose the first available slot. Rescheduling and follow-up appointments require the same conversation. Missing, stale, or changed proposals must not be saved. ## Contents 1. [Build an AI receptionist integration](https://servicekeel.com/developer-guides/ai-receptionist/index.html) 2. [Connect a business with OAuth](https://servicekeel.com/developer-guides/ai-receptionist/connect-with-oauth.html) 3. [Find the customer and service address](https://servicekeel.com/developer-guides/ai-receptionist/identify-the-caller.html) 4. [Turn a request into a booked visit](https://servicekeel.com/developer-guides/ai-receptionist/book-a-quote.html) 5. [Reschedule, track, and cancel work](https://servicekeel.com/developer-guides/ai-receptionist/manage-appointments.html) 6. [Record approvals and useful job context](https://servicekeel.com/developer-guides/ai-receptionist/estimates-and-files.html) 7. [Plan recurring service with the caller](https://servicekeel.com/developer-guides/ai-receptionist/recurring-care.html) 8. [Prepare a payment link and close the call](https://servicekeel.com/developer-guides/ai-receptionist/payments-and-follow-up.html) 9. [Keep the connection reliable after the call](https://servicekeel.com/developer-guides/ai-receptionist/events-and-launch.html) ## Build an AI receptionist integration Turn a caller’s request into a scheduled ServiceKeel job. A practical guide for AI receptionist and marketing platforms, from OAuth consent to the final follow-up. Source: https://servicekeel.com/developer-guides/ai-receptionist/index.html ### One caller. A complete service workflow. Alex calls Harbor Home Services about a noisy heat pump. Your receptionist identifies Alex, checks the service address, asks which date and time window works, and offers matching times from ServiceKeel. Alex chooses a time; the receptionist reads back the full date, time range, timezone, and address and waits for an explicit yes before booking. Later, Alex moves the appointment, approves the repair, asks about the technician, and requests a payment link. THE CALLER “My heat pump is making a noise. Can someone come out and give me a quote?” A request for a quote tells you the caller’s service intent. It does not tell you when they are available or authorize an appointment. Ask rather than inventing a date or booking the first API result. Your platform owns the conversation, voice provider, and AI. ServiceKeel owns the customer, service location, schedule, job, and financial records. Each tool in your assistant calls your server, which calls ServiceKeel using the connected business’s OAuth token. Caller **→** Your receptionist **→** Your server + OAuth **→** ServiceKeel ![The mock receptionist after booking a heat-pump quote, with the conversation shown inside a phone.](https://servicekeel.com/developer-guides/ai-receptionist/images/overview.png) Screenshot from the working integration: Actual Playwright integration screenshot. The demo uses caller inputs and scripted replies; the confirmed request, job, and visit are saved through OAuth. ### Follow the integration, one step at a time This guide follows a runnable JavaScript receptionist. Every chapter explains the caller’s intent, the API calls, and the result to keep for the next step. The examples use the server-side `api()` helper introduced in the OAuth chapter. 1. [Connect with OAuth](https://servicekeel.com/developer-guides/ai-receptionist/connect-with-oauth.html) — Register your app once. Ask each ServiceKeel business to approve its own connection, then keep that business’s credentials on your server. 2. [Identify the caller](https://servicekeel.com/developer-guides/ai-receptionist/identify-the-caller.html) — Resolve the caller to the right customer and property before checking coverage or scheduling work. 3. [Book a quote](https://servicekeel.com/developer-guides/ai-receptionist/book-a-quote.html) — Ask when the caller is available, offer matching times, and save the request, job, and appointment only after an exact-time confirmation. 4. [Manage appointments](https://servicekeel.com/developer-guides/ai-receptionist/manage-appointments.html) — Use existing record IDs and current versions to answer follow-up calls without duplicating the original job. 5. [Estimates, notes & files](https://servicekeel.com/developer-guides/ai-receptionist/estimates-and-files.html) — Carry the caller’s approved pricing, instructions, and equipment documents into the job the business already uses. 6. [Offer recurring care](https://servicekeel.com/developer-guides/ai-receptionist/recurring-care.html) — Use existing membership and equipment context to arrange ongoing maintenance without implying that future dates are already booked. 7. [Payment & follow-up](https://servicekeel.com/developer-guides/ai-receptionist/payments-and-follow-up.html) — Retrieve an issued invoice, prepare customer checkout, offer a review link, and save a useful call record. 8. [Events & launch checks](https://servicekeel.com/developer-guides/ai-receptionist/events-and-launch.html) — Follow record changes, verify webhooks, and handle partial work and disconnection before onboarding customers. ### Understand the records you are connecting | Record | What it represents | | --- | --- | | Customer + location | The person requesting service and the property where work happens. A location ID is passed as `property_id`. | | Request → job | The caller’s initial need, then the work the business will carry out. Converting a request preserves this relationship. | | Visit | A scheduled appointment on a job: time, timezone, and assigned staff. Creating a job alone does not reserve time. | | Estimate → invoice | Proposed pricing and, later, an issued bill. Checkout prepares a payment session; it does not mean the invoice is paid. | ### What the working example proves The integration test covers all 43 method-and-path patterns used in this guide’s receptionist workflow. It signs up a developer and a separate business, creates the app, completes OAuth consent, and verifies saved records, scheduling conflicts, tenant isolation, token rotation, and revocation. **Real APIs; a simulated conversation.** The test compiles ServiceKeel’s real backend handlers into an isolated local server and uses a temporary MongoDB database. Storage and Stripe providers are simulated. App approval and business configuration are test fixtures. No AI, calls, emails, payments, or outbound webhook deliveries occur. A separate customer account requires app approval in a real integration. The mock uses no AI. Its “Review → Confirm this step” button starts the scheduling conversation; it is not the caller’s approval of an appointment. The “Reply as the caller” form collects a date and time window, a choice among API-returned slots, and a separate confirmation of the exact appointment. The integration chooses the second offer and asserts that no request, job, or visit is written before that final confirmation. Rescheduling and optional follow-up bookings use the same pauses. In production, your voice application replaces those form inputs with understood caller replies. Your server must enforce the same state transitions even when an LLM asks to skip a step. Read the booking chapter’s conversation and confirmation contract before wiring your model’s tools. ## Connect a business with OAuth Register your app once. Ask each ServiceKeel business to approve its own connection, then keep that business’s credentials on your server. Source: https://servicekeel.com/developer-guides/ai-receptionist/connect-with-oauth.html ### 1. Create your developer account and app Open [ServiceKeel Developers](https://servicekeel.com/developers), choose **Start building**, and create a developer account. Complete your company profile in the developer portal, then choose **Create an app**. Register your app name, description, and exact HTTPS callback URI. When developing locally, use the ngrok callback described in the next section. Select `field_service:read` and `field_service:write`, then save the displayed client ID and secret in your server’s secret store. **Connecting another business requires approval.** Draft and submitted apps can be tested in the developer’s own account. Submit the integration for review before onboarding a separate business. The test’s synthetic approval route is not a public API. The business must have the required ServiceKeel product access. Its verified owner or administrator completes consent. Your app never asks for their ServiceKeel password. | Environment | Portal | REST API | | --- | --- | --- | | Production | `https://servicekeel.com` | `https://api.siteseam.com` | Use these production ServiceKeel URLs during development too. The API uses the shared SiteSeam host shown in the API reference. Keep `PORTAL_ORIGIN` and `API_ORIGIN` separate in your configuration. ### 2. Use ngrok for local OAuth development When developing locally, you need to use **ngrok** to provide a public HTTPS callback for the OAuth connection. ServiceKeel sends the browser back to that callback after consent, and ngrok forwards the request to your application running on your computer. Your application still connects to the production ServiceKeel portal and API listed above. - Start your application server. Install ngrok and connect its agent to your account using the [official ngrok setup guide](https://ngrok.com/docs/share-localhost/quickstart). - Run `ngrok http 4190` in another terminal, replacing `4190` with your application’s listening port. Keep the tunnel running throughout the connection. - Copy the HTTPS forwarding URL ngrok displays and append `/oauth/callback`. Register that exact callback URI on your OAuth app and set your application’s `SERVICEKEEL_REDIRECT_URI` to the same value. - Configure your application’s public/base URL and allowed host/origin settings for the ngrok HTTPS origin. Open the application through that ngrok URL before starting OAuth so the callback receives the same session cookie and state. Use secure, HttpOnly session cookies that support the top-level OAuth callback. The authorization request and token exchange must both send the same registered `redirect_uri`, including its scheme, hostname, path, and trailing slash. If your ngrok URL changes, update the app registration and your configuration, then start a new connection. When you deploy your application, register its deployed HTTPS callback instead. ### 3. Send the business to the consent screen Generate a cryptographically random state and an S256 PKCE verifier for each connection attempt. Store them in a short-lived, one-use server session associated with the signed-in user of your application. Redirect the browser to the *portal* URL below. ServiceKeel displays the app and its requested permissions. #### Create the authorization URL on your server ```javascript import { randomBytes, createHash } from 'node:crypto'; const PORTAL_ORIGIN = 'https://servicekeel.com'; const API_ORIGIN = 'https://api.siteseam.com'; // During development: ngrok's HTTPS forwarding URL + /oauth/callback. // Register this exact value on your OAuth app before starting a connection. const redirectUri = process.env.SERVICEKEEL_REDIRECT_URI; if (!redirectUri || new URL(redirectUri).protocol !== 'https:') { throw new Error('Configure the registered HTTPS OAuth callback'); } const verifier = randomBytes(32).toString('base64url'); const state = randomBytes(32).toString('base64url'); // Persist { verifier, state, createdAt, redirectUri } in this user's session. const query = new URLSearchParams({ client_id: process.env.SERVICEKEEL_CLIENT_ID, redirect_uri: redirectUri, // Must exactly match the registered callback. response_type: 'code', scope: 'field_service:read field_service:write', code_challenge_method: 'S256', code_challenge: createHash('sha256').update(verifier).digest('base64url'), state, }); const authorizeUrl = PORTAL_ORIGIN + '/partner/oauth/authorize?' + query; // Redirect the browser to authorizeUrl. ``` ![ServiceKeel OAuth consent showing the Harbor Mock Receptionist app and requested read and write permissions.](https://servicekeel.com/developer-guides/ai-receptionist/images/consent.png) Screenshot from the working integration: The business chooses whether to authorize the app. Denial returns to your callback without an access token. ### 4. Exchange the returned code At your callback, validate state against the stored session, reject expired attempts, and consume the state exactly once. Handle `error=access_denied` without exchanging a code. Only after those checks, exchange the authorization code and original verifier from your server. Use HTTP Basic client authentication, or the supported form-body credentials used in the demo. Keep the client secret, access token, and refresh token out of browser code, URLs, analytics, and transcripts. #### POST /partner/oauth/token Exchange a partner authorization code or rotate a refresh token Parameters from the OpenAPI specification: - `grant_type` (formData, required; string): authorization_code or refresh_token - `client_id` (formData, optional; string): OAuth client ID when not using HTTP Basic - `client_secret` (formData, optional; string): Client secret when not using HTTP Basic - `code` (formData, optional; string): One-use authorization code - `redirect_uri` (formData, optional; string): Exact original redirect URI for code exchange - `code_verifier` (formData, optional; string): Original PKCE verifier - `refresh_token` (formData, optional; string): Current refresh token - `scope` (formData, optional; string): Refresh-only downscope; omitted preserves current permissions, explicit empty removes them Documented response codes: 200, 400, 401. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/partner-oauth/POST/partner/oauth/token #### Exchange code for a tenant-bound token ```javascript const response = await fetch(API_ORIGIN + '/partner/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: process.env.SERVICEKEEL_CLIENT_ID, client_secret: process.env.SERVICEKEEL_CLIENT_SECRET, redirect_uri: pending.redirectUri, code: callbackCode, code_verifier: pending.verifier, }), redirect: 'error', signal: AbortSignal.timeout(15000), }); const tokens = await response.json(); if (!response.ok) throw new Error(tokens.error || 'OAuth exchange failed'); // Encrypt and save tokens under this business's connection. ``` ### 5. Call the API from your application server The remaining chapters use this helper with paths relative to `/field_service`. Load `connection` from your authenticated application session. The token determines the ServiceKeel tenant; a caller-supplied customer or account identifier must never select a different connection. The following snippets continue the same workflow and reuse returned objects such as `customer`, `job`, and `visit`. IDs and versions must come from API responses. #### A minimal server-side request helper ```javascript async function api(method, path, data) { const response = await fetch(API_ORIGIN + '/field_service' + path, { method, headers: { Authorization: 'Bearer ' + connection.access_token, ...(data === undefined ? {} : { 'Content-Type': 'application/json' }), }, body: data === undefined ? undefined : JSON.stringify(data), redirect: 'error', signal: AbortSignal.timeout(20000), }); const result = await response.json(); if (!response.ok) { // Let your workflow handle 401, 403, validation errors, and 409 conflicts. throw Object.assign(new Error(result.error || 'ServiceKeel request failed'), { status: response.status, details: result, }); } return result; } ``` ### 6. Rotate tokens and respect disconnection Refresh tokens rotate on every use. Serialize refreshes per connection and atomically store the new access and refresh tokens before allowing more requests. Replaying an old refresh token revokes the grant; do not blindly retry a refresh after an uncertain network response. Refresh cannot add permissions. New scopes require fresh customer consent. A revoked or invalid connection must stop background work and ask the business to reconnect. Never switch to an owner login token as a fallback. #### Refresh form fields ```text grant_type=refresh_token client_id=YOUR_CLIENT_ID client_secret=YOUR_CLIENT_SECRET refresh_token=CURRENT_REFRESH_TOKEN ``` ## Find the customer and service address Resolve the caller to the right customer and property before checking coverage or scheduling work. Source: https://servicekeel.com/developer-guides/ai-receptionist/identify-the-caller.html ### 1. Match an existing customer, or create one Ask for the caller’s name and contact information. Search using a confirmed email, then verify the returned match. A search result is not identity verification: resolve ambiguous matches with the caller before reading private job details or changing records. The demo uses a unique synthetic email. List results are paginated. Follow `has_more` with the documented `limit` and `offset` before concluding a customer does not exist. Persist the resulting ID in your call workflow so a reconnect does not create the customer again. #### GET /field_service/customers List customers Parameters from the OpenAPI specification: - `search` (query, optional; string): Search name, title, company or email - `status` (query, optional; string): Status - `customer_id` (query, optional; string): Customer - `property_id` (query, optional; string): Service property - `job_id` (query, optional; string): Job - `limit` (query, optional; integer): 1-200 - `offset` (query, optional; integer): Pagination offset - `created_after` (query, optional; string): Created at or after (RFC3339) - `created_before` (query, optional; string): Created before, exclusive (RFC3339) Documented response codes: 200, 401, 403. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/customers/GET/field_service/customers #### POST /field_service/customers Create customer Parameters from the OpenAPI specification: - `record` (body, required; models.FieldServiceRecord): Record Documented response codes: 201, 400, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/customers/POST/field_service/customers #### Resolve the caller ```javascript const email = 'alex@example.com'; // Confirmed with the caller. let customer; for (let offset = 0; ; offset += 100) { const query = new URLSearchParams({ search: email, limit: '100', offset: String(offset) }); const result = await api('GET', '/customers?' + query); customer = result.items.find(item => item.email === email); if (customer || !result.has_more) break; } if (!customer) { customer = await api('POST', '/customers', { name: 'Alex Morgan', email, phone: '+12025550142', status: 'active', }); } ``` ### 2. Save only the confirmed changes A customer record carries a `version`. Send the current version when patching it and replace your saved copy with the response. A stale update returns a conflict; reload, reconcile the change, and ask again if the caller’s intended outcome has changed. Use a separate call note for new instructions when you should preserve existing notes. This example updates the synthetic customer’s note field deliberately. #### PATCH /field_service/customers/{id} Update customer (PATCH) Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID - `record` (body, required; models.FieldServiceRecord): Record fields and current version Documented response codes: 200, 400, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/customers/PATCH/field_service/customers/{id} #### Update the customer with optimistic concurrency ```javascript customer = await api('PATCH', '/customers/' + customer.id, { version: customer.version, notes: 'Caller confirmed the best callback time is after 3 pm.', }); ``` ### 3. Confirm the service location List the customer’s locations and confirm which property needs service. Do not assume the first address is the right one. If the address is new, create it under that customer. The returned location’s `id` becomes `property_id` in downstream requests. The caller can now hear an address confirmation before you check whether the business serves the property. #### GET /field_service/locations List tenant service locations Parameters from the OpenAPI specification: - `customer_id` (query, optional; string): Customer ID - `property_id` (query, optional; string): Service location ID - `location_id` (query, optional; string): Alias for property_id; must agree when both are supplied - `address` (query, optional; string): Case-insensitive location address search - `postal_code` (query, optional; string): Exact postal code, case-insensitive - `limit` (query, optional; integer): Page size - `offset` (query, optional; integer): Pagination offset Documented response codes: 200, 400, 401, 403, 500. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/locations/GET/field_service/locations #### POST /field_service/locations Create a customer service location Parameters from the OpenAPI specification: - `location` (body, required; models.FieldServiceLocation): Service location; customer_id and address are required Documented response codes: 201, 400, 401, 403, 404, 409, 500. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/locations/POST/field_service/locations #### Create a caller-confirmed new location ```javascript const locations = await api('GET', '/locations?' + new URLSearchParams({ customer_id: customer.id, })); // Review existing locations with the caller first. If this is a new address: const location = await api('POST', '/locations', { customer_id: customer.id, name: 'Home', address: '123 Demo Lane', city: 'Madison', region: 'WI', postal_code: '53703', country: 'US', }); ``` ## Turn a request into a booked visit Ask when the caller is available, offer matching times, and save the request, job, and appointment only after an exact-time confirmation. Source: https://servicekeel.com/developer-guides/ai-receptionist/book-a-quote.html ### 1. Check the service and coverage Load the business’s active services and confirm which one matches the caller’s need. Check coverage for the customer, property, and service together. A staff list helps you explain who can handle the work, but the availability response determines which staff are eligible for each slot. The business configures its catalog, service areas, staff qualifications, and booking hours in ServiceKeel before connecting your app. The example expects a 60-minute “Heat-pump quote” service covering ZIP 53703. #### GET /field_service/services List catalog services with approved fees, duration and required skills Parameters from the OpenAPI specification: - `service_type` (query, optional; string): Service type - `branch_id` (query, optional; string): Branch - `active` (query, optional; boolean): Active catalog items Documented response codes: 200. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/services/GET/field_service/services #### GET /field_service/service_area Check service coverage by postal code or saved service location Parameters from the OpenAPI specification: - `postal_code` (query, optional; string): Postal code - `address` (query, optional; string): Exact saved service address - `property_id` (query, optional; string): Saved service location - `customer_id` (query, optional; string): Customer owning the location - `service_id` (query, optional; string): Catalog service - `service_type` (query, optional; string): Configured service type Documented response codes: 200. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/scheduling/GET/field_service/service_area #### GET /field_service/staff List shared staff with branch, skills, coverage and on-call details Parameters from the OpenAPI specification: - `branch_id` (query, optional; string): Branch - `skills` (query, optional; string): Comma-separated required skills - `postal_code` (query, optional; string): Coverage postal code - `active` (query, optional; boolean): Active staff (defaults to true) - `on_call` (query, optional; boolean): On-call status Documented response codes: 200. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/staff/GET/field_service/staff #### Check whether the business can serve this address ```javascript const services = await api('GET', '/services?active=true'); const service = services.items.find(item => item.name === 'Heat-pump quote'); if (!service) throw new Error('Ask the business to configure this service'); const coverage = await api('GET', '/service_area?' + new URLSearchParams({ customer_id: customer.id, property_id: location.id, service_id: service.id, })); if (!coverage.covered) throw new Error('Offer a handoff: address outside service area'); const staff = await api('GET', '/staff?active=true&postal_code=53703'); ``` ### 2. Ask the caller when they are available “Could someone quote a heat-pump repair?” identifies a service need. It supplies no appointment date or time. Ask **“What date and time window works for you? All times are Central time.”** Wait for the caller’s answer before looking for matching slots. Collect a calendar date, a time window, and the business timezone. In this demo the caller enters a date and chooses morning (8 AM–noon) or afternoon (noon–5 PM) in `America/Chicago`. A production voice assistant can accept natural language, but must resolve “tomorrow” or “next Friday” against the business’s local date and clarify ambiguous answers. Ask again if the caller gives no date, changes the service/address, or has not agreed to the timezone. THE SCHEDULING CONVERSATION **Caller:** Could someone quote a heat-pump repair at 123 Demo Lane? **Receptionist:** What date and time window works for you? All times are Central time. **Caller:** Sunday, October 4, 2026, in the afternoon. **Receptionist:** I can offer October 4, noon–1 PM or 1–2 PM Central time. Which works for you? **Caller:** 1–2 PM works. **Receptionist:** To confirm: book a quote at 123 Demo Lane for Sunday, October 4, 2026, 1–2 PM Central time. Shall I save that appointment? **Caller:** Yes, book that time. **Receptionist, after a successful write:** You’re booked for Sunday, October 4, 2026, 1–2 PM Central time. These dialogue dates are illustrative. The runnable demo offers actual API results for the date the caller enters; the test selects a future date at runtime. The demo’s form simulates a caller reply, not speech recognition or AI reasoning. ### 3. Offer times that match the caller’s answer Query availability for the caller’s date, customer, property, and service. Filter returned slots to the requested date and time window in the business timezone. Offer at most two distinct matching times with their complete date, start, end, and timezone. Wait for a selection; never automatically book the first result. Keep the returned slot and eligible staff on your server, associated with an opaque offer ID for this call. If no slot matches, explain that nothing has been booked and ask for another date or window, or offer a human handoff. Do not silently widen the search and book a different day. **Availability is not a reservation.** The demo expires its offers after five minutes. Another booking can take the same time. The visit write rechecks scheduling and returns HTTP 409 if the slot conflicts. Refresh availability and offer a new choice instead of promising a booking. #### GET /field_service/availability Find tenant appointment availability across booking forms Parameters from the OpenAPI specification: - `service_id` (query, required; string): Catalog service - `starts_after` (query, required; string): Range start RFC3339 - `ends_before` (query, required; string): Range end RFC3339, at most 31 days - `timezone` (query, optional; string): Response timezone - `booking_form_id` (query, optional; string): Restrict to one booking form - `customer_id` (query, optional; string): Customer - `property_id` (query, optional; string): Saved service location - `staff_id` (query, optional; string): Optional specific staff member - `duration_minutes` (query, optional; integer): Override duration, 15-480 minutes Documented response codes: 200, 400. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/scheduling/GET/field_service/availability #### Get candidate appointment times ```javascript // preferences comes from the caller: validated YYYY-MM-DD and // window = 'morning' or 'afternoon', with timezone confirmed as America/Chicago. const timezone = 'America/Chicago'; const [firstHour, lastHour] = { morning: [8, 12], afternoon: [12, 17] }[preferences.window]; // A bounded UTC envelope covers the requested Chicago date across DST. const start = new Date(preferences.date + 'T00:00:00Z'); const end = new Date(start.getTime() + 2 * 24 * 60 * 60 * 1000); const availability = await api('GET', '/availability?' + new URLSearchParams({ service_id: service.id, customer_id: customer.id, property_id: location.id, starts_after: start.toISOString(), ends_before: end.toISOString(), timezone, })); const inBusinessTime = value => { const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }).formatToParts(new Date(value)).map(({ type, value }) => [type, value])); return { date: [p.year, p.month, p.day].join('-'), minute: +p.hour * 60 + +p.minute }; }; const matching = availability.items.filter(slot => { const start = inBusinessTime(slot.starts_at), end = inBusinessTime(slot.ends_at); return start.date === preferences.date && end.date === preferences.date && start.minute >= firstHour * 60 && end.minute <= lastHour * 60 && Date.parse(slot.starts_at) > Date.now() && slot.eligible_staff_ids?.length; }); // Deduplicate matching times, offer two, and WAIT for a caller selection. // No matching slots: ask for another date/window. Do not save anything. ``` ![The caller chooses between two API-returned appointment times after supplying a date and afternoon window.](https://servicekeel.com/developer-guides/ai-receptionist/images/book-options.png) Screenshot from the working integration: The demo is paused for the caller’s choice. No request, job, or appointment has been saved. ### 4. Require an explicit appointment confirmation After the caller selects one offered time, read back the service, property, full calendar date, start–end time range, and timezone. Ask “Shall I save that appointment?” and wait for an explicit yes to that exact proposal. A quote request, an availability question, silence, or choosing a time is not the final confirmation. Enforce `preferences → offered → confirmation → saved` on your server. Only accept offer IDs from this call’s current availability response. Generate a fresh confirmation ID for the selected proposal; bind it to the business, caller, customer, property, service, and selected slot. Changing any of those details, choosing another date, or expiring the offers invalidates the earlier confirmation. Reject missing, invented, stale, or already-consumed IDs before any booking write. In a real AI receptionist, expose separate tools for collecting preferences, offering times, selecting a proposal, and confirming it. The conversation layer may submit confirmation only after an unambiguous caller reply to the readback. An LLM-generated `confirmed: true` is not evidence by itself: associate it with the actual caller turn and the current proposal. Keep the transcript and tool state for review. The runnable mock implements these guards in `Receptionist.appointment()`. Its `/api/appointments/preferences`, `/select`, `/revise`, and `/confirm` routes belong to the example application, not the ServiceKeel API. The explicit confirmation form substitutes for a spoken caller reply. #### Enforce the gate before invoking any booking writes ```javascript // Application-server pseudocode; pending is stored per connected call. // Called only after the conversation layer captures the caller's explicit yes. if (pending.phase !== 'confirmation' || callerReply.confirmed !== true || callerReply.confirmation_id !== pending.proposal.id || pending.expiresAt <= Date.now()) { throw new Error('Ask the caller to select and confirm a current offered time'); } // Take the slot from server state, never arbitrary timestamps supplied by a model. const selectedSlot = pending.proposal.slot; // Serialize confirmation per call, consume it once, and retain every saved ID. // Only now run the request → job → visit writes shown below. ``` ### 5. Save the request and convert it to a job Only after the appointment confirmation gate succeeds, check existing requests for this customer so a repeat call does not open duplicate work. After establishing this is a new request, save the caller’s description and chosen property. Convert that request to a job rather than creating an unrelated job. Save the returned job ID and its relationship to the request. The conversion uses the shared record-action endpoint in addition to the core receptionist endpoints. Keep each successful result before proceeding; the workflow is not one transaction. If a later visit write fails, preserve these records for reconciliation and do not blindly create them again. #### GET /field_service/requests List requests Parameters from the OpenAPI specification: - `search` (query, optional; string): Search name, title, company or email - `status` (query, optional; string): Status - `customer_id` (query, optional; string): Customer - `property_id` (query, optional; string): Service property - `job_id` (query, optional; string): Job - `limit` (query, optional; integer): 1-200 - `offset` (query, optional; integer): Pagination offset - `created_after` (query, optional; string): Created at or after (RFC3339) - `created_before` (query, optional; string): Created before, exclusive (RFC3339) Documented response codes: 200, 401, 403. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/requests/GET/field_service/requests #### POST /field_service/requests Create request Parameters from the OpenAPI specification: - `record` (body, required; models.FieldServiceRecord): Record Documented response codes: 201, 400, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/requests/POST/field_service/requests #### POST /field_service/requests/{id}/actions Run request action Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID - `action` (body, required; gin.FieldServiceActionRequest): Workflow action Documented response codes: 200, 400, 403, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/requests/POST/field_service/requests/{id}/actions #### Keep the request → job relationship ```javascript const existingRequests = await api('GET', '/requests?customer_id=' + customer.id); // Reconcile the caller's request with existingRequests before creating. const request = await api('POST', '/requests', { title: 'Heat-pump repair quote', customer_id: customer.id, property_id: location.id, description: 'Caller requests a quote for a noisy heat pump.', custom_fields: { service_id: service.id }, }); let job = await api('POST', '/requests/' + request.id + '/actions', { action: 'convert_to_job', version: request.version, }); ``` ### 6. Reserve the confirmed time Create a visit attached to the job using the selected slot and eligible staff. Only after the write succeeds should your receptionist say “You’re booked.” Persist the visit ID separately from the job ID so later calls can reschedule the appointment without replacing the job. The example disables notifications. Choose notification behavior deliberately with the business before enabling it in a live integration. #### POST /field_service/visits Create visit Parameters from the OpenAPI specification: - `record` (body, required; models.FieldServiceRecord): Record Documented response codes: 201, 400, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/visits/POST/field_service/visits #### Create the appointment ```javascript let visit = await api('POST', '/visits', { title: job.title, job_id: job.id, customer_id: customer.id, property_id: location.id, starts_at: selectedSlot.starts_at, ends_at: selectedSlot.ends_at, timezone: selectedSlot.timezone, assigned_user_ids: [selectedSlot.eligible_staff_ids[0]], custom_fields: { service_id: service.id }, notify_customer: false, notify_assigned_users: false, }); ``` ![The receptionist confirms a booked heat-pump quote in the phone transcript.](https://servicekeel.com/developer-guides/ai-receptionist/images/book.png) Screenshot from the working integration: The complete conversation shows the date question, caller’s preferences, offered times, selected time, readback, and explicit yes. Only then do the request, job, and visit writes run and produce the booking confirmation. ## Reschedule, track, and cancel work Use existing record IDs and current versions to answer follow-up calls without duplicating the original job. Source: https://servicekeel.com/developer-guides/ai-receptionist/manage-appointments.html ### 1. Find the right job and visit After verifying the caller, list their jobs and the selected job’s visits. Confirm which appointment they mean, then load the current record details. Keep the latest `version` from every response; another user may have changed the schedule since the original call. #### GET /field_service/jobs List jobs Parameters from the OpenAPI specification: - `search` (query, optional; string): Search name, title, company or email - `status` (query, optional; string): Status - `customer_id` (query, optional; string): Customer - `property_id` (query, optional; string): Service property - `job_id` (query, optional; string): Job - `limit` (query, optional; integer): 1-200 - `offset` (query, optional; integer): Pagination offset - `created_after` (query, optional; string): Created at or after (RFC3339) - `created_before` (query, optional; string): Created before, exclusive (RFC3339) Documented response codes: 200, 401, 403. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/jobs/GET/field_service/jobs #### GET /field_service/jobs/{id} Get job Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID Documented response codes: 200, 404. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/jobs/GET/field_service/jobs/{id} #### GET /field_service/visits List visits Parameters from the OpenAPI specification: - `search` (query, optional; string): Search name, title, company or email - `status` (query, optional; string): Status - `customer_id` (query, optional; string): Customer - `property_id` (query, optional; string): Service property - `job_id` (query, optional; string): Job - `limit` (query, optional; integer): 1-200 - `offset` (query, optional; integer): Pagination offset - `created_after` (query, optional; string): Created at or after (RFC3339) - `created_before` (query, optional; string): Created before, exclusive (RFC3339) Documented response codes: 200, 401, 403. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/visits/GET/field_service/visits #### GET /field_service/visits/{id} Get visit Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID Documented response codes: 200, 404. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/visits/GET/field_service/visits/{id} #### Reload the records before editing ```javascript const jobs = await api('GET', '/jobs?customer_id=' + customer.id); // callerSelectedJobId is selected from jobs, not an unverified external ID. job = await api('GET', '/jobs/' + callerSelectedJobId); const visits = await api('GET', '/visits?job_id=' + job.id); visit = await api('GET', '/visits/' + callerSelectedVisitId); ``` ### 2. Move the appointment “Could we move that appointment?” does not specify a replacement time. Repeat the booking chapter’s conversation: ask for a new date and window, fetch matching availability, offer times, wait for a choice, read back the exact replacement, and wait for an explicit yes. Until then, leave the existing appointment unchanged. Use the server-held confirmed proposal as `replacementSlot`; do not select the first later slot automatically. After that confirmation, patch the existing visit with the new slot and current version, then save relevant job context. The API releases the old time when the reschedule succeeds. A conflict requires a fresh choice and confirmation. #### PATCH /field_service/visits/{id} Update visit (PATCH) Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID - `record` (body, required; models.FieldServiceRecord): Record fields and current version Documented response codes: 200, 400, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/visits/PATCH/field_service/visits/{id} #### PATCH /field_service/jobs/{id} Update job (PATCH) Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID - `record` (body, required; models.FieldServiceRecord): Record fields and current version Documented response codes: 200, 400, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/jobs/PATCH/field_service/jobs/{id} #### Save the caller-confirmed replacement slot ```javascript visit = await api('PATCH', '/visits/' + visit.id, { version: visit.version, starts_at: replacementSlot.starts_at, ends_at: replacementSlot.ends_at, timezone: replacementSlot.timezone, assigned_user_ids: [replacementSlot.eligible_staff_ids[0]], notify_customer: false, notify_assigned_users: false, }); job = await api('PATCH', '/jobs/' + job.id, { version: job.version, notes: 'Caller requested a later quote appointment.', }); ``` ![The mock receptionist confirms that the appointment moved and the original slot was released.](https://servicekeel.com/developer-guides/ai-receptionist/images/reschedule.png) Screenshot from the working integration: The integration verifies no appointment changes before the caller’s final yes, persistence of the second offered time the caller selected, and availability of the released slot. ### 3. Report technician status When the caller asks “Where is my technician?”, read the visit’s tracking response. Report only the status and ETA the business actually provides. An absent ETA should lead to a handoff or an honest “I don’t have an arrival estimate yet.” The demo simulates an authorized technician progressing through `en_route`, `arrive`, `start`, and `complete`. In a live integration, those writes need an authorized dispatch or technician event; a status question from a caller is not permission to advance the work. #### GET /field_service/visits/{id}/tracking Read an appointment's dispatch status, arrival window and available ETA Parameters from the OpenAPI specification: - `id` (path, required; string): Visit ID Documented response codes: 200. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/visits/GET/field_service/visits/{id}/tracking #### POST /field_service/visits/{id}/actions Run visit action Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID - `action` (body, required; gin.FieldServiceActionRequest): Workflow action Documented response codes: 200, 400, 403, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/visits/POST/field_service/visits/{id}/actions #### POST /field_service/jobs/{id}/actions Run job action Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID - `action` (body, required; gin.FieldServiceActionRequest): Workflow action Documented response codes: 200, 400, 403, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/jobs/POST/field_service/jobs/{id}/actions #### Read status; demonstrate a separate authorized lifecycle update ```javascript const tracking = await api('GET', '/visits/' + visit.id + '/tracking'); // Report tracking to the caller. The following is demo dispatch simulation: for (const action of ['en_route', 'arrive', 'start', 'complete']) { visit = await api('POST', '/visits/' + visit.id + '/actions', { action, version: visit.version, }); } job = await api('POST', '/jobs/' + job.id + '/actions', { action: 'complete', version: job.version, }); ``` ### 4. Cancel with an explicit scope Confirm the job, reason, and which appointments should be cancelled. Supported scopes are `job_only`, `future_appointments`, and `all_open_appointments`. Completed visits are retained. The demo first runs a separate “Book a follow-up” conversation, collecting the caller’s date, window, selected slot, and explicit confirmation before creating its job and visit. A later “Cancel a follow-up” step cancels those existing records. A cancellation request never creates a new appointment. Cancellation processes at most 25 visits per call. If `complete` is false, follow the reference’s continuation contract using the same reason and scope. Do not announce that everything is cancelled until the operation is complete. #### POST /field_service/jobs Create job Parameters from the OpenAPI specification: - `record` (body, required; models.FieldServiceRecord): Record Documented response codes: 201, 400, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/jobs/POST/field_service/jobs #### POST /field_service/jobs/{id}/cancel Cancel a job and optionally its open appointments in resumable batches Parameters from the OpenAPI specification: - `id` (path, required; string): Job ID - `cancellation` (body, required; gin.fieldServiceCancelJobRequest): Reason and scope Documented response codes: 200, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/jobs/POST/field_service/jobs/{id}/cancel #### Cancel only the confirmed optional follow-up ```javascript // Earlier call: ONLY after the caller confirms the follow-up's exact slot. const followup = await api('POST', '/jobs', { title: 'Optional follow-up', customer_id: customer.id, property_id: location.id, }); // Save its visit with that confirmed slot using the previous chapter's flow. // Persist both IDs and return the success to the caller. // Later cancellation request: reload the existing follow-up and confirm scope. // Do not create any new job or visit here. const currentFollowup = await api('GET', '/jobs/' + followup.id); const cancellation = await api('POST', '/jobs/' + followup.id + '/cancel', { version: currentFollowup.version, reason: 'Caller no longer needs the optional follow-up.', scope: 'all_open_appointments', }); // Inspect cancellation.complete and the returned job before confirming. ``` ![The receptionist confirms cancellation of the extra follow-up while preserving the completed repair.](https://servicekeel.com/developer-guides/ai-receptionist/images/cancel.png) Screenshot from the working integration: The job and its open appointment are cancelled together, with a reason attached. ## Record approvals and useful job context Carry the caller’s approved pricing, instructions, and equipment documents into the job the business already uses. Source: https://servicekeel.com/developer-guides/ai-receptionist/estimates-and-files.html ### 1. Retrieve or prepare an estimate Look for an existing estimate before preparing a new one. Prices are integer cents and totals are calculated by the server. The demo uses a fixed $125 repair; a real receptionist must use pricing authorized by the business, not invent a price from the conversation. Read back the current estimate and the actual work covered before asking for approval. Patches include the current version. #### GET /field_service/estimates List estimates Parameters from the OpenAPI specification: - `search` (query, optional; string): Search name, title, company or email - `status` (query, optional; string): Status - `customer_id` (query, optional; string): Customer - `property_id` (query, optional; string): Service property - `job_id` (query, optional; string): Job - `limit` (query, optional; integer): 1-200 - `offset` (query, optional; integer): Pagination offset - `created_after` (query, optional; string): Created at or after (RFC3339) - `created_before` (query, optional; string): Created before, exclusive (RFC3339) Documented response codes: 200, 401, 403. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/estimates/GET/field_service/estimates #### POST /field_service/estimates Create estimate Parameters from the OpenAPI specification: - `record` (body, required; models.FieldServiceRecord): Record Documented response codes: 201, 400, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/estimates/POST/field_service/estimates #### GET /field_service/estimates/{id} Get estimate Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID Documented response codes: 200, 404. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/estimates/GET/field_service/estimates/{id} #### PATCH /field_service/estimates/{id} Update estimate (PATCH) Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID - `record` (body, required; models.FieldServiceRecord): Record fields and current version Documented response codes: 200, 400, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/estimates/PATCH/field_service/estimates/{id} #### Prepare the demonstration quote ```javascript const estimates = await api('GET', '/estimates?customer_id=' + customer.id); // After confirming that a new estimate is needed: let estimate = await api('POST', '/estimates', { customer_id: customer.id, property_id: location.id, title: 'Heat-pump repair', line_items: [{ name: 'Heat-pump repair', quantity: 1, unit_price_cents: 12500 }], }); estimate = await api('GET', '/estimates/' + estimate.id); estimate = await api('PATCH', '/estimates/' + estimate.id, { version: estimate.version, description: 'Repair scope reviewed with the caller.', }); ``` ### 2. Record approval and apply pricing to the job Only run the approval action after capturing the customer’s explicit approval through a method accepted by the business. Do not fabricate a signature from the caller’s name. The demo uses a clearly marked synthetic signature. Applying the approved estimate to the existing job keeps the request, appointment, and pricing together. Save the updated job version returned by the action. #### POST /field_service/estimates/{id}/actions Run estimate action Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID - `action` (body, required; gin.FieldServiceActionRequest): Workflow action Documented response codes: 200, 400, 403, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/estimates/POST/field_service/estimates/{id}/actions #### Apply an explicitly approved estimate ```javascript estimate = await api('POST', '/estimates/' + estimate.id + '/actions', { action: 'approve', version: estimate.version, signature: callerConfirmedSignature, }); job = await api('POST', '/jobs/' + job.id + '/actions', { action: 'apply_estimate', version: job.version, estimate: estimate.id, }); ``` ![The phone transcript confirms the $125 approved quote was applied to the existing job.](https://servicekeel.com/developer-guides/ai-receptionist/images/estimate.png) Screenshot from the working integration: The approval and application are separate writes. Persist the result of each before moving on. ### 3. Add a note to the call’s records Save operational context such as access instructions as a note linked to the customer and job. Read notes with the appropriate customer filter so another caller’s details never enter the conversation. Store only information the business needs to perform the work. #### POST /field_service/notes Create note Parameters from the OpenAPI specification: - `record` (body, required; models.FieldServiceRecord): Record Documented response codes: 201, 400, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/notes/POST/field_service/notes #### GET /field_service/notes List notes Parameters from the OpenAPI specification: - `search` (query, optional; string): Search name, title, company or email - `status` (query, optional; string): Status - `customer_id` (query, optional; string): Customer - `property_id` (query, optional; string): Service property - `job_id` (query, optional; string): Job - `limit` (query, optional; integer): 1-200 - `offset` (query, optional; integer): Pagination offset - `created_after` (query, optional; string): Created at or after (RFC3339) - `created_before` (query, optional; string): Created before, exclusive (RFC3339) Documented response codes: 200, 401, 403. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/notes/GET/field_service/notes #### Save and retrieve job instructions ```javascript const note = await api('POST', '/notes', { name: 'Receptionist call note', customer_id: customer.id, job_id: job.id, notes: 'Gate instructions confirmed by the caller. Equipment PDF attached.', }); const notes = await api('GET', '/notes?customer_id=' + customer.id); ``` ### 4. Upload, complete, then attach a PDF Document upload has three stages: request a signed upload URL, send the bytes to storage, and complete the asset. Then attach the completed asset with a versioned job patch. The signed URL is bound to the source record, content type, and exact byte length. Use the returned upload headers unchanged. Never send your OAuth token to the storage URL. Completion validates the file before it can become an attachment. Preserve existing attachments when you append the new one; this uses the generic record PATCH contract with `kind=jobs`. #### POST /field_service/files/upload Prepare a private Field Service PDF upload Parameters from the OpenAPI specification: - `payload` (body, required; gin.FieldServiceFileUploadInput): PDF and source record Documented response codes: 200, 400. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/files/POST/field_service/files/upload #### POST /field_service/files/{asset_id}/complete Complete and validate a private PDF upload Parameters from the OpenAPI specification: - `asset_id` (path, required; string): Upload ID Documented response codes: 200. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/files/POST/field_service/files/{asset_id}/complete #### PATCH /field_service/jobs/{id} Update job (PATCH) Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID - `record` (body, required; models.FieldServiceRecord): Record fields and current version Documented response codes: 200, 400, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/jobs/PATCH/field_service/jobs/{id} #### Upload a PDF from your server ```javascript const upload = await api('POST', '/files/upload', { filename: 'equipment.pdf', size_bytes: pdfBytes.length, kind: 'jobs', record_id: job.id, }); const destination = new URL(upload.upload_url); if (destination.protocol !== 'https:') throw new Error('Expected HTTPS storage'); const put = await fetch(destination, { method: 'PUT', headers: upload.upload_headers, body: pdfBytes, redirect: 'error', signal: AbortSignal.timeout(20000), }); if (!put.ok) throw new Error('Upload failed; do not complete or attach it'); const asset = await api('POST', '/files/' + upload.asset_id + '/complete', {}); job = await api('PATCH', '/jobs/' + job.id, { version: job.version, attachments: [...(job.attachments || []), { asset_id: asset.id, name: upload.filename, title: 'Equipment information', shared_with_customer: false, }], }); ``` ![The receptionist confirms that instructions and a private equipment PDF were saved to the job.](https://servicekeel.com/developer-guides/ai-receptionist/images/notes.png) Screenshot from the working integration: The local test substitutes storage, while the real backend validates completion and saves the attachment relationship. ## Plan recurring service with the caller Use existing membership and equipment context to arrange ongoing maintenance without implying that future dates are already booked. Source: https://servicekeel.com/developer-guides/ai-receptionist/recurring-care.html ### 1. Check memberships and equipment Before offering maintenance, retrieve the customer’s memberships and the equipment at the confirmed service location. Explain the benefits actually attached to the membership. Do not assume a discount, coverage, or renewal is available just because a membership record exists. The integration seeds one synthetic membership and one heat pump through the business owner’s session. The receptionist reads those records with its OAuth token. #### GET /field_service/memberships List memberships Parameters from the OpenAPI specification: - `search` (query, optional; string): Search name, title, company or email - `status` (query, optional; string): Status - `customer_id` (query, optional; string): Customer - `property_id` (query, optional; string): Service property - `job_id` (query, optional; string): Job - `limit` (query, optional; integer): 1-200 - `offset` (query, optional; integer): Pagination offset - `created_after` (query, optional; string): Created at or after (RFC3339) - `created_before` (query, optional; string): Created before, exclusive (RFC3339) Documented response codes: 200, 401, 403. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/memberships/GET/field_service/memberships #### GET /field_service/equipment List tenant service-location equipment Parameters from the OpenAPI specification: - `customer_id` (query, optional; string): Customer ID - `property_id` (query, optional; string): Service location ID - `location_id` (query, optional; string): Alias for property_id; must agree when both are supplied - `address` (query, optional; string): Case-insensitive location address search - `postal_code` (query, optional; string): Exact postal code, case-insensitive - `limit` (query, optional; integer): Page size - `equipment_type` (query, optional; string): Exact equipment type, case-insensitive - `cursor` (query, optional; string): Opaque next_cursor from the previous response Documented response codes: 200, 400, 401, 403, 409, 500. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/equipment/GET/field_service/equipment #### GET /field_service/recurring_services List tenant recurring services Parameters from the OpenAPI specification: - `customer_id` (query, optional; string): Customer ID - `property_id` (query, optional; string): Service location ID - `location_id` (query, optional; string): Alias for property_id - `search` (query, optional; string): Search name, title, company or email - `status` (query, optional; string): Job status - `active` (query, optional; boolean): Filter active jobs - `assigned_user_id` (query, optional; string): Assigned staff user ID - `service_id` (query, optional; string): Service catalog ID - `starts_after` (query, optional; string): Start boundary (RFC3339) - `ends_before` (query, optional; string): End boundary (RFC3339) - `created_after` (query, optional; string): Created at or after (RFC3339) - `created_before` (query, optional; string): Created before, exclusive (RFC3339) - `limit` (query, optional; integer): Page size - `offset` (query, optional; integer): Pagination offset Documented response codes: 200, 400, 401, 403, 500. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/recurring-services/GET/field_service/recurring_services #### Load context for a maintenance conversation ```javascript const memberships = await api('GET', '/memberships?customer_id=' + customer.id); const equipment = await api('GET', '/equipment?' + new URLSearchParams({ customer_id: customer.id, property_id: location.id, })); const plans = await api('GET', '/recurring_services?customer_id=' + customer.id); // Check existing plans before proposing a new series. ``` ### 2. Confirm the cadence and create the series Confirm the scope, start date, cadence, number of occurrences, and authorized price. This example creates three monthly maintenance dates at $75 per visit. Use values approved for the business rather than carrying this example’s price into production. **A planned service date is not a booked appointment.** Recurring service returns planned dates. Use availability and the visit-booking flow to reserve staff and times for individual appointments. Tell the caller which parts are planned and which are confirmed. #### POST /field_service/recurring_services Create a tenant recurring service Parameters from the OpenAPI specification: - `record` (body, required; models.FieldServiceRecord): Recurring job; recurrence and starts_at are required Documented response codes: 201, 400, 401, 403, 409, 500. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/recurring-services/POST/field_service/recurring_services #### Create a caller-approved maintenance series ```javascript const recurring = await api('POST', '/recurring_services', { title: 'Monthly heat-pump maintenance', customer_id: customer.id, property_id: location.id, starts_at: callerConfirmedStart, recurrence: { frequency: 'monthly', interval: 1, count: 3, billing_mode: 'per_visit', }, line_items: [{ name: 'Maintenance', quantity: 1, unit_price_cents: 7500 }], }); // Inspect recurring.upcoming_service_dates, then book visits separately. ``` ![The receptionist reports existing memberships and equipment, then explains that three monthly dates are planned.](https://servicekeel.com/developer-guides/ai-receptionist/images/plans.png) Screenshot from the working integration: The wording distinguishes planned recurrence from reserved appointments. ## Prepare a payment link and close the call Retrieve an issued invoice, prepare customer checkout, offer a review link, and save a useful call record. Source: https://servicekeel.com/developer-guides/ai-receptionist/payments-and-follow-up.html ### 1. Find the business’s issued invoice The business creates and issues the invoice. Your receptionist retrieves it and checks that it belongs to the selected job and customer. Read the current collectible balance instead of repeating the estimate total. **Invoice issuance is outside this OAuth workflow.** The integration uses the business owner’s session to prepare the invoice. The receptionist cannot issue invoices, charge a saved card directly, or refund a payment. Its token is used only for the allowed reads and checkout operation. #### GET /field_service/invoices List invoices Parameters from the OpenAPI specification: - `search` (query, optional; string): Search name, title, company or email - `status` (query, optional; string): Status - `customer_id` (query, optional; string): Customer - `property_id` (query, optional; string): Service property - `job_id` (query, optional; string): Job - `limit` (query, optional; integer): 1-200 - `offset` (query, optional; integer): Pagination offset - `created_after` (query, optional; string): Created at or after (RFC3339) - `created_before` (query, optional; string): Created before, exclusive (RFC3339) Documented response codes: 200, 401, 403. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/invoices/GET/field_service/invoices #### GET /field_service/invoices/{id} Get invoice Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID Documented response codes: 200, 404. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/invoices/GET/field_service/invoices/{id} #### Retrieve the current invoice balance ```javascript const invoices = await api('GET', '/invoices?customer_id=' + customer.id); const match = invoices.items.find(item => item.status === 'sent' && (item.job_id === job.id || item.job_ids?.includes(job.id))); if (!match) throw new Error('Ask the business to issue the invoice first'); const invoice = await api('GET', '/invoices/' + match.id); ``` ### 2. Prepare checkout after the caller asks Ask whether the caller wants a payment link, then request checkout for that invoice. The backend calculates the amount and returns the hosted checkout URL. Creating checkout may lock financial edits until it is paid or explicitly cancelled, so avoid generating unused sessions on every call. Share the returned URL through a channel the customer has agreed to use. A created link is not proof of payment. Refresh the invoice or process a confirmed payment event before reporting a paid balance. #### POST /field_service/payments/invoices/{id}/checkout Create invoice or approved estimate deposit checkout Parameters from the OpenAPI specification: - `id` (path, required; string): Record ID - `options` (body, optional; gin.FieldServiceCheckoutOptions): Payment amount, expiration and method Documented response codes: 200, 403, 409, 503. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/payments/POST/field_service/payments/invoices/{id}/checkout #### Prepare hosted card checkout ```javascript const checkout = await api('POST', '/payments/invoices/' + invoice.id + '/checkout', { payment_method_type: 'card', }); const paymentUrl = checkout.url || checkout.checkout_url; // Present paymentUrl and checkout.amount_cents. Do not mark the invoice paid. ``` ![The phone transcript says a payment link is ready and no payment is made in the demo.](https://servicekeel.com/developer-guides/ai-receptionist/images/payment.png) Screenshot from the working integration: The test exercises the real invoice and checkout handlers against a local Stripe substitute. It never takes a payment. ### 3. Offer a review link After the completed work, create a review request using the business’s chosen destination. The example uses `channel: link`, which prepares the link without sending a message. Use a stable idempotency key for this specific job and review request. #### POST /field_service/review_requests Prepare or email a completed-job review request The specification does not enumerate parameters here. Use the workflow example for the request body. Documented response codes: 201. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/review-requests/POST/field_service/review_requests #### Create a link-only review request ```javascript const review = await api('POST', '/review_requests', { customer_id: customer.id, job_id: job.id, channel: 'link', review_url: businessReviewUrl, idempotency_key: 'receptionist-review-' + job.id, }); ``` ### 4. Save what happened during the call Save a concise outcome and the permitted transcript under the customer, request, and job. Record what actually succeeded, especially if a later step failed. Do not include OAuth credentials, payment-card data, or unrelated customer details in the transcript. Your voice provider remains responsible for the real telephone call. Creating this record does not place or answer a call. #### POST /field_service/calls Create call record Parameters from the OpenAPI specification: - `record` (body, required; models.FieldServiceRecord): Record Documented response codes: 201, 400, 409. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/call-records/POST/field_service/calls #### Persist the receptionist outcome ```javascript const call = await api('POST', '/calls', { title: 'Receptionist call', customer_id: customer.id, request_id: request.id, job_id: job.id, interaction: { caller_number: customer.phone, outcome: 'Quote booked; customer requested a payment link.', transcript: permittedTranscript, }, custom_fields: { source: 'your-receptionist-app' }, }); ``` ![The receptionist confirms the review link, saved call transcript, and event subscription.](https://servicekeel.com/developer-guides/ai-receptionist/images/wrap.png) Screenshot from the working integration: The final call record connects the conversation to the work it created. ## Keep the connection reliable after the call Follow record changes, verify webhooks, and handle partial work and disconnection before onboarding customers. Source: https://servicekeel.com/developer-guides/ai-receptionist/events-and-launch.html ### 1. Follow changes with the event cursor Use the event feed to reconcile changes made after event capture was enabled. Events contain record identifiers; retrieve the current record through the appropriate endpoint when you need its details. Store the returned cursor after processing, and continue while `has_more` is true. Events settle for 30 seconds before becoming visible in this feed. Do not treat an immediately empty result as a failed write. Keep a cursor per connected business, and deduplicate event IDs. #### GET /field_service/events Read tenant record change events Parameters from the OpenAPI specification: - `event_type` (query, optional; string): Event type - `record_id` (query, optional; string): Record ID - `after` (query, optional; string): RFC3339 start time - `cursor` (query, optional; string): Continuation cursor Documented response codes: 200. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/events/GET/field_service/events #### Read the next batch of changes ```javascript const query = new URLSearchParams(); if (savedCursor) query.set('cursor', savedCursor); const events = await api('GET', '/events?' + query); // Process events.items idempotently, then persist events.next_cursor. // Repeat immediately when events.has_more; otherwise poll later. ``` ### 2. Register a webhook for future changes Register a publicly reachable HTTPS destination for the events you need. The subscription is bound to the current OAuth application and grant and requires both read and write scopes. Register during connection setup in production so it is ready before the business starts using your receptionist. Store `signing_secret` securely when returned; it is shown once. The demo registers its subscription at the end to demonstrate the endpoint. Its integration test does not exercise outbound delivery. #### POST /field_service/webhooks Subscribe to tenant record events The specification does not enumerate parameters here. Use the workflow example for the request body. Documented response codes: 201. Full API reference: https://servicekeel.com/swagger_api/index.html#rest/tag/webhooks/POST/field_service/webhooks #### Subscribe to request, job, and appointment changes ```javascript const hook = await api('POST', '/webhooks', { url: 'https://your-app.example/servicekeel/events', events: ['request.created', 'job.updated', 'appointment.updated'], }); // Securely persist hook.signing_secret and hook.subscription.id. // The appointment event name is appointment.updated, not visit.updated. ``` ### 3. Verify delivery before processing Verify the HMAC over the exact raw request bytes and reject timestamps outside a five-minute window. The current wire headers are `X-SiteStack-Timestamp` and `X-SiteStack-Signature`; event deliveries also include `X-Servicekeel-Event-ID`. Header names are case-insensitive. After verification, deduplicate the event ID within the connection, durably enqueue work, and respond successfully. Delivery is at least once, so a repeated event must not create another customer, appointment, or charge. #### Verify the current webhook signature format ```javascript import { createHmac, timingSafeEqual } from 'node:crypto'; function verifyWebhook(rawBody, headers, secret) { const timestamp = headers['x-sitestack-timestamp']; const signature = headers['x-sitestack-signature']; if (typeof timestamp !== 'string' || !/^\d+$/.test(timestamp)) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; if (typeof signature !== 'string' || !/^v1=[a-f0-9]{64}$/.test(signature)) return false; const expected = createHmac('sha256', secret) .update(timestamp + '.').update(rawBody).digest(); const received = Buffer.from(signature.slice(3), 'hex'); return received.length === expected.length && timingSafeEqual(received, expected); } // Verify BEFORE JSON parsing; then deduplicate the delivered event ID. ``` ### 4. Make failures visible and recover deliberately | Result | Receptionist behavior | | --- | --- | | 400 / validation | Preserve the proposed values, inspect field issues, and ask for the missing information. Do not claim the write succeeded. | | 401 | Check expiry and connection status. Use serialized token rotation when appropriate; a revoked grant needs fresh consent. | | 403 | Check consented scopes, app approval, and business access. Do not broaden access silently. | | 404 | Reconcile the selected record within this connection. Never try a different business’s token to locate it. | | 409 | Reload the record or availability. Reconcile the caller’s choice before another versioned write. | | Timeout / server failure | The write may already have succeeded. Inspect saved IDs and current state before retrying. Use idempotency keys only on endpoints that document them. | Persist a small workflow record in your system: connected business, call ID, caller-confirmed choices, completed steps, and returned ServiceKeel IDs. This lets a human resume partial work without replaying the entire conversation. ### 5. Validate with your own voice application - Test consent denial, expired state, token rotation, revocation, and a second business’s isolation. - Configure the business’s catalog, service coverage, qualified staff, and booking hours. - Check no availability, a competing booking, stale versions, and a partially completed workflow. - Capture explicit approval for scheduling, cancellations, pricing, and recurring work. - Verify real storage, payment configuration, and signed webhook delivery in your intended environment. - Submit your app, support information, and setup instructions for ServiceKeel review before onboarding other businesses. The working example lives in `examples/mock-receptionist` in the frontend repository. Its Playwright test is `tests/integration/receptionist.integration.spec.mts`. The local test needs Node dependencies, Playwright Chromium, Go, the backend checkout, and local MongoDB. #### Run the reproducible local workflow ```sh npm run test:receptionist # Watch the browser: npm run test:receptionist -- --headed # Capture chapter screenshots from the same integration: SERVICEKEEL_CAPTURE_GUIDE=1 npm run test:receptionist ``` ## Support Developer support: support@servicekeel.com Endpoint metadata is generated from the same OpenAPI specification used by Scalar. Account roles and consented OAuth scopes still apply.