Just A Good Guy Docs
Sign in

Developers

Public API

The browser-safe endpoints the widget uses, so you can build your own booking or chat interface.

For developers
On this page

When to use it

The public API is what the widget calls. Use it directly if you want to build your own booking form or chat window in the browser. For anything on your server, use the REST API with a secret key instead.

Base URLhttps://justagoodguy.us/api/public
AuthYour publishable key in the X-GG-Key header (or a key query parameter)
CORSOpen to any origin, for GET, POST and OPTIONS. No cookies.
Errors{ "error": "message" } with a real status, the same as the REST API

If you've set allowed sites, browser requests from other sites get 403.

Important

Times in the public API are milliseconds since 1970 (UTC), as numbers, not ISO strings. The REST API uses ISO strings.

Rate limits

Limits are per visitor IP address, to keep bots from filling your calendar:

EndpointLimit
POST /events300 per 10 minutes
GET /slots120 per 10 minutes
POST /bookings10 per hour
POST /callbacks6 per hour
POST /leads6 per hour
POST /chat40 per 10 minutes, and 150 per visitor token per day
/api/manage/...60 per 10 minutes

Over the limit you get 429 with a friendly message and Retry-After: 60.

Config

GET /config returns everything the widget needs to draw itself.

Shell
curl https://justagoodguy.us/api/public/config -H "X-GG-Key: pk_your_key"
JSON
{
  "business": { "name": "Joe's Plumbing", "slug": "joes-plumbing", "phone": "+15555550100", "timezone": "America/Chicago", "bookingPageUrl": "https://justagoodguy.us/b/joes-plumbing" },
  "widget": { "color": "#3346D3", "position": "right", "label": "Book or ask a question", "showOnMobile": true },
  "assistantName": "Assistant",
  "greeting": "Hi! I’m Joe's Plumbing’s assistant. I can answer questions or book you in.",
  "ai": true,
  "openNow": true,
  "callNow": false,
  "allowPickStaff": true,
  "turnstileSiteKey": null,
  "siteId": null,
  "practiceAi": false,
  "hours": {
    "booking": [{ "weekday": 1, "label": "Monday", "ranges": [{ "startMin": 480, "endMin": 1020, "label": "8:00 AM – 5:00 PM" }] }],
    "calls": []
  },
  "address": "12 Main St, Springfield, IL, 62701"
}
  • ai is false when the AI is paused or unavailable. Chat still answers with a fallback message.
  • callNow is true when someone can take a live call right now.
  • hours lists weekdays 1 (Monday) to 7 (Sunday). Times are minutes after local midnight.
  • siteId is set when the request comes from one of your hosted sites.
  • practiceAi is true when the server answers chat with its built-in practice AI instead of a real model (no AI key set up, or practice mode forced on).

Services

GET /services

JSON
{
  "services": [
    { "id": "svc_2n4p6r8t0v1x3z5b", "name": "Drain cleaning", "description": null, "kind": "visit", "durationMin": 60, "priceLabel": "From $120", "staff": [{ "id": "stf_9h7f5d3b1z0x8v6t", "name": "Joe" }] }
  ]
}

staff is empty when the business doesn't let customers pick a person.

Slots

GET /slots?serviceId=...&staffId=...&from=...&days=...

QueryRequiredNotes
serviceIdyes
staffIdno
fromnoMilliseconds. Default now.
daysnoDefault 7, at most 21
JSON
{
  "timezone": "America/Chicago",
  "days": [
    { "date": "2026-11-03", "label": "Tuesday, Nov 3", "slots": [{ "start": 1793718000000, "staffIds": ["stf_9h7f5d3b1z0x8v6t"], "label": "9:00 AM" }] }
  ]
}

For classes, each slot also has sessionId and seatsLeft.

Bookings

POST /bookings

JSON
{
  "serviceId": "svc_2n4p6r8t0v1x3z5b",
  "staffId": "stf_9h7f5d3b1z0x8v6t",
  "start": 1793718000000,
  "sessionId": null,
  "customer": { "name": "Sam Lee", "phone": "+15555550123", "email": "" },
  "notes": "Side gate is open",
  "address": { "line1": "12 Oak Ave", "postal": "62704" },
  "source": "widget"
}
  • customer needs a name and a phone or email.
  • address.line1 is required for visit services. If the service has a service area and the postal code isn't in it, you get 400.
  • sessionId comes from the slot, for classes.
  • source is widget (default) or booking_page.

Returns 201:

JSON
{
  "booking": {
    "id": "bkg_8f3k2m9q1x7w4r6t",
    "status": "confirmed",
    "whenLabel": "Tuesday, Nov 3 at 9:00 AM",
    "serviceName": "Drain cleaning",
    "staffName": "Joe",
    "manageUrl": "https://justagoodguy.us/m/…"
  }
}

status is confirmed, or pending_approval when the business approves each booking first.

Call-backs

GET /callback-windows returns the next open call-back windows (up to 10, in the next 7 days):

JSON
{ "callNow": false, "windows": [{ "start": 1793725200000, "end": 1793732400000, "label": "Tuesday, Nov 3 at 11:00 AM – 1:00 PM" }] }

POST /callbacks with { "name", "phone", "reason"?, "windowStart"? } returns 201:

JSON
{ "callback": { "id": "cbk_3j5l7n9q1s3u5w7y", "windowLabel": "Tuesday, Nov 3 at 11:00 AM – 1:00 PM" } }

Pick windowStart from a window, or leave it out for the next one. A call-back covers up to two hours.

Messages and quote requests

POST /leads with { "name", "phone"?, "email"?, "details", "serviceId"?, "subject"?, "pageUrl"?, "fields"?, "source"? } returns 201 with { "lead": { "id" }, "emailed": true | false }. A phone or email is required, and details up to 3,000 characters. fields holds any other form fields ({ "Budget": "$500" }), and source is "widget" (default) or "site_form" for a contact form on the business's own site.

