Back to Home

Developer API

Read-only JSON access to the e-phan competition calendar and member registry. No key required — the same records the platform shows its users, served the moment they change.

Base URL

https://api.e-phan.com/api/v1

How to connect

  1. 1

    Send a GET

    No key, no signup, no header. GET /competitions?period=upcoming returns a JSON envelope over HTTPS.

  2. 2

    Constrain the query

    Combine country, city, period, search and the date range freely; anything outside the accepted values is a 400 rather than a silent fallback.

  3. 3

    Revalidate, do not refetch

    Store the ETag and return it as If-None-Match. Unchanged data answers 304 with no body — that saves bandwidth, not rate-limit budget.

Access

No authentication. /api/v1 accepts anonymous GET over HTTPS: nothing to obtain, nothing to send. The endpoints expose the published calendar and registry the site already renders for anonymous visitors, so there is no per-caller data and no credential to protect.

The limit is keyed on the caller address, so browser-side calls draw a budget per visitor while a server-side integration spends one budget across all of its traffic. Server-side still issues far fewer requests, since one response you cache serves every visitor — and it is the only option if you need the markup indexable, a fallback while we are unreachable, or your visitors kept out of our logs.

Code samples

curl "https://api.e-phan.com/api/v1/competitions?period=upcoming&country=UA&limit=10"

The registry: states and statuses

Every dancer, coach, judge and club carries two independent fields. registryState says where the record sits; status says whether the dues behind it are paid. They do not move together, and reading only one of them will mislead you.

To find out who counts as a member today, read registryState — not status. A dancer whose dues lapsed stays listed for the length of the grace period, so status: "INACTIVE" together with registryState: "REGISTRY" is normal, not a contradiction.

registryState — where the record sits

Present on every dancer, coach, judge and club, and accepted as a filter on all four listings.

  • REGISTRY

    On the main list. Includes people whose dues have lapsed but whose grace period is still running, so a listed record is not necessarily a paid-up one. Coaches and clubs hold no membership of their own — their place is granted by the members attached to them, and losing the last one takes them off this API entirely until they acquire another or the archive claims them.

  • ARCHIVE

    A separate section of the registry, not a deletion. Archived records keep their ids and stay readable, but never appear in a listing unless you ask for them by name.

status — whether the dues are paid

Held per role, not per person: the same human can be ACTIVE as a judge and INACTIVE as a dancer, and each endpoint reports 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. 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.

  • ACTIVE

    Dues are paid and the term is still running.

  • LIMITED

    A paid membership too, cheaper than ACTIVE. They differ in what they admit you to: LIMITED competes at levels 1-3, ACTIVE at any level. Treat it as its own value, not a shade of ACTIVE or INACTIVE.

  • INACTIVE

    The paid term has run out. The record stays listed until the grace period ends; registryUntil is the day the archive claims it.

Reading the archive

Archiving is how the registry stays a list of current members without losing its history. Records leave the main list but keep everything else.

  • Listings default to registryState=REGISTRY. The archive is never mixed in — pass registryState=ARCHIVE to read it as its own section, or list both states to merge them.
  • The counts from /registry/filters are scoped exactly the way the listings are, so a facet never promises rows a listing will not return. The response states that scope in countsScope. Page through a listing with registryState=ARCHIVE to size the archive.
  • Archiving a role closes its club and coach links. The rows survive as history, but dancersCount and coachesCount on a club stop counting them from the day they close.
  • Returning from the archive requires a paid reinstatement, so do not expect a record to drift back on its own. archivedAt carries the date it left.

Asking for the archive

# Listings return the registry. The archive is not in them.
curl "https://api.e-phan.com/api/v1/dancers?limit=20"

# Ask for the archive by name to read it as its own section.
curl "https://api.e-phan.com/api/v1/dancers?registryState=ARCHIVE&limit=20"

# Or merge the two, if you are reconciling a full history.
curl "https://api.e-phan.com/api/v1/dancers?registryState=REGISTRY,ARCHIVE&limit=20"

