taptime · legal

API for developers

A simple REST API to browse public events and manage your own events and blog posts from an external app.

1. Authentication

The API uses Bearer tokens. Generate one for your account in Account settings → API. The token is shown only once — store it somewhere safe.

Send it in every request:

Authorization: Bearer <token>
Accept: application/json

Base URL:

https://taptime.fun/api/v1

The API is available to accounts with an active Premium plan, verified accounts and admins: only they can create tokens, and the check runs on every request — when Premium expires, existing tokens get 403 premium_required until it is renewed. Every endpoint requires a token — there is no anonymous access. Writing works only on resources owned by the token's user; other people's data is read-only and limited to public events (see Discover).

  • Each token has an access level and an optional expiry, chosen when you create it: "Read only" (read) allows only GET requests, "Read and write" (read + write) allows everything; an expired token gets 401. A read-only token trying to write gets 403 with code insufficient_scope. Tokens created before this option have full access and no expiry. You can have at most 10 tokens; revoke them any time in the same settings tab. A banned account's tokens stop working (403, code banned).
  • Rate limit: 60 requests per minute per token (300 per IP). Over the limit you get 429 with a Retry-After header; every response carries X-RateLimit-Limit / X-RateLimit-Remaining.
  • Daily creation limit: at most 2 new events per account per day (Europe/Prague calendar day) — POST /events and POST /series (a series counts once; its generated occurrences do not) share it, as does the web app. Over the limit you get 429 with code daily_event_limit and Retry-After (seconds until midnight). Deleting an event does not give the slot back; requests that fail validation do not use one.
  • Responses are always JSON (errors too), even without the Accept header — sending it is still recommended.
  • Versioning: v1 only gets backwards-compatible changes (new fields, new endpoints) — clients must ignore unknown fields. Breaking changes will ship as /api/v2 and v1 will keep working for an announced transition period.
  • Lists are paginated (?page=, ?per_page=1–100, default 20); the response has data, links and meta. Event ids are short hashids (the same as in web URLs); the numeric id is accepted too.
GET /user Basic details of the token owner (id, name, nickname, slug, locale, timezone) plus a token object with abilities and expires_at

2. Events

GET /events My events by relation (?relation=, ?scope=; paginated) — your own by default
POST /events Create a new event
GET /events/{id} Show an event
PATCH /events/{id} Update an event
DELETE /events/{id} Delete an event
POST /events/{id}/images Upload photos (multipart, images[] field)
DELETE /events/{id}/images/{imageId} Delete a photo
POST /events/{id}/images/{imageId}/primary Set the primary photo
  • GET /events?relation= organizing (default: events you organize) | joined (you are a confirmed participant) | invited (a pending invitation) | waitlisted | requested (a join request awaiting the organizer). ?scope= upcoming (active and not over yet, soonest first) | past (the rest, newest first). Without scope you get everything for organizing/joined, and only upcoming for invited/waitlisted (an invitation to a finished event is not actionable). organizing returns the fields above; the other relations return the same shape as /discover/events (organizer, viewer.participation, can_join…) so you can accept an invitation or leave with the join endpoints. Events you were blocked from are not listed.
  • Times: send an ISO 8601 datetime with an offset or Z (recommended). A value without an offset is read as Europe/Prague local time. Responses are always UTC (Z / +00:00).
  • DELETE returns 409 when the event has confirmed participants — cancel it instead (PATCH status=cancelled).
  • Read-only in responses: ticket_price_credits, photos_enabled, current_participants, group (hashid), is_recurring, parent_event, boosted_until, is_boosted, url.

Fields (create/update)

