Developers
Public API
The browser-safe endpoints the widget uses, so you can build your own booking or chat interface.
For developersOn 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 URL | https://justagoodguy.us/api/public |
| Auth | Your publishable key in the X-GG-Key header (or a key query parameter) |
| CORS | Open 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:
| Endpoint | Limit |
|---|---|
POST /events | 300 per 10 minutes |
GET /slots | 120 per 10 minutes |
POST /bookings | 10 per hour |
POST /callbacks | 6 per hour |
POST /leads | 6 per hour |
POST /chat | 40 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.
curl https://justagoodguy.us/api/public/config -H "X-GG-Key: pk_your_key"{
"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"
}aiisfalsewhen the AI is paused or unavailable. Chat still answers with a fallback message.callNowistruewhen someone can take a live call right now.hourslists weekdays 1 (Monday) to 7 (Sunday). Times are minutes after local midnight.siteIdis set when the request comes from one of your hosted sites.practiceAiistruewhen 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
{
"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=...
| Query | Required | Notes |
|---|---|---|
serviceId | yes | |
staffId | no | |
from | no | Milliseconds. Default now. |
days | no | Default 7, at most 21 |
{
"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
{
"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"
}customerneeds anameand a phone or email.address.line1is required for visit services. If the service has a service area and thepostalcode isn't in it, you get400.sessionIdcomes from the slot, for classes.sourceiswidget(default) orbooking_page.
Returns 201:
{
"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):
{ "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:
{ "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:
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.
| Field | Required | Notes |
|---|---|---|
message | yes | Up to 1,500 characters |
visitorToken | yes | 16 to 64 random URL-safe characters you generate and keep for this visitor |
conversationId | no | From the start event of an earlier reply. Leave it out to start a new conversation. |
page | no | The page the visitor is on |
selection | no | { 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: 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"}| Event | Data | What to do |
|---|---|---|
start | conversationId | Save it and send it with the next message |
delta | text | Append to the assistant's reply |
status | text | Show a short "working on it" line, like "Booking…" |
card | card | Render a card (see below) |
replace | text | Replace the whole reply text so far with this. Sent when a reply had to be corrected. |
done | messageId | The reply is finished |
error | error | Show 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.type | Fields |
|---|---|
slots | serviceId, serviceName, staffId, timezone, days[] of { date, label, slots[] }; each slot has start, label, staffIds, and sessionId for classes |
services | items[] of { id, name, durationMin, priceLabel, kind } |
booking | bookingId, status, serviceName, staffName, start, end, timezone, whenLabel, manageUrl |
callback | callbackId, windowLabel |
lead | leadId, summary |
verify | channel (sms or email), destinationHint (a masked phone or email) |
handoff | message |
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
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:
{
"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: 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.
Manage links and offers
These use the token from the customer's link instead of a key.
| Request | What it does |
|---|---|
GET /api/manage/:token | The 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/:token | A waitlist offer: serviceName, whenLabel, available, claimed |
POST /api/offer/:token/claim | Claims it. 409 if someone else got it first; 410 if it expired. |