# Tixbae Developer API Read-only HTTP access to RSVP campaign data for approved third parties. Version 1.0.0. Last updated 2026-08-06. Base URL: https://api.tixbae.com/developer/v1 Auth: Authorization: Bearer tbk_live_... Format: JSON (application/json) on every endpoint except llms.txt and skill.zip. Machine-readable spec: https://api.tixbae.com/developer/v1/openapi.json Human reference: https://api.tixbae.com/developer/v1/docs This file: https://api.tixbae.com/developer/v1/llms.txt Claude skill (zip): https://api.tixbae.com/developer/v1/skill.zip ## READ THIS FIRST — the four rules integrators get wrong These four are the difference between an integration that works and one that silently corrupts data. Everything else on this page is detail. 1. THE FIELD SET VARIES PER API KEY. NEVER ASSUME A FIELD EXISTS. Each key carries a per-campaign allow-list of RSVP field keys. Only those keys appear inside the "fields" object of a respondent. A field that is not allow-listed, or that a respondent left blank, is OMITTED from the object — it is NEVER present with a null value. So `"email" in fields` is the correct test and `fields.email !== null` is not. Write code that reads what is there; do not build a fixed-column model and index into it. Two keys pointed at the same campaign can legitimately return different shapes, and an operator can widen or narrow an allow-list at any time without warning. 2. PAGE TO EXHAUSTION USING meta.totalPages. DO NOT STOP AT A SHORT PAGE. A page can contain fewer than `limit` rows and still not be the last page — responses are filtered server-side, so short pages in the middle are normal. The loop terminates when `page >= meta.totalPages`, never when `data.length < limit`. Read `meta.totalPages` fresh from every response; it can grow while you are paging because RSVPs keep arriving. 3. ON 429, HONOUR Retry-After. The rate limit is 120 requests per minute per key. Exceeding it returns HTTP 429 with a `Retry-After` header (seconds). Sleep for that many seconds and retry the SAME request. Do not retry immediately, do not retry in a tight loop, and do not open parallel connections to route around it. The limit is enforced per backend process, so treat the exact number as a guideline and the 429 response as the authority. 4. submittedAt IS A LAST-MODIFIED TIMESTAMP. UPSERT BY id; NEVER APPEND BLINDLY. `submittedAt` is bumped when a respondent EDITS their RSVP, not only when they first submit it. So an incremental sync using `?since=` will re-deliver rows you have already stored, with changed content and the same `id`. Store rows keyed by `id` and upsert. If you append, an edited RSVP becomes a duplicate attendee. Same consequence in reverse: do not treat a row's absence from a `since` window as a deletion — it only means it was not edited. ## Authentication Send your key as a bearer token on every request: Authorization: Bearer tbk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx Keys look like `tbk_live_` followed by 64 hexadecimal characters. They are issued by a Tixbae super admin and shown exactly once at creation time; Tixbae stores only a hash and cannot recover a lost key. Treat the key as a secret: server-side only, never in browser JavaScript, a mobile app, or a public repo. A key is rejected (401) if it is unknown, revoked, past its `expiresAt`, or belongs to a deactivated account. During a key rotation the old key stays valid for a short overlap window so a live integration does not break mid-swap. There is no login, no refresh token, and no OAuth flow on this API. ## Scope model A key grants access to an explicit list of campaigns, and within each campaign an explicit list of field keys. Nothing else on the Tixbae platform is reachable with a developer key: no events, tickets, orders, participants, payments or race data. There are no write endpoints anywhere under /developer/v1 — every request is a GET, and no request you can construct will mutate Tixbae data. Three values are never returned under any circumstances, even if an operator mistakenly adds them to an allow-list: `ipAddress`, `token`, `guestToken`. ## Endpoints There are exactly three data endpoints. ### GET /campaigns Lists the campaigns this key may read. No parameters. curl -sS https://api.tixbae.com/developer/v1/campaigns \ -H "Authorization: Bearer $TIXBAE_API_KEY" Response: { "data": [ { "id": "cmc1abcd0000xyz", "slug": "cutting-edge-academy-graduation-2026", "title": "Cutting Edge Academy — Graduation 2026", "eventDate": "2026-09-12T02:00:00.000Z", "location": "The House Main Hall, Bandung", "responseCount": 128 } ] } `eventDate` and `location` may be null — a campaign is not required to set them. A key with no campaigns assigned returns `{"data": []}`, not an error. Use the `id` from this list for the other two endpoints; do not hardcode ids. ### GET /campaigns/{id}/summary Aggregate counts for one campaign. curl -sS "https://api.tixbae.com/developer/v1/campaigns/cmc1abcd0000xyz/summary" \ -H "Authorization: Bearer $TIXBAE_API_KEY" Response: { "campaign": { "id": "cmc1abcd0000xyz", "slug": "cutting-edge-academy-graduation-2026", "title": "Cutting Edge Academy — Graduation 2026" }, "totals": { "responses": 128, "attending": 120, "notAttending": 8 }, "breakdowns": { "onsite_online": { "Onsite": 96, "Online (for overseas students only)": 24 } } } `breakdowns` counts answer values for single-choice questions (RADIO and SELECT fields) that are ALSO in your key's allow-list. A question you are not allowed to read does not appear here — same omission rule as rule 1. `breakdowns` is `{}` when no such field is allow-listed, and the set of keys inside it can change if an operator adjusts your allow-list. Do not assume `totals` has exactly the keys shown. Read the counters you recognise and ignore any others; new ones may be added. ### GET /campaigns/{id}/respondents Paginated respondent rows, limited to your key's allow-listed fields. Query parameters: page 1-based page number. Integer >= 1. Default 1. limit Rows per page. Integer 1..200. Default 50. since ISO-8601 timestamp. Returns rows whose submittedAt is STRICTLY greater than this value. See rule 4 — this is a last-modified filter, not a created-at filter. Unknown query parameters are rejected rather than ignored, so a typo like `?pge=2` is an error, not a silent full-first-page. curl -sS "https://api.tixbae.com/developer/v1/campaigns/cmc1abcd0000xyz/respondents?page=1&limit=100" \ -H "Authorization: Bearer $TIXBAE_API_KEY" curl -sS "https://api.tixbae.com/developer/v1/campaigns/cmc1abcd0000xyz/respondents?since=2026-08-05T00:00:00.000Z" \ -H "Authorization: Bearer $TIXBAE_API_KEY" Response: { "data": [ { "id": "cmr9zzzz0001abc", "submittedAt": "2026-08-05T09:12:00.000Z", "attendanceStatus": "YES", "fields": { "name": "Rina", "email": "rina@example.com" } } ], "meta": { "total": 128, "page": 1, "limit": 50, "totalPages": 3 } } Row shape: id Stable, unique, permanent. Your upsert key. Never changes, including when the respondent edits their RSVP. submittedAt ISO-8601 UTC. LAST MODIFIED, not created. See rule 4. attendanceStatus "YES", "NO" or "MAYBE". A first-class column, not a custom field — it is ALWAYS present regardless of the allow-list, and it never appears inside `fields`. fields Object of allow-listed answers keyed by RSVP field key. Values are strings, or arrays of strings for multi-select (checkbox) questions. Absent key = not allow-listed OR not answered. Never null. May be `{}`. Ordering is `submittedAt` ascending, then `id` ascending. That secondary sort is what makes pagination stable when two responses share a timestamp; without it rows can be duplicated or skipped across page boundaries. Do not re-sort pages client-side before you have collected them all. ## Errors Every error uses the same envelope: { "error": { "code": "forbidden", "message": "Campaign is not in scope for this API key." } } HTTP code Meaning and what to do ---- ------------------- ------------------------------------------------ 400 bad_request Malformed or invalid query parameter (bad page, / invalid_parameter limit above 200, unparseable since, unknown parameter name). Fix the request. Do NOT retry. 401 unauthorized Missing, malformed, unknown, revoked or expired key, or a deactivated account. Do NOT retry with the same key; get a new one. 403 forbidden The campaign exists but is not in this key's scope. Ask the Tixbae operator to add it. 404 not_found No such campaign, or it was deleted. 403 and 404 are deliberately distinct: the API will not tell you whether an out-of-scope id exists. 429 rate_limited Over 120 req/min. Sleep for `Retry-After` seconds, then retry the same request. 500 internal_error Transient. Retry once with backoff, then alert. Retry policy: retry only 429 (after `Retry-After`) and 5xx (exponential backoff, cap the attempts). Never auto-retry 400, 401, 403 or 404 — they will fail identically forever and a retry loop only burns your rate limit. ## Worked example — full sync, TypeScript const BASE = "https://api.tixbae.com/developer/v1"; const KEY = process.env.TIXBAE_API_KEY!; type Respondent = { id: string; submittedAt: string; attendanceStatus: "YES" | "NO" | "MAYBE"; fields: Record; }; type Page = { data: Respondent[]; meta: { total: number; page: number; limit: number; totalPages: number }; }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function get(url: string): Promise { for (let attempt = 0; attempt < 5; attempt++) { const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` }, }); // Rule 3: honour Retry-After on 429. if (res.status === 429) { const wait = Number(res.headers.get("Retry-After") ?? 60); await sleep(wait * 1000); continue; } if (res.status >= 500) { await sleep(2 ** attempt * 1000); continue; } if (!res.ok) { // 400/401/403/404 are permanent. Do not retry. const body = await res.json().catch(() => ({})); throw new Error( `${res.status} ${body?.error?.code ?? "error"}: ${body?.error?.message ?? res.statusText}`, ); } return (await res.json()) as T; } throw new Error("Gave up after repeated 429/5xx responses"); } async function fetchAllRespondents( campaignId: string, since?: string, ): Promise { const out: Respondent[] = []; let page = 1; let totalPages = 1; // Rule 2: drive the loop off meta.totalPages, never off a short page. do { const qs = new URLSearchParams({ page: String(page), limit: "200" }); if (since) qs.set("since", since); const body = await get( `${BASE}/campaigns/${campaignId}/respondents?${qs}`, ); out.push(...body.data); totalPages = body.meta.totalPages; page += 1; } while (page <= totalPages); return out; } // Rule 4: upsert by id. `since` re-delivers EDITED rows, so appending // would duplicate every attendee who changed their answer. function mergeById( store: Map, incoming: Respondent[], ): void { for (const row of incoming) store.set(row.id, row); } // Rule 1: never assume a field exists. Read defensively. function displayName(r: Respondent): string { const name = r.fields["name"]; if (typeof name === "string" && name.length > 0) return name; return `Guest ${r.id.slice(-6)}`; } ## Worked example — full sync, Python import os import time import requests BASE = "https://api.tixbae.com/developer/v1" KEY = os.environ["TIXBAE_API_KEY"] HEADERS = {"Authorization": f"Bearer {KEY}"} def get(url, params=None): for attempt in range(5): res = requests.get(url, headers=HEADERS, params=params, timeout=30) # Rule 3: honour Retry-After on 429. if res.status_code == 429: time.sleep(int(res.headers.get("Retry-After", 60))) continue if res.status_code >= 500: time.sleep(2 ** attempt) continue if not res.ok: # 400/401/403/404 are permanent. Do not retry. body = {} try: body = res.json() except ValueError: pass err = body.get("error", {}) raise RuntimeError( f"{res.status_code} {err.get('code', 'error')}: " f"{err.get('message', res.reason)}" ) return res.json() raise RuntimeError("Gave up after repeated 429/5xx responses") def fetch_all_respondents(campaign_id, since=None): rows = [] page = 1 total_pages = 1 # Rule 2: drive the loop off meta.totalPages, never off a short page. while page <= total_pages: params = {"page": page, "limit": 200} if since: params["since"] = since body = get(f"{BASE}/campaigns/{campaign_id}/respondents", params) rows.extend(body["data"]) total_pages = body["meta"]["totalPages"] page += 1 return rows # Rule 4: upsert by id. `since` re-delivers EDITED rows. def merge_by_id(store, incoming): for row in incoming: store[row["id"]] = row return store # Rule 1: never assume a field exists. def display_name(row): name = row["fields"].get("name") return name if isinstance(name, str) and name else f"Guest {row['id'][-6:]}" if __name__ == "__main__": campaigns = get(f"{BASE}/campaigns")["data"] store = {} for campaign in campaigns: merge_by_id(store, fetch_all_respondents(campaign["id"])) print(len(store), "respondents") ## Incremental sync recipe 1. First run: page to exhaustion with no `since`. Store every row keyed by `id`. 2. Record the maximum `submittedAt` you saw, as a UTC ISO-8601 string. 3. Next run: page to exhaustion with `since=` and UPSERT by `id`. Rows you already hold will come back with newer content — that is the point, not a bug. 4. Advance the watermark only after the whole sync succeeds. A crash mid-sync must re-run the same window, and upsert makes that harmless. 5. Never delete a stored row just because it was absent from a `since` window. Absence means "not edited", not "withdrawn". To detect removals, do a periodic full sync with no `since` and reconcile against the complete set. ## Data notes - Field keys are per campaign and stable once created. Read them from the data you receive, or ask your Tixbae contact for the allow-list on your key; do not hardcode a schema derived from one sample response. - Values inside `fields` are strings, or arrays of strings for multi-select questions. Numbers and booleans arrive as the strings the respondent chose. - All timestamps are ISO-8601 in UTC. Campaigns are Indonesian; render in Asia/Jakarta if you display them. - Text is UTF-8 and may contain non-ASCII characters. - This data is real attendees' personal information. Handle it accordingly: transport over HTTPS only, store no more than you need, and do not republish fields you were granted for internal use. ## Support Contact your Tixbae operator to change which campaigns or fields a key can read, to rotate a key, or to report a discrepancy. Field allow-lists and campaign scopes are granted by a Tixbae super admin; there is no self-serve endpoint for them on this API.