FieldType / allowed values
titlestring, 3–100 chars, required
descriptionstring, max 5000 (HTML is sanitized: only b, i, u, s, em, strong, ul, ol, li, p, br, a, h1–h4, blockquote)
typedrink | food | sport | culture | party | walk | work | game | other
starts_atISO 8601 datetime, required; on create must not be in the past
ends_atISO 8601 datetime, after starts_at (on update also checked against the stored start)
location_namestring, max 100
location_addressstring, max 200
latitude / longitudefloat
is_publicboolean
join_modeopen | password | tickets | request (people send a request the organizer approves; no paid tickets, no waitlist)
join_passwordstring, required if join_mode=password (ignored for other modes)
max_participantsinteger, 2–100
seeking_companyboolean — the event shows up in the "looking for company" card deck on the web. Only for a public event, not imported, join_mode open or request, without a paid ticket and with 2–10 max_participants (422 otherwise)
age_min / age_maxinteger, 1–120
gender_restrictionmale | female
price_czknumeric, 0–10000
price_notestring, max 50
ticket_urlURL, max 500 chars
status (update only)active | cancelled — cancelling refunds paid credit tickets and notifies participants; a cancelled event cannot be reactivated

Example — create an event

curl -X POST https://taptime.fun/api/v1/events \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "title": "Pivo na terase",
    "type": "drink",
    "starts_at": "2027-06-01T18:00:00+02:00",
    "join_mode": "open"
  }'

Example response

{
  "data": {
    "id": "eoQ1wr",
    "title": "Pivo na terase",
    "type": "drink",
    "status": "active",
    "starts_at": "2027-06-01T16:00:00+00:00",
    "join_mode": "open",
    "is_public": true,
    "current_participants": 1,
    "images": [],
    "url": "https://taptime.fun/events/eoQ1wr"
  }
}

Uploading photos

Multipart upload, one or more files under the images[] field (max 5 MB each, max 10 files per request). The first photo uploaded to an event with no photos becomes the primary one automatically.

curl -X POST https://taptime.fun/api/v1/events/eoQ1wr/images \
  -H "Authorization: Bearer <token>" \
  -H "Accept: application/json" \
  -F "images[]=@photo1.jpg" \
  -F "images[]=@photo2.jpg"

3. Recurring events

A series is a template plus a recurrence rule. Its occurrences are ordinary events (managed through /events, marked by series in the event response); the first ~8 weeks are generated on creation and a daily job adds the rest.

GET /series List your own recurring events (paginated)
POST /series Create a recurring event (template + recurrence rule)
GET /series/{id} Show a series
PATCH /series/{id} Update the template — applied to all upcoming occurrences
POST /series/{id}/cancel Cancel the series: stops generation and cancels upcoming occurrences
GET /series/{id}/events Occurrences of the series (?scope=upcoming for active future ones only)

Fields on create

starts_atstart of the FIRST occurrence (ISO 8601; without an offset = Europe/Prague), not in the past, required
frequencydaily | weekly | monthly, required
intervalinteger 1–52, default 1 (every N days/weeks/months)
untilYYYY-MM-DD after the first occurrence — OR
occurrences_limitinteger 2–104 (cannot be combined with until; neither = repeats until cancelled)
duration_minutesinteger 15–1440 (sets ends_at of every occurrence)
title, description, type, location_*, latitude, longitude, is_public, photos_enabled, join_mode, join_password, max_participants, age_*, gender_restriction, price_czk, price_note, ticket_urltemplate — same rules as for events
  • PATCH edits only the template (the recurrence rule cannot be changed after creation, same as on the web) and returns meta.updated_events. Times and status of occurrences and their participants are not touched.
  • POST …/cancel cancels every upcoming occurrence like cancelling an event does (credit tickets refunded, participants notified) and returns meta.cancelled_events. A finite series whose occurrences are all generated is already active=false but can still be cancelled while occurrences remain in the future; otherwise 409.
  • Limit: at most 5 live series per account (still generating, or with occurrences still in the future); creating another returns 409 until you cancel one. The web form has the same limit.
  • Series are addressed by hashid, like events. price_czk is in CZK.
curl -X POST https://taptime.fun/api/v1/series \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "title": "Středeční běh",
    "type": "sport",
    "starts_at": "2027-06-02T18:00:00+02:00",
    "frequency": "weekly",
    "occurrences_limit": 10,
    "duration_minutes": 90,
    "join_mode": "open"
  }'

