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.
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.
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 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.
| 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. |
| 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". |
| 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', 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.