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.

Request an API key
Base URL  https://vteegolf.com/api/v1

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"

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.

Every key is scoped to one business. Building for someone else's venue? Name theirs — we confirm with the owner before issuing.

What this key gets called on our side — one per integration, so it can be rotated on its own.

Endpoints you expect to use

Not sure yet? Leave it blank — keys aren't restricted per endpoint today, this just tells us what to keep an eye on.

Keys are issued by hand, usually within one business day.

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-DD strings and times are 24-hour HH: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 args object, so Retell custom functions work without a wrapper.
  • Multi-location businesses can pass locationId on 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)" }
StatusMeaning
400Invalid or missing parameters
401Missing, invalid, or revoked API key
404Resource not found (or belongs to another business)
409Conflict — e.g. the slot was just taken, or the time is appointment-only
429Rate limit exceeded — retry after Retry-After seconds
500Something went wrong on our side

Endpoints

Business info

GET/business/info

Name, 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.

Query parameters
NameTypeRequiredDescription
locationIdintegeroptionalLocation 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

GET/availability?date=2026-08-30
POST/availability

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

Query parameters (GET) or JSON body (POST)
NameTypeRequiredDescription
datestringrequiredDay to check, YYYY-MM-DD.
bookingTypestringoptionalSIMULATOR (default), LESSONS, …
locationIdintegeroptionalLocation to check.
durationintegeroptionalMinutes. 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)

GET/reservations/calendar?startDate=2026-08-01&endDate=2026-08-31
POST/reservations/calendar

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

Query parameters (GET) or JSON body (POST)
NameTypeRequiredDescription
startDatestringrequiredFirst day of the range, YYYY-MM-DD (inclusive).
endDatestringrequiredLast day of the range, YYYY-MM-DD (inclusive). At most 31 days after startDate.
statusstringoptionalComma-separated statuses to include. Default: ACTIVE,HOLD,PENDING_PAYMENT,COMPLETED (everything occupying bay time). Add CANCELED, REFUNDED, or FAILED explicitly if you need them.
bookingTypestringoptionalFilter to one type: SIMULATOR, LESSONS, MEMBERSHIP, EVENT, PACKAGE, ADMIN_BLOCK.
locationIdintegeroptionalFilter 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"
    }
  ]
}
  • customer is null for admin blocks; isGuest distinguishes walk-in guest bookings from account holders. Customer emails are never included in the calendar feed.
  • endDate differs from date only when a booking crosses midnight; endTime is 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

GET/bays/status

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

Query parameters
NameTypeRequiredDescription
locationIdintegeroptionalFilter 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
    }
  ]
}
  • kiosk and session are null on a bay with no SimLock device — there is nothing to report a session from. online means the kiosk checked in within the last five minutes.
  • session.source is how the bay was opened: RESERVATION, EVENT, ACCESS_CODE, MASTER_CUSTOMER or MASTER_ADMIN (staff codes), MASTER on older sessions, or null for a staff unlock with no recorded mode. A booking-backed session follows the booking if it is extended.
  • currentReservation and nextReservation cover 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. nextReservation looks 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

GET/reservations?phone=5125550199
POST/reservations/lookup

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

Parameters
NameTypeRequiredDescription
phonestringrequiredCustomer phone number; punctuation and country code are normalized.
locationIdintegeroptionalFilter 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

POST/reservations

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

JSON body
NameTypeRequiredDescription
datestringrequiredYYYY-MM-DD.
startTimestringrequiredHH:MM, 24-hour, business-local. Use a time offered by the availability endpoint.
durationintegerrequiredMinutes (max 1440). Must be one of the offered duration options.
guestPhonestringrequiredCustomer's phone — used to match an existing account.
guestFirstNamestringoptionalRequired when the phone matches no existing customer.
guestLastNamestringoptionalRequired when the phone matches no existing customer.
bayIdintegeroptionalSpecific bay; omitted = auto-select.
bookingTypestringoptionalDefault SIMULATOR.
notesstringoptionalFree-text note shown to staff.
locationIdintegeroptionalLocation 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

PATCH/reservations/{id}
POST/reservations/modify

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