4. Discover public events

Read-only access to other people's public events, with the same rules as the web list: only active events that have not ended yet, boosted ones first, then by start time.

GET /discover/events List other people's public events (paginated, filters below)
GET /discover/events/{id} Show a public event (a private one is visible only to its organizer and participants/invitees, otherwise 404)

Query parameters (all optional)

ParameterMeaning
qtext search in title, description and location (max 100 chars). Every word must match, by word prefix and ignoring diacritics ("pivo" finds "pivovar"); queries with words shorter than 3 characters fall back to a plain substring search
typedrink | food | sport | culture | party | walk | work | game | other
sourcecommunity | editorial (user-created vs. imported from external sources)
date_from / date_toYYYY-MM-DD, by event start; days are Europe/Prague calendar days
min_participantsinteger, 0–100
seeking_companyboolean, 1 = only events whose organizer is looking for company (join them with POST /events/{id}/join)
lat + lngsearch around a point (both required together); results then include distance_km
radius_km1–200, default 10, used with lat/lng
page, per_pagepagination, per_page 1–100

What the response adds compared to your own events

  • organizer — name, nickname, slug, avatar_url
  • source — {name, url} for imported events, null for user-created ones
  • viewer — your relation to the event: is_organizer, participation (confirmed | pending | waitlisted | requested | declined | null) and can_join
  • distance_km — only when lat/lng are sent
  • Owner-only fields (is_public, join_password…) are never included.
curl "https://taptime.fun/api/v1/discover/events?lat=50.0755&lng=14.4378&radius_km=25&type=culture&per_page=5" \
  -H "Authorization: Bearer <token>" \
  -H "Accept: application/json"

5. Joining events and managing participants

For participants (any event you can see)

POST /events/{id}/join Join someone else's event (or accept an invitation); with join_mode=request it sends a request (viewer.participation = requested). Body: password / ticket_code depending on join_mode
DELETE /events/{id}/join Leave the event / decline the invitation / leave the waitlist / cancel a request (depending on your state)
POST /events/{id}/waitlist Join the waitlist of a full event

Join and waitlist return the event (as in Discover) with your fresh viewer block; leaving returns 204.

Events with a credit-priced ticket (ticket_price_credits > 0) cannot be joined, waitlisted or promoted through the API — the request fails with 422 and code paid_ticket_unsupported. Complete those in the taptime app. Leaving such an event is allowed and refunds the credits.

For organizers (your own events only)

GET /events/{id}/participants List participants of your event (?status=confirmed|pending|waitlisted|requested|declined|banned)
POST /events/{id}/participants Invite a user — body {"user": "<slug>"}
DELETE /events/{id}/participants/{slug} Remove a participant / cancel an invitation / remove from the waitlist / unban; declines a request (the requester stays as declined), deleting a declined request frees it; ?ban=1 blocks a confirmed one
POST /events/{id}/participants/{slug}/promote Promote from the waitlist to confirmed / approve a join request
PUT /events/{id}/participants/{slug}/check-in Check a participant in manually
DELETE /events/{id}/participants/{slug}/check-in Undo a check-in
POST /events/{id}/check-in Check-in by scanned code — body {"code": "..."} (participant QR or ticket code)
GET /events/{id}/tickets Ticket codes of an event with join_mode=tickets
POST /events/{id}/tickets Generate codes — body {"count": 1–100}, capped by max_participants and 500 per event in total
DELETE /events/{id}/tickets/{code} Delete a not yet used code

Users are addressed by slug (the same as in profile URLs; it is in the participant list). A participant is {user:{name,nickname,slug,avatar_url}, status, checked_in_at, joined_at, …}, status = confirmed | pending | waitlisted | requested | declined | banned. Checking in a participant of a credit-priced event releases the held credits to you and cannot be reversed.

Error codes

Refusals in this section carry a machine-readable code next to the message: {"message": "...", "code": "event_full"}.