emailed is true only when a copy of the message (or the receptionist's answer) really went to their email. Only tell people "check your email" when it is.

Analytics events

POST /events counts visits and widget opens. It accepts a text/plain body so it works with navigator.sendBeacon, and the key goes inside the body:

JavaScript
navigator.sendBeacon('https://justagoodguy.us/api/public/events', JSON.stringify({ key: 'pk_your_key', type: 'page_view', url: location.href, referrer: document.referrer }));

type is page_view, widget_open or widget_ping. It returns 204. Only the page's path is kept, not its query string. Visitors are counted with a hash that changes every day, and nothing is stored in the browser. page_view and widget_ping from your own site are also what makes the dashboard's install check pass.

Chat

Sending a message

POST /chat streams the receptionist's reply as Server-Sent Events. EventSource only does GET, so read the stream with fetch.

FieldRequiredNotes
messageyesUp to 1,500 characters
visitorTokenyes16 to 64 random URL-safe characters you generate and keep for this visitor
conversationIdnoFrom the start event of an earlier reply. Leave it out to start a new conversation.
pagenoThe page the visitor is on
selectionno{ serviceId, start, staffId?, sessionId? } when the visitor tapped a time in a slots card

Events in the stream

Each event has a name and a data line of JSON with a matching type:

Event stream
event: start
data: {"type":"start","conversationId":"cnv_5b7d9f1h3k5m7p9r","messageId":""}

event: status
data: {"type":"status","text":"Checking open times…"}

event: delta
data: {"type":"delta","text":"Here are the next open times"}

event: card
data: {"type":"card","card":{"type":"slots","serviceId":"svc_2n4p6r8t0v1x3z5b","serviceName":"Haircut","staffId":null,"timezone":"America/New_York","days":[]}}

event: done
data: {"type":"done","messageId":"msg_0a2c4e6g8j0l2n4q"}
EventDataWhat to do
startconversationIdSave it and send it with the next message
deltatextAppend to the assistant's reply
statustextShow a short "working on it" line, like "Booking…"
cardcardRender a card (see below)
replacetextReplace the whole reply text so far with this. Sent when a reply had to be corrected.
donemessageIdThe reply is finished
errorerrorShow the message. The stream ends.
ping{}Keep-alive every 15 seconds. Ignore it.

Errors before the stream starts (a bad key, a missing field, a rate limit) come back as normal JSON errors with a status code, not as events.

If a person from the business has taken the conversation over, the reply is just start, a status of "Sent to the team", and done. Their answer arrives on the live stream.

Cards

card.typeFields
slotsserviceId, serviceName, staffId, timezone, days[] of { date, label, slots[] }; each slot has start, label, staffIds, and sessionId for classes
servicesitems[] of { id, name, durationMin, priceLabel, kind }
bookingbookingId, status, serviceName, staffName, start, end, timezone, whenLabel, manageUrl
callbackcallbackId, windowLabel
leadleadId, summary
verifychannel (sms or email), destinationHint (a masked phone or email)
handoffmessage

When the visitor taps a time in a slots card, send a normal message (the widget sends "9:00 AM on Tuesday, Nov 3 works for me") with selection set to that slot. That tells the receptionist exactly which time was picked.

Reading the stream

JavaScript
async function chat({ message, visitorToken, conversationId }, onEvent) {
  const res = await fetch('https://justagoodguy.us/api/public/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-GG-Key': 'pk_your_key' },
    body: JSON.stringify({ message, visitorToken, conversationId }),
  });
  if (!res.ok) throw new Error((await res.json()).error);

  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = '';
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;
    let end;
    while ((end = buffer.indexOf('\n\n')) >= 0) {
      const block = buffer.slice(0, end);
      buffer = buffer.slice(end + 2);
      const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n');
      if (!data) continue;
      const event = JSON.parse(data);
      if (event.type !== undefined) onEvent(event);
    }
  }
}

A conversation can run for 80 messages, after which the visitor is asked to start a new chat or call.

History

GET /conversation?conversationId=...&visitorToken=... returns the last 50 messages, so you can redraw the chat after a reload:

JSON
{
  "conversationId": "cnv_5b7d9f1h3k5m7p9r",
  "humanTakeover": false,
  "messages": [
    { "id": "msg_…", "role": "user", "content": "Do you have anything Saturday?", "cards": [], "createdAt": 1793700000000 },
    { "id": "msg_…", "role": "assistant", "content": "Saturday has a few times open.", "cards": [{ "type": "slots", "...": "..." }], "createdAt": 1793700002000 }
  ]
}

role is user, assistant or owner. The visitorToken must match the one that started the conversation.

Live replies from the team

GET /conversation/stream?conversationId=...&visitorToken=...&key=pk_... is a normal EventSource stream. When someone from the business replies in their Inbox, you get:

Event stream
event: message
data: {"id":"msg_…","role":"owner","content":"Hi Sam, yes we can fit you in at 10.","author":"Joe"}

It also sends ready when it opens and ping every 20 seconds.

These use the token from the customer's link instead of a key.

RequestWhat it does
GET /api/manage/:tokenThe booking, the business, and whether it can still be changed (canChange)
GET /api/manage/:token/slots?from=Open times for the next 14 days, for moving it
POST /api/manage/:token/reschedule{ "start": ms }
POST /api/manage/:token/cancel{ "reason"? }. Returns { ok: true }.
GET /api/offer/:tokenA waitlist offer: serviceName, whenLabel, available, claimed
POST /api/offer/:token/claimClaims it. 409 if someone else got it first; 410 if it expired.

Search the docs