Developers
REST API
Read and create bookings, check availability and look up customers from your own server, with a secret key.
For developersOn this page
Overview
| Base URL | https://justagoodguy.us/api/v1 |
| Auth | Authorization: Bearer sk_... |
| Format | JSON in, JSON out. Send Content-Type: application/json with every body. |
| Times | ISO 8601. Responses use UTC, like 2026-11-03T19:00:00.000Z. |
| Rate limit | 600 requests per minute per key |
| Machine-readable spec | /api/openapi.json, browsable in the API reference |
The OpenAPI document is generated from the same schemas the server uses to check requests, so it always matches what the server accepts.
Authentication
Create a secret key in More → Developers (only the business owner can). It starts with sk_ and is shown once, so copy it somewhere safe. We only keep a fingerprint of it.
curl https://justagoodguy.us/api/v1/services \
-H "Authorization: Bearer sk_your_secret_key"- Secret keys can read and change your bookings and customers. Keep them on your server. Never put one in a web page or an app.
- For the browser, use your publishable key (
pk_...) with the widget or the public API instead. - Revoke a key in More → Developers. It stops working straight away. The dashboard also shows when each key was last used.
Errors
Errors always come back as JSON with a plain-English message and a real HTTP status:
{ "error": "Sorry, that time was just taken or isn’t available. Please pick another time." }| Status | Meaning |
|---|---|
400 | Something in the request is missing or wrong. The message says what, for example Please check the “service_id” field. |
401 | No key, not a secret key, or a revoked key |
404 | That booking, customer or service doesn't exist in your business |
409 | The time isn't available (taken, outside hours, too soon or too far ahead) |
429 | Over the rate limit. Wait for the Retry-After header (60 seconds) and try again. |
500 | Something broke on our side. It's safe to retry reads; check before retrying a booking. |
The messages are written for people, so you can show them to your users as they are.
Rate limits
Each secret key can make 600 requests per minute. The count resets every minute. Requests over the limit get 429 with { "error": "Rate limit: 600 requests per minute." } and a Retry-After: 60 header.
Booking statuses
status | Meaning |
|---|---|
pending_approval | Waiting for the business to approve it |
confirmed | Booked |
completed | The business marked it done |
cancelled | Cancelled by the customer, the business or the API |
no_show | The customer didn't turn up |
source says where the booking came from: web_chat, widget, booking_page, phone, sms, dashboard, assistant or api.
The Booking object
{
"id": "bkg_8f3k2m9q1x7w4r6t",
"status": "confirmed",
"source": "api",
"start": "2026-11-03T19:00:00.000Z",
"end": "2026-11-03T19:45:00.000Z",
"timezone": "America/New_York",
"service": { "id": "svc_2n4p6r8t0v1x3z5b", "name": "Haircut" },
"staff": { "id": "stf_9h7f5d3b1z0x8v6t", "name": "Dana" },
"customer": { "id": "cus_4k6m8p0r2t4v6x8z", "name": "Sam Lee", "phone": "+15555550123", "email": null },
"priceCents": 4500,
"notes": null
}staff is null when no person is attached. Money is in cents of the business's currency. priceCents is null when the service has no set price.
Services and team
List services
GET /v1/services returns your active services.
curl https://justagoodguy.us/api/v1/services -H "Authorization: Bearer $JAGG_KEY"{
"data": [
{ "id": "svc_2n4p6r8t0v1x3z5b", "name": "Haircut", "kind": "appointment", "durationMin": 45, "priceCents": 4500, "description": null }
]
}kind is appointment, visit, class or quote. See service kinds.
List team members
GET /v1/staff returns your active team members.
curl https://justagoodguy.us/api/v1/staff -H "Authorization: Bearer $JAGG_KEY"{ "data": [{ "id": "stf_9h7f5d3b1z0x8v6t", "name": "Dana", "color": "#2563eb" }] }Availability
Open times for a service
GET /v1/availability
| Query | Required | Notes |
|---|---|---|
service_id | yes | |
staff_id | no | Only this person's times. Without it, a time is open if anyone who does the service is free. |
from | no | ISO date-time to start from. Defaults to now. Times in the past are never returned. |
days | no | How many days to look ahead, 1 to 31. Default 7. |
curl "https://justagoodguy.us/api/v1/availability?service_id=svc_2n4p6r8t0v1x3z5b&days=3" \
-H "Authorization: Bearer $JAGG_KEY"{
"timezone": "America/New_York",
"data": [
{
"date": "2026-11-03",
"label": "Tuesday, Nov 3",
"slots": [
{ "start": "2026-11-03T19:00:00.000Z", "label": "2:00 PM", "staffIds": ["stf_9h7f5d3b1z0x8v6t"] }
]
}
]
}Days are grouped by the business's local date, and label is ready to show. Days with no open times are left out. The business's minimum notice and how-far-ahead rules apply, and quote-type services never have open times.
Bookings
List bookings
GET /v1/bookings
| Query | Notes |
|---|---|
from | ISO date-time. Default: 24 hours ago. |
to | ISO date-time. Default: 30 days after from. |
status | Comma-separated statuses, for example confirmed,pending_approval. Default: all. |
Returns bookings that overlap the range, oldest first.
curl "https://justagoodguy.us/api/v1/bookings?from=2026-11-01T00:00:00Z&to=2026-11-08T00:00:00Z&status=confirmed" \
-H "Authorization: Bearer $JAGG_KEY"{ "data": [ { "id": "bkg_8f3k2m9q1x7w4r6t", "status": "confirmed", "...": "a Booking object" } ] }Create a booking
POST /v1/bookings
| Field | Required | Notes |
|---|---|---|
service_id | yes | |
start | yes | An exact start from availability. Send it back as you got it. |
staff_id | no | Book with this person. Without it, the booking goes to whoever is free with the fewest bookings that day. |
customer.name | yes | |
customer.phone or customer.email | one of them | Used to match an existing customer and to send the confirmation |
notes | no | Up to 1,000 characters, shown to the business |
curl https://justagoodguy.us/api/v1/bookings \
-H "Authorization: Bearer $JAGG_KEY" \
-H "Content-Type: application/json" \
-d '{
"service_id": "svc_2n4p6r8t0v1x3z5b",
"start": "2026-11-03T19:00:00.000Z",
"customer": { "name": "Sam Lee", "phone": "+15555550123" },
"notes": "Booked through our members portal"
}'Returns 201 with the Booking.
What happens next is the same as for any other booking:
- The customer is matched by phone or email, or added as a new customer.
- They get a confirmation by text and email (or "Request received" if the business approves each booking).
- The business gets a "New booking" alert, and the
booking.createdwebhook fires. - If the service needs approval,
statusispending_approval.
There are no idempotency keys. If a create request times out, list bookings for that time before retrying. Retrying a booking that did go through usually fails with 409, because the time is now taken.
Get a booking
GET /v1/bookings/{id}
curl https://justagoodguy.us/api/v1/bookings/bkg_8f3k2m9q1x7w4r6t -H "Authorization: Bearer $JAGG_KEY"Move a booking
POST /v1/bookings/{id}/reschedule
| Field | Required | Notes |
|---|---|---|
start | yes | An exact open start from availability |
staff_id | no | Move to this person. Without it, the same person is kept if they're free, otherwise anyone free. |
notify | no | Text and email the customer about the change. Default true. |
curl https://justagoodguy.us/api/v1/bookings/bkg_8f3k2m9q1x7w4r6t/reschedule \
-H "Authorization: Bearer $JAGG_KEY" \
-H "Content-Type: application/json" \
-d '{ "start": "2026-11-04T15:30:00.000Z" }'Only pending_approval and confirmed bookings can be moved (others get 400). Reminders are rescheduled for the new time, and the booking.rescheduled webhook fires.
Cancel a booking
POST /v1/bookings/{id}/cancel
| Field | Required | Notes |
|---|---|---|
reason | no | Saved on the booking |
notify | no | Tell the customer. Default true. |
curl https://justagoodguy.us/api/v1/bookings/bkg_8f3k2m9q1x7w4r6t/cancel \
-H "Authorization: Bearer $JAGG_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer called to cancel" }'Cancelling an already-cancelled booking returns it unchanged. Pending reminders are stopped and the booking.cancelled webhook fires.
Customers
The Customer object
{ "id": "cus_4k6m8p0r2t4v6x8z", "name": "Sam Lee", "phone": "+15555550123", "email": null, "visitCount": 3, "tags": ["regular"] }List or search customers
GET /v1/customers
| Query | Notes |
|---|---|
q | Part of a name, for example sam |
limit | 1 to 500. Default 100. |
Most recently updated first.
curl "https://justagoodguy.us/api/v1/customers?q=sam&limit=20" -H "Authorization: Bearer $JAGG_KEY"Create or update a customer
POST /v1/customers with name and a phone or email. If a customer with that phone or email exists, it's updated instead of duplicated:
- a missing phone or email is filled in
- an existing name is kept (a different spelling is saved as an alias)
curl https://justagoodguy.us/api/v1/customers \
-H "Authorization: Bearer $JAGG_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Sam Lee", "phone": "+15555550123", "email": "[email protected]" }'Returns 201 with the Customer. Phone numbers are stored in international format (+1...); a 10-digit number is read as a US or Canadian number.
Get a customer
GET /v1/customers/{id}
curl https://justagoodguy.us/api/v1/customers/cus_4k6m8p0r2t4v6x8z -H "Authorization: Bearer $JAGG_KEY"Requests
Open call-backs
GET /v1/callbacks returns call-back requests that are still open.
curl https://justagoodguy.us/api/v1/callbacks -H "Authorization: Bearer $JAGG_KEY"{
"data": [
{ "id": "cbk_3j5l7n9q1s3u5w7y", "customerId": "cus_4k6m8p0r2t4v6x8z", "windowStart": "2026-11-03T21:00:00.000Z", "windowEnd": "2026-11-03T23:00:00.000Z", "reason": "Quote for a new water heater", "status": "open" }
]
}Messages and quote requests
GET /v1/leads returns messages and quote requests with status new or contacted.
curl https://justagoodguy.us/api/v1/leads -H "Authorization: Bearer $JAGG_KEY"{
"data": [
{ "id": "led_6c8e0g2j4l6n8q0s", "customerId": "cus_4k6m8p0r2t4v6x8z", "kind": "quote", "details": "Leaking tap in the kitchen", "status": "new", "createdAt": "2026-11-02T14:12:00.000Z" }
]
}kind is quote (taken by the receptionist) or message (sent from a contact form).
A complete example
Find the next open time for a service and book it, in Node 18 or later:
const API = 'https://justagoodguy.us/api/v1';
const headers = { Authorization: `Bearer ${process.env.JAGG_KEY}`, 'Content-Type': 'application/json' };
async function call(path, init) {
const res = await fetch(API + path, { headers, ...init });
const body = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${body.error}`);
return body;
}
const { data: services } = await call('/services');
const haircut = services.find((s) => s.name === 'Haircut');
const { data: days } = await call(`/availability?service_id=${haircut.id}&days=7`);
const first = days[0]?.slots[0];
if (!first) throw new Error('Nothing open this week');
const booking = await call('/bookings', {
method: 'POST',
body: JSON.stringify({ service_id: haircut.id, start: first.start, customer: { name: 'Sam Lee', phone: '+15555550123' } }),
});
console.log(booking.status, booking.start);