422 paid_ticket_unsupported The event has a credit-priced ticket — finish joining in the app
422 wrong_password Missing/wrong password (join_mode=password)
422 invalid_ticket_code Missing/invalid/used code (join_mode=tickets)
409 event_full The event is full — try the waitlist
409 cannot_join Joining is not possible (private, cancelled, age/gender, blocked, own event…)
409 already_participant / already_waitlisted / already_invited Already joined / on the waitlist / user already invited
409 not_participant You are neither joined nor invited
403 banned You are blocked from the event
409 ticket_limit_reached / ticket_used Ticket limit / deleting a used ticket
409 already_requested You already sent a join request
403 request_declined The organizer declined your join request
409 request_limit_reached Too many pending requests (100 per event, 20 per user)
409 invite_limit_reached The event already has 100 pending invitations
422 user_not_invitable The user cannot be invited (search visibility turned off in their profile)
curl -X POST https://taptime.fun/api/v1/events/eoQ1wr/join \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"password": "tajne"}'

6. Groups

Groups are addressed by hashid, users by slug. Only the founder manages a group (invites, removes members, approves requests, sets images). Group chat is not part of the API.

GET /groups Your groups (?relation=member default | invited | requested)
GET /groups/discover Public groups you are not in yet (optionally ?lat=&lng=&radius_km=)
POST /groups Create a group
GET /groups/{id} Show a group (a private one is visible only to members, invitees and requesters, otherwise 404)
PATCH /groups/{id} Edit name, description, visibility and location (owner)
DELETE /groups/{id} Delete the group (owner)
POST /groups/{id}/join Accept an invitation; otherwise request to join a public group
DELETE /groups/{id}/join Leave / decline an invitation / cancel a request (depending on state)
GET /groups/{id}/members Members (?status=member — a member; invited | requested — the owner)
POST /groups/{id}/members Invite a user — body {"user": "<slug>"} (owner)
DELETE /groups/{id}/members/{slug} Remove a member / cancel an invitation / decline a request (owner)
POST /groups/{id}/members/{slug}/approve Approve a join request (owner)
POST /groups/{id}/invite-link Permanent invite link {"data": {"url": …}} (owner)
POST /groups/{id}/avatar Upload the avatar (multipart, avatar field, max 2 MB)
DELETE /groups/{id}/avatar Remove the avatar
POST /groups/{id}/cover Upload the cover image (multipart, cover field, max 5 MB)
DELETE /groups/{id}/cover Remove the cover image
GET /groups/{id}/events Group events (?scope=upcoming | past; a member)
PUT /groups/{id}/events/{eventId}/interest Add someone else's event as the group's "we're going" (a member)
DELETE /groups/{id}/events/{eventId}/interest Remove an event from the group's "we're going" (a member)
  • Create body: name (2–60), description (max 500), is_public (default false), location_name, latitude + longitude. Response viewer.role = owner | member | invited | requested | null; viewer.can_request = you can request to join.
  • PATCH takes the same fields as create, all optional (latitude and longitude only together; both null clears the location); renaming also renames the group chat. DELETE removes the memberships and the "we're going" links; the group's events and the chat history are kept, they just no longer belong to a group, and the invite link stops working.
  • Limits: at most 5 groups founded per account and 30 pending invitations per group. Only users who can be found in the app's search (searchable) can be invited.
  • POST /events accepts group (hashid of a group you belong to): the event is then private unless you send is_public, and all confirmed group members are invited — like "Create an event with the group" on the web. The event response has group.
  • GET /groups/{id}/events items are the same as in Discover plus group_link: own (created by the group) | interest (added as "we're going").

Error codes

409group_limit_reachedYou have already created the maximum of 5 groups
409invite_limit_reachedThe group has 30 pending invitations
422invalid_invitee / user_not_invitableThe owner or a user who cannot be found in search cannot be invited
409already_related / has_pending_request / already_requestedThe user is already in the group, invited, or has requested to join
403owner_cannot_leave / cannot_remove_ownerThe owner can neither leave nor be removed
404not_requested / not_relatedThe user has no join request / does not belong to the group

7. Blog posts

