A partner's guide
Add court booking & payments to your product in an afternoon
This is the friendly, start-to-finish version of the Partner API reference. We'll walk the whole journey — from your first API key to a paid, confirmed reservation — and link to the exact reference entry for every call so you always know where the precise contract lives.
The API is a thin, honest REST layer over the same booking engine our own apps use. If you can call fetch, you can build on it. No SDK required — just an API key, a club that has approved you, and the seven endpoints below.
What you'll build
By the end of this guide you'll have a working flow that lets one of your users pick a court and a time, pay, and walk away with a confirmed booking — all without ever leaving your product (or with a quick, hosted detour to pay, if you'd rather we handle cards). Here is the shape of it:
Your product TEKKA Partner API
──────────── ─────────────────
1. show open times ──► GET …/timeslots
2. user confirms slot
3. create the reservation ──► POST …/bookings (status: draft)
4. collect payment ─┐
├─ hosted ─► POST /api/developers/checkout-token
└─ your PSP ─► POST …/bookings/{id}/payment
5. show confirmation ◄─────────── booking becomes confirmedAvailability
Real open slots, with tournaments, lessons, maintenance and closures already subtracted.
Bookings
Create, price, and cancel reservations tied to your own order ids.
Payments
Charge on your own PSP, or hand off to our hosted checkout — your choice, per booking.
Before you start
Three things stand between you and your first booking. Two are one-time setup; the third is a handshake with each club you want to serve.
- A partner profile. Register your organization in the developer hub. This is also where you list the HTTPS origins hosted checkout is allowed to return users to — you'll want that later.
- An API key. Create one on your dashboard. The full key is shown exactly once — copy it into a secret manager immediately.
- Approved access to a club. Every club-scoped call is gated on an explicit approval from that club's manager. More on this in chapter 2.
Everything below assumes the base URL https://api.jointekka.com/v1/partner. You can confirm your environment's value in the reference's Overview.
1 · Your credentials
An API key looks like tbk_<keyId>_<secret>. Send it on every partner request as a bearer token (or in the X-Api-Key header — same string, your pick):
curl https://api.jointekka.com/v1/partner/clubs/CLUB_ID \
-H "Authorization: Bearer tbk_live_xxx_yyy"One nuance worth internalizing early, because it trips people up: there are two ways to talk to TEKKA, and they use different credentials.
| Surface | Auth | What it's for |
|---|---|---|
Partner API/v1/partner/… | API key (Bearer) | Reading clubs, availability; creating, paying for, and cancelling bookings — server to server. |
Web app/api/developers/… | Developer session cookie | Minting a hosted-checkout token. That's the only call you make with your session. |
Full details, including the 403 you'll see before a club approves you, live in Authentication.
2 · Getting a club to approve you
TEKKA is a marketplace of independent clubs, so access is per-club, not global. You request access to a club by its id from your dashboard's Club access tab; a manager at that club then approves or rejects you from /partner-access. Until they say yes, every club-scoped endpoint returns an access error (usually 403).
In practice: onboard the club as part of your sales conversation, have them approve the request, then treat the approval as a gate in your own code — if a call comes back with an access error, surface "pending club approval" rather than a generic failure.
The manager-side flow is documented under Club admin approval.
3 · Read the club and its courts
Start by pulling the club profile. It gives you the court list you need for everything else, plus the flags that shape your UX: membershipRequired, requireBookingApproval, and currency.
const res = await fetch(
"https://api.jointekka.com/v1/partner/clubs/CLUB_ID",
{ headers: { Authorization: `Bearer ${process.env.TEKKA_API_KEY}` } }
);
const club = await res.json();
// club.courts → [{ id, name, surface, hourlyRate, maintenanceStatus, … }]Need prices to render a quote before booking? The dedicated pricing endpoint returns a normalized structure (default rates plus any seasonal intervals) that's easier to reason about than the raw hourlyRate blob.
Field-by-field shapes: GET club, Court pricing, and the Types section.
4 · Find open time
This is the endpoint you'll call most. Give it a court and a local date; it returns the start times that are genuinely bookable. The heavy lifting — subtracting existing bookings, tournaments, group lessons, maintenance windows, and closed days — is done for you, so you can render availableTimes straight into a picker.
const url = new URL(
"https://api.jointekka.com/v1/partner/clubs/CLUB_ID/timeslots"
);
url.searchParams.set("courtId", "COURT_ID");
url.searchParams.set("date", "2026-04-23"); // YYYY-MM-DD, club-local
const slots = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.TEKKA_API_KEY}` },
}).then((r) => r.json());
// { availableTimes: ["2026-04-23T09:00:00", …], totalSlots: 12, businessHours: {…} }When a day yields nothing, you still get 200 with an empty array and a human message ("Club closed this day", "Court is under maintenance") — handy for showing the right empty state instead of a spinner that never resolves. Note the timestamps come back as local wall-clock strings; pass them straight back when you book.
Query params (including excludeBookingId for edit flows) and every empty-state message: GET timeslots.
5 · Create the booking
Post the slot along with a primaryCustomer. If that person doesn't exist on TEKKA yet, we create a lightweight account for them automatically — you don't manage passwords or identities. Always attach your own order id as externalReference: it flows back on the booking object and is how you'll reconcile later.
const booking = await fetch(
"https://api.jointekka.com/v1/partner/clubs/CLUB_ID/bookings",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TEKKA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
courtId: "COURT_ID",
startTime: "2026-04-23T10:00:00", // straight from availableTimes
endTime: "2026-04-23T11:00:00",
primaryCustomer: {
email: "[email protected]",
name: "Player One",
phone: "+3612345678",
},
externalReference: "order-8842",
}),
}
).then((r) => r.json());
// → { id: "bk_…", status: "draft", totalPrice: 8000, currency: "HUF", … }The status you get back tells you what happens next — read it, don't assume it:
| status | Meaning |
|---|---|
| draft | Held for payment. Proceed to hosted checkout, or register a payment. Holds expire after ~15 minutes. |
| pending | Waiting on the club — manual approval, or a lottery entry that will be drawn later. |
| confirmed | Done. The club takes no online payment and requires no approval; the court is booked. |
A few honest guardrails to design around: partner bookings use standard court pricing (smart-discount and promo tokens are rejected on purpose), a slot can be a lottery entry rather than an instant book, and a range that straddles a lottery window is refused so you split it. All of these come back as clear 4xx errors with an error.code.
The full request body — equipment rentals, additional players — and every error code are in POST booking and the Booking type.
6 · Take the payment
Here you pick your adventure. Both paths end with a confirmed booking; they differ only in who handles the card.
Path A · Hosted checkout (we handle cards)
Mint a single-use token for the draft booking, then redirect the payer to the checkoutUrl. They pay on TEKKA using the club's configured providers (Stripe, TBC, and friends), and land back on your returnUrl. They never sign in. Remember: this one call uses your developer session, not the API key, and lives on the web app.
// On your server, with the developer session cookie:
const { checkoutUrl } = await fetch("/api/developers/checkout-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bookingId: "bk_…",
returnUrl: "https://yourapp.com/orders/8842/complete",
cancelUrl: "https://yourapp.com/orders/8842",
}),
}).then((r) => r.json());
// Full-page redirect (not an iframe):
res.redirect(302, checkoutUrl);The token and the draft hold both live ~15 minutes. returnUrl and cancelUrl must match an HTTPS origin you registered on your profile. TEKKA won't append your order id for you — build it into the URL yourself (this is why externalReference matters).
Path B · You charged them (your PSP, cash, invoice)
Already took the money on your side? Just tell TEKKA. Post the amount, currency, and your provider's reference, and the booking is marked paid.
await fetch(
"https://api.jointekka.com/v1/partner/clubs/CLUB_ID/bookings/bk_…/payment",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TEKKA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 8000,
currency: "HUF",
providerReference: "psp-txn-abc123",
}),
}
);
// → { paymentId: "…", status: "completed" }Hosted checkout end to end — flow diagram, use cases, and the errors-and-limits table — is in Hosted checkout. The server-to-server variant is POST payment.
7 · Cancellations & the errors you'll actually hit
Cancelling is a single POST. One rule to bake in: a booking with a completed payment can't be cancelled through the partner API — that's a refund conversation, by design, not a silent reversal.
await fetch(
"https://api.jointekka.com/v1/partner/clubs/CLUB_ID/bookings/bk_…/cancel",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TEKKA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ reason: "Customer requested" }),
}
);
// → { cancelled: true }Every error on a partner route is the same shape — { error: { code, message, details, requestId } } — so a single handler covers you. Branch on error.code (the machine-stable signal, always present); log error.message and error.requestId — quote the request id to support. A 429 (PARTNER_RATE_LIMITED) means back off for Retry-After seconds.
async function partnerFetch(input: string | URL, init?: RequestInit) {
const res = await fetch(input, init);
if (res.ok) return res.json();
const body = await res.json().catch(() => null);
const code = body?.error?.code ?? "PARTNER_INTERNAL_ERROR";
if (code === "PARTNER_RATE_LIMITED") {
const wait = Number(res.headers.get("Retry-After") ?? 60);
await new Promise((r) => setTimeout(r, wait * 1000));
return partnerFetch(input, init); // one retry, then give up
}
throw Object.assign(new Error(body?.error?.message ?? res.statusText), {
code,
requestId: body?.error?.requestId,
});
}POST cancel, the error shape, and the checkout errors & limits table.
Putting it together
Here's the whole hosted-checkout happy path in one readable file. It's deliberately dependency-free — copy it, swap the constants, and you have a working spike.
const BASE = "https://api.jointekka.com/v1/partner";
const APP = "https://api.jointekka.com"; // your TEKKA web app origin
const KEY = process.env.TEKKA_API_KEY!;
const auth = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
async function bookAndPay(clubId: string, courtId: string, date: string) {
// 1. Find an open slot
const url = new URL(`${BASE}/clubs/${clubId}/timeslots`);
url.searchParams.set("courtId", courtId);
url.searchParams.set("date", date);
const { availableTimes } = await fetch(url, { headers: auth }).then((r) => r.json());
if (!availableTimes.length) throw new Error("No open slots");
const startTime = availableTimes[0];
const endTime = addOneHour(startTime); // your helper
// 2. Create the booking (status: draft)
const booking = await fetch(`${BASE}/clubs/${clubId}/bookings`, {
method: "POST",
headers: auth,
body: JSON.stringify({
courtId,
startTime,
endTime,
primaryCustomer: { email: "[email protected]", name: "Player One" },
externalReference: "order-8842",
}),
}).then((r) => r.json());
if (booking.error) throw new Error(booking.error.code); // e.g. PARTNER_SLOT_UNAVAILABLE
// 3. Mint a hosted-checkout link (developer session, on the web app)
const { checkoutUrl } = await fetch(`${APP}/api/developers/checkout-token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
bookingId: booking.id,
returnUrl: "https://yourapp.com/orders/8842/complete",
cancelUrl: "https://yourapp.com/orders/8842",
}),
}).then((r) => r.json());
// 4. Send the payer to checkoutUrl; booking confirms after they pay.
return checkoutUrl;
}Going to production
Before you flip the switch, walk this list. Most support tickets we see are one of these five things.
Store the key as a secret.
It's shown once. Keep it server-side; never ship it to a browser or mobile bundle.
Register your HTTPS return origins.
Hosted checkout rejects return/cancel URLs whose origin you haven't listed. http://localhost won't work — use an HTTPS tunnel in dev.
Treat externalReference as your idempotency anchor.
Put your order id on every booking so retries and webhooks reconcile to the right order.
Handle 'club not approved' as a first-class state.
Access is per club and can be revoked; don't assume a club you booked yesterday is still approved.
Send X-Request-Id.
Optional, but it lets us trace a specific call if you ever need support.
Where to go next
You now have the whole picture. When you need the exact contract for a call, the reference is the source of truth — and the OpenAPI file drops straight into Postman or your codegen.