Endpoint reference

Every endpoint is read-only and returns JSON. Filters can be combined freely: they apply with AND.

Competitions

GET/api/v1/competitions

Paginated list of competitions. Organiser drafts are never returned.

  • searchstring

    Searches name, city, country, venue and description. At least 2 characters.

    Example: search=kyiv open

  • countrystring

    Country name or ISO 3166-1 alpha-2 code.

    Example: country=UA

  • countryIdinteger

    Internal country id, see /filters.

    Example: countryId=804

  • citystring

    City name, case-insensitive prefix match.

    Example: city=Kyiv

  • statusopen | completed

    Competition status. Several values, comma-separated.

    Example: status=open

  • typestring

    Competition rank, by code. Several values, comma-separated; see /filters for the current list.

    Example: type=ranking_tournament

  • registrationStatusopen | closed

    Registration state as set by the organiser.

    Example: registrationStatus=open

  • registrationOpenboolean

    true keeps only competitions accepting entries right now — the same thing as registration.state == "open". Both the opening day and the deadline day count as open.

    Example: registrationOpen=true

  • periodupcoming | ongoing | past

    Period by calendar day. A competition running today is ongoing for the whole day.

    Example: period=upcoming

  • dateFromdate

    Calendar day, inclusive. Keeps competitions overlapping the range.

    Example: dateFrom=2026-09-01

  • dateTodate

    Calendar day, inclusive. Keeps competitions overlapping the range.

    Example: dateTo=2026-12-31

  • updatedSincedate-time

    Only competitions changed after this timestamp. For incremental sync; these responses are never cached.

    Example: updatedSince=2026-07-01T00:00:00Z

  • includecategories, entriesCount

    Extra data in the response. Several values, comma-separated.

    Example: include=categories

  • sortstartDate | endDate | name | createdAt | updatedAt

    Field to sort by.

    Example: sort=startDate

  • orderasc | desc

    Sort direction.

    Example: order=asc

  • pageinteger

    Page number, starting at 1.

    Example: page=1

  • limitinteger, 1-100

    Items per page, 1 to 100. Anything outside the range is a 400, not a silent trim.

    Example: limit=20

GET/api/v1/competitions/{idOrSlug}

One competition with its full category list, the current counts and the organiser metadata. Accepts either the id or the slug.

GET/api/v1/competitions/{idOrSlug}/categories

Categories of a single competition: age groups, classes, styles, the dances in each and the number of starts. Accepts either the id or the slug.

  • entryTypecouple | solo | duo-synchro | mix | show

    Type of performance. Several values, comma-separated.

    Example: entryType=couple

  • stylenone | latin | standard | combined

    Programme of the category.

    Example: style=latin

  • levelinteger, 1-10

    Class (level) of the category.

    Example: level=4

  • searchstring

    Searches name, city, country, venue and description. At least 2 characters.

    Example: search=kyiv open

GET/api/v1/filters

Filter values: countries and cities that currently host competitions, plus the accepted enum values.

Registry

GET/api/v1/dancers

Paginated list of dancers currently in the registry. Archived dancers are excluded unless registryState asks for them.

  • searchstring

    Case-insensitive search over name and surname (both native and Latin variants). At least 2 characters.

    Example: search=Bohdan

  • sexMALE | FEMALE

    Gender: MALE or FEMALE.

    Example: sex=FEMALE

  • countryIdinteger

    Internal country id, see /filters.

    Example: countryId=804

  • countrystring

    Country name or ISO 3166-1 alpha-2 code.

    Example: country=UA

  • clubIduuid

    UUID of the club. Matches current membership only — links closed by an archival do not count.

    Example: clubId=0f1c7a2e-...

  • coachIduuid

    UUID of the coach. Matches current membership only.

    Example: coachId=0f1c7a2e-...

  • danceClassinteger

    Dance class level.

    Example: danceClass=3

  • hasPartnerboolean

    Filter by presence of a partner: true or false.

    Example: hasPartner=true

  • registryStateREGISTRY | ARCHIVE

    Where the record sits: REGISTRY or ARCHIVE, comma-separated. Defaults to REGISTRY — the archive is only returned when you name it here. Any other value is rejected.

    Example: registryState=ARCHIVE

  • statusACTIVE | LIMITED | INACTIVE

    Membership status of this role. Several values, comma-separated. Matched per role, so a person whose dues lapsed in another role is still returned here. Independent of registryState.

    Example: status=ACTIVE

  • sortname | approvedAt

    Field to sort registry by: name or approvedAt.

    Example: sort=name

  • orderasc | desc

    Sort direction.

    Example: order=asc

  • pageinteger

    Page number, starting at 1.

    Example: page=1

  • limitinteger, 1-100

    Items per page, 1 to 100. Anything outside the range is a 400, not a silent trim.

    Example: limit=20