GET /posts List your own posts (paginated)
POST /posts Create a post
GET /posts/{slug} Show a post
PATCH /posts/{slug} Update a post
DELETE /posts/{slug} Delete a post
POST /posts/{slug}/cover Upload/replace the cover image (multipart, cover field)
DELETE /posts/{slug}/cover Delete the cover image

Posts are addressed by slug (not numeric id) in the URL, matching the public blog URLs.

Fields (create/update)

FieldType / allowed values
titlestring, 3–150 chars, required
categoriesarray, 1–3 items, required — see below
event_idevent id (hashid as returned by /events, or numeric) — an event you organize or attend; response also has event = hashid
locationstring, max 150
excerptstring, max 300
bodyHTML string, max 20000, required
statusdraft | published (default draft)

Allowed category values

evropa asie afrika amerika australie_oceanie gastronomie kultura_umeni priroda_outdoor mista adrenalin wellness nocni_zivot backpacking rodinna_dovolena digital_nomad roadtrip kolo vlastni_ose ubytovani doprava zivotni_zkusenosti

Example — create a post

curl -X POST https://taptime.fun/api/v1/posts \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "title": "Jak jsme objevili Kutnou Horu",
    "categories": ["mista"],
    "body": "<p>Text příspěvku…</p>",
    "status": "published"
  }'

Cover image

curl -X POST https://taptime.fun/api/v1/posts/jak-jsme-objevili-kutnou-horu/cover \
  -H "Authorization: Bearer <token>" \
  -H "Accept: application/json" \
  -F "cover=@cover.jpg"

8. Comments

GET /events/{id}/comments Comments of an event (root threads with replies, paginated)
POST /events/{id}/comments Add a comment to an event
GET /posts/{slug}/comments Comments of a post
POST /posts/{slug}/comments Add a comment to a post
DELETE /comments/{id} Delete a comment (author or moderator)
  • Who can read: whoever can see the event/post (a private event only its organizer, participants and invitees, a draft only its author — otherwise 404). Who can write: to an event only the organizer and confirmed participants (403 comment_forbidden), to a published post any user.
  • Body (JSON or multipart): body (text, max 2000, HTML tags are stripped), image (file, max 15 MB) — at least one of them; parent = id of the comment you reply to (must belong to the same event/post, otherwise 422 parent_invalid; a reply to a reply is moved under the thread root, threads are two levels deep).
  • At most 10 comments per minute per user (shared with the web app) — over it 429 rate_limited with Retry-After. An event keeps its newest 100 threads; older ones are deleted together with their replies and images.
  • Comment: id, body, image_url, author, parent, replies, viewer.can_delete, created_at. GIFs and translations are web-only.

9. Event expenses

The organizer of an event records who paid what and among whom it is split; the API computes balances and who should send whom how much (the fewest payments). The organizer and confirmed participants can read it (the split concerns everyone); only the organizer, on their own events, can add and delete expenses. Response viewer.can_manage tells which you are.

