Skip to content
Docs menu

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.

The machine-readable spec at /api/v1/openapi.json is generated from the same schemas the server validates with. If this page and the spec disagree, the spec is the source of truth.

Basics

API basics
Value
Base URLhttps://teemarshal.com/api/v1
AuthenticationAuthorization: Bearer <key>
FormatJSON requests and responses, UTF-8
Rate limit600 requests per minute per API key
Scopesread (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

Read endpoints
Method and pathReturns
GET /courses/meThe course this key belongs to
GET /teesTees (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/:idOne 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/:idOne golfer
GET /ratesThe rate grid
GET /reports/daily?from=&to=Daily rounds, revenue, and utilization

Write (scope: write)

Write endpoints
Method and pathWhat it does
POST /bookingsCreate and confirm a booking. Recorded with source api.
PATCH /bookings/:idMove a booking to another tee time (slot_id), change the number of players, or edit notes
DELETE /bookings/:idCancel a booking. ?refund=policy (default), full, or none.
POST /golfersCreate a golfer. 409 if the email or phone already exists.
PATCH /golfers/:idUpdate a golfer
POST /blocksBlock 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 carry local_date and local_time in the course's time zone, so you never have to convert. The date, from, and to query parameters are calendar dates (YYYY-MM-DD) in that time zone, which is returned by GET /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."
  }
}
HTTP status codes
StatusMeaning
400The body is not valid JSON
401Missing, invalid, or revoked API key
403The key does not have the scope for this endpoint
404No such record for this course
409The change conflicts with the sheet, for example the tee time is full (slot_full) or blocked
422The request failed validation. The body says which field.
429Rate limit exceeded
5xxSomething 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", "...": "..." }
  }
}
Webhook events
EventSent when
booking.createdA booking is created and confirmed. Sent together with booking.confirmed.
booking.confirmedA booking is confirmed (paid, or accepted as pay at course)
booking.movedA booking moves to a different tee time
booking.cancelledA booking is cancelled
booking.checked_inPlayers are checked in
booking.no_showA booking is marked as a no-show
golfer.createdA golfer record is created
golfer.updatedA golfer record changes
payment.succeededA payment succeeds
payment.refundedA payment is fully or partly refunded

Delivery and retries

  • Respond with any 2xx status 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", 200
The most common cause of a signature mismatch is a framework that parses and re-serializes the JSON before your code sees it. Compute the HMAC over the raw bytes, as both samples do.

OpenAPI 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.