Partner API
For tee-time booking platforms: when a golfer books 2 or 3 tee times, create the outing for them, hand back one invite link for the group chat, and hear about every claim and drop-out.
Access
Each partner gets its own tenant and one or more API clients. Your outings, golfers and webhooks are isolated from everyone else’s at the database level (Postgres row-level security). Ask us for a client; you’ll get a client_id and a client_secret once. Keep the secret on your servers: the API has no CORS, so it can’t be called from a browser.
| Scope | Lets you |
|---|---|
outings:read | Read outings and tee sheets. |
outings:write | Create and change outings, seat and release golfers, manage invite links. |
players:read | See golfer names on tee sheets (otherwise spots are just open or taken). |
webhooks:manage | Add, change and remove webhook endpoints. |
Tokens
OAuth 2.0 client credentials. Tokens are JWTs that last 15 minutes; ask for a subset of your scopes with scope. Revoking a client stops its tokens immediately.
curl -s https://ironed-out-alpha.vercel.app/api/v1/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials \
-d scope="outings:read outings:write players:read"
{ "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 900, "scope": "…" }Quickstart
Find the course, then create the outing from the booking. The organizer takes the first spot.
curl -s "https://ironed-out-alpha.vercel.app/api/v1/courses?query=pleasant" -H "Authorization: Bearer $TOKEN"
curl -s https://ironed-out-alpha.vercel.app/api/v1/outings \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"course_id": "<id from /courses>",
"play_date": "2026-10-17",
"tee_times": { "first": "07:40", "count": 3, "players_each": 4, "interval_minutes": 10 },
"price_cents": 4500,
"external_ref": "RES-88231",
"organizer": { "name": "Mike Brennan", "phone": "410-555-0142" },
"players": [{ "name": "Dave K" }]
}'The response is the outing with its tee sheet and invite_url. Show that link to the golfer (“Invite your group”): friends open it, pick a spot and confirm their number by text. No account needed.
“Invite your group” button
Rather than creating every outing up front, put a button on your booking confirmation page. Nothing is created until the golfer taps it: they land on a short Ironed Out page showing their booking, tap Make the invite link, and get the link to drop in the group chat. The outing is created exactly once, as if you had called POST /outings, so your webhooks fire as usual. Try the demo.
1. On your server, send the same body as POST /outings (plus an optional expires_in, up to 24 hours) when you render the confirmation page:
curl -s https://ironed-out-alpha.vercel.app/api/v1/widget-tokens \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: RES-88231-widget" \
-d '{ "course_id": "…", "play_date": "2026-10-17",
"tee_times": { "first": "07:40", "count": 3 },
"external_ref": "RES-88231", "organizer": { "name": "Mike Brennan" } }'
{ "token": "v1.…", "url": "https://ironed-out-alpha.vercel.app/w/v1.…", "expires_at": "…" }2. Put the URL in a link and load the script:
<a href="https://ironed-out-alpha.vercel.app/w/v1.…" data-ironed-out>Invite your group</a> <script src="https://ironed-out-alpha.vercel.app/widget/v1.js" async></script>
- Without the script it’s still a working link. With it, the link becomes a styled button (in a shadow root, so your CSS and ours stay apart) that opens in a new tab.
- Works under a strict Content Security Policy: allow
ironed-out-alpha.vercel.appinscript-src. The script sets no cookies and makes no requests. - The token is the booking, encrypted, so names and numbers in it can’t be read from your page. It only works while your client is active and before it expires. Optional
data-ironed-out-subtitleadds a second line under the label. - Buttons added or re-rendered later (single-page apps, React hydration) are picked up automatically;
window.IronedOut.mount()forces a pass.
Conventions
- JSON in and out, snake_case fields, ISO-8601 timestamps in UTC. Tee times are created in the course’s local time.
- Every
POSTneeds anIdempotency-Keyheader (any unique string up to 255 characters). Retrying with the same key replays the first response for 24 hours and addsIdempotent-Replayed: true. - Lists use cursors: pass
next_cursorback as?cursor=until it’s null. - Each client gets 300 requests per 60 seconds. Responses carry
RateLimit-Limit,RateLimit-RemainingandRateLimit-Reset; a 429 carriesRetry-After. - Send an
X-Request-Idto trace a call; we echo it (or make one up) on every response. - Golfer phone numbers you send are used to match the same person across outings, but they aren’t verified, so we don’t text them until the golfer confirms the number on the invite page. Phone numbers are never returned.
- Versioned by URL. Breaking changes get a new version; fields may be added at any time.
Endpoints
Base URL https://ironed-out-alpha.vercel.app/api/v1. Full schemas: openapi.json (load it in any OpenAPI viewer or client generator).
- POST
/oauth/tokenGet an access token (OAuth 2.0 client credentials) - GET
/coursesSearch courses - GET
/outingsList outings · outings:read - POST
/outingsCreate an outing with tee times · outings:write - GET
/outings/{id}Get an outing and its tee sheet · outings:read - PATCH
/outings/{id}Update note or price, lock or unlock · outings:write - POST
/outings/{id}/tee-timesAdd a tee time after the last one · outings:write - PATCH
/tee-times/{id}Change the number of spots (2–5) · outings:write - DELETE
/tee-times/{id}Delete an empty tee time · outings:write - POST
/slots/{id}/claimSeat a golfer (and guests) in a spot · outings:write - POST
/slots/{id}/releaseFree a spot (a golfer and their guests, or one guest) · outings:write - POST
/outings/{id}/invite-linksCreate another invite link (optionally expiring) · outings:write - DELETE
/invite-links/{id}Revoke an invite link · outings:write - POST
/widget-tokensMake an "Invite your group" button for a booking · outings:write - GET
/webhooksList webhook endpoints · webhooks:manage - POST
/webhooksAdd a webhook endpoint · webhooks:manage - GET
/webhooks/{id}Get a webhook endpoint · webhooks:manage - PATCH
/webhooks/{id}Change URL, events, description, or pause it · webhooks:manage - DELETE
/webhooks/{id}Delete a webhook endpoint · webhooks:manage - GET
/webhooks/{id}/deliveriesRecent deliveries to an endpoint · webhooks:manage
Webhooks
Register an https endpoint with POST /webhooks and the events you want. We POST JSON within a few minutes of each change; any 2xx is success. Failures are retried with exponential backoff (1 minute doubling to an hour) for 24 hours. GET /webhooks/:id/deliveries shows recent attempts.
| Event | When |
|---|---|
outing.created | A new outing (from the app or the API). |
outing.updated | Note or price changed. data.fields says which. |
outing.locked | The organizer locked the tee sheet. |
outing.unlocked | The tee sheet is open again. |
outing.full | The last open spot was just claimed. |
slot.claimed | A golfer took a spot (data.guest_count guests came along). |
slot.released | A golfer dropped out (reason dropped_out) or was taken off (reason removed). |
tee_time.added | A tee time was added after the last one. |
tee_time.removed | An empty tee time was deleted. |
tee_time.capacity_changed | A tee time gained or lost spots (data.from → data.to). |
POST /your/endpoint
Ironed-Signature: t=1791200000,v1=5f2c…
Ironed-Event: slot.claimed
Ironed-Delivery: 0199b1c2-…
{
"id": "0199b1c2-…", // unique per delivery: dedupe on this
"type": "slot.claimed",
"api_version": "2026-10-01",
"created_at": "2026-10-04T14:03:11.000Z",
"data": {
"outing_id": "…", "external_ref": "RES-88231",
"slot_id": "…", "starts_at": "2026-10-17T11:50:00.000Z",
"player": { "id": "…", "name": "Jen Park" },
"guest_count": 1, "guest_slot_ids": ["…"]
}
}Verify every delivery: compute HMAC-SHA256 of `${t}.${rawBody}` with your endpoint’s signing secret and compare it to v1 in constant time. Reject timestamps more than 5 minutes old, and ignore an id you’ve already processed, so a captured request can’t be replayed.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, rawBody, header) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
return expected.length === parts.v1?.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Errors
Errors are application/problem+json (RFC 9457) with a stable code. The token endpoint uses OAuth’s own format instead ({ error, error_description }).
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{ "type": "https://ironed-out-alpha.vercel.app/developers#error-slot_taken", "title": "Conflict", "status": 409,
"detail": "Someone just grabbed that spot. Pick another one.", "code": "slot_taken",
"outing": { …current tee sheet… } }| code | Status | Meaning |
|---|---|---|
missing_token / invalid_token | 401 | No bearer token, or it expired (tokens last 15 minutes). Get a new one. |
insufficient_scope | 403 | The token doesn’t carry the scope this endpoint needs. |
not_found | 404 | No such id in your tenant. Other tenants’ data is never visible. |
invalid_input | 422 | Validation failed. errors maps each field to a message. |
invalid_json | 400 | The body isn’t JSON. |
idempotency_key_required | 400 | POST requests need an Idempotency-Key header. |
idempotency_mismatch | 422 | The key was already used with a different request body or path. |
idempotency_in_flight | 409 | The first request with this key is still running. Retry in a moment. |
slot_taken | 409 | Someone got there first. outing holds the current tee sheet. |
already_in | 409 | That golfer already has a spot in this outing. |
no_room | 409 | Not enough open spots for the guests. |
locked | 409 | The outing is locked; unlock it first. |
conflict | 409 | Any other state conflict (e.g. deleting a tee time with golfers in it). |
rate_limited | 429 | Over the limit. Wait Retry-After seconds. |
internal_error | 500 | Our fault. Retry with the same Idempotency-Key; quote X-Request-Id if it persists. |