# AI website build guide

> The instruction book for AI coding agents that build a small-business website on Just a Good Guy, or connect an existing one. What to gather, what to paste, the quality bar, and how to ship and check it.

Source: https://justagoodguy.us/docs/ai-build-guide

> [!IMPORTANT]
> **AI agents: read this whole page before you write any code.** Every attribute, method, event and endpoint below is checked against the source. Don't use names that aren't on this page or its linked references.
>
> - This page as plain Markdown: [/docs/ai-build-guide.md](https://justagoodguy.us/docs/ai-build-guide.md)
> - This page plus every reference page in one file: [/llms-full.txt](https://justagoodguy.us/llms-full.txt)
> - Starter site to copy: [/docs/starter-site.zip](https://justagoodguy.us/docs/starter-site.zip) ([browse the files](https://justagoodguy.us/docs/starter-site/README.md))

## What Just a Good Guy is

- An AI receptionist for small businesses. It answers the website chat, the phone and texts.
- It books customers into real open times, and takes call-back requests, messages and quote requests for the owner.
- A website connects with **one script tag**, the widget. It adds a chat and booking button, inline booking and other widgets, and lets any button open booking or chat.
- A **public API** with a publishable `pk_` key gives a web page live services, prices and hours, and accepts call-back and message forms. A **REST API** with a secret `sk_` key, plus signed webhooks, is for servers.
- It can **host the site** too: on a free address like `https://joes-plumbing.justagoodguy.us`, on the business's own domain, or on the business's own server.

## Rules you never break

- **Never put a secret key (`sk_...`) in anything a browser loads.** No HTML, no JavaScript, no public repo. Web pages only ever use the publishable key (`pk_...`).
- **Never invent facts.** No made-up prices, reviews, star ratings, awards, years in business, licences, staff, opening hours or service areas. Use the intake answers, the live API and what the operator tells you. If a fact is missing, leave it out or ask.
- **Book only through Just a Good Guy.** Use the widget, its buttons or the public API. Never build a form that emails a "booking request" instead.
- **Load the widget once per page**, with the business's own key.
- **Inline SVG for icons.** Never emoji or Unicode symbols as icons.
- **No native date, time or `<select>` pickers** in forms you build. Use buttons, radio buttons or text fields. (The widget already does dates and times with buttons.)
- **No tracking without consent.** Don't add analytics, pixels or cookies unless the owner asks and the visitor agrees. The widget sets no cookies.
- **Show the server's error message as it is.** The API's `{ "error": "..." }` messages are written for customers.

## What you are given

The operator (or the owner) gives you these. Ask for anything missing before you start. You can't work out the key from the business name.

| Item | Looks like | Where it comes from |
|---|---|---|
| Business name and slug | `Joe's Plumbing`, `joes-plumbing` | The admin's business page, or `business.name` and `business.slug` from [`GET /config`](#step-2-read-the-live-business-data) |
| Publishable key | `pk_2Jd8sQx0Lr5mWb7nVt3cKa` | Operator: **Admin → Businesses → the business → AI and API → Public key** ([/app/admin/businesses](https://justagoodguy.us/app/admin/businesses)). Owner: **Website** ([/app/website](https://justagoodguy.us/app/website)) or **Developers** ([/app/developers](https://justagoodguy.us/app/developers)). |
| Intake answers | pages, look, about, current site, domain, notes | The "Build it for me" request in **Admin → Builds** ([/app/admin/builds](https://justagoodguy.us/app/admin/builds)) |
| Logo and photos | files | The same request. Files are listed by name; the owner's logo is marked **Logo:**. |
| Hosted address | `https://joes-plumbing.justagoodguy.us` | The admin's business page, **Website → Host a website for them** ("Will be live at") |
| Domain they want | `joesplumbing.com` | The intake's domain answer |
| Secret key | `sk_...` | Only if the site has its own server. **AI and API → Secret API keys** in the admin, or **Developers** for the owner. Shown once. |

The "Build it for me" form asks the owner for:

- **Pages you want**: any of Home, About, Services, Prices, Gallery, Team, Reviews, FAQ, Contact
- **About your business**, **The look you like**, **Your current website**, **Domain you'd like to use**
- **Logo** and **Photos** (PNG, JPG, WebP, GIF, SVG or PDF, up to 15 MB each)
- **Anything else we should know**

A hand-off brief from the operator looks like this. Ask for one in this shape:

```text
Business: Joe's Plumbing
Slug: joes-plumbing
Publishable key: pk_...
Hosted address: https://joes-plumbing.justagoodguy.us
Domain wanted: joesplumbing.com
Pages: Home, Services, About, Contact
Look: clean, blue, friendly; likes example.com
About: (the owner's own words)
Current website: (address, if any)
Phone to publish: (the number on their Google profile)
Files: logo.svg, van.jpg, team.jpg
Deliver: site.zip for the build queue
```

> [!TIP]
> Build and test against a **test business** first. The operator can make one from the **Test as a customer** card on the admin overview. Bookings, call-backs and messages you send while testing are real: they alert the owner and text or email whoever's details you typed.

## The golden path

1. **Gather the facts.** Read the brief and the intake. List what's missing and ask. See [Step 1](#step-1-gather-the-facts).
2. **Read the live business data** from the public API: services, prices, hours, phone, booking page. See [Step 2](#step-2-read-the-live-business-data).
3. **Plan the pages** from the template for the business type. See [Step 3](#step-3-plan-the-pages).
4. **Build** from the [starter kit](#the-starter-kit), to the [quality bar](#step-4-build-to-the-quality-bar).
5. **Wire booking and chat**: the script tag, booking buttons, inline widgets. See [Step 5](#step-5-wire-booking-and-chat).
6. **Show live services, prices and hours**, with the same content written into the page as a fallback. See [Step 6](#step-6-show-live-services-prices-and-hours).
7. **Search and sharing**: titles, descriptions, LocalBusiness JSON-LD with real hours, Open Graph, sitemap. See [Step 7](#step-7-search-and-sharing).
8. **Check accessibility and speed** against the checklist. See [Step 8](#step-8-check-accessibility-and-speed).
9. **Package** the site as a zip the uploader accepts. See [Step 9](#step-9-package-the-site).
10. **Deploy and verify** the live site: widget loaded, data live, buttons working. See [Step 10](#step-10-deploy-and-verify).

## Step 1: Gather the facts

Collect these before you design anything. Note where each one came from.

| Fact | Source | Used for |
|---|---|---|
| Business name, exactly as they write it | Intake, `business.name` | Header, titles, JSON-LD |
| Phone number to publish | Intake or operator | Call buttons, footer, JSON-LD. See the note below. |
| Street address, city, region, postcode | Intake, `address` | Footer, contact page, JSON-LD, map link |
| Opening hours | `hours.booking` from the API | Hours lists, JSON-LD |
| Services, durations, prices | `GET /services` | Services page, price list |
| Service area (towns or postcodes) | Intake, for businesses that travel | Service area section, JSON-LD `areaServed` |
| What makes them different, in their words | Intake "About your business" | Headline, about text |
| Look and brand colour | Intake "The look you like", logo, `widget.color` | Theme tokens, `--gg-color` |
| Logo, photos | Intake files | Header, hero, gallery |
| Policies, aftercare, directions, what's included | The owner's **Business info** topics (Menu → Business info; the operator can read them with **View as**), intake notes | FAQ, policies and visit pages. Only if given. Never write one yourself. |
| Social profiles, Google Business Profile | Intake or operator | Footer links, JSON-LD `sameAs` |
| Domain | Intake | Canonical URLs, sitemap |

> [!NOTE]
> `business.phone` in the API is the business's Just a Good Guy number when it has one, otherwise its own number. Publish the number the owner gives out (the one on their Google profile, for consistency). If the two differ, ask which one.

## Step 2: Read the live business data

The public API is open to any origin and needs only the publishable key. Fetch the two endpoints you need:

```bash
KEY=pk_your_key
curl -s "https://justagoodguy.us/api/public/config?key=$KEY"
curl -s "https://justagoodguy.us/api/public/services?key=$KEY"
```

The starter kit does this for you and writes the results into the pages: `node tools/bake.mjs pk_your_key`. See [The starter kit](#the-starter-kit).

### What config gives you

`GET /api/public/config`, abridged:

```json
{
  "business": { "name": "Maple Street Barbers", "slug": "maple-street-barbers", "phone": "+15552013344", "timezone": "America/Chicago", "bookingPageUrl": "https://justagoodguy.us/b/maple-street-barbers" },
  "widget": { "color": "#1f6feb", "position": "right", "label": "Book or ask a question", "showOnMobile": true },
  "assistantName": "Assistant",
  "ai": true,
  "openNow": false,
  "callNow": false,
  "allowPickStaff": true,
  "hours": {
    "booking": [
      { "weekday": 1, "label": "Monday", "ranges": [{ "startMin": 540, "endMin": 1020, "label": "9:00 AM – 5:00 PM" }] },
      { "weekday": 7, "label": "Sunday", "ranges": [] }
    ],
    "calls": []
  },
  "address": "12 Main St, Springfield, IL, 62701"
}
```

| Field | Meaning |
|---|---|
| `business.bookingPageUrl` | The hosted booking page. Use it as the `href` of every booking button. |
| `business.timezone` | IANA timezone. Hours are in this zone. |
| `widget.color` | The brand colour set in the dashboard (**Settings → Website chat**) |
| `ai` | `false` when the AI is paused. Chat still shows a fallback and a message form. |
| `openNow` | Open right now, counting closures and holidays |
| `callNow` | Someone can take a live call right now |
| `allowPickStaff` | Customers can choose a person when booking |
| `hours.booking` | Weekly booking hours: `weekday` 1 (Monday) to 7 (Sunday), times in minutes after local midnight, plus a ready-made `label`. An empty `ranges` list means closed. |
| `hours.calls` | Weekly call hours, for the phone |
| `address` | The address on file, as one string. Use the intake for the separate parts. |

The response has a few more fields the widget uses (`greeting`, `siteId`, `turnstileSiteKey`, `practiceAi`). See [Public API: config](https://justagoodguy.us/docs/public-api#config).

### What services gives you

`GET /api/public/services`:

```json
{
  "services": [
    { "id": "svc_j0bkxg20s55jtenm", "name": "Haircut", "description": "Wash, cut and style.", "kind": "appointment", "durationMin": 30, "priceLabel": "$35", "staff": [{ "id": "stf_996s6vakq60vvw2j", "name": "Dana" }] },
    { "id": "svc_qk3z9ewfvs2ce56f", "name": "Wedding party grooming", "description": null, "kind": "quote", "durationMin": 0, "priceLabel": null, "staff": [] }
  ]
}
```

| Field | Meaning |
|---|---|
| `kind` | `appointment` (at the business), `visit` (at the customer's address), `class` (set sessions with limited spots) or `quote` (priced first, can't be booked at a time) |
| `priceLabel` | Ready to show, like `$35`, `From $40` or `Price varies`. `null` when no price is set: show nothing, or "By quote" for quote services. Never make one up. |
| `durationMin` | Length in minutes. Don't show it for quote services. |
| `staff` | People who do it. Empty when customers can't choose. |

Services come in the order the owner set. Only active services are listed.

## Step 3: Plan the pages

Use the pages the owner asked for. Every page has the same header (logo, name, menu, **Book** button) and footer (name, address, phone, hours, links, the chat privacy note). Put the most useful action first on phones: book, call or ask.

### Barber, hair or beauty salon, spa

| Page | Sections, in order |
|---|---|
| Home | Headline with Book / Call / Ask buttons; live hours card with "Open now"; top services with prices; "Ask before you book" question buttons; short about; location |
| Services and prices | Full live price list with a Book button on each; real policies, if any |
| Team | Only real people the owner listed. Add "Book with Dana" buttons when `allowPickStaff` is true (see [Booking with a person](#booking-with-a-person)). |
| Gallery | Real photos only, with alt text |
| Contact | Inline booking widget, address with a map link, phone, hours |

JSON-LD type: `HairSalon`, `BeautySalon`, `NailSalon` or `DaySpa`.

### Trades: plumber, electrician, cleaner, landscaper

| Page | Sections, in order |
|---|---|
| Home | Headline with **Book a visit** / **Call** / **Get a quote**; the towns they cover; main services; how a visit works (only what's true); live hours |
| Services | Visit services with "At your address"; quote services with **Get a quote** |
| Service area | Towns or postcodes from the intake. Visits outside a service's area are refused at booking, so make the list match. |
| Get a quote | Inline `contact` widget tied to the quote service, or your own form through [the API](#callback-and-quote-forms) |
| Contact | Call-back widget (it shows **Call now** when someone can answer), phone, hours |

JSON-LD type: `Plumber`, `Electrician`, `HVACBusiness`, `RoofingContractor`, `Locksmith`, `HousePainter`, `GeneralContractor` or `MovingCompany`. Add `areaServed` with the towns.

### Classes and studios: yoga, fitness, dance, lessons

| Page | Sections, in order |
|---|---|
| Home | Headline with **Book a class**; this week's classes; prices; teachers |
| Timetable | Upcoming sessions from [`GET /slots`](#a-class-timetable) with spots left, each with a Book button |
| Prices | From the API; memberships and passes only as the owner describes them |
| Teachers | Real teachers only |
| Contact | Address, hours, message form |

JSON-LD type: `ExerciseGym` or `SportsActivityLocation`.

### Clinics and appointments: physio, dental, therapy, vet

| Page | Sections, in order |
|---|---|
| Home | Headline with **Book an appointment** (or **Request an appointment** if the business approves each booking); treatments; practitioners; location and hours |
| Treatments | Live list with durations, and prices if the owner shows them |
| Practitioners | Real names and qualifications, exactly as given |
| New patients | What to bring and what happens, from the owner |
| Contact | Booking widget, call-back widget, address, hours |

Ask visitors not to put medical details in message forms. JSON-LD type: `MedicalClinic`, `Dentist`, `Physiotherapy`, `Optician` or `VeterinaryCare`.

## Step 4: Build to the quality bar

Start from the [starter kit](#the-starter-kit). Plain HTML, CSS and a little JavaScript is the right tool for these sites. Don't add a framework, a CSS library or jQuery.

### Mobile first

- Design at 375px wide first. No sideways scrolling at any width from 320px up.
- Tap targets at least 44 by 44px. Body text at least 16px.
- Put `viewport-fit=cover` in the viewport tag, so the widget can keep clear of the iPhone home bar:

  ```html
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  ```

- Leave room at the bottom of the page for the floating button (about 96px), so it never covers the last line of the footer.

### Fast

- Aim for under 100 KB of your own HTML, CSS and JavaScript, not counting photos, fonts and the widget.
- Load one font family, or two at most, from Google Fonts in the `<head>`, with `display=swap` and `preconnect`.
- Photos: WebP or AVIF where you can, sized for the screen, under 200 KB for the largest. Always set `width` and `height`. Add `loading="lazy"` to everything below the first screen.
- No sliders, carousels, autoplay video, or embedded maps on the home page. Link to a map instead.
- Load the widget with `async` and your own script with `defer`. The widget's loader is under 1 KB; the rest loads in the background.
- Version your CSS and JS file names or URLs (`styles.css?v=2`) when you change them. Hosted sites cache them for an hour.

### Accessible (WCAG 2.2 AA)

- One `<h1>` per page, then headings in order. Landmarks: `header`, `nav`, `main`, `footer`.
- A "Skip to content" link first in the page.
- Text contrast at least 4.5:1 (3:1 for large text and for borders of controls). Check every colour you change.
- A visible focus outline on every link and button.
- Real `<a>` and `<button>` elements for anything clickable. Never a `<div>` with a click handler.
- Every form field has a visible `<label>`. Hints sit below the label and are tied with `aria-describedby`. Errors say what to fix.
- Every image has `alt` text that says what's in it, or `alt=""` if it's decoration.
- Links make sense on their own. "Book a haircut", not "click here". If several buttons say "Book", add the service name in visually hidden text.
- Respect `prefers-reduced-motion`. Don't animate on load.

### Clear actions

- The three actions every small-business visitor wants: **Book**, **Call**, **Ask a question**. Put all three in the first screen on phones.
- Button text says exactly what happens: "Book a time", "Call (217) 555-0142", "Get a quote".
- If the business approves each booking, say "Request a time", not "Book".

### Content

- Name, address and phone (NAP) exactly the same on every page, in the JSON-LD and on the owner's Google profile.
- Write short, plain sentences in the owner's voice. No filler, no marketing clichés, no lorem ipsum.
- Only real reviews, with the owner's permission, attributed as the owner says. No star ratings unless they come from a source you can link to.
- A footer note about the chat: who runs it, that it's an AI, that messages are kept, and that it sets no cookies. Link to a privacy page. The starter kit has both.
- A `404.html` that links home and to booking.

## Step 5: Wire booking and chat

### The script tag

Put this once on every page, just before `</body>`, with the business's key:

```html
<script src="https://justagoodguy.us/w.js" data-key="pk_your_key" async></script>
```

It adds the floating chat and booking button, renders inline widgets, and makes booking buttons work. It never sets cookies, and it doesn't store anything in the browser until the visitor opens the chat or sends a form.

On Just a Good Guy hosting (and the host app), the server adds this tag before `</body>` of every page for you, but only to pages that don't already load it. Keep the tag in your files anyway: the site then works on any host and in a local preview, and you control the options below.

> [!WARNING]
> A page with its own tag keeps the key written in it. If the business ever replaces its publishable key (**Developers → Replace this key**), the old key stops working at once and those pages lose the widget and live data until you put the new key in and upload again (`node tools/bake.mjs pk_new_key` does it in the starter kit). Pages that rely on the server's tag pick up the new key by themselves. If the owner can't call you when that happens, leave the tag out of hosted pages and let the server add it; you lose `data-font` and the other options, so set `--gg-font` instead.

### Script options

All optional except `data-key`. Put them on the same `<script>` tag.

| Attribute | What it does |
|---|---|
| `data-key="pk_..."` | The publishable key. **Required.** |
| `data-launcher="false"` | No floating button. Use it when the page only has inline widgets and your own buttons. |
| `data-position="left"` or `"right"` | Side for the floating button. Overrides the dashboard. |
| `data-color="#a61e2d"` | Brand colour. Overrides the dashboard; `--gg-color` in your CSS overrides this. |
| `data-open="chat"` or `"booking"` | Open that view as soon as the page loads |
| `data-font="inherit"` | Use the page's font instead of loading Figtree |
| `data-api="https://..."` | The server to talk to. Defaults to where the script came from. |

If the tag appears more than once on a page, only the first one to run is used and the rest are ignored, even if they have different keys. Keep exactly one.

### Contact forms

A contact page needs no special code: a plain `<form>` with no `action`, a `<textarea>` and an email or phone field is picked up by the widget and goes through the business's email system (inbox thread, an email back to the customer, a text and email to the owner). Give every field a visible `<label>`. Add `data-gg-contact` to be explicit, `data-gg-thanks="..."` for your own thank-you line, and never point the form at another service.

```html
<form data-gg-contact>
  <label for="c-name">Name</label> <input id="c-name" name="name" autocomplete="name" required>
  <label for="c-email">Email</label> <input id="c-email" name="email" type="email" autocomplete="email">
  <label for="c-phone">Phone</label> <input id="c-phone" name="phone" type="tel" autocomplete="tel">
  <label for="c-msg">Message</label> <textarea id="c-msg" name="message" required></textarea>
  <button type="submit">Send message</button>
</form>
```

### Buttons that open booking or chat

Add one attribute to any `<a>` or `<button>`. Clicking it opens the panel. Give links a real `href` to the booking page, so they still work if the widget can't load.

```html
<a href="https://justagoodguy.us/b/your-business-slug" data-gg-book="">Book now</a>
<a href="https://justagoodguy.us/b/your-business-slug" data-gg-book="Haircut">Book a haircut</a>
<button type="button" data-gg-open="callback">Call me back</button>
<a href="contact.html#message" data-gg-ask="Do you take walk-ins?">Do you take walk-ins?</a>
```

| Attribute | Opens |
|---|---|
| `data-gg-book=""` | Booking at the first step |
| `data-gg-book="Haircut"` | Booking with that service picked. Use the service `id` (safest) or its name. Names match exactly first (ignoring case), then by "contains". A quote service opens its quote form. |
| `data-gg-open="chat"` | The panel on a view: `chat`, `booking`, `callback` or `contact` |
| `data-gg-ask="Is there parking?"` | Chat, and sends that question for the visitor. If the AI is paused, it opens the message form instead. |

- If an element has more than one, `data-gg-ask` wins, then `data-gg-book`, then `data-gg-open`.
- The click's normal action is cancelled, so the `href` is only used when the widget isn't running.
- Elements added later (menus, modals) work without extra code.
- They work with `data-launcher="false"` too.

More: [Buttons and triggers](https://justagoodguy.us/docs/triggers).

### Inline widgets

Put a placeholder element where the widget should appear. It renders inside its own shadow root, so your CSS can't break it.

```html
<div data-gg-widget="booking">
  <p>Loading the booking form. You can also <a href="https://justagoodguy.us/b/your-business-slug">book on our booking page</a>.</p>
</div>
```

Anything inside the placeholder shows until the widget draws itself, then disappears. If the widget can't load, it stays. Always put a fallback link or phone number there.

| `data-gg-widget` | Shows | Options |
|---|---|---|
| `booking` | The booking flow: service, person, day and time buttons, details, confirm | `data-gg-service` (id or name), `data-gg-staff` (staff id), `data-gg-source="booking_page"` |
| `chat` | The AI chat, inline | `data-gg-height` in pixels (default 480, at least 320) |
| `services` | Services with price labels and a Book button on each | none |
| `callback` | **Call now** when someone can answer, otherwise a call-back request form with time-window buttons | none |
| `contact` | A message or quote request form | `data-gg-service` (service id) |
| `hours` | Weekly hours with an Open now or Closed now badge, address and phone | none |
| `manage` | One customer's booking (from their private link) | `data-gg-token` (required) |
| `offer` | A waitlist offer | `data-gg-token` (required) |

- Inline widgets draw their own small headings ("Services", "Hours", "Chat with us", "Request a call-back"). Put each one in a section with your own `<h2>`, and don't repeat the same words right above it.
- Each element renders once. To change options, replace the element.
- If the page only uses inline widgets and your own buttons, add `data-launcher="false"` to the script tag.

More: [Widgets reference](https://justagoodguy.us/docs/widgets).

### Booking with a person

There is no button attribute for a staff member. Use the inline widget or the JavaScript API, with the staff id from `GET /services` (`staff[].id`):

```html
<div data-gg-widget="booking" data-gg-staff="stf_996s6vakq60vvw2j"></div>

<button type="button" id="book-dana">Book with Dana</button>
<script>
  window.GoodGuy = window.GoodGuy || function () { (GoodGuy.q = GoodGuy.q || []).push(arguments) };
  document.getElementById('book-dana').addEventListener('click', function () {
    GoodGuy('book', { staff: 'stf_996s6vakq60vvw2j' });
  });
</script>
```

This only matters when `allowPickStaff` is `true`.

### The JavaScript API

The widget loads `async`, so your code may run first. Paste this queue stub before your own calls; calls made early run in order once the widget is ready.

```html
<script>
  window.GoodGuy = window.GoodGuy || function () { (GoodGuy.q = GoodGuy.q || []).push(arguments) };
  GoodGuy('on', 'booking.created', function (b) { console.log('Booked', b.serviceName, b.whenLabel); });
</script>
<script src="https://justagoodguy.us/w.js" data-key="pk_your_key" async></script>
```

Call methods as `GoodGuy('method', ...args)`. Once loaded, `GoodGuy.method(...)` works too.

| Method | What it does |
|---|---|
| `open(view?, opts?)` | Opens the panel on `'chat'`, `'booking'`, `'callback'` or `'contact'`. For booking, `opts` can be `{ service, staff }`. |
| `close()` | Closes the panel |
| `toggle(view?)` | Opens or closes it |
| `ask(text)` | Opens chat and sends `text` as the visitor's message |
| `book({ service?, staff? })` | Opens booking from the start, with a service (id or name) and person (staff id) picked |
| `identify({ name?, phone?, email? })` | Prefills the visitor's details. Nothing is sent until they submit something. |
| `mount(element, type, options?)` | Renders an inline widget into an element. `options`: `service`, `staff`, `source`, `height`, `token`. |
| `on(event, fn)` / `off(event, fn)` | Subscribe and unsubscribe |
| `config` | The loaded `/config` object, or `null` until it arrives |
| `version` | The widget version |

### Events

| Event | When | Callback gets |
|---|---|---|
| `ready` | Settings loaded | The config object. Subscribing late still runs it once. |
| `open` | Panel opened | `{ view }` |
| `close` | Panel closed | nothing |
| `message` | A chat message was sent or arrived | `{ role, content }`; `role` is `user`, `assistant` or `owner`. Assistant messages also have `cards`. |
| `booking.created` | A booking was made in the booking flow or in chat | `id`, `status`, `serviceName`, `staffName`, `whenLabel`, `manageUrl`, `source`. From chat also `start` (ms). |
| `callback.created` | A call-back was requested | `{ id, windowLabel, source }` |
| `lead.created` | A message or quote request was sent | `{ id, source }` |

`source` is `widget` or `booking_page` from the booking flow, `widget` from the call-back and message forms, and `chat` when the AI did it. `status` can be `confirmed` or `pending_approval` (the business approves each booking).

Send bookings to analytics only if the owner already has analytics with visitor consent:

```js
GoodGuy('on', 'booking.created', function (b) {
  if (Array.isArray(window.dataLayer)) {
    window.dataLayer.push({ event: 'booking_created', booking_status: b.status, booking_source: b.source });
  }
});
```

Events only run in the visitor's browser. To record bookings reliably, use [webhooks](#server-side-integration).

More: [JavaScript API](https://justagoodguy.us/docs/javascript-api).

### Match the widget to the site

Set these on `:root` in your stylesheet. They reach inside the widget's shadow root.

```css
:root {
  --gg-color: #a61e2d;   /* brand colour for buttons, links and the floating button */
  --gg-radius: 12px;     /* corner radius; default 18px */
  --gg-font: 'Archivo', system-ui, sans-serif;
}
```

- Any colour works: the widget works out readable shades for text and focus rings in light and dark mode.
- To use the page's font and skip loading Figtree, add `data-font="inherit"` to the script tag. Make sure the font is loaded on the page.
- The widget follows the visitor's light or dark setting, not your site's own switch.
- Under 520px wide the panel opens full screen. The floating button sits 16px from the bottom corner at `z-index: 2147483000`.

More: [Styling the widget](https://justagoodguy.us/docs/styling).

### The booking page and QR code

Every business has a hosted booking page and a QR code for it:

```text
https://justagoodguy.us/b/your-business-slug
https://justagoodguy.us/b/your-business-slug/qr.svg
```

- Use the booking page as the `href` of every booking button, so booking works without JavaScript and on builders that can't run the widget.
- The QR code is an SVG for print: a counter sign, a flyer, a van. Don't put it on the website itself.
- Customers' confirmations link to their own change-or-cancel page, `https://justagoodguy.us/m/<token>`. You don't need to build one.

## Step 6: Show live services, prices and hours

Write the services, prices and hours into the HTML when you build (from [Step 2](#step-2-read-the-live-business-data)), then refresh them in the browser from the API. The page is never empty: if the API can't be reached, the written content stays.

Rules for live data:

- GET requests carry the key as `?key=`. That avoids a CORS preflight. (`X-GG-Key` as a header works too.)
- Time out after about 8 seconds. Keep the written content on any error.
- Only replace a list when the API returned something. Never replace content with an empty list.
- Insert text with `textContent` or escape it. Never put API strings into `innerHTML` unescaped.
- `/config` and `/services` have no rate limit. Fetch them once per page view.

The starter kit's `app.js` does all of this. The core of it, which you can use on its own:

```html
<ul id="services">
  <li><strong>Haircut</strong> $35 <a href="https://justagoodguy.us/b/your-business-slug" data-gg-book="Haircut">Book</a></li>
</ul>
<dl id="hours">
  <div><dt>Monday to Friday</dt><dd>9:00 AM – 5:00 PM</dd></div>
</dl>

<script>
  (function () {
    var KEY = 'pk_your_key';
    var API = 'https://justagoodguy.us/api/public';
    function get(path) {
      var ctrl = new AbortController();
      setTimeout(function () { ctrl.abort(); }, 8000);
      return fetch(API + path + (path.indexOf('?') < 0 ? '?' : '&') + 'key=' + encodeURIComponent(KEY), { credentials: 'omit', signal: ctrl.signal })
        .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); });
    }
    function add(parent, tag, text) {
      var el = document.createElement(tag);
      el.textContent = text;
      parent.appendChild(el);
      return el;
    }
    Promise.all([get('/config'), get('/services')]).then(function (r) {
      var cfg = r[0], services = r[1].services;
      if (services.length) {
        var list = document.getElementById('services');
        list.textContent = '';
        services.forEach(function (s) {
          var li = add(list, 'li', '');
          add(li, 'strong', s.name);
          li.appendChild(document.createTextNode(' ' + (s.priceLabel || (s.kind === 'quote' ? 'By quote' : '')) + ' '));
          var a = add(li, 'a', s.kind === 'quote' ? 'Get a quote' : 'Book');
          a.href = cfg.business.bookingPageUrl;
          a.setAttribute('data-gg-book', s.id);
          a.setAttribute('aria-label', (s.kind === 'quote' ? 'Get a quote: ' : 'Book ') + s.name);
        });
      }
      if (cfg.hours.booking.some(function (d) { return d.ranges.length; })) {
        var dl = document.getElementById('hours');
        dl.textContent = '';
        cfg.hours.booking.forEach(function (d) {
          var row = add(dl, 'div', '');
          add(row, 'dt', d.label);
          add(row, 'dd', d.ranges.length ? d.ranges.map(function (x) { return x.label; }).join(', ') : 'Closed');
        });
      }
    }).catch(function (err) {
      console.warn('Live data unavailable, keeping the written content:', err.message);
    });
  })();
</script>
```

### Open now

`config.openNow` is `true` when the business is open right now, counting closures and holidays. Show it as a small badge next to the hours. Leave the badge out of the written HTML (it can't know the time), and only show it once the API answers.

### A class timetable

For class services, `GET /slots` returns the upcoming sessions with the spots left:

```bash
curl -s "https://justagoodguy.us/api/public/slots?serviceId=svc_wgxx2f70khaabvmm&days=14&key=$KEY"
```

```json
{
  "timezone": "America/Chicago",
  "days": [
    { "date": "2026-10-20", "label": "Tuesday, Oct 20", "slots": [{ "start": 1792508400000, "staffIds": [], "label": "10:00 AM", "seatsLeft": 8, "sessionId": "cls_dtm5gr0n7cqef908" }] },
    { "date": "2026-10-27", "label": "Tuesday, Oct 27", "slots": [{ "start": 1793113200000, "staffIds": [], "label": "10:00 AM", "seatsLeft": 8, "sessionId": "cls_syyh4npnyx3b2zzf" }] }
  ]
}
```

- `days` can be up to 21. `start` is milliseconds since 1970 (UTC); show the ready-made `label`s.
- `sessionId` and `seatsLeft` are only on class slots.
- This endpoint allows 120 requests per 10 minutes per visitor, so fetch it for a few services, not dozens. Link each session to booking with `data-gg-book="<service id>"`.

### Callback and quote forms

Two ways. Either works with the business's inbox and alerts.

**Use the widgets** (least code, handles every case):

```html
<div data-gg-widget="callback"><p>Call us on <a href="tel:+12175550142">(217) 555-0142</a>.</p></div>
<div data-gg-widget="contact" data-gg-service="svc_qk3z9ewfvs2ce56f"><p>Email <a href="mailto:hello@example.com">hello@example.com</a>.</p></div>
```

**Or build your own form** and send it to the API. This matches the site's design exactly. A message or quote request:

```bash
curl -s -X POST "https://justagoodguy.us/api/public/leads" \
  -H "X-GG-Key: pk_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Sam Lee", "phone": "+15555550123", "details": "Quote for six groomsmen on June 12", "serviceId": "svc_qk3z9ewfvs2ce56f" }'
```

```json
{ "lead": { "id": "led_vrybxfp0rkfcc9yb" } }
```

A call-back request:

```bash
curl -s -X POST "https://justagoodguy.us/api/public/callbacks" \
  -H "X-GG-Key: pk_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Sam Lee", "phone": "5555550123", "reason": "Question about prices" }'
```

```json
{ "callback": { "id": "cbk_rkbq55gzwpaqsyk6", "windowLabel": "Monday, Oct 19 at 9:00 AM – 11:00 AM" } }
```

| Endpoint | Body | Notes |
|---|---|---|
| `POST /leads` | `name`, `details` (up to 3,000 characters), and `phone` or `email`; `serviceId` optional | Returns `201`. 6 per hour per visitor. |
| `POST /callbacks` | `name`, `phone`; `reason` (up to 500) and `windowStart` optional | Returns `201`. Leave out `windowStart` for the next window, or pick one from `GET /callback-windows`. 6 per hour per visitor. |

- Send JSON with `Content-Type: application/json` and the key in `X-GG-Key`.
- On an error, show `error` from the response as it is, for example "Add a phone number or email so we can reply."
- Hosted sites only serve files (GET and HEAD). A form can't post to the site itself; it has to call the API from JavaScript.
- Without JavaScript the form can't send. Show the phone number and email in a `<noscript>` note.

The starter kit's contact page has a complete, accessible message form wired this way.

## Step 7: Search and sharing

Every page's `<head>`:

```html
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Joe's Plumbing | Plumber in Springfield, IL</title>
<meta name="description" content="Leaks, drains and water heaters in Springfield, IL. Book a visit online.">
<link rel="canonical" href="https://www.joesplumbing.com/">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Joe's Plumbing">
<meta property="og:title" content="Joe's Plumbing | Plumber in Springfield, IL">
<meta property="og:description" content="Leaks, drains and water heaters in Springfield, IL. Book a visit online.">
<meta property="og:url" content="https://www.joesplumbing.com/">
<meta property="og:image" content="https://www.joesplumbing.com/og-image.jpg">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
```

- Titles under 60 characters: what, where, who. Descriptions under 155 characters.
- `og:image` must be a real photo or logo image, 1200 by 630, JPG or PNG. Leave the tag out if there isn't one.
- `canonical` and `og:url` use the final address: the custom domain if there is one, otherwise the hosted address.
- Add `robots.txt` and `sitemap.xml` listing every page.

### LocalBusiness JSON-LD

Put this on the home page, with facts from the intake and hours from the API:

```html
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Plumber",
  "name": "Joe's Plumbing",
  "url": "https://www.joesplumbing.com/",
  "telephone": "+12175550188",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "40 Oak Ln",
    "addressLocality": "Springfield",
    "addressRegion": "IL",
    "postalCode": "62704",
    "addressCountry": "US"
  },
  "areaServed": ["Springfield", "Chatham", "Rochester"],
  "openingHoursSpecification": [
    { "@type": "OpeningHoursSpecification", "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], "opens": "08:00", "closes": "17:00" }
  ]
}
</script>
```

Turning `hours.booking` into `openingHoursSpecification`:

| API | JSON-LD |
|---|---|
| `weekday` 1 to 7 | `dayOfWeek` `"Monday"` to `"Sunday"` |
| `startMin` 540 | `opens` `"09:00"` (24-hour, `HH:MM`) |
| `endMin` 1020 | `closes` `"17:00"`; use `"23:59"` for 1440 |
| empty `ranges` | leave the day out |
| two ranges in one day | two entries |

Group days with the same hours into one entry. The starter kit's `tools/bake.mjs` does this conversion and writes it into the page. Only add `sameAs` (social profiles), `logo`, `image` and `priceRange` when you have the real values.

## Step 8: Check accessibility and speed

Go through this list for every page, at 375px and at 1280px wide:

- No sideways scroll. Nothing overlaps.
- Tab through the page: the skip link comes first, every control shows a focus outline, the order makes sense.
- Every image has suitable `alt` text; every form field has a visible label.
- Text contrast passes 4.5:1, in light and (if you support it) dark mode.
- One `<h1>`, headings in order, landmarks present.
- Book, Call and Ask are in the first screen on a phone.
- Booking buttons have the booking page as `href`. Phone links use `tel:+1...`.
- Services, prices and hours on the page match the API.
- No invented facts. Search the files for leftover sample text.
- The page works with JavaScript turned off: content shows, links go to the booking page.
- Total of your own HTML, CSS and JS under 100 KB; images sized and lazy-loaded.

If you can run them: Lighthouse on mobile should score 90 or more for Performance, Accessibility, Best Practices and SEO, and axe should report no serious or critical issues.

## Step 9: Package the site

Make a `.zip` of the site files. The uploader checks it like this:

| Rule | Detail |
|---|---|
| Home page | `index.html` at the top of the zip. If everything sits in one folder, that folder is lifted up a level. Otherwise the upload stops. |
| Size | Up to 150 MB zipped, 400 MB unzipped, 5,000 files |
| Left out | `__MACOSX`, `.DS_Store`, `Thumbs.db`, `.git` folders, `.env` and `.env.*` files |
| Rejected | A zip with a path that leaves its folder (`../`), an absolute path or a drive letter |
| Format | The operator's build queue takes `.zip` only. The owner's dashboard also takes a folder. |

How the hosting serves it:

- **Clean URLs.** `/about` serves `about.html`; `/about/` serves `about/index.html`. Links like `about.html` work too.
- **404.** Your `404.html` is used for any missing address, with status 404. It's served at any depth, so its links, styles and scripts must start with `/` (`/styles.css`, `/`).
- **Hidden files.** Files and folders starting with `.` are never served, except `/.well-known/`.
- **Caching.** HTML is sent with `no-cache`; everything else is cached for an hour. Change the file name or add `?v=2` when you change CSS or JS.
- **Static only.** GET and HEAD. No PHP, no server code, no form posts to the site.
- **Compression and HTTPS** are handled for you.
- **The widget tag** is added before `</body>` of every HTML page unless the page already has a `<script>` whose `src` ends in `/w.js` and that has `data-key`. A page with no `</body>` gets it at the end. The owner can turn this off per site.

Make the zip from inside the site folder, then check `index.html` is at the top:

```bash
cd my-site
zip -r ../site.zip . -x 'tools/*' 'README.md' '*.DS_Store'
unzip -l ../site.zip | head
```

## Step 10: Deploy and verify

### From the operator's build queue

1. Open **Admin → Builds** ([/app/admin/builds](https://justagoodguy.us/app/admin/builds)) and find the request.
2. Tap **Upload finished site (.zip)** and pick the zip. It goes live on the business's hosted address straight away, the request moves to **Owner reviewing**, and the owner gets an email.
3. Check the live site (below). Send the owner a reply on the request with the address.
4. When the owner is happy, set the status to **Live**. They get an email each time the status changes.

The same upload is on the business's admin page under **Website → Host a website for them**, with the version list, the custom domain and the **Add the AI to every page** switch. Every upload is kept as a version; the owner can roll back in their dashboard.

> [!WARNING]
> Hosted sites are served by a paired host server (**Admin → Hosts**), or by the business's own server. If none is connected, an upload is accepted but nothing serves it. If the hosted address doesn't load after an upload, check that a host is online.

### From the owner's dashboard

The owner can upload the same zip, or the folder itself, in **Website** ([/app/website](https://justagoodguy.us/app/website)). Big uploads go up in pieces.

### Addresses

- **Hosted address:** `https://<slug>.justagoodguy.us`, live as soon as the upload finishes.
- **Custom domain:** add it (without `www` or `https://`) on the site in the dashboard or the admin page. At the domain's DNS host, add a **CNAME** for `www` pointing at the target the dashboard shows (by default `<slug>.justagoodguy.us`), and forward the bare domain to `https://www.` (or use an ALIAS or ANAME record if the DNS host has one). The dashboard's DNS check turns ready when `www` points at the target. See [Use your own domain](https://justagoodguy.us/docs/hosting#use-your-own-domain).
- **The business's own server:** the host app serves folders from `/opt/goodguy/sites/`. Name the folder after the domain. To stop the widget being added to one folder, put a `.goodguy.json` file in it containing `{ "injectWidget": false }`. See [Use your own server](https://justagoodguy.us/docs/own-server).

### Verify the live site

Do all of these on the live address, not a local copy.

1. **The widget tag is there once.** `curl -s https://joes-plumbing.justagoodguy.us/ | grep -c '/w.js'` prints `1`.
2. **It loads.** Open the page in a browser. The floating button appears (unless you used `data-launcher="false"`). In the browser console, `GoodGuy.version` prints a version and `GoodGuy.config.business.slug` prints the right slug.
3. **The requests succeed.** In the network panel: `/w.js` and `/w/app.<hash>.js` answer 200, `/api/public/config` 200, `/api/public/events` 204, and `/api/public/services` 200 if the page shows live services. The console has no `[GoodGuy]` warnings.
4. **Live data shows.** Services, prices and hours match the dashboard. "Open now" is right for the business's local time.
5. **Every button works.** Book opens booking (with the right service, where set). Ask sends the question. Call starts a call on a phone. Each `href` is the real booking page.
6. **Inline widgets render** and their fallback text disappears.
7. **Booking works end to end.** Walk through the booking flow to the confirm step. Only book for real with the operator's agreement, using their own details, then cancel from the link in the confirmation.
8. **Allowed sites.** If the business's key has an allowed-sites list, it must include every address the site answers on, apart from its hosted address and connected custom domains (see below).
9. **The install check.** For a site on another host, use the owner's **Website → I already have a site → Check install**. It passes once a page with the widget has been visited from that site in the last 30 days. Visit one live page first; the first visit records the site's address, so the check passes straight away.
10. **Phones.** Check at 375px: the panel opens full screen and closes with its X.

### Allowed sites

By default a publishable key works on any website. If the owner or operator lists allowed sites (**Developers** for the owner, **Websites allowed to use their AI** on the admin page), requests from any other site get `403` with "This site isn't allowed to use this booking assistant."

- The list is checked against the browser's `Origin`. An entry is an exact origin (`https://joesplumbing.com`), a host name (`joesplumbing.com`, any scheme) or a wildcard (`*.joesplumbing.com`, subdomains only, entered in the owner's Developers page).
- `joesplumbing.com` does not cover `www.joesplumbing.com`. Add both.
- The business's own hosted address (`https://joes-plumbing.justagoodguy.us`) and its connected custom domains always work, list or no list, and so does the booking page on https://justagoodguy.us. List the other sites only.
- `localhost` and pages opened from a file (`file://`) are refused while a list is set. Leave the list empty while you build.

## Connecting an existing website

When the business already has a site, you only add the widget, buttons and maybe an inline booking widget. You don't need to rebuild anything. Paste the [script tag](#the-script-tag) once, sitewide, before `</body>`.

| Platform | Script tag (whole site) | Inline widget or booking button on a page |
|---|---|---|
| **Plain HTML** | Before `</body>` on every page, or once in the shared footer file | Paste `<div data-gg-widget="booking"></div>` and `data-gg-book` links where you want them |
| **WordPress** | A "header and footer code" plugin, footer box; or before `</body>` in a child theme's `footer.php`. Clear any cache plugin afterwards. | A **Custom HTML** block with the `<div data-gg-widget="booking">` or a `<a data-gg-book="">` link. The normal Button block can't take data attributes, so link it to the booking page instead. |
| **Wix** | **Settings → Custom code → Add Custom Code**, All pages, Body - end. Needs a paid plan and a connected domain. | Wix's **Embed HTML** element is a separate frame: put the script tag (with `data-launcher="false"`) and the `<div data-gg-widget="booking">` together inside it and give it enough height, or link a button to the booking page. |
| **Squarespace** | **Settings → Code injection → Footer**. Only on some plans. | A **Code** block with the inline widget or a `data-gg-book` link |
| **Shopify** | **Online Store → Themes → Edit code → `layout/theme.liquid`**, before `</body>`. Repeat after changing themes. | A **Custom Liquid** section with the inline widget, or `data-gg-book` links in templates |
| **Webflow** | **Site settings → Custom code → Footer code**, then Publish. Needs a paid site plan. | An **Embed** element for the inline widget. Add `data-gg-book` to any button with **Custom attributes** in its settings. |
| **GoDaddy and others** | A sitewide "custom code", "footer code" or "tracking code" box | If code only goes in a box on one page, that box is a frame: use an inline booking widget inside it, or link buttons to the booking page |

After installing, run the [verify list](#verify-the-live-site). The owner's dashboard check passes once a page with the widget has had a visit. Full steps: [Add the widget to your site](https://justagoodguy.us/docs/add-widget).

> [!NOTE]
> If the site sends a `Content-Security-Policy` header, allow the widget's server in `script-src` and `connect-src`, and Google Fonts in `style-src` and `font-src` (or use `data-font="inherit"`). See [Content Security Policy](https://justagoodguy.us/docs/add-widget#content-security-policy).

For single-page apps (React, Vue and so on): add the script tag once in the app shell. Triggers work on elements rendered later; render inline widgets from code with `GoodGuy('mount', element, 'booking', { service: 'Haircut' })`.

## Server-side integration

Only when the site has its own backend (a members area, an order system, a CRM sync). Everything a normal business site needs works from the browser with the publishable key.

> [!WARNING]
> The secret key (`sk_...`) can read and change every booking and customer. Keep it in a server environment variable. Never send it to a browser, never commit it, never put it in a static site.

### REST API

| | |
|---|---|
| Base URL | `https://justagoodguy.us/api/v1` |
| Auth | `Authorization: Bearer sk_...` |
| Format | JSON in and out. Times are ISO 8601 in UTC. |
| Rate limit | 600 requests per minute per key |
| Spec | [`/api/openapi.json`](https://justagoodguy.us/api/openapi.json) |

| Request | What it does |
|---|---|
| `GET /v1/services` | Active services: `id`, `name`, `kind`, `durationMin`, `priceCents`, `description` |
| `GET /v1/staff` | Active team members |
| `GET /v1/availability?service_id=&staff_id=&from=&days=` | Open times, grouped by local date. `days` 1 to 31. |
| `GET /v1/bookings?from=&to=&status=` | Bookings in a range |
| `POST /v1/bookings` | `service_id`, `start` (exactly as availability gave it), `customer: { name, phone or email }`, optional `staff_id` and `notes` |
| `GET /v1/bookings/{id}` | One booking |
| `POST /v1/bookings/{id}/reschedule` | `start`, optional `staff_id` and `notify` |
| `POST /v1/bookings/{id}/cancel` | Optional `reason` and `notify` |
| `GET /v1/customers?q=&limit=`, `POST /v1/customers`, `GET /v1/customers/{id}` | Find, create or update (matched by phone or email), get |
| `GET /v1/callbacks`, `GET /v1/leads` | Open call-back requests; new messages and quote requests |

```bash
curl -s "https://justagoodguy.us/api/v1/availability?service_id=svc_j0bkxg20s55jtenm&days=3" \
  -H "Authorization: Bearer $JAGG_SECRET_KEY"
```

- There are no idempotency keys. If a create request times out, list bookings for that time before you retry. A retry of a booking that went through fails with `409` because the time is taken.
- Errors are `{ "error": "..." }` with `400`, `401`, `404`, `409` or `429`.

Full reference: [REST API](https://justagoodguy.us/docs/rest-api).

### Webhooks

Set them up in **Developers** (owner only): an `https://` URL and the events you want (`booking.created`, `booking.rescheduled`, `booking.cancelled`, `callback.created`, `lead.created`, `customer.created`, `conversation.closed`, or `*`). You get a `whsec_` signing secret once.

- Each request has `X-JAGG-Signature: t=<unix seconds>,v1=<hex>`. `v1` is HMAC-SHA256 of `<t>.<raw body>` with the secret. Check it against the **raw** body with a constant-time compare, and reject timestamps more than 5 minutes off.
- The body is `{ id, type, created, data }`. `id` stays the same on retries: store it and skip duplicates.
- Answer `2xx` within 10 seconds, then do the work. Each delivery is tried up to 8 times, with waits that double from 2 minutes to about 2 hours.
- Events can arrive out of order. Fetch `GET /v1/bookings/{id}` for the latest state.

```js
import crypto from 'node:crypto';

export function verifyJaggSignature(rawBody, header, secret, toleranceSec = 300) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(',').map((kv) => { const i = kv.indexOf('='); return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()]; }));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(parts.v1, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Full reference, with Express, Flask and PHP handlers and a test vector: [Webhooks](https://justagoodguy.us/docs/webhooks).

## Troubleshooting

| What you see | Why | Fix |
|---|---|---|
| "That site key isn't valid." in the widget, `401` from the API | Wrong or revoked key | Copy the key again from the admin page or the dashboard. Revoking a key makes a new one. |
| "Missing site key." | No `data-key`, or it doesn't start with `pk_` | Fix the script tag. Never use an `sk_` key here. |
| "This site isn't allowed to use this booking assistant." (`403`) | The key has an allowed-sites list without this address | Add every other address the site answers on, including `www.`. (The hosted address and connected custom domains are always allowed.) See [Allowed sites](#allowed-sites). |
| "This booking assistant is turned off." (`403`) | The business is suspended | Ask the operator |
| No floating button on phones | **Show on phones** is off in **Settings → Website chat**, or the tag has `data-launcher="false"` | Turn it on, or remove the attribute |
| Two tags, or the wrong business | Your tag and another one with a different key | Keep one tag per page. Only the first one to run is used. |
| The widget stopped working after a key change | The page has its own tag with the old key | Put the new key in every page and upload again. See the warning under [The script tag](#the-script-tag). |
| Buttons do nothing | The widget didn't load (blocked script, typo in the tag), or another script cancels the click | Check the console for `[GoodGuy]` warnings and the network panel for `/w.js` |
| Widget blocked by the browser | The site's `Content-Security-Policy` | Allow https://justagoodguy.us in `script-src` and `connect-src` |
| "Mixed content" errors | The script tag uses `http://` on an `https://` page | Always use `https://` |
| Old styles or text after an upload | CSS and JS are cached for an hour; `w.js` for 5 minutes | Add `?v=2` to changed file names; hard-reload |
| `429` "You're going a bit fast…" | Rate limit per visitor (forms: 6 per hour) | Wait 60 seconds. Don't test forms in a loop. |
| Live data works on the hosted address but not in your preview | Allowed-sites list, or a `file://` page | Preview over `http://localhost` with an empty list |
| A form shows "Add a phone number or email so we can reply." | The API's own check | Show it as it is; that's the fix the visitor needs |
| Upload says it can't find `index.html` | The home page isn't at the top of the zip, or there are several top folders | Zip from inside the site folder |
| The floating button is stuck inside a box | The builder put your code in a frame | Use the sitewide code setting, or an inline widget plus booking-page links |
| Custom domain shows an error page | DNS not pointed yet, or the domain isn't saved on the site | Run the DNS check in the dashboard. Changes can take up to a day. Tell the operator if it stays broken. |

## The starter kit

A complete small-business site that already does everything on this page. Plain HTML, CSS and JavaScript, no build step.

```bash
curl -fsSLO https://justagoodguy.us/docs/starter-site.zip
unzip starter-site.zip
cd starter-site
node tools/bake.mjs pk_the_business_key
```

`tools/bake.mjs` (Node 18 or later) puts the key in every page, swaps in the real booking page, writes the live services, prices and hours into the HTML, fills the JSON-LD opening hours, and saves the raw data to `tools/business.json`.

| File | What it is |
|---|---|
| `index.html` | Home: headline, Book / Call / Ask, live hours card with "Open now", top services, suggested questions, short about |
| `services.html` | Full live price list with Book buttons |
| `about.html` | The business's story |
| `contact.html` | Inline booking widget, call-back widget, message form through the API, address and hours |
| `privacy.html` | Privacy page that covers the chat assistant |
| `404.html` | Not-found page with root-relative links |
| `styles.css` | Mobile-first styles; colours and fonts are tokens at the top; light and dark |
| `app.js` | Live services and hours with fallback, the message form, the booking event hook |
| `favicon.svg`, `robots.txt`, `sitemap.xml` | Placeholders to replace |
| `README.md` | What to replace, how to preview, how to zip |

Then replace the sample business, re-theme the tokens, add the real logo and photos, and set the real address in `canonical`, `og:url`, the JSON-LD, `robots.txt` and `sitemap.xml`. The README has a `grep` command that must find no leftover sample text. Browse every file in the kit at [/docs/starter-site/](https://justagoodguy.us/docs/starter-site/README.md).

## A complete one-file page

The smallest page that does everything that matters: the widget, booking buttons with fallbacks, an inline booking widget and quote form, live services and hours over written content, and JSON-LD. Replace the sample business, the key and `your-business-slug`. You can see it at [/docs/examples/one-page.html](https://justagoodguy.us/docs/examples/one-page.html).

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Oak Lane Plumbing | Plumber in Springfield, IL</title>
<meta name="description" content="Leaks, drains and water heaters in Springfield, IL. See our services and book a visit online.">
<link rel="canonical" href="https://www.example.com/">
<meta property="og:type" content="website">
<meta property="og:title" content="Oak Lane Plumbing | Plumber in Springfield, IL">
<meta property="og:description" content="Leaks, drains and water heaters in Springfield, IL. Book a visit online.">
<meta property="og:url" content="https://www.example.com/">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230f5e7a'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;600;700&display=swap">
<style>
  :root { --brand: #0f5e7a; --brand-hover: #0b4a61; --ink: #13212b; --ink-2: #4b5a64; --line: #d8e0e5; --bg: #ffffff; --bg-2: #f2f6f8; --gg-color: #0f5e7a; color-scheme: light dark; }
  @media (prefers-color-scheme: dark) { :root { --brand: #7cc7e3; --brand-hover: #a3d9ec; --ink: #e8eef2; --ink-2: #b2c0c9; --line: #2b3b46; --bg: #0f1a21; --bg-2: #15242d; } }
  * { box-sizing: border-box; }
  body { margin: 0; background: var(--bg); color: var(--ink); font: 1.0625rem/1.55 'Instrument Sans', system-ui, sans-serif; }
  .wrap { max-width: 64rem; margin: 0 auto; padding: 0 1.25rem; }
  h1, h2, h3 { line-height: 1.15; margin: 0 0 .75rem; }
  h1 { font-size: clamp(2.25rem, 8vw, 3.5rem); }
  h2 { font-size: clamp(1.625rem, 5vw, 2.25rem); }
  h3 { font-size: 1.125rem; margin: 0; }
  p { margin: 0 0 1rem; }
  a { color: var(--brand); }
  :focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
  .skip { position: absolute; left: .75rem; top: -4rem; padding: .75rem 1rem; background: var(--bg); border: 2px solid var(--brand); }
  .skip:focus { top: .75rem; }
  header { padding: 1rem 0; border-bottom: 1px solid var(--line); }
  header .wrap { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
  .name { font-weight: 700; font-size: 1.25rem; color: var(--ink); text-decoration: none; }
  section { padding: 3rem 0; }
  .alt { background: var(--bg-2); }
  .lede { color: var(--ink-2); font-size: 1.1875rem; max-width: 36rem; }
  .actions { display: flex; flex-wrap: wrap; gap: .75rem; }
  .btn { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; min-height: 3rem; padding: .625rem 1.25rem; border: 2px solid var(--brand); border-radius: 10px; background: var(--brand); color: #fff; font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; }
  .btn:hover { background: var(--brand-hover); border-color: var(--brand-hover); }
  .btn.ghost { background: transparent; color: var(--brand); }
  .btn svg { width: 1.25em; height: 1.25em; flex: none; }
  @media (prefers-color-scheme: dark) { .btn { color: #0f1a21; } .btn.ghost { color: var(--brand); } }
  .services { list-style: none; margin: 0; padding: 0; display: grid; gap: .75rem; }
  .services li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .5rem 1rem; padding: 1rem; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); }
  .meta { margin: .25rem 0 0; color: var(--ink-2); }
  .hours { margin: 0; max-width: 26rem; }
  .hours div { display: flex; justify-content: space-between; padding: .5rem 0; border-bottom: 1px solid var(--line); }
  .hours dd { margin: 0; color: var(--ink-2); }
  .box { border: 1px solid var(--line); border-radius: 10px; padding: .5rem; min-height: 10rem; background: var(--bg); }
  footer { padding: 2rem 0 7rem; border-top: 1px solid var(--line); color: var(--ink-2); }
</style>
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Plumber",
  "name": "Oak Lane Plumbing",
  "url": "https://www.example.com/",
  "telephone": "+12175550188",
  "address": { "@type": "PostalAddress", "streetAddress": "40 Oak Ln", "addressLocality": "Springfield", "addressRegion": "IL", "postalCode": "62704", "addressCountry": "US" },
  "openingHoursSpecification": [
    { "@type": "OpeningHoursSpecification", "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], "opens": "08:00", "closes": "17:00" }
  ]
}
</script>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header>
  <div class="wrap">
    <a class="name" href="#main">Oak Lane Plumbing</a>
    <!-- data-gg-book opens the booking panel. The href is the fallback if the widget can't run. -->
    <a class="btn" href="https://justagoodguy.us/b/your-business-slug" data-gg-book="">Book a visit</a>
  </div>
</header>

<main id="main">
  <section>
    <div class="wrap">
      <h1>Leaks, drains and water heaters, fixed.</h1>
      <p class="lede">Plumbing for homes in Springfield and nearby towns. Book a visit online, call us, or ask a question.</p>
      <div class="actions">
        <a class="btn" href="https://justagoodguy.us/b/your-business-slug" data-gg-book="">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 10h18"/></svg>
          Book a visit
        </a>
        <a class="btn ghost" href="tel:+12175550188">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></svg>
          Call (217) 555-0188
        </a>
        <a class="btn ghost" href="#quote" data-gg-ask="Do you cover my area?">Do you cover my area?</a>
      </div>
    </div>
  </section>

  <section class="alt" aria-labelledby="services-title">
    <div class="wrap">
      <h2 id="services-title">Services</h2>
      <!-- Written from the API when the page was built; refreshed live by the script below. -->
      <ul class="services" id="services">
        <li><div><h3>Drain cleaning</h3><p class="meta">From $120</p></div><a class="btn ghost" href="https://justagoodguy.us/b/your-business-slug" data-gg-book="Drain cleaning" aria-label="Book Drain cleaning">Book</a></li>
        <li><div><h3>Water heater replacement</h3><p class="meta">By quote</p></div><a class="btn ghost" href="https://justagoodguy.us/b/your-business-slug" data-gg-book="Water heater replacement" aria-label="Get a quote: Water heater replacement">Get a quote</a></li>
      </ul>
    </div>
  </section>

  <section aria-labelledby="book-title">
    <div class="wrap">
      <h2 id="book-title">Book online</h2>
      <!-- The inline booking widget. The text inside shows until it renders, and stays if it can't. -->
      <div class="box" data-gg-widget="booking">
        <p>Loading the booking form. You can also <a href="https://justagoodguy.us/b/your-business-slug">book on our booking page</a>.</p>
      </div>
    </div>
  </section>

  <section class="alt" aria-labelledby="quote">
    <div class="wrap">
      <h2 id="quote">Ask for a quote</h2>
      <div class="box" data-gg-widget="contact">
        <p>Call us on <a href="tel:+12175550188">(217) 555-0188</a>.</p>
      </div>
    </div>
  </section>

  <section aria-labelledby="hours-title">
    <div class="wrap">
      <h2 id="hours-title">Hours</h2>
      <dl class="hours" id="hours">
        <div><dt>Monday to Friday</dt><dd>8:00 AM – 5:00 PM</dd></div>
        <div><dt>Saturday and Sunday</dt><dd>Closed</dd></div>
      </dl>
    </div>
  </section>
</main>

<footer>
  <div class="wrap">
    <p><strong>Oak Lane Plumbing</strong>, 40 Oak Ln, Springfield, IL 62704. <a href="tel:+12175550188">(217) 555-0188</a></p>
    <p>Booking and our chat assistant are run by Just a Good Guy. The assistant is an AI. It keeps your messages so we can follow up, and it doesn't use cookies.</p>
  </div>
</footer>

<!-- The widget: once per page, at the end of the body. -->
<script src="https://justagoodguy.us/w.js" data-key="pk_REPLACE_ME" async></script>
<script>
  // Queue stub: GoodGuy(...) calls made before the widget loads run once it's ready.
  window.GoodGuy = window.GoodGuy || function () { (GoodGuy.q = GoodGuy.q || []).push(arguments); };

  // Live services and hours. The written content above stays if this fails.
  (function () {
    var tag = document.querySelector('script[src*="/w.js"][data-key]');
    var key = tag.getAttribute('data-key');
    var api = new URL(tag.src).origin + '/api/public';
    if (/REPLACE/.test(key)) return;
    function get(path) {
      return fetch(api + path + (path.indexOf('?') < 0 ? '?' : '&') + 'key=' + encodeURIComponent(key), { credentials: 'omit' })
        .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); });
    }
    function el(tagName, text, cls) {
      var e = document.createElement(tagName);
      if (text) e.textContent = text;
      if (cls) e.className = cls;
      return e;
    }
    Promise.all([get('/config'), get('/services')]).then(function (r) {
      var cfg = r[0];
      var services = r[1].services;
      if (services.length) {
        var list = document.getElementById('services');
        list.textContent = '';
        services.forEach(function (s) {
          var li = el('li');
          var info = el('div');
          info.appendChild(el('h3', s.name));
          info.appendChild(el('p', s.priceLabel || (s.kind === 'quote' ? 'By quote' : ''), 'meta'));
          var a = el('a', s.kind === 'quote' ? 'Get a quote' : 'Book', 'btn ghost');
          a.href = cfg.business.bookingPageUrl;
          a.setAttribute('data-gg-book', s.id);
          a.setAttribute('aria-label', (s.kind === 'quote' ? 'Get a quote: ' : 'Book ') + s.name);
          li.appendChild(info);
          li.appendChild(a);
          list.appendChild(li);
        });
      }
      var days = cfg.hours.booking.filter(function (d) { return d.ranges.length; });
      if (days.length) {
        var dl = document.getElementById('hours');
        dl.textContent = '';
        cfg.hours.booking.forEach(function (d) {
          var row = el('div');
          row.appendChild(el('dt', d.label));
          row.appendChild(el('dd', d.ranges.length ? d.ranges.map(function (x) { return x.label; }).join(', ') : 'Closed'));
          dl.appendChild(row);
        });
      }
    }).catch(function (err) {
      console.warn('[site] Live data unavailable, keeping the written content:', err.message);
    });
  })();

  // A booking was made in the widget or in chat.
  GoodGuy('on', 'booking.created', function (b) {
    console.log('Booked', b.serviceName, b.whenLabel, b.status);
  });
</script>
</body>
</html>
```
