DocsAPI overview
API overview
A REST API and signed webhooks for a course's own data. Included on every plan, because your data is yours.
Basics
| Value | |
|---|---|
| Base URL | https://teemarshal.com/api/v1 |
| Authentication | Authorization: Bearer <key> |
| Format | JSON requests and responses, UTF-8 |
| Rate limit | 600 requests per minute per API key |
| Scopes | read (default) and write |
| Spec | /api/v1/openapi.json |
Authentication
Create a key under Settings, Integrations, API keys. Keys look like tm_live_ followed by 32 characters. The full key is shown once, at creation. We store only a hash and the first 12 characters, which appear in the list so you can tell keys apart. Send the key as a bearer token on every request:
curl "https://teemarshal.com/api/v1/availability?date=2027-05-15&players=4&holes=18" \
-H "Authorization: Bearer $TEEMARSHAL_API_KEY"A key belongs to one course, and every request is scoped to that course. There is no course id parameter. Keep keys on a server: never ship one in a website or mobile app. Revoke a key from the same settings page and it stops working immediately.
Scopes
- read allows every GET endpoint. New keys have this scope only.
- write also allows POST, PATCH, and DELETE. A request to a write endpoint with a read-only key is rejected with
403.
Rate limit
Each key may make 600 requests per minute. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (Unix seconds). Beyond the limit, requests receive 429 with a Retry-After header in seconds. If you are polling for changes, use webhooks or the updated_since filter instead.
Endpoints
Read
| Method and path | Returns |
|---|---|
GET /courses/me | The course this key belongs to |
GET /tees | Tees (for example Front 9, Back 9) |
GET /slots?date= | Every tee time on a date, with status and capacity |
GET /availability?date=&players=&holes= | Bookable tee times for a party, with prices. The staff view by default; add online=true to apply the rules golfers see on your booking site. |
GET /bookings?from=&to=&status=&updated_since= | Bookings by play date, status, or last change. Paginated. |
GET /bookings/:id | One booking with its players |
GET /golfers?updated_since=&email=&phone= | Golfers, optionally only those changed since a timestamp, or matched by email or phone. Paginated. |
GET /golfers/:id | One golfer |
GET /rates | The rate grid |
GET /reports/daily?from=&to= | Daily rounds, revenue, and utilization |
Write (scope: write)
| Method and path | What it does |
|---|---|
POST /bookings | Create and confirm a booking. Recorded with source api. |
PATCH /bookings/:id | Move a booking to another tee time (slot_id), change the number of players, or edit notes |
DELETE /bookings/:id | Cancel a booking. ?refund=policy (default), full, or none. |
POST /golfers | Create a golfer. 409 if the email or phone already exists. |
PATCH /golfers/:id | Update a golfer |
POST /blocks | Block a range of tee times |
curl -X POST "https://teemarshal.com/api/v1/bookings" \
-H "Authorization: Bearer $TEEMARSHAL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2b9e-0c0d-4c8e-9a51-2f6d7e8a9b10" \
-d '{
"slot_id": "SLOT_ID_FROM_AVAILABILITY",
"holes": 18,
"players": 2,
"cart": true,
"rate_classes": ["public", "senior"],
"golfer": { "first_name": "Sam", "last_name": "Rivera", "phone": "+15035550123" }
}'You never send a price. The server prices every booking from the course's rates at the moment it is created and returns the totals. rate_classes is one rate class code per player (see GET /rates); leave it out to price everyone at the default. Bookings made through the API are confirmed immediately as pay at course.
Pagination
GET /bookings and GET /golfers return up to limit records (default 50, at most 200) and a next_cursor. Pass it back as cursor to get the next page. A null next_cursor means you have everything.
Safe retries
Send an Idempotency-Key header (any unique string, such as a UUID) on POST requests. If the same key arrives again within 24 hours with the same body, we return the first response with Idempotent-Replayed: true instead of doing the work twice. The same key with a different body is rejected with 409.
Texting consent
You cannot set a golfer's sms_opt_in to true on its own. Send a consent object alongside it with a source (where the golfer agreed), a timestamp (when), and optionally the text they saw. We keep it in the course's consent log. A golfer who replied STOP cannot be opted back in through the API.
Conventions
- Money is in integer cents, in USD.
- Timestamps are ISO 8601 with the course's UTC offset, for example
2027-05-15T08:10:00-07:00. Bookings and tee times also carrylocal_dateandlocal_timein the course's time zone, so you never have to convert. Thedate,from, andtoquery parameters are calendar dates (YYYY-MM-DD) in that time zone, which is returned byGET /courses/me. - Phone numbers are E.164, for example
+15035550123. - Ids are UUIDs.
Errors
Every error has the same JSON shape. code is stable and safe to branch on; message is for people. Validation errors add details.fields with a message per field. Every response has an X-Request-Id header: include it if you write to support.
{
"error": {
"code": "slot_full",
"message": "Only 1 spot is left at that time."
}
}| Status | Meaning |
|---|---|
| 400 | The body is not valid JSON |
| 401 | Missing, invalid, or revoked API key |
| 403 | The key does not have the scope for this endpoint |
| 404 | No such record for this course |
| 409 | The change conflicts with the sheet, for example the tee time is full (slot_full) or blocked |
| 422 | The request failed validation. The body says which field. |
| 429 | Rate limit exceeded |
| 5xx | Something went wrong on our side. Safe to retry GET requests. |
Webhooks
Add an endpoint under Settings, Integrations, Webhooks, and choose the events it should receive. Each endpoint has its own secret, shown when you create it and available again from the same page. We send an HTTPS POST with a JSON body for each event. The data object holds the booking, golfer, or payment in the same shape the API returns.
{
"id": "evt_3f9a1c0b7d2e4f6a8b9c0d1e2f3a4b5c",
"type": "booking.confirmed",
"created": 1810000000,
"course_id": "c0000000-0000-4000-8000-000000000001",
"data": {
"booking": { "id": "...", "confirmation_code": "PV7K2M", "status": "confirmed", "...": "..." }
}
}| Event | Sent when |
|---|---|
booking.created | A booking is created and confirmed. Sent together with booking.confirmed. |
booking.confirmed | A booking is confirmed (paid, or accepted as pay at course) |
booking.moved | A booking moves to a different tee time |
booking.cancelled | A booking is cancelled |
booking.checked_in | Players are checked in |
booking.no_show | A booking is marked as a no-show |
golfer.created | A golfer record is created |
golfer.updated | A golfer record changes |
payment.succeeded | A payment succeeds |
payment.refunded | A payment is fully or partly refunded |
Delivery and retries
- Respond with any
2xxstatus within 10 seconds to acknowledge an event. - Anything else, or a timeout, counts as a failure. We retry with increasing delays (30 seconds, 2 minutes, 10 minutes, then longer) for up to 24 hours, then stop. Redirects are not followed.
- If every delivery to an endpoint fails for 3 days, we turn the endpoint off and email the course owner. Turn it back on from Settings, Integrations, where you can also see recent deliveries, re-send one, and send a test event.
- Because of retries, the same event can arrive more than once and events can arrive out of order. Make your handler safe to run twice, and fetch the current record from the API if order matters.
Verifying signatures
Every delivery carries an X-TeeMarshal-Signature header of the form t=<unix seconds>,v1=<signature>. The signature is the hex-encoded HMAC-SHA256 of the timestamp, a period, and the raw request body (t.body), computed with your endpoint's secret. To verify a delivery, compute the same value over the exact bytes you received, compare the two with a constant-time comparison, and reject timestamps more than five minutes old so a captured request cannot be replayed. Verify before you parse the JSON.
Node:
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.TEEMARSHAL_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
// Header: t=<unix seconds>,v1=<hex signature>
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", secret).update(parts.t + "." + rawBody).digest("hex");
const a = Buffer.from(parts.v1, "utf8");
const b = Buffer.from(expected, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Use the raw body. Do not parse JSON before verifying.
app.post("/webhooks/teemarshal", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verify(rawBody, req.get("X-TeeMarshal-Signature") ?? "")) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(rawBody);
// event.id is the same on every retry: use it to skip events you already handled.
// Handle event.type, for example "booking.confirmed".
// Respond quickly and do slow work in the background.
res.status(200).send("ok");
});
app.listen(3000);Python:
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["TEEMARSHAL_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
def verify(raw: bytes, header: str) -> bool:
# Header: t=<unix seconds>,v1=<hex signature>
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
if "t" not in parts or "v1" not in parts:
return False
if abs(time.time() - int(parts["t"])) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, parts["t"].encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts["v1"], expected)
@app.post("/webhooks/teemarshal")
def teemarshal_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify(raw, request.headers.get("X-TeeMarshal-Signature", "")):
abort(401)
event = json.loads(raw)
# event["id"] is the same on every retry: use it to skip events you already handled.
# Handle event["type"], for example "booking.confirmed".
return "ok", 200OpenAPI spec
Download the spec at /api/v1/openapi.json to generate a client or to explore the API in a tool such as Postman or Insomnia.
Help
Building an integration for a course? Email support@teemarshal.com and tell us what you are connecting.