Ironed Out

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.

ScopeLets you
outings:readRead outings and tee sheets.
outings:writeCreate and change outings, seat and release golfers, manage invite links.
players:readSee golfer names on tee sheets (otherwise spots are just open or taken).
webhooks:manageAdd, 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>

Conventions

Endpoints

Base URL https://ironed-out-alpha.vercel.app/api/v1. Full schemas: openapi.json (load it in any OpenAPI viewer or client generator).

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.

EventWhen
outing.createdA new outing (from the app or the API).
outing.updatedNote or price changed. data.fields says which.
outing.lockedThe organizer locked the tee sheet.
outing.unlockedThe tee sheet is open again.
outing.fullThe last open spot was just claimed.
slot.claimedA golfer took a spot (data.guest_count guests came along).
slot.releasedA golfer dropped out (reason dropped_out) or was taken off (reason removed).
tee_time.addedA tee time was added after the last one.
tee_time.removedAn empty tee time was deleted.
tee_time.capacity_changedA 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… } }
codeStatusMeaning
missing_token / invalid_token401No bearer token, or it expired (tokens last 15 minutes). Get a new one.
insufficient_scope403The token doesn’t carry the scope this endpoint needs.
not_found404No such id in your tenant. Other tenants’ data is never visible.
invalid_input422Validation failed. errors maps each field to a message.
invalid_json400The body isn’t JSON.
idempotency_key_required400POST requests need an Idempotency-Key header.
idempotency_mismatch422The key was already used with a different request body or path.
idempotency_in_flight409The first request with this key is still running. Retry in a moment.
slot_taken409Someone got there first. outing holds the current tee sheet.
already_in409That golfer already has a spot in this outing.
no_room409Not enough open spots for the guests.
locked409The outing is locked; unlock it first.
conflict409Any other state conflict (e.g. deleting a tee time with golfers in it).
rate_limited429Over the limit. Wait Retry-After seconds.
internal_error500Our fault. Retry with the same Idempotency-Key; quote X-Request-Id if it persists.