API Reference Appointments - knowtific/helppuff GitHub Wiki

Appointments API

Booking in the chat (Settings → Bookings: date picker): what is in force, the free times, appointments on jobs (from the chat, the dashboard, your systems or the synced calendar), and the sync. On only with the date picker; the assistant never books. Part of the API reference: setup, shared types and errors are there.

How visitors book

GET /bookings · scope settings:read

What is in force: effective.mode is none, link or picker (the owner's choice, chosen: true, else the booking link found on the website), the calendar the picker syncs with and its connection, when it was last read, and how many appointments are coming up. The settings themselves are bookings in GET /settings.

Parameter In Required Description
site query no The site (a key always uses its own).

Request

curl "$HELPPUFF_URL/api/v1/bookings" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/bookings`, {
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
  },
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as HowVisitorsBookResponse;

Response 200

{
  "site": "acme",
  "effective": {
    "mode": "picker",
    "link": null,
    "label": "Book a time",
    "chosen": true
  },
  "found": {
    "url": "https://calendly.com/acme/consultation",
    "provider": "Calendly",
    "page": "https://acme.example/contact",
    "at": 1760000000000
  },
  "timezone": "Australia/Melbourne",
  "calendar": {
    "sync": "calcom",
    "integration": {
      "kind": "calcom",
      "name": "Cal.com",
      "connected": true,
      "baseUrl": null,
      "status": "ok",
      "lastError": null,
      "lastAt": 1760000000000,
      "account": null,
      "plan": null,
      "createdAt": 1760000000000,
      "updatedAt": 1760000000000
    },
    "ready": true
  },
  "syncedAt": 1760000000000,
  "upcoming": 3
}
type HowVisitorsBookResponse = {
  site: string;
  effective: {
    mode: string;
    link: string | null;
    label: string;
    chosen: boolean;
  };
  found: {
    url: string;
    provider: string;
    page: string;
    at: number;
  } | null;
  timezone: string;
  calendar: {
    sync: string;
    integration: {
      kind: string;
      name: string;
      connected: boolean;
      baseUrl: string | null;
      status: string;
      lastError: string | null;
      lastAt: number;
      account: string | null;
      plan: string | null;
      createdAt: number;
      updatedAt: number;
    };
    ready: boolean;
  };
  syncedAt: number | null;
  upcoming: number;
};

Free times

GET /bookings/slots · scope settings:read

The free times the date picker would offer now, by day, with the settings in force (up to 14 days from from). Nothing is booked: for previews and checks.

Parameter In Required Description
from query no The first day, YYYY-MM-DD (default today).
to query no The last day (default six days on).
tz query no The time zone the days are in (default the business's).
site query no The site (a key always uses its own).

Request

curl "$HELPPUFF_URL/api/v1/bookings/slots" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/bookings/slots`, {
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
  },
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as FreeTimesResponse;

Response 200

{
  "timezone": "Australia/Melbourne",
  "days": [
    {
      "date": "2025-10-15",
      "slots": [
        {
          "start": 1760500800000,
          "end": 1760502600000
        }
      ]
    }
  ],
  "next": "2025-10-22",
  "title": "Consultation",
  "duration": 30,
  "phone": "optional"
}
type FreeTimesResponse = {
  timezone: string;
  days: Array<{
    date: string;
    slots: Array<{
      start: number;
      end: number;
    }>;
  }>;
  next: string;
  title: string;
  duration: number;
  phone: string;
};

Check the calendar

POST /bookings/test · scope settings:read

Checks the synced calendar's connection (or another one, sync), and lists its event types (Cal.com, Calendly) to choose from.

Body field Required Description
sync no calcom, calendly, google or outlook (default the one in force).

Request

curl -X POST "$HELPPUFF_URL/api/v1/bookings/test" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "sync": "calcom"
}'
type CheckCalendarRequest = {
  /** `calcom`, `calendly`, `google` or `outlook` (default the one in force). */
  sync?: string;
};

