VTee API
A REST API for building on top of your VTee business — pull your booking calendar into home-automation dashboards, let an AI call agent check availability and book bays, or sync reservations into your own tools. JSON in, JSON out, scoped to your business by an API key. Building a venue's website instead? The website widgets need no key at all.
Getting started
Every request is scoped to a single VTee business by its API key — there is no business ID in the URL. To get a key for your integration (an AI call agent, a Home Assistant setup, a partner app), fill in the request form below. Keys are issued per integration, shown once at creation, and can be rotated or revoked at any time without affecting your other integrations.
curl https://vteegolf.com/api/v1/business/info \ -H "Authorization: Bearer vtk_your_api_key"
Website widgets
For designers building a venue's site on Squarespace, Wix, WordPress or plain HTML: VTee pieces you paste in as two lines of HTML. No API key. The venue copies each snippet, already filled in, from Admin → Website → Website Widgets. The examples below use your-venue where the venue's VTee slug goes (the part after vteegolf.com/ in their booking link).
Every widget loads through the same script, https://vteegolf.com/embed.js, chosen by data-vtee-widget. Each one is an iframe that sizes itself to its content, so there is no inner scrollbar, and several can sit on one page. Where to paste it: a Code block in Squarespace, a Custom HTML block in WordPress, an Embed HTML element in Wix. If a builder strips <script> tags, the admin screen also gives a plain <iframe> version at a fixed height.
Attributes common to all widgets: data-vtee-slug (required), data-vtee-mount="#my-div" (render into a specific element instead of #vtee-booking / #vtee-signup / #vtee-events), data-vtee-height (starting height before it measures itself), and data-vtee-bg: site (the venue's brand background), transparent, or a hex color like #0b1120.
Booking widget
The venue's live availability, in their colors. When a visitor picks a time they go to the venue's own booking page with that time already selected, and sign in and pay there. Browsers don't keep a login inside a frame on another website, so checkout always happens on the venue's page.
<div id="vtee-booking"></div> <script src="https://vteegolf.com/embed.js" data-vtee-slug="your-venue" async></script>
| Name | Type | Required | Description |
|---|---|---|---|
| data-vtee-location | number | optional | Pin to one location of a multi-site venue. |
| data-vtee-product | number | optional | Open with one booking option chosen. |
| data-vtee-target | "top" | optional | Send the visitor to the booking page in the same tab (default: a new tab). |
| data-vtee-picker | "0" | optional | Hide the location tabs. |
| data-vtee-types | "0" | optional | Hide the reservation-type tabs. |
Email signup widget
An email box (and optionally a first-name box) that adds people straight to one of the venue's Marketing Lists, ready to target in their VTee email campaigns. The venue turns this on per list under Marketing → Lists → Website signups, which gives the list its signup key. Switching it off stops the form taking signups at once, and switching it back on revives the same snippet.
<div id="vtee-signup"></div> <script src="https://vteegolf.com/embed.js" data-vtee-widget="signup" data-vtee-slug="your-venue" data-vtee-list="LIST_SIGNUP_KEY" async></script>
| Name | Type | Required | Description |
|---|---|---|---|
| data-vtee-list | string | required | The list's signup key, from the venue's admin. |
| data-vtee-name | "1" | optional | Ask for a first name as well. |
| data-vtee-heading | string | optional | A heading above the form (up to 80 characters). |
| data-vtee-button | string | optional | Button text (default "Sign up", up to 30 characters). |
| data-vtee-text | "light" | optional | White text, for a transparent widget on a dark page. |
People who sign up become customers of the venue (an existing customer is simply linked). Their email preferences are respected: someone who unsubscribed stays unsubscribed. To use your own form design instead, post to the signup endpoint.
Events & leagues widget
The venue's upcoming public events and public leagues as cards, styled for a website: transparent by default, with the venue's accent color. Each card's Register (or Get Tickets, or Standings) button opens the venue's VTee page for it. It updates on its own whenever the venue adds or changes an event or league.
<div id="vtee-events"></div> <script src="https://vteegolf.com/embed.js" data-vtee-widget="events" data-vtee-slug="your-venue" async></script>
| Name | Type | Required | Description |
|---|---|---|---|
| data-vtee-show | "events" | "leagues" | optional | Only one of the two (default: both). |
| data-vtee-location | number | optional | One location's events and leagues (venue-wide ones still show). |
| data-vtee-limit | number | optional | Most cards per section, 1–24 (default 12). |
| data-vtee-target | "top" | optional | Open links in the same tab (default: a new tab). |
| data-vtee-text | "light" | optional | White text, for a transparent widget on a dark page. |
TV displays
The full-screen displays venues run on TVs (lobby, events, league leaderboards, live scoring, records) can also be framed. They are dark and made for a screen across a room, so for a website the widgets above usually fit better. Add ?embed=1 to hide their header and footer. The venue copies each URL from Admin → Displays.
<iframe src="https://vteegolf.com/your-venue/display/leagues?embed=1" style="width:100%;height:600px;border:0"></iframe>
Available screens: lobby, events, leagues, scoring, records, and all (rotates through them).
Signup endpoint
https://vteegolf.com/api/embed/signupAdds an email to a venue's Marketing List from your own form. This is what the signup widget uses behind the scenes. No API key: the list's signup key names the list, and it only works while the venue has Website signups switched on for that list. It answers any origin (CORS *), so you can call it with fetchfrom the venue's site. It sets no cookies.
| Name | Type | Required | Description |
|---|---|---|---|
| list | string | required | The list's signup key. |
| string | required | The address to add. | |
| firstName | string | optional | Saved on the list entry, and on the customer if new. |
| website | string | optional | Honeypot. Include it as a hidden field and leave it empty; a filled value is accepted and silently dropped. |
fetch('https://vteegolf.com/api/embed/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ list: 'LIST_SIGNUP_KEY', email: 'pat@example.com', firstName: 'Pat' }),
}).then(r => r.json());
// → { "ok": true }An address already on the list also returns { "ok": true }. Errors return { "ok": false, "error": "…" } with a message you can show the visitor: 400 invalid email, 404 unknown list or signups switched off, 429 rate limited (8 signups per visitor per 10 minutes, 300 per list per hour, 1,000 per list per day).
Request an API key
Tell us what you're building and which business the integration serves. Keys are issued by hand — one per integration, so a single key can be rotated or revoked without touching the rest — and emailed to you once. We confirm with the business owner before issuing a key for a venue you don't run.
Authentication
Send your key on every request as a bearer token. Keys start with vtk_.
Authorization: Bearer vtk_...
- A missing or malformed header, or an invalid or revoked key, returns
401. - Treat the key like a password: server-side only, never in a browser, mobile app, or repository. If a key leaks, ask for a rotation — the old key stops working the moment the new one is issued.
Rate limits
Each API key may make 120 requests per minute across all endpoints (a fixed one-minute window). Higher limits can be granted per key — ask when you request the key. When the limit is exceeded, requests return 429 until the window resets:
HTTP/1.1 429 Too Many Requests
Retry-After: 21
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 21
{ "error": "Rate limit exceeded — try again shortly" }Retry-After/X-RateLimit-Reset— seconds until the window resets. Wait that long before retrying.X-RateLimit-Limit/X-RateLimit-Remaining— your per-minute ceiling and what is left of it. Rate-limit headers are also included on successful responses from newer endpoints (such as the bookings calendar), so clients can pace themselves before hitting the wall.- Back off exponentially on repeated 429s rather than hammering the reset.
Conventions & errors
- All requests and responses are JSON. Dates are
YYYY-MM-DDstrings and times are 24-hourHH:MMstrings, both in the business's local timezone (returned by business info). Durations are integer minutes. - Read endpoints that matter to voice platforms have a POST twin that accepts the same arguments in the JSON body — platforms like Retell can only POST LLM-generated arguments to a static URL. The twin also unwraps arguments nested under a top-level
argsobject, so Retell custom functions work without a wrapper. - Multi-location businesses can pass
locationIdon most endpoints; omitting it uses the default location (or all locations for the calendar feed).
Errors always carry an error message:
{ "error": "Missing or invalid date parameter (YYYY-MM-DD)" }| Status | Meaning |
|---|---|
| 400 | Invalid or missing parameters |
| 401 | Missing, invalid, or revoked API key |
| 404 | Resource not found (or belongs to another business) |
| 409 | Conflict — e.g. the slot was just taken, or the time is appointment-only |
| 429 | Rate limit exceeded — retry after Retry-After seconds |
| 500 | Something went wrong on our side |
Endpoints
Business info
/business/infoName, address, phone, timezone, weekly hours, today's effective hours (including holiday overrides), bay count, and booking configuration for the business your key belongs to. Call it once at startup to learn the timezone and booking rules.
| Name | Type | Required | Description |
|---|---|---|---|
| locationId | integer | optional | Location to describe (multi-location businesses). Defaults to the primary location. |
{
"name": "Iron Tee Golf",
"address": "123 Fairway Dr, Austin, TX, 78701",
"phone": "+15125550142",
"timezone": "America/Chicago",
"todayHours": { "date": "2026-08-26", "isOpen": true, "openTime": "09:00", "closeTime": "22:00" },
"hours": [
{ "day": "monday", "isOpen": true, "openTime": "09:00", "closeTime": "22:00", "appointmentOnly": false },
{ "day": "tuesday", "isOpen": false }
],
"bayCount": 6,
"durationConfig": { "minDuration": 30, "maxDuration": 240, "interval": 30, "advanceBookingDays": 10 }
}Availability
/availability?date=2026-08-30/availabilityOpen time slots for one day: which bays are free at each slot, which durations fit, and what each duration costs. This is what an agent should call before booking. Dates beyond the business's advance-booking window return 400 with the furthest bookable date.
| Name | Type | Required | Description |
|---|---|---|---|
| date | string | required | Day to check, YYYY-MM-DD. |
| bookingType | string | optional | SIMULATOR (default), LESSONS, … |
| locationId | integer | optional | Location to check. |
| duration | integer | optional | Minutes. When set, the response is the compact shape below: only start times where that length fits, one price each, no bay list. |
{
"isOpen": true,
"timeSlots": [
{
"time": "18:00",
"timeDisplay": "6:00 PM",
"isPeak": true,
"isAppointmentOnly": false,
"availableBays": [{ "bayId": 3, "name": "Bay 3", "type": "GOLF_SIM" }],
"durationOptions": [
{ "minutes": 60, "label": "1 hour", "price": 45 },
{ "minutes": 90, "label": "1.5 hours", "price": 67.5 }
]
}
]
}With duration (what an AI agent should pass once the customer has said how long they want) the payload collapses to one price per start time:
{
"isOpen": true,
"date": "2026-08-30",
"duration": 60,
"slots": [
{ "time": "18:00", "timeDisplay": "6:00 PM", "price": 45, "isPeak": true, "isAppointmentOnly": false }
]
}When no start time fits that length, slots is empty and a note names the lengths that do fit. A closed day returns { "isOpen": false, "timeSlots": [] } — with "appointmentOnly": true when the day is bookable only by contacting the business.
Bookings calendar (date range)
/reservations/calendar?startDate=2026-08-01&endDate=2026-08-31/reservations/calendarEvery booking in a date range — the feed for calendar views, dashboards, and home-automation panels. Returns each reservation with its bay, times, status, and customer. Ranges are capped at 31 days per call; page by month for longer horizons. Cross-midnight bookings that spill into the range are included.
| Name | Type | Required | Description |
|---|---|---|---|
| startDate | string | required | First day of the range, YYYY-MM-DD (inclusive). |
| endDate | string | required | Last day of the range, YYYY-MM-DD (inclusive). At most 31 days after startDate. |
| status | string | optional | Comma-separated statuses to include. Default: ACTIVE,HOLD,PENDING_PAYMENT,COMPLETED (everything occupying bay time). Add CANCELED, REFUNDED, or FAILED explicitly if you need them. |
| bookingType | string | optional | Filter to one type: SIMULATOR, LESSONS, MEMBERSHIP, EVENT, PACKAGE, ADMIN_BLOCK. |
| locationId | integer | optional | Filter to one location. Omit for all locations. |
curl "https://vteegolf.com/api/v1/reservations/calendar?startDate=2026-08-01&endDate=2026-08-31" \ -H "Authorization: Bearer vtk_your_api_key"
{
"startDate": "2026-08-01",
"endDate": "2026-08-31",
"count": 2,
"reservations": [
{
"id": 18412,
"date": "2026-08-14",
"endDate": "2026-08-14",
"startTime": "18:00",
"endTime": "19:30",
"durationMinutes": 90,
"status": "ACTIVE",
"type": "SIMULATOR",
"price": 67.5,
"bay": { "id": 3, "name": "Bay 3", "type": "GOLF_SIM" },
"locationId": null,
"guestCount": 0,
"customer": { "name": "Jordan Smith", "phone": "5125550199", "isGuest": false },
"product": { "id": 12, "name": "Sim Rental" },
"groupId": null,
"eventId": null,
"createdAt": "2026-08-02T16:21:09.000Z"
},
{
"id": 18475,
"date": "2026-08-20",
"endDate": "2026-08-20",
"startTime": "09:00",
"endTime": "12:00",
"durationMinutes": 180,
"status": "ACTIVE",
"type": "ADMIN_BLOCK",
"price": 0,
"bay": { "id": 1, "name": "Bay 1", "type": "GOLF_SIM" },
"locationId": null,
"guestCount": 0,
"customer": null,
"product": { "id": 12, "name": "Sim Rental" },
"groupId": null,
"eventId": 91,
"createdAt": "2026-08-10T11:00:00.000Z"
}
]
}customerisnullfor admin blocks;isGuestdistinguishes walk-in guest bookings from account holders. Customer emails are never included in the calendar feed.endDatediffers fromdateonly when a booking crosses midnight;endTimeis wall-clock and wraps accordingly.- Multi-bay group bookings share a
groupId. - Responses over 5,000 rows set
"truncated": true— narrow the range if you ever see it.
Live bay status
/bays/statusEvery bay right now, in one call: whether its SimLock kiosk is online, whether the bay is locked or in a session (and how the session was opened, when it ends, which simulator is running), the booking in progress and the next one, and any open Live Assistance request. Built for an operations dashboard or a remote concierge desk — poll it every 15–60 seconds. It is the live picture; history and future bookings stay on /reservations/calendar.
| Name | Type | Required | Description |
|---|---|---|---|
| locationId | integer | optional | Filter to one location. Omit for all locations. |
curl https://vteegolf.com/api/v1/bays/status \ -H "Authorization: Bearer vtk_your_api_key"
{
"generatedAt": "2026-09-23T19:00:00.000Z",
"count": 2,
"bays": [
{
"id": 3,
"name": "Bay 3",
"position": 3,
"type": "GOLF_SIM",
"locationId": 1,
"kiosk": { "deviceCount": 1, "online": true, "lastSeenAt": "2026-09-23T18:59:12.000Z", "appVersion": "6.2.0", "screensOff": false },
"session": { "state": "unlocked", "source": "RESERVATION", "endsAt": "2026-09-23T19:30:00.000Z", "reservationId": 18412, "activeSim": "GSPro" },
"currentReservation": { "id": 18412, "type": "SIMULATOR", "startsAt": "2026-09-23T18:30:00.000Z", "endsAt": "2026-09-23T19:30:00.000Z", "customerName": "Jordan Smith" },
"nextReservation": { "id": 18420, "type": "SIMULATOR", "startsAt": "2026-09-23T20:00:00.000Z", "endsAt": "2026-09-23T21:00:00.000Z", "customerName": "Ana" },
"helpRequestedAt": null
},
{
"id": 9,
"name": "Ocean Bay",
"position": 1,
"type": "GOLF_SIM",
"locationId": 2,
"kiosk": null,
"session": null,
"currentReservation": null,
"nextReservation": null,
"helpRequestedAt": null
}
]
}kioskandsessionarenullon a bay with no SimLock device — there is nothing to report a session from.onlinemeans the kiosk checked in within the last five minutes.session.sourceis how the bay was opened:RESERVATION,EVENT,ACCESS_CODE,MASTER_CUSTOMERorMASTER_ADMIN(staff codes),MASTERon older sessions, ornullfor a staff unlock with no recorded mode. A booking-backed session follows the booking if it is extended.currentReservationandnextReservationcover confirmed bookings and holds (ACTIVE,HOLD) — what the bay will actually unlock for. A checkout still paying (PENDING_PAYMENT) shows on the calendar feed but not here.nextReservationlooks as far as tomorrow.helpRequestedAtis set while a guest's Live Assistance request is open on the bay. It clears when staff act on it (Clear, End Session, the master code) or the guest takes it back; a session ending on its own leaves it standing so a late request still reaches someone.- All times are UTC ISO 8601; convert to the venue's zone for display.
Look up reservations by phone
/reservations?phone=5125550199/reservations/lookupA customer's reservations, matched by phone number (with or without country code). This is how a call agent answers "when is my booking again?". The POST twin takes { "phone": "..." } in the body.
| Name | Type | Required | Description |
|---|---|---|---|
| phone | string | required | Customer phone number; punctuation and country code are normalized. |
| locationId | integer | optional | Filter to one location. |
{
"reservations": [
{
"id": 18412,
"date": "2026-08-14",
"startTime": "18:00",
"duration": 90,
"bayName": "Bay 3",
"price": 67.5,
"status": "ACTIVE",
"user": { "id": 512, "firstName": "Jordan", "lastName": "Smith" }
}
]
}Create a reservation
/reservationsBooks a bay. If the phone number matches an existing customer the booking lands on their account (names optional); unknown callers must include first and last name and are booked as guests. Omit bayId to let VTee pick the optimal bay. The customer gets a confirmation email; callers with no email on file get a confirmation SMS with a link to pay or manage the booking instead. Payment is otherwise collected in store.
| Name | Type | Required | Description |
|---|---|---|---|
| date | string | required | YYYY-MM-DD. |
| startTime | string | required | HH:MM, 24-hour, business-local. Use a time offered by the availability endpoint. |
| duration | integer | required | Minutes (max 1440). Must be one of the offered duration options. |
| guestPhone | string | required | Customer's phone — used to match an existing account. |
| guestFirstName | string | optional | Required when the phone matches no existing customer. |
| guestLastName | string | optional | Required when the phone matches no existing customer. |
| bayId | integer | optional | Specific bay; omitted = auto-select. |
| bookingType | string | optional | Default SIMULATOR. |
| notes | string | optional | Free-text note shown to staff. |
| locationId | integer | optional | Location to book at. |
curl -X POST https://vteegolf.com/api/v1/reservations \
-H "Authorization: Bearer vtk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"date": "2026-08-30",
"startTime": "18:00",
"duration": 90,
"guestPhone": "512-555-0199",
"guestFirstName": "Jordan",
"guestLastName": "Smith"
}'HTTP/1.1 201 Created
{
"reservation": {
"id": 18412,
"date": "2026-08-30",
"startTime": "18:00",
"duration": 90,
"bayName": "Bay 3",
"price": 67.5,
"status": "ACTIVE"
}
}Conflicts (the slot was just taken, no bay fits, the time is appointment-only) return 409 with a human-readable error an agent can relay verbatim. Dates past the advance-booking window return 400 with the furthest bookable date.
Modify a reservation
/reservations/{id}/reservations/modifyReschedules a reservation — date, start time, duration, and/or bay. Only the fields you send change; price recalculates automatically when the duration changes. The POST twin takes reservationId in the body instead of the URL.
| Name | Type | Required | Description |
|---|---|---|---|
| reservationId | integer | required | POST twin only — the reservation to change (PATCH takes it in the URL). |
| date | string | optional | New date, YYYY-MM-DD. |
| startTime | string | optional | New start time, HH:MM. |
| duration | integer | optional | New duration in minutes. |
| bayId | integer | optional | Move to a specific bay. |
| locationId | integer | optional | Location context for the change. |
Returns the updated reservation summary, or 409 when the new slot conflicts.
Cancel a reservation
/reservations/{id}Cancels a reservation belonging to your business. Canceling an already-canceled reservation is a no-op that returns { "success": true, "alreadyCanceled": true }.
curl -X DELETE https://vteegolf.com/api/v1/reservations/18412 \
-H "Authorization: Bearer vtk_your_api_key"
{ "success": true }Customer lookup
/customers/lookupChecks whether a phone number belongs to a known customer — lets an agent greet a regular by name without re-asking for details. Body: { "phone": "512-555-0199" }.
{ "found": true, "firstName": "Jordan", "lastName": "Smith" }
// or
{ "found": false }Memberships
/memberships?phone=5125550199/memberships/plans/memberships?phone=returns a customer's memberships with plan, status, and minute usage — { "customer": null, "memberships": [] } when the phone matches nobody. /memberships/planslists the plans the business offers (name, pricing cadence, signup URL where self-serve signup is enabled), for answering "what memberships do you have?".
{
"customer": { "id": 512, "firstName": "Jordan", "lastName": "Smith", "email": "jordan@example.com", "phone": "5125550199" },
"memberships": [
{
"id": 88,
"status": "ACTIVE",
"plan": { "id": 4, "name": "Gold", "type": "UNLIMITED", "monthlyPrice": 199 },
"minutesUsed": 340,
"minutesAllowed": 1200,
"startDate": "2026-01-05",
"billingDate": 5
}
]
}Webhooks
Instead of polling, have VTee push events to your system as they happen: a booking made, changed or canceled; a bay unlocked, re-locked or extended; a guest pressing Live Assistance. Ask for a webhook the same way you request an API key — we register your URL, choose which events it gets, and hand you a signing secret once.
Each delivery is a JSON POST, retried on the ladder 1 min, 5 min, 30 min, 2 h, 12 h (six attempts in all) if your endpoint answers anything other than 2xx or takes longer than 10 seconds. Redirects are not followed. After 25 failures in a row the endpoint is switched off; ask us to re-enable it once your receiver is healthy. Answer 200 quickly and do the work afterwards.
Deliveries are posted in parallel and are not ordered: two events for the same booking can arrive out of sequence, so order on createdAt in the body rather than on arrival. A booking is announced only once it is real — an online checkout in progress (status HOLD or PENDING_PAYMENT) sends reservation.created when it completes and nothing if it is abandoned. Staff blocks (type: ADMIN_BLOCK) are announced like bookings, since they occupy the bay.
| Name | Type | Required | Description |
|---|---|---|---|
| reservation.created | event | optional | A booking was made — online, at the counter, by the API or the phone agent. data.reservation matches the calendar shape. |
| reservation.updated | event | optional | A booking changed. data.change is status, moved, extended or bay_swapped. |
| reservation.canceled | event | optional | data.reason is canceled, refunded, failed or deleted. A deleted booking reduces to { id, deleted: true }. |
| session.started | event | optional | A kiosk device unlocked. data: bayId, deviceId, source (RESERVATION, EVENT, ACCESS_CODE, MASTER_CUSTOMER, MASTER_ADMIN, ADMIN, STAFF_QR, or null for a staff unlock with no recorded mode), endsAt, reservationId. One per device — a bay with a check-in screen and a simulator PC sends two; group on bayId. |
| session.ended | event | optional | A bay re-locked. data.source is RESERVATION, EXPIRED, ADMIN or MASTER; data.reason explains a reservation relock (reservation_canceled, reservation_moved). |
| session.extended | event | optional | The session runs later than announced: time added from the panel (data.addedMinutes) or the booking itself extended (source RESERVATION). data.endsAt is the new end. |
| bay.help_requested | event | optional | A guest pressed Live Assistance at the bay. data: bayId, deviceId. |
| bay.help_cleared | event | optional | The request was cleared, by the guest (source BAY), by staff (source ADMIN) or because the session ended (source EXPIRED). |
| bay.call_started | event | optional | A Remote Assist video call was answered. data: bayId, deviceId, callId, source (BAY = the guest called, STAFF = staff called), by. |
| bay.call_ended | event | optional | A Remote Assist call ended. data.source is why: HANGUP_BAY, HANGUP_STAFF, ROOM_EMPTY, TIMEOUT (never answered), DECLINED, MAX_DURATION; data.durationSeconds is the answered time. |
| bay.remote_control_started | event | optional | Staff took remote control of the bay PC. data: bayId, deviceId, by, endsAt. |
| bay.remote_control_ended | event | optional | Remote control ended. data.source: ADMIN (staff ended it) or EXPIRED (30 minutes passed). |
| webhook.test | event | optional | A ping sent from our side so you can confirm the URL and your signature check. Delivered to the endpoint whatever events it subscribes to. |
POST https://ops.example.com/vtee/webhook
Content-Type: application/json
X-VTee-Event: session.started
X-VTee-Delivery-Id: 7f6c0c8e-1c8a-4a5e-9d0e-3c8f2c9a1b21
X-VTee-Timestamp: 1758654000 (unix seconds)
X-VTee-Signature: sha256=3f1a…e9
{
"id": "7f6c0c8e-1c8a-4a5e-9d0e-3c8f2c9a1b21",
"event": "session.started",
"createdAt": "2026-09-23T19:00:00.000Z",
"businessId": 42,
"data": {
"bayId": 3,
"deviceId": 5,
"source": "RESERVATION",
"endsAt": "2026-09-23T19:30:00.000Z",
"reservationId": 18412
}
}Verify the signature before trusting a delivery: compute HMAC-SHA256 over <timestamp>.<raw body> with your secret, hex-encode it, prefix sha256=, and compare in constant time. Reject timestamps more than five minutes old. id is the same on every endpoint that receives the event, so de-duplicate on it if you register more than one.
import { createHmac, timingSafeEqual } from 'crypto';
function verify(secret, timestamp, rawBody, signatureHeader) {
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
return expected.length === signatureHeader.length
&& timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}Analytics & conversion tracking
No API key needed: a venue connects its own Google tag and VTee reports sign-ups and payments to it from the browser. Paste the ID under Admin → SEO → Settings → Tracking & Analytics. It loads on every public page, both the venue's website and its booking pages (booking, memberships, leagues, events, customer accounts). It never loads on admin screens, display screens or embedded widgets.
Two kinds of ID are accepted:
- A Tag Manager container (
GTM-XXXXXXX). Events are pushed to thedataLayerand nothing reaches GA4, Google Ads or any other platform until you add a Custom Event trigger and a tag for it in GTM. Use this to send to several platforms (GA4 and Ads, Meta, TikTok). - A Google tag (
G-…from GA4,AW-…from Google Ads). Events are sent straight to that tag. Forms arrive under the namesite_form_submitrather thanform_submit, so they don't mix with theform_submitGA4 records on its own. For Google Ads to count a conversion, import the event from a linked GA4 property.
Consent. The tag uses Google Consent Mode v2. Analytics and advertising storage start as denied and are granted, with no page reload, when the visitor accepts the cookie banner. Until then Google receives cookieless pings only, so GA4 and Ads will report fewer conversions than your sales records.
form_submit
Pushed once when a form submits successfully. Use one Custom Event trigger on form_submit and filter on formType, so new form types start reporting without container changes. Don't use GTM's built-in Form Submission trigger: these forms submit through JavaScript and it catches them inconsistently.
| Name | Type | Required | Description |
|---|---|---|---|
| event | string | required | form_submit (site_form_submit on a G-/AW- tag). |
| formType | string | required | Which form. See the values below. |
| formName | string | optional | Context where there is some: the plan or league name, the block heading, or the requested instructor. |
| Name | Type | Required | Description |
|---|---|---|---|
| email_capture | value | optional | An email capture block was submitted. |
| contact | value | optional | The contact form was sent. |
| survey | value | optional | A membership survey was submitted. |
| group_booking | value | optional | A group or corporate inquiry was sent. |
| lesson_inquiry | value | optional | A lesson inquiry was sent. |
| membership_waitlist | value | optional | Someone joined a full plan's waitlist. |
| membership_presell | value | optional | Someone completed Pre-Register on a plan that hasn't launched (no charge yet). |
| membership_signup | value | optional | Someone completed enrollment in a live plan. |
| booking_waitlist | value | optional | Someone asked to be told when a booked-out slot frees up. |
| league_signup | value | optional | A captain completed a league registration, or a teammate joined through an invite. Any payment is reported separately as payment_complete. |
payment_complete
Pushed once for each successful online payment. For leagues: registration, a teammate paying their share, side-event buy-ins, and later balance or weekly payments from the customer's team page or a payment link. For memberships: what the customer paid at checkout on a plan's purchase page, alongside its membership_signup or membership_presell form event. Use paymentFor to tell them apart. In GTM, pass value, currency and transaction_id to your GA4 event tag.
| Name | Type | Required | Description |
|---|---|---|---|
| event | string | required | payment_complete (the same name on a G-/AW- tag). |
| value | number | required | Amount charged in this payment, before tax, rounded to cents. Prepaid booking credit is not counted, so a registration paid entirely with credit reports 0. |
| currency | string | required | ISO 4217 code from the venue's settings, e.g. USD. |
| paymentType | string | required | What the payment covered. See the values below. |
| paymentFor | string | required | league or membership. Added October 2026; league payments sent before then have no paymentFor. |
| transaction_id | string | optional | The Square order ID, so GA4 and Ads drop duplicates and you can match a conversion to the sale. Absent when nothing was charged (credit only). |
| Name | Type | Required | Description |
|---|---|---|---|
| deposit | value | optional | Registration paid with only the deposit; the rest is owed later. |
| full | value | optional | Everything in that checkout: the entry fee plus any side-event buy-ins and tee times. Also used for a buy-in paid on its own. Means "not a deposit", not "exactly the entry fee". On a membership: the first period, plus any activation fee, charged at checkout. |
| installment | value | optional | One week of a weekly payment plan. |
| balance | value | optional | A later payment that settles what is still owed, after a deposit or on a weekly plan. Deposit plus balance adds up to the fee. |
// A $100 deposit at registration, then the $350 balance a week later
{ event: 'payment_complete', value: 100, currency: 'USD',
paymentType: 'deposit', paymentFor: 'league', transaction_id: 'Qx7…' }
{ event: 'payment_complete', value: 350, currency: 'USD',
paymentType: 'balance', paymentFor: 'league', transaction_id: 'Lp2…' }Memberships. A membership checkout sends paymentType: 'full' and paymentFor: 'membership'. The value is what was charged today, before tax: the first period (after any promo or intro price), plus the activation fee when it's charged then. It is not sent when nothing was charged: a free plan, a pre-registration that pays at launch, or a first bill scheduled for a later date. Renewals happen in the background with no visitor on the page, so they are never reported. Use membership_signup to count every enrollment and payment_complete for revenue.
// A $199/month founding membership bought at checkout
{ event: 'form_submit', formType: 'membership_signup', formName: 'Founding Membership' }
{ event: 'payment_complete', value: 199, currency: 'USD',
paymentType: 'full', paymentFor: 'membership', transaction_id: 'Hn4…' }Filtering on paymentType? Include every value you want counted. A filter on deposit and full alone misses balance and weekly payments. New values may be added over time and will be listed here.
Checking it works. With GTM, open the site in GTM Preview, complete a sign-up or payment, and confirm the event and its variables appear. If it shows in Preview but not in GA4, the container is missing a trigger or tag for it. With a G- tag, connect the site in Google Tag Assistant and watch GA4 Admin → DebugView. In the browser console, window.dataLayer lists every push. Test payments are real charges, so refund them afterwards.
Need an endpoint that isn't here, or a higher rate limit than the form covers? Get in touch — the API grows with what integrators need.