GET/api/v1/coaches

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.

  • searchstring

    Case-insensitive search over name and surname (both native and Latin variants). At least 2 characters.

    Example: search=Bohdan

  • sexMALE | FEMALE

    Gender: MALE or FEMALE.

    Example: sex=FEMALE

  • countryIdinteger

    Internal country id, see /filters.

    Example: countryId=804

  • countrystring

    Country name or ISO 3166-1 alpha-2 code.

    Example: country=UA

  • clubIduuid

    UUID of the club. Matches current membership only — links closed by an archival do not count.

    Example: clubId=0f1c7a2e-...

  • highestClassinteger

    Highest dance class level.

    Example: highestClass=5

  • registryStateREGISTRY | ARCHIVE

    Where the record sits: REGISTRY or ARCHIVE, comma-separated. Defaults to REGISTRY — the archive is only returned when you name it here. Any other value is rejected.

    Example: registryState=ARCHIVE

  • sortname | approvedAt

    Field to sort registry by: name or approvedAt.

    Example: sort=name

  • orderasc | desc

    Sort direction.

    Example: order=asc

  • pageinteger

    Page number, starting at 1.

    Example: page=1

  • limitinteger, 1-100

    Items per page, 1 to 100. Anything outside the range is a 400, not a silent trim.

    Example: limit=20

GET/api/v1/judges

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.

  • searchstring

    Case-insensitive search over name and surname (both native and Latin variants). At least 2 characters.

    Example: search=Bohdan

  • sexMALE | FEMALE

    Gender: MALE or FEMALE.

    Example: sex=FEMALE

  • countryIdinteger

    Internal country id, see /filters.

    Example: countryId=804

  • countrystring

    Country name or ISO 3166-1 alpha-2 code.

    Example: country=UA

  • judgeCategoryinteger

    Judge qualification category.

    Example: judgeCategory=2

  • positionjudge | headJudge | sportsInspector

    Position: judge, headJudge or sportsInspector.

    Example: position=judge

  • registryStateREGISTRY | ARCHIVE

    Where the record sits: REGISTRY or ARCHIVE, comma-separated. Defaults to REGISTRY — the archive is only returned when you name it here. Any other value is rejected.

    Example: registryState=ARCHIVE

  • statusACTIVE | LIMITED | INACTIVE

    Membership status of this role. Several values, comma-separated. Matched per role, so a person whose dues lapsed in another role is still returned here. Independent of registryState.

    Example: status=ACTIVE

  • sortname | approvedAt

    Field to sort registry by: name or approvedAt.

    Example: sort=name

  • orderasc | desc

    Sort direction.

    Example: order=asc

  • pageinteger

    Page number, starting at 1.

    Example: page=1

  • limitinteger, 1-100

    Items per page, 1 to 100. Anything outside the range is a 400, not a silent trim.

    Example: limit=20

GET/api/v1/clubs