const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/bookings/test`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    sync: 'calcom'
  } satisfies CheckCalendarRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as CheckCalendarResponse;

Response 200

{
  "ok": true,
  "detail": "Connected: 2 event types.",
  "eventTypes": [
    {
      "id": "1234",
      "name": "30 min consultation",
      "duration": 30
    }
  ]
}
type CheckCalendarResponse = {
  ok: boolean;
  detail: string;
  eventTypes: Array<{
    id: string;
    name: string;
    duration: number;
  }>;
};

List appointments

GET /appointments · scope jobs:read

In time order, up to 500. By default every one that takes up time (not cancelled), including busy time read from the synced calendar that matched no contact.

Parameter In Required Description
from query no Ends after this (ISO 8601 or milliseconds).
to query no Starts before this.
status query no active (default), all, appointments (not busy time), or one of confirmed, pending, cancelled, completed, no_show, busy.
job query no One job's appointments.
contact query no One contact's (their lead id).
conversation query no The ones booked in one conversation.
site query no The site (a key always uses its own).

Request

curl "$HELPPUFF_URL/api/v1/appointments" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/appointments`, {
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
  },
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as ListAppointmentsResponse;

Response 200

{
  "items": [
    {
      "id": "apt_4f1c2b9e8d7a6c5b4a3f",
      "title": "Consultation",
      "start": 1760500800000,
      "end": 1760502600000,
      "startIso": "2025-10-15T04:00:00.000Z",
      "endIso": "2025-10-15T04:30:00.000Z",
      "timezone": "Australia/Melbourne",
      "status": "confirmed",
      "source": "chat",
      "provider": "none",
      "externalId": null,
      "location": "Our office, 12 Smith St",
      "notes": null,
      "attendee": {
        "name": "Ada Lovelace",
        "email": "[email protected]",
        "phone": "0400 111 222"
      },
      "job": {
        "id": "job_mfx2k1a9b3c4d5e6",
        "number": 1042,
        "title": "Consultation: Ada Lovelace"
      },
      "contact": {
        "id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
        "name": "Ada Lovelace",
        "email": "[email protected]"
      },
      "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
      "rescheduleUrl": null,
      "createdBy": "Ada Lovelace",
      "createdAt": 1760000000000,
      "updatedAt": 1760000000000,
      "syncedAt": null
    }
  ]
}
type ListAppointmentsResponse = {
  items: Array<{
    id: string;
    title: string;
    start: number;
    end: number;
    startIso: string;
    endIso: string;
    timezone: string;
    status: string;
    source: string;
    provider: string;
    externalId: string | null;
    location: string | null;
    notes: string | null;
    attendee: {
      name: string;
      email: string;
      phone: string;
    } | null;
    job: {
      id: string;
      number: number;
      title: string;
    } | null;
    contact: Record<string, string> | null;
    conversationId: string | null;
    rescheduleUrl: string | null;
    createdBy: string | null;
    createdAt: number;
    updatedAt: number;
    syncedAt: number | null;
  }>;
};

Errors

Status Code When
400 bad_request Appointments are off: the date picker is not on (Settings → Bookings).

Get an appointment

GET /appointments/:id · scope jobs:read

Parameter In Required Description
id path yes The record's id.

Request

curl "$HELPPUFF_URL/api/v1/appointments/ID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/appointments/${id}`, {
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
  },
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as GetAppointmentResponse;

Response 200

{
  "id": "apt_4f1c2b9e8d7a6c5b4a3f",
  "title": "Consultation",
  "start": 1760500800000,
  "end": 1760502600000,
  "startIso": "2025-10-15T04:00:00.000Z",
  "endIso": "2025-10-15T04:30:00.000Z",
  "timezone": "Australia/Melbourne",
  "status": "confirmed",
  "source": "chat",
  "provider": "none",
  "externalId": null,
  "location": "Our office, 12 Smith St",
  "notes": null,
  "attendee": {
    "name": "Ada Lovelace",
    "email": "[email protected]",
    "phone": "0400 111 222"
  },
  "job": {
    "id": "job_mfx2k1a9b3c4d5e6",
    "number": 1042,
    "title": "Consultation: Ada Lovelace"
  },
  "contact": {
    "id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
    "name": "Ada Lovelace",
    "email": "[email protected]"
  },
  "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
  "rescheduleUrl": null,
  "createdBy": "Ada Lovelace",
  "createdAt": 1760000000000,
  "updatedAt": 1760000000000,
  "syncedAt": null
}
type GetAppointmentResponse = {
  id: string;
  title: string;
  start: number;
  end: number;
  startIso: string;
  endIso: string;
  timezone: string;
  status: string;
  source: string;
  provider: string;
  externalId: string | null;
  location: string | null;
  notes: string | null;
  attendee: {
    name: string;
    email: string;
    phone: string;
  } | null;
  job: {
    id: string;
    number: number;
    title: string;
  } | null;
  contact: Record<string, string> | null;
  conversationId: string | null;
  rescheduleUrl: string | null;
  createdBy: string | null;
  createdAt: number;
  updatedAt: number;
  syncedAt: number | null;
};

