Partner API
Reference
Jump to section
Partner API
Server-to-server REST endpoints for the clubs that approved your integration. Use this page for request and response shapes; the Types section lists JSON entities returned by the Partner API. For hosted checkout (pay on TEKKA, return to your site), start at Hosted checkout. Export OpenAPI 3.1 for Postman, codegen, and Swagger UI (Partner REST only).
New here? Start with the integration guide
A narrative, start-to-finish walkthrough from first API key to a paid booking.
Every path below is relative to this root.
https://api.jointekka.com/v1/partnerJSON over HTTPS.
Times are club-local wall clocks without an offset (2026-04-23 10:00). Read the club's IANA zone from timeZone on GET club.
Include X-Request-Id (optional) for support correlation.
Authentication
API key (recommended header)
Authorization: Bearer tbk_<keyId>_<secret>Or send the same token in X-Api-Key.
Every club requires approved access from a club manager. While your request is pending or rejected, club routes return 403 PARTNER_CLUB_NOT_APPROVED. A club you never requested access to answers 404 PARTNER_CLUB_NOT_FOUND, exactly like an id that does not exist.
Rate limits
Limits are applied per API key on a rolling one-minute window. These are the fair-use limits referenced by section 3.2 of the Developer Terms of Service
| Bucket | Limit | Applies to |
|---|---|---|
| All requests | 120 / min | Every authenticated /v1/partner/* call. |
| Writes | 30 / min | Create booking, register payment, cancel — counted in addition to the overall budget. |
Every partner response carries the current budget. When a bucket is exhausted the API replies 429 with error.code = "PARTNER_RATE_LIMITED" and a Retry-After header. Back off for that many seconds — do not retry immediately, and do not spread one integration across extra API keys to raise your ceiling.
RateLimit-Limit: 120 // ceiling for the current window
RateLimit-Remaining: 118 // calls left in the current window
RateLimit-Reset: 42 // seconds until the window resets
Retry-After: 42 // 429 responses only
// X-RateLimit-Limit / -Remaining / -Reset are sent as aliases.{
"error": {
"code": "PARTNER_RATE_LIMITED",
"message": "Rate limit exceeded for this API key. Retry after 42s.",
"details": {},
"requestId": "3f0c…"
}
}Need a higher ceiling for a launch or a bulk sync? Contact support with your organisation name and expected peak — limits are raised per key, not per request.
Club details and courts
Public-safe club profile: time zone, hours, court list, and flags such as membership or lottery mode.
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}
/v1/partner/clubs/{clubId}Responses
{
"id": "cl_…",
"name": "Example Club",
"description": "…",
"logo": "https://…",
"bannerImage": "https://…",
"address": { },
"contactInfo": { },
"businessHours": [ { "dayOfWeek": 1, "openTime": "08:00", "closeTime": "22:00", "isClosed": false } ],
"timeZone": "Europe/Budapest",
"currency": "HUF",
"membershipRequired": false,
"requireBookingApproval": false,
"bookingDecidedByLottery": false,
"courts": [
{
"id": "ct_…",
"name": "Court 1",
"courtNumber": 1,
"type": "outdoor",
"surface": "clay",
"amenities": null,
"hourlyRate": { },
"maintenanceStatus": "operational",
"isActive": true
}
]
}{
"error": {
"code": "PARTNER_CLUB_NOT_FOUND",
"message": "Club not found",
"details": {},
"requestId": "3f0c…"
}
}
// 404 also when you never requested access to this club.
// 403 while your access request is pending or rejected:
// "code": "PARTNER_CLUB_NOT_APPROVED"Cover image (redirect)
Redirect to the club banner, or the logo, for hotlinking in your UI.
The API responds with HTTP 302 and a Location header pointing at the image URL (or 404 if there is no image).
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}/cover
/v1/partner/clubs/{clubId}/coverResponses
Location: <resolved image URL>Court pricing
Normalized pricing configuration per active court (used with duration when quoting).
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}/courts/prices
/v1/partner/clubs/{clubId}/courts/pricesResponses
{
"courts": [
{
"id": "ct_…",
"name": "Court 1",
"pricing": { }
}
]
}Available time slots
Start times the court is bookable for the given club-local date: inside opening hours, clear of existing bookings, tournaments, group lessons, maintenance and closed days, not already started, and inside the club's booking window for a regular customer. POST booking applies the same hours, holds and booking-window rules (plus membership, lottery and duration rules).
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}/timeslots?courtId=&date=2026-04-23
/v1/partner/clubs/{clubId}/timeslotsQuery
courtId(required) — target courtdate(required) —YYYY-MM-DD, a real calendar date (anything else is400)excludeBookingId(optional) — ignore one of your own bookings when computing conflicts (e.g. editing a hold); other ids are ignoredexcludeTournamentId(optional) — skip blocks from a tournament
Responses
{
"availableTimes": [ "09:00", "09:30", "10:00" ],
"date": "2026-04-23",
"courtId": "ct_…",
"totalSlots": 12,
"timeZone": "Europe/Budapest",
"businessHours": {
"openTime": "08:00",
"closeTime": "22:00",
"dayOfWeek": 3
}
}{
"availableTimes": [],
"date": "2026-04-23",
"courtId": "ct_…",
"totalSlots": 0,
"timeZone": "Europe/Budapest",
"businessHours": { "openTime": "08:00", "closeTime": "22:00", "dayOfWeek": 0 },
"reason": "club_closed",
"message": "Club closed this day"
}
// reason: club_closed | court_unavailable | maintenance | day_blocked
// | date_in_past | beyond_booking_window | club_not_accepting_bookings
// Branch on reason (new values may be added); message is for humans.// 400 — missing query params, or date is not a real YYYY-MM-DD
{ "error": { "code": "PARTNER_VALIDATION", "message": "courtId and date are required", "details": {}, "requestId": "…" } }
// 400 — club has no business hours for that weekday
{ "error": { "code": "PARTNER_BUSINESS_HOURS_NOT_CONFIGURED", "message": "…", "details": {}, "requestId": "…" } }
// 404 — unknown court for this club
{ "error": { "code": "PARTNER_COURT_NOT_FOUND", "message": "Club or court not found", "details": {}, "requestId": "…" } }Equipment catalog & availability
Without start/end, returns the club's active equipment list. With both query params, each row includes available units for that window (overlapping rentals are subtracted).
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}/equipment
/v1/partner/clubs/{clubId}/equipmentQuery
startTime/endTime— both optional, but you must pass both to getavailableUnits. Club-local wall clocks in the same form as POST booking (2026-04-23 10:00; anything else is400 PARTNER_VALIDATION)
Responses
[ { "id": "…", "name": "Racket", "stock": 4, "clubId": "…" } ][ { "id": "…", "name": "Racket", "stock": 4, "availableUnits": 2, … } ]Create booking
Checks the slot against the club's opening hours, closed days, maintenance windows, tournament and lesson holds, existing bookings, the booking window and a maximum length (the club's own, else 240 minutes), then creates a customer account for the primary customer if needed and places the booking.
startTime / endTime are club-local wall clocks without an offset: 2026-04-23 10:00 (the T form 2026-04-23T10:00:00 is accepted too). The response always returns them as YYYY-MM-DD HH:mm. A booking ends on the day it starts, or exactly at the next midnight.
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}/bookings
/v1/partner/clubs/{clubId}/bookingsRequest body
{
"courtId": "ct_…",
"startTime": "2026-04-23 10:00",
"endTime": "2026-04-23 11:00",
"primaryCustomer": {
"email": "[email protected]",
"name": "Player One",
"phone": "+36…"
},
"notes": "optional",
"externalReference": "your-order-123",
"equipment": [
{ "equipmentId": "eq_…", "quantity": 1 }
],
"players": [
{ "name": "Guest", "email": "…", "isGuest": true }
]
}Responses
{ /* PartnerBooking — see Types section */
"id": "bk_…",
"clubId": "cl_…",
"courtId": "ct_…",
"startTime": "2026-04-23 10:00",
"endTime": "2026-04-23 11:00",
"duration": 60,
"totalPrice": 8000,
"currency": "HUF",
"status": "draft",
"isLotteryEntry": false,
"partnerDeveloperId": "…",
"externalReference": "your-order-123",
"idempotentReplay": false
}{ /* the booking from the first call, unchanged */
"id": "bk_…",
…
"externalReference": "your-order-123",
"idempotentReplay": true
}{
"error": {
"code": "PARTNER_SLOT_UNAVAILABLE",
"message": "Human-readable message",
"details": {},
"requestId": "3f0c…"
}
}
// 400 PARTNER_VALIDATION bad body; details.field names the field
// 400 PARTNER_BOOKING_TOO_LONG details.maxDurationMinutes
// 400 BOOKING_START_TIME_IN_PAST / BOOKING_TOO_FAR_AHEAD (details.maxAdvanceDays)
// 400 BOOKING_RANGE_SPANS_LOTTERY
// 403 CLUB_NOT_ACCEPTING_BOOKINGS (club inactive or not verified)
// 404 PARTNER_COURT_NOT_FOUND
// 409 BOOKING_SLOT_NOT_BOOKABLE outside hours / closed / maintenance / hold
// (details.rule: outside_hours | closed | blocked …)
// 409 PARTNER_SLOT_UNAVAILABLE another booking holds the slot
// 409 PARTNER_COURT_UNAVAILABLE, BOOKING_LOTTERY_ENTRY_DUPLICATE
// 409 FAIRPLAY_QUOTA_EXCEEDED customer over a club fair-play limit
// (details.reason: weekly_hours | active_bookings, limit, used, remaining)
// 409 PARTNER_EXTERNAL_REFERENCE_CONFLICT, PARTNER_REQUEST_IN_PROGRESS
// (see Retries below)Retries and externalReference
Lost the response to a create? Send the same request again with the same externalReference. While the first booking is live (not cancelled, and not a lapsed draft hold), the API returns it with HTTP 200 and idempotentReplay: true instead of booking twice. Concurrent retries are serialized.
- The match is on your developer account (any of its API keys), the reference, and the same club, court, start and end. The same reference for a different slot is
409 PARTNER_EXTERNAL_REFERENCE_CONFLICTwith the existingdetails.bookingId. - Without an
externalReferencea retry creates a second booking (or fails withPARTNER_SLOT_UNAVAILABLEbecause your first booking holds the slot). - There is no
Idempotency-Keyheader; it is ignored if sent. - Cancel is retry-safe too: cancelling an already cancelled booking returns
200withalreadyCancelled: true.
Get booking
Read back a booking your integration created — its current status, whether it has been paid and whether it was cancelled. Use it to reconcile after a lost response, a crash or a retry instead of caching the create response. Only bookings created with your developer account are returned.
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}/bookings/bk_…
/v1/partner/clubs/{clubId}/bookings/{bookingId}Responses
{ /* PartnerBookingDetails — see Types section */
"id": "bk_…",
"clubId": "cl_…",
"courtId": "ct_…",
"startTime": "2026-04-23 10:00",
"endTime": "2026-04-23 11:00",
"duration": 60,
"totalPrice": 8000,
"currency": "HUF",
"status": "confirmed",
"isLotteryEntry": false,
"partnerDeveloperId": "…",
"externalReference": "your-order-123",
"paidAmount": 8000,
"draftExpiresAt": null,
"cancelledAt": null,
"createdAt": "2026-04-20T08:00:00.000Z"
}{
"error": {
"code": "PARTNER_BOOKING_NOT_FOUND",
"message": "Booking not found",
"details": {},
"requestId": "3f0c…"
}
}
// Also: PARTNER_BOOKING_NOT_OWNED, PARTNER_CLUB_NOT_APPROVED,
// PARTNER_RATE_LIMITEDRegister external payment
Use when the customer pays on your site (your PSP, cash, invoice). Do not use this for TEKKA hosted checkout — use Hosted checkout below instead. Marks the booking paid in TEKKA.
Hosted checkout (card/wallet on TEKKA) uses POST …/bookings/{bookingId}/checkout-token, not this endpoint.
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}/bookings/bk_…/payment
/v1/partner/clubs/{clubId}/bookings/{bookingId}/paymentRequest body
{
"amount": 15000,
"currency": "HUF",
"providerReference": "psp-txn-id-or-receipt-123",
"metadata": { "lane": "pos-2" }
}Responses
{ "paymentId": "…", "status": "completed" }{
"error": {
"code": "PARTNER_PAYMENT_VALIDATION",
"message": "…",
"details": {},
"requestId": "3f0c…"
}
}
// Also: PARTNER_BOOKING_NOT_FOUND, PARTNER_BOOKING_NOT_OWNED,
// PARTNER_BOOKING_STATE_INVALID (409: hold expired, or settled meanwhile —
// no payment recorded), PARTNER_SLOT_UNAVAILABLE, PARTNER_RATE_LIMITEDCancel booking
Cancel a booking. Retry-safe: an already cancelled booking returns 200 with alreadyCancelled: true. If the booking has completed payments, it cannot be cancelled via the partner API (refund may be required).
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}/bookings/bk_…/cancel
/v1/partner/clubs/{clubId}/bookings/{bookingId}/cancelRequest body
{
"reason": "Customer requested"
}
// or empty object / omit body
{}Responses
{
"cancelled": true,
"bookingId": "bk_…",
"status": "cancelled",
"alreadyCancelled": false,
"cancelledAt": "2026-04-20T08:00:00.000Z"
}{
"error": {
"code": "PARTNER_BOOKING_HAS_PAYMENTS",
"message": "…",
"details": {},
"requestId": "3f0c…"
}
}
// Also: PARTNER_BOOKING_NOT_FOUND, PARTNER_BOOKING_NOT_OWNED,
// PARTNER_BOOKING_NOT_CANCELLABLE (409; details.status, e.g. "completed"),
// PARTNER_RATE_LIMITEDHosted checkout
Send payers to TEKKA to complete card/wallet payment with the club's configured providers. Your app keeps the booking UX; TEKKA handles PSP redirects, webhooks, and confirming the reservation. Payers do not sign in to TEKKA.
One server-side auth mode
- Partner API (Bearer key) — create bookings, mint hosted-checkout links, cancel, or register payment on your site.
- Keep the key on your backend. The payer receives only the opaque
checkoutUrlreturned byPOST …/checkout-token.
| Pay on TEKKA | Pay on your site |
|---|---|
checkout-token → redirect checkoutUrl | POST …/payment with your PSP reference |
| Payer sees /partner/checkout | Payer never leaves your checkout |
POST /v1/partner/clubs/{clubId}/bookings/{bookingId}/checkout-token{
"returnUrl": "https://yourapp.com/orders/8842/complete",
"cancelUrl": "https://yourapp.com/orders/8842"
}{
"token": "opaque-single-use",
"checkoutUrl": "https://<app>/partner/checkout?t=…",
"expiresAt": "2026-06-15T10:15:00.000Z"
}Configure Allowed redirect origins on your developer profile (HTTPS only). Token and draft booking hold: 15 minutes. Requires approved club access.
Flow diagram
Your backend TEKKA
──────────── ─────
POST /v1/partner/…/bookings → create booking (draft)
POST …/bookings/{id}/ → mint token (Bearer API key)
checkout-token
302 redirect payer → /partner/checkout?t=…
payer pays (Stripe/TBC/…)
Payer lands on returnUrl ← /partner/checkout/return
(your success page)Put your order id in externalReference when creating the booking, then build returnUrl around that id (TEKKA does not append it for you).
Use cases
Court booking app
User picks slot → you book via API → redirect to checkoutUrl → confirmation on your domain.
Pay link by email
Staff creates booking → mint token → send link; customer pays within 15 minutes without your app session.
Pay on your PSP
Skip hosted checkout; use POST …/payment after you charge the customer.
Implementation steps
- Discover & book — Partner API: timeslots, then
POST …/bookingswithprimaryCustomerandexternalReference. Saveidandstatus(oftendraft). - Mint link — From your server with the same Partner API key:
POST …/bookings/{bookingId}/checkout-tokenwith HTTPSreturnUrl/ optionalcancelUrl. - Redirect payer — HTTP 302 to
checkoutUrl(full page, not iframe). - Success page — User arrives at your
returnUrl; show order confirmation usingexternalReference. The booking becomes confirmed once the payment provider has confirmed the charge to TEKKA; read it back with GET booking to check.
// After checkout-token response:
res.redirect(302, checkoutUrl);
// returnUrl you minted earlier:
// https://yourapp.com/orders/8842/completeErrors & limits
| Situation | What to do |
|---|---|
| 403 club access | Wait for club approval or request access for that clubId. |
| 400 returnUrl origin | Add HTTPS origin to Allowed redirect origins on profile. |
| 409 not payable | Draft expired (>15m) or wrong status; create a new booking. |
| Link already used | Token consumed after success; mint only for a new payable booking. |
| 429 PARTNER_RATE_LIMITED | Fair-use limit hit. Wait Retry-After seconds, then retry — see Rate limits. |
| Payment unavailable UI | Club has no chargeable online provider for that currency. |
Payer-facing pages: /partner/checkout, …/return, …/cancel, …/fail.
Club admin approval
Club managers open /partner-access?clubId=… to approve or reject developer access for their club.
Types
Reference for JSON bodies from /v1/partner/…, common errors, and the web checkout helper. Nullable fields use | null; optional keys may be omitted. Json is an opaque club-defined object from the database.
Club & courts
GET /v1/partner/clubs/{clubId}
type PartnerClub = {
id: string;
name: string;
description: string | null;
logo: string | null;
bannerImage: string | null;
address: Json | null;
contactInfo: Json | null;
businessHours: BusinessHoursEntry[];
timeZone: string; // IANA, e.g. "Europe/Budapest" — all wall clocks are in this zone
currency: string; // ISO 4217, e.g. "HUF"
membershipRequired: boolean;
requireBookingApproval: boolean;
bookingDecidedByLottery: boolean;
courts: PartnerCourt[];
};
type BusinessHoursEntry = {
dayOfWeek: number; // 0–6 (Sunday–Saturday)
openTime: string; // "HH:mm"
closeTime: string;
isClosed: boolean;
};
type PartnerCourt = {
id: string;
name: string;
courtNumber: number | null;
type: string | null;
surface: string | null;
amenities: Json | null;
hourlyRate: Json | null; // raw DB; see Court pricing for NormalizedCourtPricing
maintenanceStatus: string;
isActive: boolean;
};Court pricing
GET /v1/partner/clubs/{clubId}/courts/prices
type CourtPricesResponse = {
courts: {
id: string;
name: string;
pricing: NormalizedCourtPricing;
}[];
};
/** @tennis-booking/court-pricing */
type NormalizedCourtPricing = {
default: HourlyRateSlot[];
intervals: SeasonalInterval[];
};
type HourlyRateSlot = {
price: number;
currency: string;
from: string; // "HH:mm"
to: string;
isDefault?: boolean;
};
type SeasonalInterval = {
from: string; // YYYY-MM-DD
to: string;
hourlyRate: HourlyRateSlot[];
};Timeslots
GET /v1/partner/clubs/{clubId}/timeslots
type TimeSlotsResponse = {
availableTimes: string[]; // HH:mm slot starts in club-local wall time
date: string; // YYYY-MM-DD
courtId: string;
totalSlots: number;
timeZone: string; // IANA zone of the club
businessHours: {
openTime: string;
closeTime: string;
dayOfWeek: number;
};
reason?: TimeslotsEmptyReason; // when availableTimes is empty for a known reason
message?: string; // human-readable form of reason
};
type TimeslotsEmptyReason =
| "club_closed"
| "court_unavailable"
| "maintenance"
| "day_blocked"
| "date_in_past"
| "beyond_booking_window"
| "club_not_accepting_bookings"; // additive: treat unknown values as "no slots"Equipment
GET /v1/partner/clubs/{clubId}/equipment
type PartnerEquipment = {
id: string;
clubId: string;
name: string;
description: string | null;
price: string | number; // Decimal from DB
currency: string;
stock: number;
image: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
availableUnits?: number; // with startTime + endTime query params
};Booking
POST /v1/partner/clubs/{clubId}/bookings · 201
type PartnerBooking = {
id: string;
clubId: string;
courtId: string;
startTime: string; // club wall clock "YYYY-MM-DD HH:mm"
endTime: string;
duration: number; // minutes
totalPrice: number;
currency: string;
status: string;
isLotteryEntry: boolean;
partnerDeveloperId: string | null;
externalReference: string | null;
/** Create only: true when an existing booking was returned (HTTP 200). */
idempotentReplay?: boolean;
};GET …/bookings/{bookingId} · 200
type PartnerBookingDetails = PartnerBooking & {
/** Settled online or registered payments on this booking, net of refunds. */
paidAmount: number;
/** When an unpaid draft hold lapses; null otherwise. */
draftExpiresAt: string | null;
cancelledAt: string | null;
createdAt: string | null;
};Payment
POST …/bookings/{bookingId}/payment · 200
type PartnerPaymentRegistration = {
paymentId: string;
status: "completed";
};Cancel
POST …/bookings/{bookingId}/cancel · 200
type PartnerCancelResult = {
cancelled: true;
bookingId: string;
status: "cancelled";
alreadyCancelled: boolean; // true when it was already cancelled (retry)
cancelledAt: string | null;
};Errors
Every 4xx / 5xx on Partner routes returns the same nested envelope. Branch on error.code — never on the HTTP status alone, and never on error.message (copy may change without notice).
type PartnerApiError = {
error: {
/** Stable machine-readable code. Branch on this. */
code: PartnerErrorCode;
/** Human-readable; for logs and support, not for branching. */
message: string;
/** Extra context; may be empty. */
details: Record<string, unknown>;
/** Correlates with the x-request-id response header. */
requestId: string;
};
};
type PartnerErrorCode =
| "PARTNER_UNAUTHORIZED" // 401 missing/invalid API key
| "PARTNER_RATE_LIMITED" // 429 fair-use limit (see Rate limits)
| "PARTNER_CLUB_NOT_APPROVED" // 403 your access request is pending/rejected
| "PARTNER_CLUB_NOT_INTEGRABLE" // 422 club not open to partners
| "PARTNER_CLUB_NOT_FOUND" // 404 unknown, or never requested by you
| "PARTNER_COURT_NOT_FOUND" // 404
| "PARTNER_COURT_UNAVAILABLE" // 409 maintenance / inactive
| "PARTNER_BOOKING_NOT_FOUND" // 404
| "PARTNER_BOOKING_NOT_OWNED" // 403 booking belongs to another partner
| "PARTNER_BOOKING_NOT_CANCELLABLE" // 409
| "PARTNER_BOOKING_HAS_PAYMENTS" // 409 refund required first
| "PARTNER_BOOKING_STATE_INVALID" // 409
| "PARTNER_BOOKING_TOO_LONG" // 400 longer than the club allows
| "PARTNER_SLOT_UNAVAILABLE" // 409 slot taken meanwhile
| "PARTNER_EXTERNAL_REFERENCE_CONFLICT" // 409 reference used for another slot
| "PARTNER_REQUEST_IN_PROGRESS" // 409 concurrent create on this reference or court; retry shortly
| "PARTNER_VALIDATION" // 400 bad request payload/params
| "PARTNER_PAYMENT_VALIDATION" // 400 bad payment payload
| "PARTNER_NO_COVER_IMAGE" // 404 club has no banner/logo
| "PARTNER_BUSINESS_HOURS_NOT_CONFIGURED" // 400 no hours for that weekday
| "PARTNER_INTERNAL_ERROR" // 500
| "PARTNER_ROUTE_NOT_FOUND" // 404 no endpoint for this method + path
// Booking-rule codes shared with the TEKKA apps:
| "BOOKING_SLOT_NOT_BOOKABLE" // 409 outside hours / closed / maintenance / hold
| "BOOKING_START_TIME_IN_PAST" // 400
| "BOOKING_TOO_FAR_AHEAD" // 400 beyond the club's booking window
| "BOOKING_RANGE_SPANS_LOTTERY" // 400 split lottery and instant time
| "BOOKING_LOTTERY_ENTRY_DUPLICATE" // 409 one lottery entry per customer
| "FAIRPLAY_QUOTA_EXCEEDED" // 409 over a club fair-play limit (details: reason, limit, used, remaining)
| "CLUB_NOT_ACCEPTING_BOOKINGS" // 403 club inactive or not verified
| "CLUB_NOT_BOOKABLE" // 422 venue has no court booking
| "SMART_FEATURES_PARTNER_SMART_DISCOUNT_NOT_ALLOWED"; // 400 discount fields sent
// Codes are additive-only: new ones may appear, existing ones are never renamed.
// Treat an unrecognised code as a generic failure for that HTTP status.const res = await fetch(url, { headers });
if (!res.ok) {
const body = (await res.json()) as PartnerApiError;
switch (body.error.code) {
case "PARTNER_RATE_LIMITED":
await sleep(Number(res.headers.get("Retry-After") ?? 60) * 1000);
return retry();
case "PARTNER_SLOT_UNAVAILABLE":
return showSlotTaken();
default:
logFailure(body.error.code, body.error.requestId);
}
}Checkout token
Partner API route POST /v1/partner/clubs/{clubId}/bookings/{bookingId}/checkout-token. Request: returnUrl, optional cancelUrl.
type CheckoutTokenResponse = {
token: string; // opaque; single-use after successful return
checkoutUrl: string; // redirect payer here
expiresAt: string; // ISO 8601, 15 min from mint
};After POST …/bookings, status is often draft until hosted payment completes.