GET /events/{id}/expenses Expenses + a summary per currency (total, balances, settlement) and who may take part
POST /events/{id}/expenses Add an expense
DELETE /events/{id}/expenses/{expenseId} Delete an expense
  • Create body: title (max 255), amount (0.01–999999, in currency units), currency (CZK default | EUR | USD), paid_by (slug, default the organizer), shared_with (array of slugs, default the organizer + all confirmed participants). Only the organizer and confirmed participants can pay or share, otherwise 422 participant_not_eligible. An event holds at most 50 expenses (409 expense_limit_reached).
  • Currencies are never added together — every currency has its own total, balances and settlement (summary.CZK, summary.EUR…). The share of each person is amount / number of sharers.
  • Each settlement {from, to, amount} carries payment {iban, spd} when the receiving user has a bank account in their profile (spd = the content of a Czech QR Payment code, render it as a QR yourself), otherwise payment is null. The organizer gets it for every settlement, a participant only for the payment they are supposed to send (other people's bank accounts are not shown to everyone).
  • Expenses cannot be edited (as on the web) — delete and re-add.

10. Puls (local posts)

Puls is a short post tied to a place that disappears after 24 hours. You read what is happening around a point and can post your own.

GET /puls?lat=&lng= Posts around a point, nearest first (?radius_km= 1–50, default 10; ?limit= 1–100, default 50)
GET /puls/mine Your active posts
POST /puls Add a post (multipart because of the photo/video)
DELETE /puls/{id} Delete your own post together with the uploaded files
  • Create body: latitude + longitude (required), location_name (max 100), body (max 500; only inline formatting b, i, u, s, em, strong, br is kept), and at most one of image (max 5 MB) or video (max 20 MB; the server re-encodes it and trims it to 6 s). At least text, a photo or a video is required.
  • Limits: one free active post per account. The second active post costs credits in the app and cannot be added through the API (422 paid_post_unsupported). At most 3 new posts per account per day (Europe/Prague day, shared with the web app; only successfully created posts count, deleting one does not give the slot back) — over it 429 daily_puls_limit with Retry-After.
  • Post: id, body, image_url, image_thumb_url, video_url, video_poster_url, latitude, longitude, location_name, distance_km (only in the feed), likes_count, author, viewer.is_mine, expires_at, created_at. Likes and reporting are not in the API yet.
  • Other codes: 409 active_limit_reached (two active posts), 422 post_empty, 403 not_owner on deleting someone else's post.

11. Event photos and plan

GET /events/{id}/photos The event's photo gallery (paginated) + meta: can_upload, my_photos, max_per_user
POST /events/{id}/photos Upload photos (multipart, photos[] field, 1–10 files, each an image up to 15 MB)
DELETE /events/{id}/photos/{photoId} Delete a photo (its author or the organizer)
GET /events/{id}/itinerary The event plan (programme) with its stops
PUT /events/{id}/itinerary Create or replace the plan (organizer)
DELETE /events/{id}/itinerary Delete the plan (organizer)
  • Photos are the guests' gallery, not the event's own cover images (/events/{id}/images). Everyone who can see the event can read it (a private event is 404 for strangers). Upload: the organizer and confirmed, not blocked participants, and only while the event has the gallery on (photos_enabled — set it on create/update); at most 10 photos per user per event (409 photo_limit_reached, the whole request is refused). Errors: 403 upload_forbidden | photo_forbidden, 404 photo_not_found.
  • The plan is one per event: title (3–120), description (max 2000), is_public, and stops (1–50; each title max 120, notes max 500, location_name max 120, latitude + longitude together, duration_minutes 1–1440). PUT replaces the whole plan and the stops in the given order (201 when created, 200 when replaced). Anyone who can see the event can read the plan; is_public only decides whether the plan also has a public page (url).

12. Errors

StatusMeaning
401Missing or invalid token
403The resource belongs to another user, the account is banned (code banned), neither Premium nor verified status applies (code premium_required) or the token lacks the needed access (code insufficient_scope)
404Resource not found
409Conflict — e.g. deleting an event that has confirmed participants, or a refused join (see the code field in section 5)
422Validation failed — see the errors object
429Rate limit or the daily event creation limit exceeded — wait Retry-After seconds

Every error, whatever its status, has the same envelope: message (human readable, may be localized — do not parse it) and code (machine readable, stable — branch on this). Validation errors add errors. Generic codes: unauthenticated (401), forbidden (403), not_found (404), method_not_allowed (405), conflict (409), validation_failed (422), too_many_requests (429), server_error (5xx). Rule refusals carry a specific code, listed in the sections above (event_full, paid_ticket_unsupported, daily_event_limit, …).

422 responses look like:

{
  "message": "Toto pole je povinné. (and 1 more error)",
  "code": "validation_failed",
  "errors": {
    "title": ["Toto pole je povinné."],
    "type": ["Toto pole je povinné."]
  }
}

A refusal by a rule looks like:

{
  "message": "Akce je plná.",
  "code": "event_full"
}
Discover Blog Sign in Sign up
Are you sure you want to delete this photo?