Book an appointment

POST /appointments · scope jobs:write

From your own systems, or by hand: written to the synced calendar first (saved here only when it succeeded), then put on a job: jobId, else the contact's open job, else a new one. A time that overlaps another appointment is refused unless force is true. Sends appointment.created (and job.created for a new job).

Body field Required Description
start yes ISO 8601 or milliseconds.
end no Default: start + duration (or the bookings duration).
duration no Minutes, instead of end.
title no Default: the bookings title.
jobId no The job it belongs to (its contact is the attendee).
contactId no A contact's id.
contact no { name, email, phone }: matched or created.
timezone no The attendee's time zone (default the business's).
location no Where, or the meeting link.
notes no For the team; up to 4000 characters.
force no Book it even if it overlaps another appointment.

Request

curl -X POST "$HELPPUFF_URL/api/v1/appointments" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "start": "2025-10-15T04:00:00Z",
  "duration": 30,
  "contact": {
    "name": "Ada Lovelace",
    "email": "[email protected]",
    "phone": "0400 111 222"
  },
  "title": "Consultation",
  "location": "Our office, 12 Smith St"
}'
type BookAppointmentRequest = {
  /** ISO 8601 or milliseconds. */
  start: string;
  /** Default: start + `duration` (or the bookings duration). */
  end?: string;
  /** Minutes, instead of `end`. */
  duration?: number;
  /** Default: the bookings title. */
  title?: string;
  /** The job it belongs to (its contact is the attendee). */
  jobId?: string;
  /** A contact's id. */
  contactId?: string;
  /** `{ name, email, phone }`: matched or created. */
  contact?: Record<string, string>;
  /** The attendee's time zone (default the business's). */
  timezone?: string;
  /** Where, or the meeting link. */
  location?: string;
  /** For the team; up to 4000 characters. */
  notes?: string;
  /** Book it even if it overlaps another appointment. */
  force?: string;
};

const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/appointments`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    start: '2025-10-15T04:00:00Z',
    duration: 30,
    contact: {
      name: 'Ada Lovelace',
      email: '[email protected]',
      phone: '0400 111 222'
    },
    title: 'Consultation',
    location: 'Our office, 12 Smith St'
  } satisfies BookAppointmentRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as BookAppointmentResponse;

Response 201

{
  "id": "apt_4f1c2b9e8d7a6c5b4a3f",
  "title": "Consultation",
  "start": 1760500800000,
  "end": 1760502600000,
  "startIso": "2025-10-15T04:00:00.000Z",
  "endIso": "2025-10-15T04:30:00.000Z",
  "timezone": "Australia/Melbourne",
  "status": "confirmed",
  "source": "api",
  "provider": "none",
  "externalId": null,
  "location": "Our office, 12 Smith St",
  "notes": null,
  "attendee": {
    "name": "Ada Lovelace",
    "email": "[email protected]",
    "phone": "0400 111 222"
  },
  "job": {
    "id": "job_mfx2k1a9b3c4d5e6",
    "number": 1042,
    "title": "Consultation: Ada Lovelace"
  },
  "contact": {
    "id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
    "name": "Ada Lovelace",
    "email": "[email protected]"
  },
  "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
  "rescheduleUrl": null,
  "createdBy": "Website backend",
  "createdAt": 1760000000000,
  "updatedAt": 1760000000000,
  "syncedAt": null
}
type BookAppointmentResponse = {
  id: string;
  title: string;
  start: number;
  end: number;
  startIso: string;
  endIso: string;
  timezone: string;
  status: string;
  source: string;
  provider: string;
  externalId: string | null;
  location: string | null;
  notes: string | null;
  attendee: {
    name: string;
    email: string;
    phone: string;
  } | null;
  job: {
    id: string;
    number: number;
    title: string;
  } | null;
  contact: Record<string, string> | null;
  conversationId: string | null;
  rescheduleUrl: string | null;
  createdBy: string | null;
  createdAt: number;
  updatedAt: number;
  syncedAt: number | null;
};

Errors

Status Code When
409 conflict The time overlaps another appointment (without force), or was just taken.
502 connector_error The synced calendar refused it (its reason is in the message).

Change an appointment

PATCH /appointments/:id · scope jobs:write

Move it (start, end: moved in the synced calendar first), set its status (confirmed, completed, no_show, or cancelled: cancelled there too), or change its title, place or notes. Sends appointment.updated or appointment.cancelled.

Parameter In Required Description
id path yes The record's id.
Body field Required Description
start no A new start (keeps the length unless end is given).
end no A new end.
status no confirmed, cancelled, completed or no_show.
reason no Why, when cancelling.
title no Up to 160 characters.
location no Up to 300 characters; empty clears it.
notes no Up to 4000 characters; empty clears them.

Request

curl -X PATCH "$HELPPUFF_URL/api/v1/appointments/ID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "status": "completed",
  "notes": "Went well; quote to follow."
}'
type ChangeAppointmentRequest = {
  /** A new start (keeps the length unless `end` is given). */
  start?: string;
  /** A new end. */
  end?: string;
  /** `confirmed`, `cancelled`, `completed` or `no_show`. */
  status?: string;
  /** Why, when cancelling. */
  reason?: string;
  /** Up to 160 characters. */
  title?: string;
  /** Up to 300 characters; empty clears it. */
  location?: string;
  /** Up to 4000 characters; empty clears them. */
  notes?: string;
};

const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/appointments/${id}`, {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    status: 'completed',
    notes: 'Went well; quote to follow.'
  } satisfies ChangeAppointmentRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as ChangeAppointmentResponse;

