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 ten 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 …/bookings/{id}/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 403 PARTNER_CLUB_NOT_APPROVED; a club you have not requested yet answers 404, like an unknown id.
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 — opening hours, existing bookings, tournaments, group lessons, maintenance windows, closed days, slots that already started and dates beyond the club's booking window — is done for you, and the create endpoint applies the same rules, 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: ["09:00", "09:30", …], totalSlots: 12,
// timeZone: "Europe/Budapest", businessHours: {…} }When a day yields nothing, you still get 200 with an empty array, a machine-readable reason (club_closed, maintenance, date_in_past, beyond_booking_window, …) and a human message — handy for showing the right empty state instead of a spinner that never resolves. Slot starts are HH:mm wall-clock strings in the club's time zone (timeZone, an IANA id like Europe/Budapest). Combine one with the requested date before booking.
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-23 10:00", // date + selected "10:00" slot, club time
endTime: "2026-04-23 11: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. A slot outside opening hours or on a closed, maintenance or tournament day is refused with BOOKING_SLOT_NOT_BOOKABLE, and a booking can be at most as long as the club allows (240 minutes unless the club sets its own limit). All of these come back as clear 4xx errors with an error.code.
Retries are safe with an externalReference. If a create times out, send it again unchanged: while the first booking is live, you get it back with 200 and idempotentReplay: true instead of a second booking. Reusing a reference for a different slot is a 409. There is no Idempotency-Key header.
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. Mint the link from your backend with the same Partner API key you used to create the booking.
// On your server, with your Partner API key:
const { checkoutUrl } = await fetch(
`${BASE}/clubs/${clubId}/bookings/${booking.id}/checkout-token`, {
method: "POST",
headers: auth,
body: JSON.stringify({
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, bookingId: "bk_…", status: "cancelled",
// alreadyCancelled: false, cancelledAt: "…" }
// Cancelling again is safe: 200 with alreadyCancelled: 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 KEY = process.env.TEKKA_API_KEY!;
const auth = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
function localDateTime(date: string, time: string, addMinutes = 0) {
const value = new Date(`${date}T${time}:00Z`);
value.setUTCMinutes(value.getUTCMinutes() + addMinutes);
// Club wall clock "YYYY-MM-DD HH:mm" — no offset, no Z.
return value.toISOString().slice(0, 16).replace("T", " ");
}
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 = localDateTime(date, availableTimes[0]);
const endTime = localDateTime(date, availableTimes[0], 60);
// 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
if (booking.status !== "draft") return null; // already firm or awaiting approval
// 3. Mint a hosted-checkout link with the same server-side API key
const { checkoutUrl } = await fetch(
`${BASE}/clubs/${clubId}/bookings/${booking.id}/checkout-token`, {
method: "POST",
headers: auth,
body: JSON.stringify({
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.
Send your order id as externalReference.
A retried create with the same reference returns the original booking (idempotentReplay: true) instead of booking twice. There are no webhooks: read a booking back with GET booking to see payment and status changes.
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.