{"openapi":"3.0.3","info":{"title":"e-phan Public API","version":"1.0.0","description":"Read-only access to the e-phan competition calendar and member registry. No API key and no signup — the data this serves is the same one anonymous visitors see on the site.\n\nRequests are limited to 300 per minute per IP address. Every response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`; exceeding the budget answers 429 with a `Retry-After` header.\n\nResponses are cached on our side and invalidated as soon as a competition changes, so you can query the API directly instead of keeping a copy of the calendar. Send back the `ETag` of a response as `If-None-Match` and unchanged data answers 304 with no body.\n\nCache the responses on your side as well, and keep serving the last good one if we are unreachable. There is no availability guarantee here: a calendar that empties out when an upstream blips is worse than a calendar that is an hour stale.\n\n### Reading the registry\n\nTwo independent fields describe every dancer, coach, judge and club, and reading only one of them will mislead you.\n\n`registryState` says **where the record sits**: `REGISTRY` (listed) or `ARCHIVE` (a lapsed membership, kept readable as its own section). Listings return `REGISTRY` by default; the archive is a separate section you have to ask for with `registryState=ARCHIVE`.\n\nA third internal state exists — the buffer, where a coach or club waits after losing its last listed member — and this API never returns it. Those roles are absent from every listing, every count and every detail endpoint until they are either listed again or archived. If a record you saw yesterday is gone today and is not in the archive, that is what happened.\n\n`status` says **whether the dues are paid**: `ACTIVE`, `LIMITED` or `INACTIVE`. It is held per role, so one person can be `ACTIVE` as a judge and `INACTIVE` as a dancer, and each endpoint reports the role it lists. Coaches and clubs buy nothing and hold no status at all — `/coaches` and `/clubs` return no `status` field and reject `?status=`; `registryState` is what says whether they are listed.\n\nThe two do not move together. A lapsed dancer stays `REGISTRY` while their grace period runs, so they read `INACTIVE` *and* listed — that combination is normal, not a contradiction. **If you want to know who counts as a member today, read `registryState`, not `status`.**","contact":{"name":"e-phan","url":"https://api.e-phan.com/api/v1"}},"servers":[{"url":"https://api.e-phan.com/api/v1","description":"Production"}],"tags":[{"name":"Competitions","description":"The public competition calendar"},{"name":"Registry","description":"Approved dancers, coaches, judges and clubs"},{"name":"Meta","description":"Filter values"}],"paths":{"/competitions":{"get":{"tags":["Competitions"],"summary":"List competitions","description":"Paginated list of public competitions. Drafts are never returned. Combine filters freely: they are applied with AND.","parameters":[{"name":"search","in":"query","description":"Case-insensitive search over name, city, country, venue and description.","required":false,"schema":{"type":"string","minLength":2},"example":"kyiv open"},{"name":"country","in":"query","description":"Country name or ISO 3166-1 alpha-2 code.","required":false,"schema":{"type":"string"},"example":"UA"},{"name":"countryId","in":"query","description":"Internal country id (see /filters).","required":false,"schema":{"type":"integer"}},{"name":"city","in":"query","description":"Exact city name, case-insensitive.","required":false,"schema":{"type":"string"},"example":"Kyiv"},{"name":"status","in":"query","description":"Comma-separated list: open, completed.","required":false,"schema":{"type":"string"},"example":"open"},{"name":"type","in":"query","description":"Comma-separated competition type codes (see /filters for the current list).","required":false,"schema":{"type":"string"},"example":"ranking_tournament,ecds_championship"},{"name":"registrationStatus","in":"query","description":"open or closed.","required":false,"schema":{"type":"string","enum":["open","closed"]}},{"name":"registrationOpen","in":"query","description":"true keeps only competitions accepting entries right now — equivalent to `registration.state == \"open\"`: the competition is not completed, entries are open, the opening day has arrived and the deadline has not passed.","required":false,"schema":{"type":"boolean"}},{"name":"period","in":"query","description":"upcoming, ongoing or past, by calendar day. A competition running today is ongoing for the whole day.","required":false,"schema":{"type":"string","enum":["upcoming","ongoing","past"]}},{"name":"dateFrom","in":"query","description":"Calendar day, inclusive. Keeps competitions overlapping the range.","required":false,"schema":{"type":"string","format":"date"}},{"name":"dateTo","in":"query","description":"Calendar day, inclusive. Keeps competitions overlapping the range.","required":false,"schema":{"type":"string","format":"date"}},{"name":"updatedSince","in":"query","description":"ISO-8601 timestamp, for incremental synchronisation. Responses to these requests are never cached.","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"include","in":"query","description":"Comma-separated extras: categories, entriesCount.","required":false,"schema":{"type":"string"},"example":"categories"},{"name":"sort","in":"query","description":"startDate, endDate, name, createdAt or updatedAt.","required":false,"schema":{"type":"string","default":"startDate"}},{"name":"order","in":"query","description":"asc or desc.","required":false,"schema":{"type":"string","enum":["asc","desc"],"default":"asc"}},{"name":"page","in":"query","description":"Page number, starts at 1.","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","description":"Items per page, 1 to 100.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":20}}],"responses":{"200":{"description":"A page of competitions.","headers":{"ETag":{"description":"Weak validator of this payload. Send it back as `If-None-Match` to get a 304 when nothing changed.","schema":{"type":"string"}},"Cache-Control":{"description":"How long the response may be reused without revalidating. `public`, because the API carries no credentials and the same URL yields the same body for every caller — a CDN or your own reverse proxy may store it. Requests carrying `updatedSince` answer `no-store` instead.","schema":{"type":"string","example":"public, max-age=60"}},"X-Cache":{"description":"Whether the response came from our cache (`HIT`), was just built (`MISS`), or skipped the cache because the query is not cacheable (`BYPASS`).","schema":{"type":"string","enum":["HIT","MISS","BYPASS"]}},"X-RateLimit-Limit":{"description":"Requests allowed per IP address within the window.","schema":{"type":"integer","example":300}},"X-RateLimit-Remaining":{"description":"Requests left for this IP address in the current window.","schema":{"type":"integer","example":297}},"X-RateLimit-Window":{"description":"Length of the sliding window, in seconds.","schema":{"type":"integer","example":60}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Competition"}},"meta":{"$ref":"#/components/schemas/PageMeta"},"links":{"$ref":"#/components/schemas/PageLinks"}}}}}},"304":{"$ref":"#/components/responses/NotModified"},"400":{"$ref":"#/components/responses/BadRequest"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/competitions/{id}":{"get":{"tags":["Competitions"],"summary":"Get one competition","description":"Full competition record including its categories, the current number of entries and the judging-relevant metadata. Accepts either the id or the slug.","parameters":[{"name":"id","in":"path","required":true,"description":"Either the competition id (UUID) or its slug.","schema":{"type":"string"},"example":"kyiv-open-2026"}],"responses":{"200":{"description":"The competition.","headers":{"ETag":{"description":"Weak validator of this payload. Send it back as `If-None-Match` to get a 304 when nothing changed.","schema":{"type":"string"}},"Cache-Control":{"description":"How long the response may be reused without revalidating. `public`, because the API carries no credentials and the same URL yields the same body for every caller — a CDN or your own reverse proxy may store it. Requests carrying `updatedSince` answer `no-store` instead.","schema":{"type":"string","example":"public, max-age=60"}},"X-Cache":{"description":"Whether the response came from our cache (`HIT`), was just built (`MISS`), or skipped the cache because the query is not cacheable (`BYPASS`).","schema":{"type":"string","enum":["HIT","MISS","BYPASS"]}},"X-RateLimit-Limit":{"description":"Requests allowed per IP address within the window.","schema":{"type":"integer","example":300}},"X-RateLimit-Remaining":{"description":"Requests left for this IP address in the current window.","schema":{"type":"integer","example":297}},"X-RateLimit-Window":{"description":"Length of the sliding window, in seconds.","schema":{"type":"integer","example":60}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Competition"}}}}}},"304":{"$ref":"#/components/responses/NotModified"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/competitions/{id}/categories":{"get":{"tags":["Competitions"],"summary":"List the categories of a competition","parameters":[{"name":"id","in":"path","required":true,"description":"Either the competition id (UUID) or its slug.","schema":{"type":"string"},"example":"kyiv-open-2026"},{"name":"entryType","in":"query","description":"Comma-separated: couple, solo, duo-synchro, mix, show.","required":false,"schema":{"type":"string"}},{"name":"style","in":"query","description":"Comma-separated: none, latin, standard, combined.","required":false,"schema":{"type":"string"}},{"name":"level","in":"query","description":"Class/level, 1 to 10.","required":false,"schema":{"type":"integer"}},{"name":"search","in":"query","description":"Substring of the category name.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Categories of the competition.","headers":{"ETag":{"description":"Weak validator of this payload. Send it back as `If-None-Match` to get a 304 when nothing changed.","schema":{"type":"string"}},"Cache-Control":{"description":"How long the response may be reused without revalidating. `public`, because the API carries no credentials and the same URL yields the same body for every caller — a CDN or your own reverse proxy may store it. Requests carrying `updatedSince` answer `no-store` instead.","schema":{"type":"string","example":"public, max-age=60"}},"X-Cache":{"description":"Whether the response came from our cache (`HIT`), was just built (`MISS`), or skipped the cache because the query is not cacheable (`BYPASS`).","schema":{"type":"string","enum":["HIT","MISS","BYPASS"]}},"X-RateLimit-Limit":{"description":"Requests allowed per IP address within the window.","schema":{"type":"integer","example":300}},"X-RateLimit-Remaining":{"description":"Requests left for this IP address in the current window.","schema":{"type":"integer","example":297}},"X-RateLimit-Window":{"description":"Length of the sliding window, in seconds.","schema":{"type":"integer","example":60}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Category"}},"meta":{"type":"object","properties":{"total":{"type":"integer"}}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/filters":{"get":{"tags":["Meta"],"summary":"Available filter values","description":"Countries and cities that currently have public competitions, plus the accepted enum values.","responses":{"200":{"description":"Filter values.","headers":{"ETag":{"description":"Weak validator of this payload. Send it back as `If-None-Match` to get a 304 when nothing changed.","schema":{"type":"string"}},"Cache-Control":{"description":"How long the response may be reused without revalidating. `public`, because the API carries no credentials and the same URL yields the same body for every caller — a CDN or your own reverse proxy may store it. Requests carrying `updatedSince` answer `no-store` instead.","schema":{"type":"string","example":"public, max-age=60"}},"X-Cache":{"description":"Whether the response came from our cache (`HIT`), was just built (`MISS`), or skipped the cache because the query is not cacheable (`BYPASS`).","schema":{"type":"string","enum":["HIT","MISS","BYPASS"]}},"X-RateLimit-Limit":{"description":"Requests allowed per IP address within the window.","schema":{"type":"integer","example":300}},"X-RateLimit-Remaining":{"description":"Requests left for this IP address in the current window.","schema":{"type":"integer","example":297}},"X-RateLimit-Window":{"description":"Length of the sliding window, in seconds.","schema":{"type":"integer","example":60}}}},"304":{"$ref":"#/components/responses/NotModified"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/dancers":{"get":{"tags":["Registry"],"summary":"List dancers","description":"Paginated list of dancers currently in the registry. Archived dancers are excluded unless `registryState` asks for them. Combine filters freely: they are applied with AND.","parameters":[{"name":"search","in":"query","description":"Case-insensitive search over name, surname (both language variants).","required":false,"schema":{"type":"string","minLength":2}},{"name":"sex","in":"query","description":"MALE or FEMALE.","required":false,"schema":{"type":"string","enum":["MALE","FEMALE"]}},{"name":"countryId","in":"query","description":"Country id.","required":false,"schema":{"type":"integer"}},{"name":"country","in":"query","description":"Country name or ISO 3166-1 alpha-2 code.","required":false,"schema":{"type":"string"}},{"name":"clubId","in":"query","description":"Filter by club membership.","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"coachId","in":"query","description":"Filter by coach assignment.","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"danceClass","in":"query","description":"Dance class level.","required":false,"schema":{"type":"integer"}},{"name":"hasPartner","in":"query","description":"Whether the dancer has a partner.","required":false,"schema":{"type":"boolean"}},{"name":"registryState","in":"query","description":"Comma-separated registry state: `REGISTRY` or `ARCHIVE`. Defaults to `REGISTRY` — the archive is a separate section and is never mixed into a listing unless you name it here. Pass `registryState=ARCHIVE` to read the archive on its own, or both to merge them. `BUFFER` is not a public value: roles on the buffer clock are not exposed by this API and asking for them is an error.","required":false,"schema":{"type":"string","enum":["REGISTRY","ARCHIVE"]},"example":"REGISTRY"},{"name":"status","in":"query","description":"Comma-separated membership status of *this role*: `ACTIVE`, `LIMITED`, `INACTIVE`. Matched against the dancer record, so a person whose judging dues lapsed is still returned here if their dancer dues are paid. Independent of `registryState`: an `INACTIVE` record is still listed for the length of its grace period.","required":false,"schema":{"type":"string"},"example":"ACTIVE"},{"name":"sort","in":"query","description":"name or approvedAt.","required":false,"schema":{"type":"string","default":"name"}},{"name":"order","in":"query","description":"asc or desc.","required":false,"schema":{"type":"string","enum":["asc","desc"],"default":"asc"}},{"name":"page","in":"query","description":"Page number, starts at 1.","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","description":"Items per page, 1 to 100.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":20}}],"responses":{"200":{"description":"A page of dancers.","headers":{"ETag":{"description":"Weak validator of this payload. Send it back as `If-None-Match` to get a 304 when nothing changed.","schema":{"type":"string"}},"Cache-Control":{"description":"How long the response may be reused without revalidating. `public`, because the API carries no credentials and the same URL yields the same body for every caller — a CDN or your own reverse proxy may store it. Requests carrying `updatedSince` answer `no-store` instead.","schema":{"type":"string","example":"public, max-age=60"}},"X-Cache":{"description":"Whether the response came from our cache (`HIT`), was just built (`MISS`), or skipped the cache because the query is not cacheable (`BYPASS`).","schema":{"type":"string","enum":["HIT","MISS","BYPASS"]}},"X-RateLimit-Limit":{"description":"Requests allowed per IP address within the window.","schema":{"type":"integer","example":300}},"X-RateLimit-Remaining":{"description":"Requests left for this IP address in the current window.","schema":{"type":"integer","example":297}},"X-RateLimit-Window":{"description":"Length of the sliding window, in seconds.","schema":{"type":"integer","example":60}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RegistryDancer"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}}}}},"304":{"$ref":"#/components/responses/NotModified"},"400":{"$ref":"#/components/responses/BadRequest"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/coaches":{"get":{"tags":["Registry"],"summary":"List coaches","description":"Paginated list of coaches currently in the registry — a coach is listed for as long as they hold at least one registry dancer. Archived coaches are excluded unless `registryState` asks for them; coaches on the buffer clock are excluded outright.","parameters":[{"name":"search","in":"query","description":"Case-insensitive search over name, surname.","required":false,"schema":{"type":"string","minLength":2}},{"name":"sex","in":"query","description":"MALE or FEMALE.","required":false,"schema":{"type":"string","enum":["MALE","FEMALE"]}},{"name":"countryId","in":"query","description":"Country id.","required":false,"schema":{"type":"integer"}},{"name":"country","in":"query","description":"Country name or ISO 3166-1 alpha-2 code.","required":false,"schema":{"type":"string"}},{"name":"clubId","in":"query","description":"Filter by club membership.","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"highestClass","in":"query","description":"Highest dance class.","required":false,"schema":{"type":"integer"}},{"name":"registryState","in":"query","description":"Comma-separated registry state: `REGISTRY` or `ARCHIVE`. Defaults to `REGISTRY` — the archive is a separate section and is never mixed into a listing unless you name it here. Pass `registryState=ARCHIVE` to read the archive on its own, or both to merge them. `BUFFER` is not a public value: roles on the buffer clock are not exposed by this API and asking for them is an error.","required":false,"schema":{"type":"string","enum":["REGISTRY","ARCHIVE"]},"example":"REGISTRY"},{"name":"sort","in":"query","description":"name or approvedAt.","required":false,"schema":{"type":"string","default":"name"}},{"name":"order","in":"query","description":"asc or desc.","required":false,"schema":{"type":"string","enum":["asc","desc"],"default":"asc"}},{"name":"page","in":"query","description":"Page number.","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","description":"Items per page, 1 to 100.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":20}}],"responses":{"200":{"description":"A page of coaches.","headers":{"ETag":{"description":"Weak validator of this payload. Send it back as `If-None-Match` to get a 304 when nothing changed.","schema":{"type":"string"}},"Cache-Control":{"description":"How long the response may be reused without revalidating. `public`, because the API carries no credentials and the same URL yields the same body for every caller — a CDN or your own reverse proxy may store it. Requests carrying `updatedSince` answer `no-store` instead.","schema":{"type":"string","example":"public, max-age=60"}},"X-Cache":{"description":"Whether the response came from our cache (`HIT`), was just built (`MISS`), or skipped the cache because the query is not cacheable (`BYPASS`).","schema":{"type":"string","enum":["HIT","MISS","BYPASS"]}},"X-RateLimit-Limit":{"description":"Requests allowed per IP address within the window.","schema":{"type":"integer","example":300}},"X-RateLimit-Remaining":{"description":"Requests left for this IP address in the current window.","schema":{"type":"integer","example":297}},"X-RateLimit-Window":{"description":"Length of the sliding window, in seconds.","schema":{"type":"integer","example":60}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RegistryCoach"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}}}}},"304":{"$ref":"#/components/responses/NotModified"},"400":{"$ref":"#/components/responses/BadRequest"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/judges":{"get":{"tags":["Registry"],"summary":"List judges","description":"Paginated list of judges currently in the registry. A judge’s standing is its own — independent of the same person’s dancer record. Archived judges are excluded unless `registryState` asks for them.","parameters":[{"name":"search","in":"query","description":"Case-insensitive search over name, surname.","required":false,"schema":{"type":"string","minLength":2}},{"name":"sex","in":"query","description":"MALE or FEMALE.","required":false,"schema":{"type":"string","enum":["MALE","FEMALE"]}},{"name":"countryId","in":"query","description":"Country id.","required":false,"schema":{"type":"integer"}},{"name":"country","in":"query","description":"Country name or ISO 3166-1 alpha-2 code.","required":false,"schema":{"type":"string"}},{"name":"judgeCategory","in":"query","description":"Judge category level.","required":false,"schema":{"type":"integer"}},{"name":"position","in":"query","description":"Position: judge, headJudge or sportsInspector.","required":false,"schema":{"type":"string","enum":["judge","headJudge","sportsInspector"]}},{"name":"registryState","in":"query","description":"Comma-separated registry state: `REGISTRY` or `ARCHIVE`. Defaults to `REGISTRY` — the archive is a separate section and is never mixed into a listing unless you name it here. Pass `registryState=ARCHIVE` to read the archive on its own, or both to merge them. `BUFFER` is not a public value: roles on the buffer clock are not exposed by this API and asking for them is an error.","required":false,"schema":{"type":"string","enum":["REGISTRY","ARCHIVE"]},"example":"REGISTRY"},{"name":"status","in":"query","description":"Comma-separated membership status of *this role*: `ACTIVE`, `LIMITED`, `INACTIVE`. Matched against the judge record, so a person whose dancing dues lapsed is still returned here if their judge dues are paid. Independent of `registryState`: an `INACTIVE` record is still listed for the length of its grace period.","required":false,"schema":{"type":"string"},"example":"ACTIVE"},{"name":"sort","in":"query","description":"name or approvedAt.","required":false,"schema":{"type":"string","default":"name"}},{"name":"order","in":"query","description":"asc or desc.","required":false,"schema":{"type":"string","enum":["asc","desc"],"default":"asc"}},{"name":"page","in":"query","description":"Page number.","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","description":"Items per page, 1 to 100.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":20}}],"responses":{"200":{"description":"A page of judges.","headers":{"ETag":{"description":"Weak validator of this payload. Send it back as `If-None-Match` to get a 304 when nothing changed.","schema":{"type":"string"}},"Cache-Control":{"description":"How long the response may be reused without revalidating. `public`, because the API carries no credentials and the same URL yields the same body for every caller — a CDN or your own reverse proxy may store it. Requests carrying `updatedSince` answer `no-store` instead.","schema":{"type":"string","example":"public, max-age=60"}},"X-Cache":{"description":"Whether the response came from our cache (`HIT`), was just built (`MISS`), or skipped the cache because the query is not cacheable (`BYPASS`).","schema":{"type":"string","enum":["HIT","MISS","BYPASS"]}},"X-RateLimit-Limit":{"description":"Requests allowed per IP address within the window.","schema":{"type":"integer","example":300}},"X-RateLimit-Remaining":{"description":"Requests left for this IP address in the current window.","schema":{"type":"integer","example":297}},"X-RateLimit-Window":{"description":"Length of the sliding window, in seconds.","schema":{"type":"integer","example":60}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RegistryJudge"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}}}}},"304":{"$ref":"#/components/responses/NotModified"},"400":{"$ref":"#/components/responses/BadRequest"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/clubs":{"get":{"tags":["Registry"],"summary":"List clubs","description":"Paginated list of clubs currently in the registry — a club is listed for as long as it holds at least one registry member. Archived clubs are excluded unless `registryState` asks for them; clubs on the buffer clock are excluded outright. `dancersCount` and `coachesCount` report the current roster: links severed by an archival stop counting from the day they are closed.\n\nA club can run several venues. `locations` carries all of them, main office and branches alike; `address` and `country` describe the main one and are kept flat for consumers that predate branches. The `city`, `country` and `countryId` filters match any of them.\n\nFor a single club — contact details and the coaching staff included — fetch `/clubs/{id}`.","parameters":[{"name":"search","in":"query","description":"Case-insensitive search over club name.","required":false,"schema":{"type":"string","minLength":2}},{"name":"countryId","in":"query","description":"Country id. Matches any of the club’s addresses, branches included.","required":false,"schema":{"type":"integer"}},{"name":"country","in":"query","description":"Country name or ISO 3166-1 alpha-2 code. Matches any of the club’s addresses, branches included.","required":false,"schema":{"type":"string"}},{"name":"city","in":"query","description":"Case-insensitive prefix match on the city of **any** of the club’s addresses: a club that runs a hall in the city matches, whichever of its addresses is the head office. Replaces the removed `cityId` filter; `regionId` was removed with no replacement, as regions are no longer stored at all.","required":false,"schema":{"type":"string"},"example":"Kyiv"},{"name":"registryState","in":"query","description":"Comma-separated registry state: `REGISTRY` or `ARCHIVE`. Defaults to `REGISTRY` — the archive is a separate section and is never mixed into a listing unless you name it here. Pass `registryState=ARCHIVE` to read the archive on its own, or both to merge them. `BUFFER` is not a public value: roles on the buffer clock are not exposed by this API and asking for them is an error.","required":false,"schema":{"type":"string","enum":["REGISTRY","ARCHIVE"]},"example":"REGISTRY"},{"name":"sort","in":"query","description":"name or approvedAt.","required":false,"schema":{"type":"string","default":"name"}},{"name":"order","in":"query","description":"asc or desc.","required":false,"schema":{"type":"string","enum":["asc","desc"],"default":"asc"}},{"name":"page","in":"query","description":"Page number.","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","description":"Items per page, 1 to 100.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":20}}],"responses":{"200":{"description":"A page of clubs.","headers":{"ETag":{"description":"Weak validator of this payload. Send it back as `If-None-Match` to get a 304 when nothing changed.","schema":{"type":"string"}},"Cache-Control":{"description":"How long the response may be reused without revalidating. `public`, because the API carries no credentials and the same URL yields the same body for every caller — a CDN or your own reverse proxy may store it. Requests carrying `updatedSince` answer `no-store` instead.","schema":{"type":"string","example":"public, max-age=60"}},"X-Cache":{"description":"Whether the response came from our cache (`HIT`), was just built (`MISS`), or skipped the cache because the query is not cacheable (`BYPASS`).","schema":{"type":"string","enum":["HIT","MISS","BYPASS"]}},"X-RateLimit-Limit":{"description":"Requests allowed per IP address within the window.","schema":{"type":"integer","example":300}},"X-RateLimit-Remaining":{"description":"Requests left for this IP address in the current window.","schema":{"type":"integer","example":297}},"X-RateLimit-Window":{"description":"Length of the sliding window, in seconds.","schema":{"type":"integer","example":60}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RegistryClub"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}}}}},"304":{"$ref":"#/components/responses/NotModified"},"400":{"$ref":"#/components/responses/BadRequest"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/clubs/{id}":{"get":{"tags":["Registry"],"summary":"Get one club","description":"A single club in full: everything the listing carries, plus its founding date, public contact details, every address and its coaching staff. Built for a club page.\n\nArchived clubs stay fetchable: the archive is a section of the registry, not a deletion, so a link to an archived club keeps working and `registryState` on the payload tells you which it is. A club on the buffer clock is a 404 here, the same as it is absent from the listings.\n\nCoaches are inline because a club has a handful of them. Dancers can run to hundreds, so they are left to the paginated `links.dancers`. Both list the current roster only: a membership severed by an archival is closed, not deleted, and stops counting from the day it closes.","parameters":[{"name":"id","in":"path","required":true,"description":"Club id. Clubs have no slug, so anything that is not a uuid answers 404.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The club.","headers":{"ETag":{"description":"Weak validator of this payload. Send it back as `If-None-Match` to get a 304 when nothing changed.","schema":{"type":"string"}},"Cache-Control":{"description":"How long the response may be reused without revalidating. `public`, because the API carries no credentials and the same URL yields the same body for every caller — a CDN or your own reverse proxy may store it. Requests carrying `updatedSince` answer `no-store` instead.","schema":{"type":"string","example":"public, max-age=60"}},"X-Cache":{"description":"Whether the response came from our cache (`HIT`), was just built (`MISS`), or skipped the cache because the query is not cacheable (`BYPASS`).","schema":{"type":"string","enum":["HIT","MISS","BYPASS"]}},"X-RateLimit-Limit":{"description":"Requests allowed per IP address within the window.","schema":{"type":"integer","example":300}},"X-RateLimit-Remaining":{"description":"Requests left for this IP address in the current window.","schema":{"type":"integer","example":297}},"X-RateLimit-Window":{"description":"Length of the sliding window, in seconds.","schema":{"type":"integer","example":60}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ClubDetail"}}}}}},"304":{"$ref":"#/components/responses/NotModified"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/registry/filters":{"get":{"tags":["Meta"],"summary":"Registry filter values","description":"Countries with counts per entity type, available dance classes, judge categories and positions, plus the accepted `status` and `registryState` values.\n\nEvery count is scoped the way the listings are — `registryState=REGISTRY` — so a facet never promises rows a listing will not return. `countsScope` in the response states that scope explicitly. The archive is not counted here; page through `?registryState=ARCHIVE` on a listing to size it.","responses":{"200":{"description":"Registry filter values.","headers":{"ETag":{"description":"Weak validator of this payload. Send it back as `If-None-Match` to get a 304 when nothing changed.","schema":{"type":"string"}},"Cache-Control":{"description":"How long the response may be reused without revalidating. `public`, because the API carries no credentials and the same URL yields the same body for every caller — a CDN or your own reverse proxy may store it. Requests carrying `updatedSince` answer `no-store` instead.","schema":{"type":"string","example":"public, max-age=60"}},"X-Cache":{"description":"Whether the response came from our cache (`HIT`), was just built (`MISS`), or skipped the cache because the query is not cacheable (`BYPASS`).","schema":{"type":"string","enum":["HIT","MISS","BYPASS"]}},"X-RateLimit-Limit":{"description":"Requests allowed per IP address within the window.","schema":{"type":"integer","example":300}},"X-RateLimit-Remaining":{"description":"Requests left for this IP address in the current window.","schema":{"type":"integer","example":297}},"X-RateLimit-Window":{"description":"Length of the sliding window, in seconds.","schema":{"type":"integer","example":60}}}},"304":{"$ref":"#/components/responses/NotModified"},"429":{"$ref":"#/components/responses/RateLimited"}}}}},"components":{"schemas":{"Competition":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string","description":"Stable, human-readable identifier for URLs. Assigned when the competition is created and frozen the moment it becomes public — it does not change when the competition is renamed, so a link built on it keeps resolving. Accepted in place of the id on `/competitions/{id}`.","example":"kyiv-open-2026"},"name":{"type":"string"},"shortName":{"type":"string"},"description":{"type":"string","nullable":true,"description":"Plain text, derived from `descriptionFormatted`. Suitable for a card excerpt; this is also the field `?search=` matches on."},"descriptionFormatted":{"type":"string","nullable":true,"description":"The same description as authored: Markdown, with headings, lists and links."},"status":{"type":"string","enum":["open","completed"]},"type":{"type":"object","nullable":true,"description":"Rank of the tournament. `code` is stable and safe to switch on; `label` is display text and may be corrected without notice. The full list is published by `/filters`.","properties":{"code":{"type":"string","example":"ranking_tournament"},"label":{"type":"string","example":"Ranking tournament"}}},"organizer":{"type":"object","nullable":true,"description":"The body running the event. Its country is its own, not the venue’s.","properties":{"name":{"type":"string"},"shortName":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"countryCode":{"type":"string","nullable":true},"website":{"type":"string","nullable":true}}},"resultsUrl":{"type":"string","nullable":true,"description":"Where the results are published — a PDF or a results site. Null until the organiser publishes them, which is usually some days after the competition ends."},"dates":{"type":"object","properties":{"start":{"type":"string","format":"date","example":"2026-10-17"},"end":{"type":"string","format":"date","example":"2026-10-18"}}},"location":{"type":"object","properties":{"country":{"type":"string","nullable":true},"countryId":{"type":"integer","nullable":true},"countryCode":{"type":"string","nullable":true},"city":{"type":"string","nullable":true},"formattedAddress":{"type":"string","nullable":true},"placeId":{"type":"string","nullable":true},"venueName":{"type":"string","nullable":true},"venueAddress":{"type":"string","nullable":true}}},"registration":{"type":"object","properties":{"status":{"type":"string","enum":["open","closed"]},"state":{"type":"string","enum":["not_yet_open","open","closed"],"description":"Whether entries are being accepted right now. `not_yet_open` means the competition has been announced but `opensAt` has not arrived yet — a state the previous boolean `isOpen` could not express, and reported as open."},"opensAt":{"type":"string","format":"date","nullable":true,"description":"First day on which entries are accepted; that day itself is already open. Null means entries have been open since the competition was announced.","example":"2026-03-01"},"deadline":{"type":"string","format":"date","nullable":true,"description":"Last day on which entries are accepted. The deadline day itself is still open.","example":"2026-10-10"},"maxEntries":{"type":"integer","nullable":true},"entriesCount":{"type":"integer","description":"Number of entries. One entry may select several categories."},"startsCount":{"type":"integer","description":"Sum of the categories’ `startsCount`. Larger than `entriesCount`: an entry selecting three categories is one entry and three starts. Present when categories are included."}}},"banner":{"type":"object","description":"The competition's artwork, one side per orientation. Each side is either a srcset keyed by pixel width, a single `default` entry for a banner uploaded before responsive sizing existed, or `null` when nothing was uploaded for that orientation.","properties":{"horizontal":{"$ref":"#/components/schemas/BannerSide"},"vertical":{"$ref":"#/components/schemas/BannerSide"}}},"media":{"type":"object","properties":{"entryForm":{"type":"string","nullable":true}}},"categories":{"type":"array","items":{"$ref":"#/components/schemas/Category"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"links":{"type":"object","properties":{"self":{"type":"string"},"categories":{"type":"string"},"web":{"type":"string"},"registration":{"type":"string","description":"Where to enter. Points at the competition’s entry flow on e-phan, unless the organiser collects entries elsewhere — then it is their URL."}}}}},"BannerSide":{"type":"object","nullable":true,"description":"Keys are pixel widths when the file has a responsive srcset (re-uploading a legacy banner backfills this), or the single key `default` for a banner uploaded before responsive sizing existed. `null` when nothing was uploaded for this orientation.","additionalProperties":{"type":"string"},"example":{"640":"https://api.e-phan.com/uploads/competitions/kyiv-open-640.avif","1280":"https://api.e-phan.com/uploads/competitions/kyiv-open-1280.avif","1920":"https://api.e-phan.com/uploads/competitions/kyiv-open-1920.avif"}},"Category":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"entryType":{"type":"string","enum":["couple","solo","duo-synchro","mix","show"]},"style":{"type":"string","nullable":true,"enum":["none","latin","standard","combined"]},"ageGroup":{"type":"string","nullable":true},"birthYearRange":{"type":"string","nullable":true},"participantDivision":{"type":"string","nullable":true},"level":{"type":"integer","nullable":true},"dances":{"type":"array","items":{"type":"string"}},"displayOrder":{"type":"integer"},"startsCount":{"type":"integer","description":"How many entries include this category. Deliberately not called `entriesCount`: it counts a different unit, and summing it across categories gives more than the competition’s `entriesCount`."}}},"RegistryCountry":{"type":"object","nullable":true,"properties":{"id":{"type":"integer"},"name":{"type":"string"},"code":{"type":"string","description":"ISO 3166-1 alpha-2"}}},"RegistryStatus":{"type":"string","nullable":true,"enum":["ACTIVE","LIMITED","INACTIVE"],"description":"Whether the dues behind this role are currently paid up. `ACTIVE` and `LIMITED` are both paid-up memberships and differ only in what they entitle a dancer to enter: `LIMITED` competes at levels 1-3, `ACTIVE` at any level. It is its own value, not a shade of `ACTIVE` or `INACTIVE`.\n\nStatus is held **per role**, not per person: the same human can be `INACTIVE` as a dancer and `ACTIVE` as a judge, and each endpoint reports the status of the role it lists. A term runs for a configured number of months from the day it was paid, so `membershipExpiresAt` falls on no fixed date — read it, do not derive it.\n\nOnly dancers and judges buy a status, so only `/dancers` and `/judges` carry this field. A coach or a club holds no dues of their own and reports no status at all; `registryState` is what says whether they are listed."},"RegistryState":{"type":"string","nullable":true,"enum":["REGISTRY","ARCHIVE"],"description":"Where the role sits — a separate question from whether its dues are paid, and the one to read if you only want to know who counts as a member today.\n\n`REGISTRY` — on the main list. Includes people whose dues have lapsed but whose grace period is still running, so a `REGISTRY` record may well be `INACTIVE`.\n\nCoaches and clubs are listed for as long as they hold at least one registry member. On losing the last one they leave this API entirely — they spend an internal buffer period out of sight, and reappear only if they acquire a member again or when the archive takes them.\n\n`ARCHIVE` — a separate section of the registry, not a deletion. Archived records keep their ids and stay fetchable, but are left out of every listing and every count unless `registryState` asks for them by name. Returning from the archive requires a paid reinstatement, so do not expect a record to drift back on its own.\n\nAn administrator may place a bounded exception on a record; this field always reports the state that exception produces, and the filters match it, so the two never disagree."},"RegistryUntil":{"type":"string","format":"date","nullable":true,"description":"The next date on this record’s clock, whichever clock it is on.\n\nFor a `REGISTRY` dancer or judge: the day the archive claims the role if the dues are not renewed — the end of the paid term plus the grace period. Null in the archive, where nothing further is pending, and null for a listed coach or club, which is not on a clock at all."},"RegistryArchivedAt":{"type":"string","format":"date-time","nullable":true,"description":"When the record was archived. Null for anything not currently in `ARCHIVE` — including a record an administrator has temporarily lifted out of it."},"RegistryDancer":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","nullable":true},"surname":{"type":"string","nullable":true},"nameEn":{"type":"string","nullable":true},"surnameEn":{"type":"string","nullable":true},"avatar":{"type":"string","nullable":true},"danceClass":{"type":"integer","nullable":true},"country":{"$ref":"#/components/schemas/RegistryCountry"},"status":{"$ref":"#/components/schemas/RegistryStatus"},"membershipExpiresAt":{"type":"string","format":"date-time","nullable":true},"registryState":{"$ref":"#/components/schemas/RegistryState"},"registryUntil":{"$ref":"#/components/schemas/RegistryUntil"},"archivedAt":{"$ref":"#/components/schemas/RegistryArchivedAt"}}},"RegistryCoach":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","nullable":true},"surname":{"type":"string","nullable":true},"nameEn":{"type":"string","nullable":true},"surnameEn":{"type":"string","nullable":true},"avatar":{"type":"string","nullable":true},"highestClass":{"type":"integer","nullable":true},"country":{"$ref":"#/components/schemas/RegistryCountry"},"registryState":{"$ref":"#/components/schemas/RegistryState"},"registryUntil":{"$ref":"#/components/schemas/RegistryUntil"},"archivedAt":{"$ref":"#/components/schemas/RegistryArchivedAt"}}},"RegistryJudge":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","nullable":true},"surname":{"type":"string","nullable":true},"nameEn":{"type":"string","nullable":true},"surnameEn":{"type":"string","nullable":true},"avatar":{"type":"string","nullable":true},"judgeCategory":{"type":"integer","nullable":true},"country":{"$ref":"#/components/schemas/RegistryCountry"},"status":{"$ref":"#/components/schemas/RegistryStatus"},"membershipExpiresAt":{"type":"string","format":"date-time","nullable":true},"registryState":{"$ref":"#/components/schemas/RegistryState"},"registryUntil":{"$ref":"#/components/schemas/RegistryUntil"},"archivedAt":{"$ref":"#/components/schemas/RegistryArchivedAt"}}},"ClubLocation":{"type":"object","properties":{"type":{"type":"string","enum":["main","branch"],"description":"Exactly one location per club is `main`; the rest are branches."},"address":{"type":"string","nullable":true,"description":"The address as resolved by the map picker, with any supplementary detail the geocoder cannot know — floor, entrance, \"from the courtyard\" — appended in brackets. Addresses entered before the map picker existed carry that line alone. One string to print, not a set of parts."},"city":{"type":"string","nullable":true,"description":"City as the geocoder reported it, in the language the address was entered in — never translated. This is what the `city` filter matches."},"country":{"$ref":"#/components/schemas/RegistryCountry"},"placeId":{"type":"string","nullable":true,"description":"Google place id. Coordinates are deliberately not stored — Google permits keeping only the place id indefinitely — so resolve them from this when you need to pin a map."}}},"RegistryClub":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","nullable":true,"description":"Club name in Latin characters."},"logo":{"type":"string","nullable":true},"address":{"type":"string","nullable":true,"description":"The club's main location — the same string as the `main` entry in `locations`, kept flat because it predates branches. Null for a club with no address set."},"country":{"allOf":[{"$ref":"#/components/schemas/RegistryCountry"}],"description":"Country of the club's main location; the `main` entry in `locations` carries the same."},"locations":{"type":"array","description":"Every address the club has: its main location and each branch. A club may run several venues, so read this rather than `address` unless you specifically want the head office. The `main` entry comes first, branches follow oldest to newest. Empty for a club with no address on file.","items":{"$ref":"#/components/schemas/ClubLocation"}},"dancersCount":{"type":"integer"},"coachesCount":{"type":"integer"},"registryState":{"$ref":"#/components/schemas/RegistryState"},"registryUntil":{"$ref":"#/components/schemas/RegistryUntil"},"archivedAt":{"$ref":"#/components/schemas/RegistryArchivedAt"}}},"ClubContacts":{"type":"object","description":"The club’s public contact details — the same ones its page on e-phan shows. Registration documents are not public and are not exposed here.","properties":{"phone":{"type":"string","nullable":true},"email":{"type":"string","nullable":true},"website":{"type":"string","nullable":true},"instagram":{"type":"string","nullable":true},"facebook":{"type":"string","nullable":true}}},"ClubDetail":{"allOf":[{"$ref":"#/components/schemas/RegistryClub"},{"type":"object","properties":{"foundedOn":{"type":"string","nullable":true,"description":"When the club was founded, as entered. Older records hold a bare year and newer ones a full date, so this is reported verbatim rather than coerced into a format half the data cannot honour — parse defensively."},"approvedAt":{"type":"string","format":"date-time","nullable":true,"description":"When the federation approved the club."},"contacts":{"$ref":"#/components/schemas/ClubContacts"},"coaches":{"type":"array","description":"The club’s current coaching staff, sorted by surname. Same shape as a `/coaches` entry. Empty for a club with no coaches on its books.","items":{"$ref":"#/components/schemas/RegistryCoach"}},"links":{"type":"object","properties":{"self":{"type":"string"},"dancers":{"type":"string","description":"The club’s dancers, paginated — they are not inlined because there can be hundreds."},"coaches":{"type":"string","description":"The same staff as `coaches`, as a paginated listing."},"web":{"type":"string","description":"The club’s page on e-phan."}}}}}]},"PageMeta":{"type":"object","properties":{"page":{"type":"integer"},"limit":{"type":"integer"},"total":{"type":"integer"},"totalPages":{"type":"integer"},"hasNextPage":{"type":"boolean"},"hasPreviousPage":{"type":"boolean"}}},"PageLinks":{"type":"object","properties":{"self":{"type":"string"},"next":{"type":"string","nullable":true},"previous":{"type":"string","nullable":true}}},"Error":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string"},"message":{"type":"string"},"details":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"NotModified":{"description":"The payload matches the `If-None-Match` you sent. No body is returned.","headers":{"ETag":{"description":"Weak validator of this payload. Send it back as `If-None-Match` to get a 304 when nothing changed.","schema":{"type":"string"}},"Cache-Control":{"description":"How long the response may be reused without revalidating. `public`, because the API carries no credentials and the same URL yields the same body for every caller — a CDN or your own reverse proxy may store it. Requests carrying `updatedSince` answer `no-store` instead.","schema":{"type":"string","example":"public, max-age=60"}},"X-Cache":{"description":"Whether the response came from our cache (`HIT`), was just built (`MISS`), or skipped the cache because the query is not cacheable (`BYPASS`).","schema":{"type":"string","enum":["HIT","MISS","BYPASS"]}},"X-RateLimit-Limit":{"description":"Requests allowed per IP address within the window.","schema":{"type":"integer","example":300}},"X-RateLimit-Remaining":{"description":"Requests left for this IP address in the current window.","schema":{"type":"integer","example":297}},"X-RateLimit-Window":{"description":"Length of the sliding window, in seconds.","schema":{"type":"integer","example":60}}}},"BadRequest":{"description":"A query parameter is malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"No public resource with this id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RateLimited":{"description":"The per-minute budget for this IP address is exhausted. Note that a 304 and a cache hit spend budget too: the limiter runs before the cache is consulted.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per IP address within the window.","schema":{"type":"integer","example":300}},"X-RateLimit-Remaining":{"description":"Requests left for this IP address in the current window.","schema":{"type":"integer","example":297}},"X-RateLimit-Window":{"description":"Length of the sliding window, in seconds.","schema":{"type":"integer","example":60}},"Retry-After":{"description":"Seconds until the oldest request leaves the window and budget frees up.","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}