JSON body
NameTypeRequiredDescription
reservationIdintegerrequiredPOST twin only — the reservation to change (PATCH takes it in the URL).
datestringoptionalNew date, YYYY-MM-DD.
startTimestringoptionalNew start time, HH:MM.
durationintegeroptionalNew duration in minutes.
bayIdintegeroptionalMove to a specific bay.
locationIdintegeroptionalLocation context for the change.

Returns the updated reservation summary, or 409 when the new slot conflicts.

Cancel a reservation

DELETE/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

POST/customers/lookup

Checks 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

GET/memberships?phone=5125550199
GET/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.

Events
NameTypeRequiredDescription
reservation.createdeventoptionalA booking was made — online, at the counter, by the API or the phone agent. data.reservation matches the calendar shape.
reservation.updatedeventoptionalA booking changed. data.change is status, moved, extended or bay_swapped.
reservation.canceledeventoptionaldata.reason is canceled, refunded, failed or deleted. A deleted booking reduces to { id, deleted: true }.
session.startedeventoptionalA 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.endedeventoptionalA bay re-locked. data.source is RESERVATION, EXPIRED, ADMIN or MASTER; data.reason explains a reservation relock (reservation_canceled, reservation_moved).
session.extendedeventoptionalThe 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_requestedeventoptionalA guest pressed Live Assistance at the bay. data: bayId, deviceId.
bay.help_clearedeventoptionalThe request was cleared, by the guest (source BAY), by staff (source ADMIN) or because the session ended (source EXPIRED).
bay.call_startedeventoptionalA Remote Assist video call was answered. data: bayId, deviceId, callId, source (BAY = the guest called, STAFF = staff called), by.
bay.call_endedeventoptionalA 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_startedeventoptionalStaff took remote control of the bay PC. data: bayId, deviceId, by, endsAt.
bay.remote_control_endedeventoptionalRemote control ended. data.source: ADMIN (staff ended it) or EXPIRED (30 minutes passed).
webhook.testeventoptionalA 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 the dataLayer and 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 name site_form_submit rather than form_submit, so they don't mix with the form_submit GA4 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.

Fields
NameTypeRequiredDescription
eventstringrequiredform_submit (site_form_submit on a G-/AW- tag).
formTypestringrequiredWhich form. See the values below.
formNamestringoptionalContext where there is some: the plan or league name, the block heading, or the requested instructor.
formType values
NameTypeRequiredDescription
email_capturevalueoptionalAn email capture block was submitted.
contactvalueoptionalThe contact form was sent.
surveyvalueoptionalA membership survey was submitted.
group_bookingvalueoptionalA group or corporate inquiry was sent.
lesson_inquiryvalueoptionalA lesson inquiry was sent.
membership_waitlistvalueoptionalSomeone joined a full plan's waitlist.
membership_presellvalueoptionalSomeone completed Pre-Register on a plan that hasn't launched (no charge yet).
membership_signupvalueoptionalSomeone completed enrollment in a live plan.
booking_waitlistvalueoptionalSomeone asked to be told when a booked-out slot frees up.
league_signupvalueoptionalA 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 league payment: 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. In GTM, pass value, currency and transaction_id to your GA4 event tag.

Fields
NameTypeRequiredDescription
eventstringrequiredpayment_complete (the same name on a G-/AW- tag).
valuenumberrequiredAmount charged in this payment, before tax, rounded to cents. Prepaid booking credit is not counted, so a registration paid entirely with credit reports 0.
currencystringrequiredISO 4217 code from the venue's settings, e.g. USD.
paymentTypestringrequiredWhat the payment covered. See the values below.
transaction_idstringoptionalThe 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).
paymentType values
NameTypeRequiredDescription
depositvalueoptionalRegistration paid with only the deposit; the rest is owed later.
fullvalueoptionalEverything 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".
installmentvalueoptionalOne week of a weekly payment plan.
balancevalueoptionalA 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', transaction_id: 'Qx7…' }
{ event: 'payment_complete', value: 350, currency: 'USD',
  paymentType: 'balance', transaction_id: 'Lp2…' }

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.