Paginated list of clubs currently in the registry — a club is listed for as long as it holds at least one registry member. dancersCount and coachesCount report the current roster.

  • searchstring

    Case-insensitive search over club name. At least 2 characters.

    Example: search=Diamant

  • countryIdinteger

    Internal country id, see /filters.

    Example: countryId=804

  • countrystring

    Country name or ISO 3166-1 alpha-2 code.

    Example: country=UA

  • citystring

    City name, case-insensitive prefix match.

    Example: city=Kyiv

  • registryStateREGISTRY | ARCHIVE

    Where the record sits: REGISTRY or ARCHIVE, comma-separated. Defaults to REGISTRY — the archive is only returned when you name it here. Any other value is rejected.

    Example: registryState=ARCHIVE

  • sortname | approvedAt

    Field to sort registry by: name or approvedAt.

    Example: sort=name

  • orderasc | desc

    Sort direction.

    Example: order=asc

  • pageinteger

    Page number, starting at 1.

    Example: page=1

  • limitinteger, 1-100

    Items per page, 1 to 100. Anything outside the range is a 400, not a silent trim.

    Example: limit=20

GET/api/v1/clubs/{id}

One club in full: founding date, public contacts, every address and the coaching staff. Built for a club page. Archived clubs stay readable here — the archive is a section of the registry, not a deletion — so check `registryState` rather than assuming a 404. Dancers are not inlined, since a club can have hundreds; follow `links.dancers`.

GET/api/v1/registry/filters

Filter values for the member registry: countries with counts, classes, judge categories and positions, plus the accepted status and registryState values. Counts follow the same scope as the listings.

Response shape

Lists arrive in data, next to meta with the pagination state and links holding ready-made URLs for the next and previous pages.

  • Every competition carries both an id and a slug. The slug is assigned once and frozen the moment the competition becomes public, so it survives a rename — build your URLs on it, and pass either one to /competitions/{idOrSlug}.
  • registration.state is one of not_yet_open, open or closed. It replaces the former isOpen boolean, which reported a competition whose registration had not opened yet as open, because a boolean cannot express three states.
  • entriesCount and startsCount count different things. One entry may select several categories, so a category’s startsCount adds up across categories to more than the competition’s entriesCount. Use entriesCount for "how many took part", startsCount for "how full is this category".
  • banner.horizontal and banner.vertical each hold either a responsive 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.
  • Registry records carry registryState next to status, plus registryUntil — the next date on the record’s clock, whichever clock it is on — and archivedAt, which is null for anything not currently archived.
  • A club can run several venues. `locations` carries all of them — the main office first, then each branch, with its own city, country and Google place id. `address` and `country` at the top level describe the main office only, and are kept flat for consumers written before branches existed. The `city`, `country` and `countryId` filters match the main office too, so a club whose branch is in the city but whose head office is not will not be returned.
  • Competition dates and the registration deadline are calendar days in YYYY-MM-DD, with no time and no timezone, because that is what the organiser sets. createdAt and updatedAt are full timestamps.
  • New fields can appear without notice, so your client should ignore unknown keys.

GET /api/v1/competitions

{
  "data": [
    {
      "id": "0f1c7a2e-9d64-4a1b-bb02-7e9f8c3d5a11",
      "slug": "kyiv-open-2026",
      "name": "Kyiv Open Championship 2026",
      "shortName": "Kyiv Open 2026",
      "status": "open",
      "type": { "code": "ranking_tournament", "label": "Ranking tournament" },
      "organizer": {
        "name": "Ukrainian Dance Federation",
        "shortName": "UDF",
        "country": "Ukraine",
        "countryCode": "UA",
        "website": "https://example.org"
      },
      "resultsUrl": null,
      "dates": { "start": "2026-10-17", "end": "2026-10-18" },
      "location": {
        "country": "Ukraine",
        "countryCode": "UA",
        "city": "Kyiv",
        "venueName": "Palace of Sports",
        "venueAddress": "1 Sportyvna Sq."
      },
      "registration": {
        "status": "open",
        "state": "open",
        "opensAt": "2026-03-01",
        "deadline": "2026-10-10",
        "maxEntries": 800,
        "entriesCount": 317,
        "startsCount": 894
      },
      "banner": {
        "horizontal": {
          "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"
        },
        "vertical": null
      },
      "media": {
        "entryForm": null
      },
      "categories": [
        {
          "id": "6b8d1f04-2c77-4f1e-9a3b-5d2e0c8a4477",
          "name": "Juvenile I Latin, class E",
          "entryType": "couple",
          "style": "latin",
          "ageGroup": "Juvenile I",
          "level": 5,
          "dances": ["samba", "cha-cha-cha", "jive"],
          "startsCount": 42
        }
      ],
      "links": {
        "self": "…",
        "categories": "…",
        "web": "…",
        "registration": "…"
      },
      "updatedAt": "2026-07-21T10:12:44.907Z"
    }
  ],
  "meta": { "page": 1, "limit": 20, "total": 34, "totalPages": 2, "hasNextPage": true },
  "links": { "self": "…", "next": "…", "previous": null }
}