Response 200

{
  "id": "apt_4f1c2b9e8d7a6c5b4a3f",
  "title": "Consultation",
  "start": 1760500800000,
  "end": 1760502600000,
  "startIso": "2025-10-15T04:00:00.000Z",
  "endIso": "2025-10-15T04:30:00.000Z",
  "timezone": "Australia/Melbourne",
  "status": "completed",
  "source": "chat",
  "provider": "none",
  "externalId": null,
  "location": "Our office, 12 Smith St",
  "notes": "Went well; quote to follow.",
  "attendee": {
    "name": "Ada Lovelace",
    "email": "[email protected]",
    "phone": "0400 111 222"
  },
  "job": {
    "id": "job_mfx2k1a9b3c4d5e6",
    "number": 1042,
    "title": "Consultation: Ada Lovelace"
  },
  "contact": {
    "id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
    "name": "Ada Lovelace",
    "email": "[email protected]"
  },
  "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
  "rescheduleUrl": null,
  "createdBy": "Ada Lovelace",
  "createdAt": 1760000000000,
  "updatedAt": 1760000000000,
  "syncedAt": null
}
type ChangeAppointmentResponse = {
  id: string;
  title: string;
  start: number;
  end: number;
  startIso: string;
  endIso: string;
  timezone: string;
  status: string;
  source: string;
  provider: string;
  externalId: string | null;
  location: string | null;
  notes: string | null;
  attendee: {
    name: string;
    email: string;
    phone: string;
  } | null;
  job: {
    id: string;
    number: number;
    title: string;
  } | null;
  contact: Record<string, string> | null;
  conversationId: string | null;
  rescheduleUrl: string | null;
  createdBy: string | null;
  createdAt: number;
  updatedAt: number;
  syncedAt: number | null;
};

Delete an appointment

DELETE /appointments/:id · scope jobs:write

Cancels it in the synced calendar (when it is still on), then deletes the record. To keep the record, PATCH its status to cancelled instead.

Parameter In Required Description
id path yes The record's id.

Request

curl -X DELETE "$HELPPUFF_URL/api/v1/appointments/ID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/appointments/${id}`, {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
  },
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as DeleteAppointmentResponse;

Response 200

{
  "id": "apt_4f1c2b9e8d7a6c5b4a3f",
  "deleted": true
}
type DeleteAppointmentResponse = {
  id: string;
  deleted: boolean;
};

Read the synced calendar now

POST /appointments/sync · scope jobs:write

Reads 30 days back and 90 ahead: new events are added (on a job when an attendee is a contact, else as busy time), moved ones updated, cancelled or removed ones cancelled. The cron does this every bookings.syncMinutes; at most once a minute here unless force.

Body field Required Description
force no Read even if it was read less than a minute ago.

Request

curl -X POST "$HELPPUFF_URL/api/v1/appointments/sync" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "force": true
}'
type ReadSyncedCalendarNowRequest = {
  /** Read even if it was read less than a minute ago. */
  force?: boolean;
};

const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/appointments/sync`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    force: true
  } satisfies ReadSyncedCalendarNowRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as ReadSyncedCalendarNowResponse;

Response 200

{
  "synced": true,
  "added": 2,
  "updated": 1,
  "cancelled": 0,
  "busy": 5,
  "at": 1760000000000
}
type ReadSyncedCalendarNowResponse = {
  synced: boolean;
  added: number;
  updated: number;
  cancelled: number;
  busy: number;
  at: number | null;
};