Loading
REST endpoints on the Bun backend (apps/api). 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.
Set NEXT_PUBLIC_PARTNER_API_URL or NEXT_PUBLIC_API_URL
https://api.jointekka.com/v1/partnerJSON over HTTPS.
Include X-Request-Id (optional) for support correlation.
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. Until approved, protected routes return an error (typically 403).
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.
Public-safe club profile: hours, court list, and flags such as membership or lottery mode.
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}
{
"id": "cl_…",
"name": "Example Club",
"description": "…",
"logo": "https://…",
"bannerImage": "https://…",
"address": { },
"contactInfo": { },
"businessHours": [ { "dayOfWeek": 1, "openTime": "08:00", "closeTime": "22:00", "isClosed": false } ],
"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…"
}
}
// 403 when club access is not approved:
// "code": "PARTNER_CLUB_NOT_APPROVED"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
Location: <resolved image URL>Normalized pricing configuration per active court (used with duration when quoting).
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}/courts/prices
{
"courts": [
{
"id": "ct_…",
"name": "Court 1",
"pricing": { }
}
]
}Start times the court is bookable for the given local date. Excludes blocks from existing bookings, tournaments, group lessons, maintenance, and closed days.
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}/timeslots?courtId=&date=2026-04-23
courtId (required) — target courtdate (required) — YYYY-MM-DDexcludeBookingId (optional) — ignore this booking when computing conflicts (e.g. editing a hold)excludeTournamentId (optional) — skip blocks from a tournament{
"availableTimes": [ "2026-04-23T09:00:00", "2026-04-23T10:00:00" ],
"date": "2026-04-23",
"courtId": "ct_…",
"totalSlots": 12,
"businessHours": {
"openTime": "08:00",
"closeTime": "22:00",
"dayOfWeek": 3
}
}{
"availableTimes": [],
"date": "2026-04-23",
"courtId": "ct_…",
"totalSlots": 0,
"message": "Club closed this day"
}// 400 — missing query params
{ "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": "…" } }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
startTime / endTime — both optional, but you must pass both to get availableUnits[ { "id": "…", "name": "Racket", "stock": 4, "clubId": "…" } ][ { "id": "…", "name": "Racket", "stock": 4, "availableUnits": 2, … } ]Creates a user for the primary customer if needed, then places the booking. May return validation or business rule errors (HTTP 4xx) with a machine-oriented code in some cases.
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}/bookings
{
"courtId": "ct_…",
"startTime": "2026-04-23T10:00:00",
"endTime": "2026-04-23T11:00: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 }
]
}{ /* PartnerBooking — see Types section */
"id": "bk_…",
"clubId": "cl_…",
"courtId": "ct_…",
"startTime": "2026-04-23T10:00:00",
"endTime": "2026-04-23T11:00:00",
"duration": 60,
"totalPrice": 8000,
"currency": "HUF",
"status": "draft",
"isLotteryEntry": false,
"partnerDeveloperId": "…",
"externalReference": "your-order-123"
}{
"error": {
"code": "PARTNER_SLOT_UNAVAILABLE",
"message": "Human-readable message",
"details": {},
"requestId": "3f0c…"
}
}
// Also seen here: PARTNER_VALIDATION, PARTNER_COURT_NOT_FOUND,
// PARTNER_COURT_UNAVAILABLE, PARTNER_CLUB_NOT_APPROVED, PARTNER_RATE_LIMITEDUse 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 /api/developers/checkout-token, not this endpoint.
Example: https://api.jointekka.com/v1/partner/clubs/{clubId}/bookings/bk_…/payment
{
"amount": 15000,
"currency": "HUF",
"providerReference": "psp-txn-id-or-receipt-123",
"metadata": { "lane": "pos-2" }
}{ "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, PARTNER_RATE_LIMITEDCancel a booking. 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
{
"reason": "Customer requested"
}
// or empty object / omit body
{}{ "cancelled": true }{
"error": {
"code": "PARTNER_BOOKING_HAS_PAYMENTS",
"message": "…",
"details": {},
"requestId": "3f0c…"
}
}
// Also: PARTNER_BOOKING_NOT_FOUND, PARTNER_BOOKING_NOT_OWNED,
// PARTNER_BOOKING_NOT_CANCELLABLE, PARTNER_RATE_LIMITEDSend 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.
Two APIs, two auth modes
POST /api/developers/checkout-token only.| 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 /api/developers/checkout-token{
"bookingId": "bk_…",
"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.
Your backend TEKKA
──────────── ─────
POST /v1/partner/…/bookings → create booking (draft)
POST /api/developers/ → mint token (session)
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).
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.
POST …/bookings with primaryCustomer and externalReference. Save id and status (often draft).POST /api/developers/checkout-token with HTTPS returnUrl / optional cancelUrl.checkoutUrl (full page, not iframe).returnUrl; show order confirmation using externalReference. Booking becomes confirmed after PSP + webhooks complete.// After checkout-token response:
res.redirect(302, checkoutUrl);
// returnUrl you minted earlier:
// https://yourapp.com/orders/8842/complete| 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 managers open /partner-access?clubId=… to approve or reject developer access for their club.
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.
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[];
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;
};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[];
};GET /v1/partner/clubs/{clubId}/timeslots
type TimeSlotsResponse = {
availableTimes: string[]; // ISO local timestamps for slot starts
date: string; // YYYY-MM-DD
courtId: string;
totalSlots: number;
businessHours?: {
openTime: string;
closeTime: string;
dayOfWeek: number;
};
message?: string; // when availableTimes is empty (closed, maintenance, …)
};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
};POST /v1/partner/clubs/{clubId}/bookings · 201
type PartnerBooking = {
id: string;
clubId: string;
courtId: string;
startTime: string;
endTime: string;
duration: number; // minutes
totalPrice: number;
currency: string;
status: string;
isLotteryEntry: boolean;
partnerDeveloperId: string | null;
externalReference: string | null;
};POST …/bookings/{bookingId}/payment · 200
type PartnerPaymentRegistration = {
paymentId: string;
status: "completed";
};POST …/bookings/{bookingId}/cancel · 200
type PartnerCancelResult = { cancelled: true };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 club has not approved you
| "PARTNER_CLUB_NOT_INTEGRABLE" // 403 club not open to partners
| "PARTNER_CLUB_NOT_FOUND" // 404
| "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_SLOT_UNAVAILABLE" // 409 slot taken meanwhile
| "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
// 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);
}
}Web session route POST /api/developers/checkout-token. Request: bookingId, 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.