GET /api/v1/dancers

{
  "data": [
    {
      "id": "a3f2c1d8-7e4b-49a1-bc06-3d5e9f1a2b77",
      "name": "Олена",
      "surname": "Петренко",
      "nameEn": "Olena",
      "surnameEn": "Petrenko",
      "avatar": "https://api.e-phan.com/uploads/users/avatar.jpg",
      "danceClass": 3,
      "country": { "id": 1, "name": "Ukraine", "code": "UA" },

      "status": "INACTIVE",
      "membershipExpiresAt": "2025-12-31",

      "registryState": "REGISTRY",
      "registryUntil": "2026-06-30",
      "archivedAt": null
    }
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 142,
    "totalPages": 8,
    "hasNextPage": true,
    "hasPreviousPage": false
  }
}

GET /api/v1/clubs

{
  "data": [
    {
      "id": "7c2b9e14-5a83-4d20-91ff-0e6a4b8c1d33",
      "name": "Diamant Elite",
      "logo": "https://api.e-phan.com/uploads/clubs/diamant.png",

      "address": "вул. Хрещатик, 1, Київ (2 поверх)",
      "country": { "id": 1, "name": "Ukraine", "code": "UA" },

      "locations": [
        {
          "type": "main",
          "address": "вул. Хрещатик, 1, Київ (2 поверх)",
          "city": "Київ",
          "country": { "id": 1, "name": "Ukraine", "code": "UA" },
          "placeId": "ChIJBUVa4U7P1EAR_kYBF9IxSXY"
        },
        {
          "type": "branch",
          "address": "вул. М. Гришка, 6А, Київ",
          "city": "Київ",
          "country": { "id": 1, "name": "Ukraine", "code": "UA" },
          "placeId": "ChIJk3Zt1XjP1EARa4LmC2mQhZ0"
        }
      ],

      "dancersCount": 34,
      "coachesCount": 4,
      "registryState": "REGISTRY",
      "registryUntil": null,
      "archivedAt": null
    }
  ],
  "meta": { "page": 1, "limit": 20, "total": 12, "totalPages": 1, "hasNextPage": false }
}

GET /api/v1/clubs/{id}

{
  "data": {
    "id": "7c2b9e14-5a83-4d20-91ff-0e6a4b8c1d33",
    "name": "Diamant Elite",
    "logo": "https://api.e-phan.com/uploads/clubs/diamant.png",

    "address": "вул. Хрещатик, 1, Київ (2 поверх)",
    "country": { "id": 1, "name": "Ukraine", "code": "UA" },
    "locations": [ "…main and every branch…" ],

    "foundedOn": "2003",
    "approvedAt": "2026-04-06T09:44:41.666Z",
    "contacts": {
      "phone": "+380441234567",
      "email": "info@diamant.example",
      "website": "https://diamant.example",
      "instagram": "https://instagram.com/diamant",
      "facebook": null
    },

    "dancersCount": 34,
    "coachesCount": 2,
    "coaches": [
      {
        "id": "b81f0a55-2c19-4f77-9d3e-6a0c5e2b7f41",
        "name": "Ігор", "surname": "Ковальчук",
        "nameEn": "Ihor", "surnameEn": "Kovalchuk",
        "avatar": null,
        "highestClass": 8,
        "country": { "id": 1, "name": "Ukraine", "code": "UA" },
        "registryState": "REGISTRY",
        "registryUntil": null,
        "archivedAt": null
      }
    ],

    "registryState": "REGISTRY",
    "registryUntil": null,
    "archivedAt": null,

    "links": {
      "self": "…",
      "dancers": "…/dancers?clubId=7c2b9e14-…",
      "coaches": "…/coaches?clubId=7c2b9e14-…",
      "web": "…"
    }
  }
}

Caching

Successful reads are memoised in the backend process, keyed on path plus the sorted query string, so a repeat call reaches neither the query planner nor the serializer. You need not keep a copy of the calendar, and you may cache ours: responses carry Cache-Control: public, max-age=<TTL>, which a CDN or your own reverse proxy is free to honour.

  • Any write to a competition or a category clears the store through Sequelize hooks, so an edit is visible on the next request instead of when the TTL runs out.
  • TTL per endpoint: 60 s for the competition list and a single competition, 120 s for the registry listings, 300 s for categories and filter values. Requests carrying updatedSince bypass the cache entirely and answer Cache-Control: no-store.
  • X-Cache reports HIT, MISS or BYPASS on every read. ETag and If-None-Match are handled on both the hit and the miss path, so conditional requests work regardless of cache state.
  • entriesCount and startsCount can trail by up to the TTL: entries arrive continuously and invalidating the store for each one would keep it permanently empty. Registry state changes on a timer — a term simply reaching its date — so a record can read stale by up to its TTL there too.
  • Cache these responses on your side too, 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.

Conditional request: 200, then 304

# 1. Read the data and keep the validator
curl -i "https://api.e-phan.com/api/v1/competitions?period=upcoming"

# HTTP/1.1 200 OK
# ETag: W/"a3f-9Kd1ynQ+Xc0Vv2Bf1lPqE7dZs"
# Cache-Control: public, max-age=60
# X-Cache: MISS

# 2. Ask again, passing the validator back
curl -i -H 'If-None-Match: W/"a3f-9Kd1ynQ+Xc0Vv2Bf1lPqE7dZs"' \
  "https://api.e-phan.com/api/v1/competitions?period=upcoming"

# HTTP/1.1 304 Not Modified
# X-Cache: HIT

Rate limits

300 requests per minute per IP address, counted in a 60-second sliding window. Write to us if your integration needs more.

  • Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Window.
  • Exceeding the budget answers 429 with Retry-After in seconds and an error envelope of type RATE_LIMIT_EXCEEDED.
  • Every request spends budget, a 304 and a cache HIT included: the counter runs before the cache is consulted, so the limit caps how often you call us, not how much work we do.

Errors

Errors always arrive in the same envelope, with a machine-readable type field.

  • 400
    INVALID_REQUEST

    A query parameter holds an unsupported value. The details field lists them.

  • 404
    NOT_FOUND

    There is no public resource with this id.

  • 429
    RATE_LIMIT_EXCEEDED

    The per-minute budget is used up.

Error example

{
  "error": {
    "type": "INVALID_REQUEST",
    "message": "One or more query parameters are invalid.",
    "details": ["limit: expected an integer between 1 and 100"]
  }
}

Every response carries X-Request-Id. Quote it in a report and we will find the request in our logs. Supply your own and we echo it back, provided it matches [A-Za-z0-9_.:-] and is at most 64 characters; anything else is replaced with a generated id.

Terms of use

  • When you publish the data, credit e-phan and link back to the competition page.
  • The API serves published competitions only: organiser drafts stay out of the feed.
  • New fields can appear without notice, so your client should ignore unknown keys. Breaking changes are announced in advance and shipped under a new path version.

Integration questions: support@e-phan.com