# Overview > What the Tailend API returns, who it is for, and a working request for each common job. Source: https://api.tailend.dev/docs The Tailend API returns family activities as clean JSON: **events**, **classes**, **camps**, **places** and the **organizations** behind them, for each metro we cover. It is read-only, every request is a plain HTTPS `GET` with an API key, and no SDK is needed. Use it to power a family app or website, a newsletter, a map, or an AI assistant that needs to answer "what can we do this weekend?" with real, current listings. ## Examples Pick a job to see the request. Every example opens in the API reference, ready to try. ### Events this weekend Dated events for a 4-year-old over two days. Reference: [List events](https://api.tailend.dev/docs/api/list-events.md) ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/events?from=2026-10-17&to=2026-10-18&age=4" ``` ### Search everything One query across events, classes, camps and places. Reference: [Search every listing collection](https://api.tailend.dev/docs/api/search-listings.md) ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/search?q=toddler%20story%20time&age=3" ``` ### Classes starting soon Classes whose term starts next week, for a 6-year-old. Reference: [List classes](https://api.tailend.dev/docs/api/list-classes.md) ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/classes?starts_from=2026-10-19&starts_to=2026-10-25&age=6" ``` ### A month at a glance How many events start on each day, for a calendar view. Reference: [Event counts per day](https://api.tailend.dev/docs/api/get-event-calendar.md) ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/events/calendar?from=2026-11-01&to=2026-11-30" ``` ### Around an address What is near an address, grouped the way people read a neighborhood. Reference: [Search around an address](https://api.tailend.dev/docs/api/search-around-address.md) ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/around?address=1500%20Market%20St%2C%20Philadelphia%2C%20PA" ``` ### Preview the planned change-feed request (Sync pilot) The planned request for every change since an instant, removals included. Reference: [List changes](https://api.tailend.dev/docs/api/list-changes.md) ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/changes?since=2026-10-01T00%3A00%3A00Z&metro=philadelphia" ``` ## What you get back Every listing has a stable `id` and `slug`, a `type`, local and UTC times, ages, price and the place it happens at. Lists come in one envelope: ```json { "data": [{ "id": "b81a95d2-…", "type": "event", "name": "Toddler Time", "starts_at": "2026-09-14T13:30:00.000Z" }], "meta": { "limit": 50, "count": 1, "has_more": false, "next_cursor": null } } ``` - **Events** are dated occurrences. A weekly series is one listing; see [Recurring events](https://api.tailend.dev/docs/recurring-events.md). - **Classes** are enrollable programs with a term, a schedule and tuition. - **Camps** are camps and their dated, priced sessions. - **Places** are venues with hours, admission and what they offer. - **Organizations** run the listings and the places. ## How the docs are organized - **[Quickstart](https://api.tailend.dev/docs/quickstart.md)**: a key, a first request, filters and the next page. - **[API reference (try it)](https://api.tailend.dev/docs/api.md)**: every endpoint, parameter, field and status code, generated from the [OpenAPI spec](https://api.tailend.dev/docs/openapi.json). Try any request with sample data, no key needed. - **[Changelog](https://api.tailend.dev/docs/changelog.md)**: what changed, with dates. Also as RSS. Building with an AI coding agent? Point it at [`/llms.txt`](https://api.tailend.dev/llms.txt); every page is also available as Markdown. See [Build with AI agents](https://api.tailend.dev/docs/agents.md). --- # Quickstart > Make your first request, narrow it by date, age and location, and read the next page. Source: https://api.tailend.dev/docs/quickstart Get a first real result in about five minutes: a key, a first request, a filtered request and the next page. Everything is plain HTTPS with an API key; no SDK is required. ## 1. Get a key Key issuance is not self-service yet: ask us and we set one up for your account (see [Authentication](https://api.tailend.dev/docs/authentication.md)). Keep it in an environment variable and send it in the `X-API-Key` header on every request. Never put it in a URL or in browser code. ```bash export TAILEND_API_KEY="your key" ``` ## 2. Make your first request Most paths start with a metro slug, so ask for the list first instead of hard-coding one. ```bash curl -H "X-API-Key: $TAILEND_API_KEY" https://api.tailend.dev/v1/metros ``` ```javascript const res = await fetch("https://api.tailend.dev/v1/metros", { headers: { "X-API-Key": process.env.TAILEND_API_KEY }, }); const { data: metros } = await res.json(); console.log(metros[0].slug); // "philadelphia" ``` ```python import os, requests res = requests.get( "https://api.tailend.dev/v1/metros", headers={"X-API-Key": os.environ["TAILEND_API_KEY"]}, ) print(res.json()["data"][0]["slug"]) # "philadelphia" ``` ```json { "data": [ { "slug": "philadelphia", "name": "Philadelphia Area", "timezone": "America/New_York", "aliases": ["philly", "phl"], "is_active": true } ] } ``` Now list events in that metro: ```bash curl -H "X-API-Key: $TAILEND_API_KEY" https://api.tailend.dev/v1/philadelphia/events ``` ```json { "data": [ { "id": "b81a95d2-1d4e-46fd-9cff-322a41e429ad", "type": "event", "name": "Toddler Time (Ages 5 and Under)", "starts_local": "2026-09-14T09:30:00", "starts_at": "2026-09-14T13:30:00.000Z", "has_date": true, "timezone": "America/New_York", "place": { "id": "9cde4494-6c2a-4bfc-b15a-22edce13f3f7", "slug": "treehouse-world-d34361f7", "name": "Treehouse World" }, "price": { "is_free": false, "min": 12, "max": 12, "currency": "USD" }, "confidence": 1, "links": { "self": "/v1/events/b81a95d2-1d4e-46fd-9cff-322a41e429ad" } } ], "meta": { "limit": 50, "count": 1, "has_more": false, "next_cursor": null, "min_confidence": 0.7 } } ``` ## 3. Narrow it down Filters compose, so send as many as you need in one request. | Filter | Example | Keeps | | --- | --- | --- | | Dates | `from=2026-09-19&to=2026-09-21` | Listings on those local dates, inclusive, in the metro's timezone | | Age | `age=4` | Listings whose stated age range includes 4 | | Location | `lat=39.9489&lng=-75.2098&radius_m=1600` | Listings within 1.6 km (100 to 80,000 metres) | | Price | `free=true` | Free listings only | | Text | `q=story time` | Listings matching the words | ```bash curl -sS -H "X-API-Key: $TAILEND_API_KEY" --get https://api.tailend.dev/v1/philadelphia/events \ --data-urlencode "from=2026-09-19" \ --data-urlencode "to=2026-09-21" \ --data-urlencode "age=4" \ --data-urlencode "lat=39.9489" \ --data-urlencode "lng=-75.2098" \ --data-urlencode "radius_m=1600" ``` ```javascript const params = new URLSearchParams({ from: "2026-09-19", to: "2026-09-21", age: "4", lat: "39.9489", lng: "-75.2098", radius_m: "1600", }); const res = await fetch(`https://api.tailend.dev/v1/philadelphia/events?${params}`, { headers: { "X-API-Key": process.env.TAILEND_API_KEY }, }); const { data } = await res.json(); ``` ### Defaults you get without asking A few filters apply even when you do not send them: - Events cover the next 30 days. - `min_confidence` is `0.7` for events, classes and camps, and `0.5` for places. See [How we verify listings](https://api.tailend.dev/docs/how-we-verify.md). - Adult-only listings are left out. See [Audience & versioning](https://api.tailend.dev/docs/audience-and-versioning.md). Every list response reports the filters it used in `meta.applied_filters`, and names the ones that came from a default in `meta.defaults_applied`. The [API reference](https://api.tailend.dev/docs/api.md) lists every filter for each endpoint. ## 4. Get the next page Every list response carries `meta.next_cursor`. Pass it back unchanged to get the next page; it is `null` on the last one. ```bash curl -sS -H "X-API-Key: $TAILEND_API_KEY" --get https://api.tailend.dev/v1/philadelphia/events \ --data-urlencode "cursor=$NEXT_CURSOR" ``` ```javascript let cursor = null; do { const params = new URLSearchParams(cursor ? { cursor } : {}); const res = await fetch(`https://api.tailend.dev/v1/philadelphia/events?${params}`, { headers: { "X-API-Key": process.env.TAILEND_API_KEY }, }); const { data, meta } = await res.json(); for (const event of data) console.log(event.name); cursor = meta.next_cursor; } while (cursor); ``` Treat the cursor as opaque, and keep the same filters while you page. See [Pagination](https://api.tailend.dev/docs/pagination.md). ## Search across collections Not sure whether the answer is an event, a class, a camp or a place? Search all of them at once with `GET /v1/{metro}/search?q=…`. See [Search](https://api.tailend.dev/docs/search.md). ## Next steps - [API reference](https://api.tailend.dev/docs/api.md): every endpoint, field and status code - [Search](https://api.tailend.dev/docs/search.md): one query across every collection - [Shaping responses](https://api.tailend.dev/docs/shaping-responses.md): `fields=`, `expand=` and `include=` - [Errors](https://api.tailend.dev/docs/errors.md): the envelope every failure uses - [Limits & usage](https://api.tailend.dev/docs/limits-and-usage.md): rate limit and monthly quota --- # Authentication > Send your key in the X-API-Key header; what a key controls and how to keep it safe. Source: https://api.tailend.dev/docs/authentication Send your key in the `X-API-Key` header on every `/v1` request. ```bash curl -H "X-API-Key: $TAILEND_API_KEY" https://api.tailend.dev/v1/metros ``` There's no query-string form — a key in a URL ends up in access logs, browser history, and `Referer` headers, so it isn't accepted there. ## Getting a key Key issuance isn't self-service yet — ask us and we'll set one up for your account. The plaintext key is returned once — store it as a secret; we only keep a hash. Keys look like `tl_` + 8 hex + `_` + 48 hex; the `tl_` + 8 hex part is the key's display prefix, which is how it's identified in lists and usage, and is never enough to authenticate. `X-API-Key` is the only credential `/v1` accepts. ## What a key controls A key carries its own metro, resource-type, and add-on entitlements — access is evaluated per key, not just per account. A request outside a key's entitlement is refused; see [Errors](https://api.tailend.dev/docs/errors.md) for the exact codes. ## What to do - Keep keys server-side: out of browser bundles, client code, and source control. - Rotate a key rather than sharing one across environments — rotation leaves the old key working through a grace window, so nothing goes down mid-rollover. - A missing, invalid, or revoked key returns `401`; a valid key outside its entitlement returns `403`. See [Errors](https://api.tailend.dev/docs/errors.md) for what each means and what to do next. --- # Conventions > Time fields, ids, booleans, prices and the response envelope every endpoint shares. Source: https://api.tailend.dev/docs/conventions Every endpoint follows the same rules for time, ids, booleans, prices and the response envelope. Learn them once here. ## Requests - Base URL: `https://api.tailend.dev`. Every data endpoint is a `GET` under `/v1`; the API is read-only, and any other method returns `405`. - Send your key in the `X-API-Key` header. See [Authentication](https://api.tailend.dev/docs/authentication.md). - Most paths start with a metro slug: `/v1/philadelphia/events`. Get the list from `GET /v1/metros`. - List parameters take several values, comma-separated or repeated: `audience=teens,families` is the same as `audience=teens&audience=families`. ## The response envelope A list returns `data` (an array) and `meta`. A single read returns `data` (an object). Some responses also carry `notices[]`: things you should know that did not stop the request, such as an add-on your key is not entitled to. ```json { "data": [{ "id": "…", "type": "event", "name": "…" }], "meta": { "limit": 50, "count": 1, "has_more": false, "next_cursor": null }, "notices": [] } ``` Every failure uses one error envelope instead. See [Errors](https://api.tailend.dev/docs/errors.md). ## Time | Field suffix | Meaning | Example | | --- | --- | --- | | `*_at` | An instant in UTC, RFC 3339 | `starts_at: "2026-09-14T13:30:00.000Z"` | | `*_local` | Local wall-clock time with no offset; read it against the object's `timezone` | `starts_local: "2026-09-14T09:30:00"` | | `*_on` | A calendar date with no time | `starts_on: "2026-09-14"` | - `starts_local` is already the time printed on the source page. Never add an offset to it. - No field carries a `_utc` suffix: UTC is what `_at` means. - Date filters such as `from` and `to` are local calendar dates in the metro's timezone, inclusive. - Some events have no date at all. They have `has_date: false`, and `starts_at` and `starts_local` are `null`. Check `has_date` before parsing either. ## Ids and slugs Every listing has a stable `id` (a UUID) and a human-readable `slug`. Read a single listing by either: `GET /v1/events/{id}` or `GET /v1/events/slug/{slug}`. Every resource also carries `links`, relative paths to itself and its related collections. ## Booleans Yes-or-no facts are named `is_*` or `has_*` (`is_free`, `is_recurring`, `has_date`, `has_time`). When a source does not say, the value is `null`: read `null` as "unknown", never as "no". ## Prices A price is a decimal amount plus a currency, never a formatted string: ```json { "price": { "is_free": false, "min": 12, "max": 12, "currency": "USD" } } ``` ## Distances Distances and radii are in metres: `radius_m`, `distance_m`. ## Organizations, places and sources - An **organization** runs listings (`organization`). - A **place** is where a listing happens (`place`). - A **source** is the page a listing was read from, available with `include=source` on entitled keys. See [Shaping responses](https://api.tailend.dev/docs/shaping-responses.md). ## Stability v1 is frozen: every path, parameter and field in the reference is a promise. New optional parameters, new fields and new `error.code` values can appear at any time, so parse permissively. See [Audience & versioning](https://api.tailend.dev/docs/audience-and-versioning.md). --- # Build with AI agents > llms.txt, Markdown pages and the OpenAPI spec for coding agents and assistants. Source: https://api.tailend.dev/docs/agents These docs are written to be read by coding agents and AI assistants as well as people. Give your agent one of these and it has everything it needs. | Resource | URL | Use it for | | --- | --- | --- | | llms.txt | [`/llms.txt`](https://api.tailend.dev/llms.txt) | An index of every docs page, with agent instructions at the top | | llms-full.txt | [`/llms-full.txt`](https://api.tailend.dev/llms-full.txt) | Every page in one Markdown file | | Page as Markdown | add `.md` to any docs URL, e.g. [`/docs/quickstart.md`](https://api.tailend.dev/docs/quickstart.md) | One page, clean, for a context window | | OpenAPI spec | [`/docs/openapi.json`](https://api.tailend.dev/docs/openapi.json) | Generating a client or tool definitions; the source of truth | Every page also has a **Copy page** button that copies its Markdown. ## Rules for an agent - Base URL `https://api.tailend.dev`; every endpoint is a `GET`. - Send `X-API-Key` on every request. Keep the key on the server, never in a prompt, a browser bundle or a URL. - Start with `GET /v1/metros` for the metro slug. - Branch on `error.code`, not the status. Both limits return `429`: `rate_limited` can be retried after `Retry-After`, `quota_exceeded` cannot. See [Errors](https://api.tailend.dev/docs/errors.md). - An empty list is a real answer, not an error. On `/around`, check `coverage` before saying an area has nothing. ## Which endpoint to give a tool | The user asks | Tool endpoint | | --- | --- | | "Find a swim class for my 6-year-old" | [`GET /v1/{metro}/search`](https://api.tailend.dev/docs/api/search-listings.md) | | "What's on this weekend?" | [`GET /v1/{metro}/events`](https://api.tailend.dev/docs/api/list-events.md) with `from`/`to` | | "What's it like for families near this address?" | [`GET /v1/{metro}/around`](https://api.tailend.dev/docs/api/search-around-address.md) | | "Is this a lively area?" (a number only) | [`GET /v1/{metro}/activity`](https://api.tailend.dev/docs/api/get-activity.md) | ## Example tool definition A function-calling definition for the address search, ready for an Anthropic or OpenAI tool list. Your tool handler sends the arguments as query parameters to `GET /v1/{metro}/around`. ```json { "name": "search_around_address", "description": "Search an address and get back what is around it for families: dated events, weekly programs, classes, camps and places, grouped and ranked, each with its distance in metres and walking minutes. Also returns a 0-100 activity score, counts for filtering, and a coverage block saying how many nearby listings cannot be placed on the map.", "input_schema": { "type": "object", "properties": { "metro": { "type": "string", "default": "philadelphia" }, "address": { "type": "string", "maxLength": 300, "description": "Street address to search around. Give this OR lat+lng, not both." }, "lat": { "type": "number", "minimum": -90, "maximum": 90 }, "lng": { "type": "number", "minimum": -180, "maximum": 180 }, "radius_m": { "type": "integer", "minimum": 100, "maximum": 8000, "default": 1600, "description": "1600 is about a mile, walking distance for a family." }, "from": { "type": "string", "description": "YYYY-MM-DD in the metro's timezone. Defaults to today." }, "to": { "type": "string", "description": "YYYY-MM-DD, inclusive. Defaults to from + 30 days." }, "ages": { "type": "array", "items": { "type": "string", "enum": ["baby", "toddler", "young_kids", "bigger_kids"] } }, "free": { "type": "boolean" }, "types": { "type": "array", "items": { "type": "string", "enum": ["event", "class", "camp", "place"] } }, "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10, "description": "Per group, not per response." } }, "required": ["metro"] } } ``` Prefer `lat`/`lng` when your app already has coordinates: address lookups have their own usage limits, and the coordinate form always works. > **TODO (owner):** decide whether answers built on the API must carry an > attribution line (for example "Activity data from Tailend, as of {date}"), > and add the exact wording here. --- # Search > One query across events, classes, camps and places, with match levels and scores. Source: https://api.tailend.dev/docs/search Use `GET /v1/{metro}/search` when you do not know whether the answer is an event, a class, a camp or a place. One query searches all four, with the same visibility and filter rules as the individual list endpoints, so it never returns a listing those endpoints would hide. ```bash curl -G -H "X-API-Key: $TAILEND_API_KEY" \ --data-urlencode "q=toddler story time" \ --data "age=4" --data "near_zip=19148" \ --data "from=2026-10-03" --data "to=2026-10-03" \ https://api.tailend.dev/v1/philadelphia/search ``` ```javascript const params = new URLSearchParams({ q: "toddler story time", age: "4", near_zip: "19148", from: "2026-10-03", to: "2026-10-03" }); const res = await fetch(`https://api.tailend.dev/v1/philadelphia/search?${params}`, { headers: { "X-API-Key": process.env.TAILEND_API_KEY }, }); const { data, meta } = await res.json(); for (const r of data) console.log(r.type, r.name, r.match, r.score); ``` Each result keeps the shape of its own collection and adds `snippet`, `match` and `score`. Snippets are plain text, without highlight markup. ## How your words are read Search reads the query as plain words, and results can match by meaning as well as by keyword. - **Word forms match.** `swim` finds `swimming`. - **Close relatives count.** A short list of related family-activity words matches too: `pottery` finds `ceramics`. - **Filler is ignored.** Question and filler words such as "where can I", "with" or "for", and phrases such as "things to do", "near" and relative weekdays, are never required and never change a score. - **Kind words only lift.** Words that name the kind of listing ("class", "lessons", "camp") lift a listing that has them in its name, but never decide whether it matches. - **Time and price words are not filters yet.** "tonight" or "cheap" never decide a match; they come back in `meta.ignored_words`. - **Audience words do not narrow.** "parents" or "kids" in a longer query come back in `meta.ignored_words`; use the `audience` filter instead. A query made only of audience words ("Mommy and Me", "kids", "family") is searched as written. A query with nothing searchable in it ("near me", "where can I") returns `400 bad_request`. ## Ages in the query An age in the query becomes an age filter: "soccer for 5 year olds", "art ages 3-5", "toddlers" and "teens" all narrow by age. The band that was applied comes back in `meta.interpreted.age`, for example `{ "min": 1, "max": 3, "from": "toddlers" }`. Listings whose stated ages cover the band rank above listings with no stated ages, and listings whose ages exclude it are left out. An `age` or `audience` parameter you send yourself always wins: the band is not applied, and `meta.interpreted.age.overridden_by` names your parameter. ## Match levels and score Every result says how directly it matches in `match`: | `match` | Meaning | | --- | --- | | `all_terms` | Every word you typed is in the listing's name or category, or prominent in the opening of its description. | | `related` | Every word is covered that way, but at least one only through a closely related word. | | `partial` | Only some of your words, or all of them only mentioned in passing further down a description. | By default search returns `all_terms` and `related` results. Ask for loose matches with `match=all_terms,related,partial` (or `match=partial` alone). `meta.match_counts` reports how many results exist at each level whatever you selected, so you can show "17 direct, 36 related" and offer to widen. `meta.count_estimate` counts the levels you selected. `score` is an absolute 0 to 1 measure of how well one listing fits the words of the query. It is not scaled against the other results: the same listing gets the same score whatever else comes back, and scores are comparable between queries. `meta.ranking` is `hybrid` when meaning-based ranking contributed, and `keyword` when keyword ranking was used alone. ## Sorting | `sort` | Order | | --- | --- | | `relevance` (default) | `all_terms`, then `related`, then `partial`. Within a level a name match ranks above a category match, which ranks above a description match. | | `soonest` | By an event's next occurrence; places and undated results last, ties broken by relevance. | | `nearest` | By distance. Requires `lat` + `lng` or `near_zip`. | | `newest` | Most recently added first. | ```bash curl -G -H "X-API-Key: $TAILEND_API_KEY" \ --data-urlencode "q=storytime" --data "sort=nearest" \ --data "near_zip=19148" \ https://api.tailend.dev/v1/philadelphia/search ``` ## Paging and limits - Search cursors are tied to the sort and the ranking mode. Keep the same `sort` when you request the next page; changing it returns `400`. If the ranking mode changes between pages, you get `400 bad_request` with `details.reason: cursor_expired`: restart the search without the cursor. - Search does not accept the collection paging and shaping parameters (`page`, `include`, `fields`, `expand`); sending one returns `400`. - Very broad queries may be capped, and `meta.capped` is then `true`. Add filters or a more specific `q` to narrow the results. See [Search every listing collection](https://api.tailend.dev/docs/api/search-listings.md) for every parameter. --- # Around an address > Everything near an address or point, grouped and ranked, with an activity summary. Source: https://api.tailend.dev/docs/around-an-address Three endpoints answer "what is near here?". Pick by what you have and what you want back. | Endpoint | Give it | Get back | | --- | --- | --- | | [`/v1/{metro}/around`](https://api.tailend.dev/docs/api/search-around-address.md) | An address, or `lat`/`lng` | Everything nearby in five ranked groups, an activity summary, facets and a coverage note | | [`/v1/{metro}/nearby`](https://api.tailend.dev/docs/api/list-nearby.md) | `lat`/`lng` | One flat list, nearest first, with the distance on every row | | [`/v1/{metro}/activity`](https://api.tailend.dev/docs/api/get-activity.md) | An address, or `lat`/`lng` | One comparable 0 to 100 activity score, its components, and the listings nearby | ## Search around an address ```bash curl -G -H "X-API-Key: $TAILEND_API_KEY" \ --data-urlencode "address=1500 Market St, Philadelphia, PA" \ --data "radius_m=1600" \ https://api.tailend.dev/v1/philadelphia/around ``` ```javascript const params = new URLSearchParams({ address: "1500 Market St, Philadelphia, PA", radius_m: "1600" }); const res = await fetch(`https://api.tailend.dev/v1/philadelphia/around?${params}`, { headers: { "X-API-Key": process.env.TAILEND_API_KEY }, }); const { data } = await res.json(); console.log(data.location, data.activity, Object.keys(data.results)); ``` One response carries: - `location`: the resolved address, so you can see which reading of an ambiguous address was used. - `activity`: a header with the score, percentile, one sentence and its components. - `results`: five ranked groups. Every item has `distance_m` and `walk_minutes`. - `facets`: counts by type, age band, free or paid, and distance ring over the whole circle. - `coverage`: how many nearby listings cannot be shown because they have no coordinates. ## The five groups | Group | Holds | | --- | --- | | `happening_soon` | Dated events in the window | | `recurring_programs` | Weekly or monthly series and drop-in classes | | `classes_enrolling` | Term classes | | `camps` | Camps | | `places` | Places with no date of their own | `happening_soon` and `recurring_programs` overlap by design; each item's `also_in` names the other groups it appears in. `limit` is per group. Each group returns its own `next_cursor`; pass it as `cursor` to page that one group. ## Read coverage before you say "nothing" An empty group with `coverage.hidden_nearby` above `0` means some nearby listings are not visible to this endpoint, not that the area has nothing happening. Say so rather than reporting an empty area. ## Window and filters `from` and `to` are local calendar dates in the metro's timezone. `from` defaults to today and `to` to `from` plus 30 days. Narrow with `ages`, `free`, `types` and `min_confidence`; see the [reference](https://api.tailend.dev/docs/api/search-around-address.md) for every parameter. ## Address or coordinates Send `lat`/`lng` when you already have coordinates. Address lookups have their own usage limits and can return `429` when that capacity is unavailable; the coordinate form keeps working. --- # Recurring events > How a weekly or monthly series is represented and how its next date is computed. Source: https://api.tailend.dev/docs/recurring-events This page is for anyone building a calendar, feed, or newsletter view: it explains how a weekly storytime or a monthly meetup shows up in the API — one listing per series, not one row per Saturday — and what happens once a series has run its course. ```bash curl -sS -H "X-API-Key: $TAILEND_API_KEY" --get https://api.tailend.dev/v1/philadelphia/events \ --data-urlencode "recurring=true" \ --data-urlencode "limit=1" ``` ```javascript const res = await fetch( "https://api.tailend.dev/v1/philadelphia/events?recurring=true&limit=1", { headers: { "X-API-Key": process.env.TAILEND_API_KEY } }, ); const { data } = await res.json(); console.log(data[0].name, data[0].rrule, data[0].occurrence); ``` ```json { "data": [ { "id": "2f3c9d4e-3d2b-4b7d-9a3e-1b2c3d4e5f60", "name": "Storytime in the Park", "is_recurring": true, "rrule": "FREQ=WEEKLY;BYDAY=SA", "recurrence_description": "Every Saturday through October", "starts_at": "2026-08-01T14:00:00Z", "occurrence": { "next_starts_at": "2026-09-19T14:00:00Z", "next_starts_local": "2026-09-19T10:00:00" } } ] } ``` ## One listing per series A recurring event is a single row, not one row per occurrence. `is_recurring` tells you it repeats, `rrule` gives the machine-readable recurrence rule (RFC 5545), and `recurrence_description` gives the human-readable version (`"Every Saturday through October"`). There's no separate endpoint or per-occurrence resource — a weekly storytime is one `id`, one `slug`, one permalink, for the life of the series. ## How the next occurrence works in list results `starts_at`/`starts_local` on a recurring row are the series' original anchor — when it first started, not necessarily anything upcoming. The `occurrence` block carries the one that matters for "what's happening": | Field | Meaning | | --- | --- | | `occurrence.next_starts_at` | UTC instant of the next live occurrence, or `null` when the series has ended or no occurrence is known | | `occurrence.next_starts_local` | The same instant, as local wall-clock time | List results (`GET /v1/{metro}/events`) order and window recurring rows by this next occurrence rather than by the original anchor date — so a series that started in January and still meets every Saturday shows up in a `from`/`to` window that covers next Saturday, not just a window that covers January. A one-off (non-recurring) event keeps working exactly as before, windowed and ordered on its own `starts_at`. `occurrence` is additive: check for its presence rather than assuming it's always there, and always check `has_date`/`starts_at` first for whether an event has a date at all (see the [API reference](https://api.tailend.dev/docs/api.md)). ## Finished series Once a recurring series has no more live occurrences, `occurrence.next_starts_at` is `null` and the row drops out of ordinary list results — there's nothing left to window it against. The listing itself isn't deleted: `GET /v1/events/{id}` and the slug route still resolve it (so a permalink you've already published or indexed keeps working), it's just not returned by a plain `from`/`to` list call once its last occurrence has passed. ## What the API doesn't carry: per-occurrence exceptions There's no structured list of skipped or moved dates (a `2026-09-27` occurrence cancelled, or moved to `2026-09-28`) — the API carries the series' current `rrule` and `next_starts_at`/`next_starts_local`, which always reflect the source's current schedule, not a history of individual changes to it. If a specific occurrence date changed, that shows up as a new `occurrence.next_starts_at` value the next time you read the row — not as an exception record attached to the old date. If you need to know *when* a listing's dates or price changed, rather than just what's current, `GET /v1/listings/{id}/versions` gives you that history — each entry has a `changed_at`, a `kind` (`date_changed` is the one you want), and the names of the fields that changed. See the [API reference](https://api.tailend.dev/docs/api.md) for the full shape, and [Pagination](https://api.tailend.dev/docs/pagination.md) for walking it. ## What to do - Filter with `recurring=true`/`recurring=false` when you only want one kind. - Treat `occurrence.next_starts_at: null` as "nothing upcoming," not as an error — keep the listing's `id`/`slug` around if you've already published a link to it. - Don't try to reconstruct a full occurrence calendar from `rrule` and call it authoritative — `occurrence.next_starts_at` is the one the API itself computed and stands behind; a client-side RRULE expansion can drift from it (holidays, one-off cancellations, schedule changes). --- # Classes with sections > Programs offered in several variants, filtered one section at a time. Source: https://api.tailend.dev/docs/class-sections Many classes are really one program offered in several variants: a Saturday morning group for ages 3-5, a Sunday group for ages 9-12, a fall term and a spring term. The class response describes the program once and lists the variants as **sections**. ```bash curl -sS -H "X-API-Key: $TAILEND_API_KEY" --get https://api.tailend.dev/v1/philadelphia/classes \ --data-urlencode "schedule_day=saturday" \ --data-urlencode "age=4" \ --data-urlencode "openings=true" ``` ## What a section is Each item in `sections[]` is one way to take the program: its own days and times, term dates, age range, tuition and registration state. The field is always present, and is an empty array when the program has no separate variants. The program's own `schedule`, `term`, `ages` and `tuition` stay as the headline values. ```json { "name": "Kids Gymnastics", "tuition": { "amount": 185, "from": 160 }, "registration": { "has_spots_available": true, "sections_with_spots": 1 }, "sections_total": 2, "sections": [ { "id": "…", "name": "Saturday 10:00", "schedule": { "days": ["saturday"], "start_time": "10:00:00", "end_time": "11:00:00" }, "ages": { "min": 3, "max": 5 }, "tuition": { "amount": 185, "currency": "USD" }, "registration": { "status": "full", "has_spots_available": false } }, { "id": "…", "name": "Sunday 14:00", "schedule": { "days": ["sunday"], "start_time": "14:00:00", "end_time": "15:00:00" }, "ages": { "min": 9, "max": 12 }, "tuition": { "amount": 160, "currency": "USD" }, "registration": { "status": "open", "has_spots_available": true } } ] } ``` At the program level: - `sections_total` is how many sections there are. - `registration.sections_with_spots` is how many sections are known to have room: seats left, or marked available, and not closed. A section whose availability is unknown is not counted. - `registration.has_spots_available` is `true` when the program or any section has seats. - `tuition.from` is the lowest section tuition, for "from $X" displays. With no section prices it is the program's own tuition. ## Filtering A program with sections matches only when **one section** satisfies every filter you send. The filters are `schedule_day` and `weekday`, `age`, `start_after` and `start_before`, `from`, `to` and `season`, `free` and `max_price`, and `openings=true`. With a Saturday section for ages 3-5 and a Sunday section for ages 9-12: | Request | Matches? | | --- | --- | | `schedule_day=saturday&age=4` | Yes, the Saturday section fits both. | | `schedule_day=saturday&age=10` | No. The age fits Sunday, the day fits Saturday. | | `age=7` | No. 7 falls between the two sections. | `openings=true` keeps programs with open seats in the section that also meets your other filters. Programs without sections are judged on their own fields. Filters decide which programs come back and never trim `sections[]`, so you always see the whole program. ## One card per program When a provider publishes the same program as several separate listings, a class list shows them as a single card. The other listings are nested under the card's `sessions`, each a complete class you can also fetch by its own `id`. Cards are formed before paging, so a page never splits one and `limit` counts cards. Send `expand=sessions` to get every class on its own instead. A `cursor` only works with the same `expand` and `openings` values it was issued under; change either and start again from the first page. ## In the changes feed An edit to a section, or a section being added or removed, appears as an update to its program, with `sections` in `fields`, in both `/v1/changes` and `/v1/listings/{id}/versions`. It is never a creation or an archive of the program. --- # Finding a business > Look up one organization by name, website or pasted URL, and read score and match. Source: https://api.tailend.dev/docs/finding-a-business This page is for anyone who needs to look up one organization, for example an app where a provider says "this is my business". People type a business as a name, as a website address, or with a slightly different spelling, and all of those should find it. ```bash curl -sS -H "X-API-Key: $TAILEND_API_KEY" --get https://api.tailend.dev/v1/philadelphia/organizations \ --data-urlencode "q=designgym.com" \ --data-urlencode "limit=5" ``` ```javascript const res = await fetch( "https://api.tailend.dev/v1/philadelphia/organizations?q=" + encodeURIComponent("design gym"), { headers: { "X-API-Key": process.env.TAILEND_API_KEY } }, ); const { data } = await res.json(); console.log(data[0].name, data[0].score, data[0].match); ``` ```json { "data": [ { "id": "a158148a-52d4-4ea3-a6b9-0c830ddc6768", "name": "designgym.co", "website_url": "https://www.designgym.co", "score": 0.94, "match": "domain" } ] } ``` (Other fields are omitted above.) One request is enough. You do not need to try several spellings. ## What `q` understands - **Casing, punctuation and spacing do not matter.** `Design Gym`, `design-gym` and `DESIGN GYM` are the same search. - **Endings are set aside**, on both sides of the comparison: `LLC`, `Inc`, `The`, `& Co`, and address endings such as `.com`, `.co`, `.org`, `.net`, `.io` and `.us`. `designgym.com` finds an organization named `designgym.co`. - **Spaces do not matter.** `design gym` finds `designgym`, and the other way round. - **A website address or pasted URL works.** `designgym.com`, `https://www.designgym.com/classes` and `www.designgym.com` all search by the address. An organization is found by its own website whether or not `website_url` is filled in. - **A small typo still finds it.** `desing gym` finds `designgym`. - **Part of a name works too.** `gym` finds organizations with `gym` in their name, ranked below closer matches. A very short fragment is not searched as a part of a name, and a near miss never ranks above a name that contains what you typed. - **Words like `the`, `and` or `co` are not a search.** A query made only of those, or only of endings such as `llc` or `.com`, returns an empty list with a `meta.hint` saying so. - **Pages on shared platforms do not identify a business.** A social page, a booking widget, a link-in-bio page or a website builder address is never an `exact_domain`, so one provider's page cannot be mistaken for another's. Only organizations with a live listing in the metro are searched. ## Reading the result Every result carries two fields when you send `q`: | Field | Meaning | | --- | --- | | `score` | 0 to 1, higher is closer. It is the same for one organization whatever else is returned, so you can show it as a confidence. | | `match` | How it was found. | `match` is one of: | `match` | What was found | | --- | --- | | `exact_name` | The whole name was typed (ignoring casing and punctuation). | | `exact_domain` | The whole website address was typed (the host, ignoring `www.`). | | `name` | The same words as the name once spelling, spacing and endings are set aside, or the start, or a word, of the name. | | `domain` | The same, for a website address. | | `fuzzy` | A small typo away. | Results are ordered best first, then by name. `page`, `limit` and `cursor` work as they do everywhere else; see [Pagination](https://api.tailend.dev/docs/pagination.md). A good rule for "is this my business": treat `exact_name` and `exact_domain` as a confident match, `name` and `domain` with a high `score` as a likely one worth confirming with the person, and `fuzzy` as a suggestion. A `domain` match on a different ending (`acme.org` for `acme.com`) is a suggestion to confirm, not a confirmation, and so is the same registered domain on another host. An empty list means we found nothing close, which is also worth telling the person. ## Website address `website_url` is the organization's own website. When none is recorded, it is the address of the site the organization itself runs, if we have one. It stays `null` when there is nothing trustworthy to show. --- # Market overview with facets > Counts by category, weekday, time, price, age and organization over a whole filtered set. Source: https://api.tailend.dev/docs/market-overview-with-facets This page is for anyone showing "the market near here": how many classes and events, on which days, at what times and prices, for which ages, run by whom. You can get those numbers without downloading the listings. Add `facets=` to a list request and the response carries counts over **every listing that matches your filters**, not just the page you asked for. It also covers the other half of the same question: when a count looks smaller than you expected, the response tells you which filters produced it. ## Two things to know first Three filters narrow a list even when you do not send them: | Filter | Default | Applies to | | --- | --- | --- | | `from` / `to` | `from` is today in the metro; on events `to` is `from` + 30 days | events; classes and camps default only `from`, so their window is open-ended | | `min_confidence` | `0.7` (`0.5` on places) | events, classes, camps, places | | `audience` | everything except `adults` | events, classes, camps, places | A request with none of them sent therefore sees one month of events and leaves out lower-confidence and adult-audience listings. That is the right default for a family product, but it is easy to forget when you compare a list against a market-wide total. Every list response says what it used. `meta.applied_filters` holds the value in effect for each filter the endpoint applies, and `meta.defaults_applied` names the ones that came from a default rather than from you: ```bash curl -sS -H "X-API-Key: $TAILEND_API_KEY" --get https://api.tailend.dev/v1/philadelphia/events \ --data-urlencode "lat=39.9526" \ --data-urlencode "lng=-75.1652" \ --data-urlencode "radius_m=8000" \ --data-urlencode "limit=1" ``` ```json { "meta": { "applied_filters": { "from": "2026-09-30", "to": "2026-10-30", "min_confidence": 0.7, "audience": ["not_adults"], "lat": 39.9526, "lng": -75.1652, "radius_m": 8000 }, "defaults_applied": ["from", "to", "min_confidence", "audience"] } } ``` To widen the view, send the filter yourself: `to=2027-09-29` for a year of events, `min_confidence=0` for everything regardless of confidence, `audience=all` to include adult listings. The echo then shows your value and drops the name from `defaults_applied`. The echoed value is always one you can send back: `audience=not_adults` (the default) returns exactly the set you got without the parameter. The same two fields appear on events, classes, camps, places, `/search`, `/nearby` and `/around`. Only filters that endpoint applies are listed, so a `weekday` sent to `/camps` does not appear: the endpoint ignored it. ## Ask for the counts `facets` takes any of `category`, `weekday`, `time_block`, `price_band`, `age_band` and `organization`, comma-separated. Add `limit=0` when you only want the counts: ```bash curl -sS -H "X-API-Key: $TAILEND_API_KEY" --get https://api.tailend.dev/v1/philadelphia/classes \ --data-urlencode "lat=39.9526" \ --data-urlencode "lng=-75.1652" \ --data-urlencode "radius_m=8000" \ --data-urlencode "facets=category,weekday,time_block,price_band,age_band,organization" \ --data-urlencode "limit=0" ``` ```javascript const params = new URLSearchParams({ lat: "39.9526", lng: "-75.1652", radius_m: "8000", facets: "category,weekday,time_block,price_band,age_band,organization", limit: "0", }); const res = await fetch(`https://api.tailend.dev/v1/philadelphia/classes?${params}`, { headers: { "X-API-Key": process.env.TAILEND_API_KEY }, }); const { facets, meta } = await res.json(); console.log(facets.total, "classes;", meta.defaults_applied); ``` ```json { "data": [], "facets": { "total": 112, "is_complete": true, "category": [ { "value": "arts_creative", "count": 38 }, { "value": "sports_active_play", "count": 27 }, { "value": "music_movement", "count": 19 }, { "value": "unspecified", "count": 4 } ], "weekday": [ { "value": "monday", "count": 31 }, { "value": "tuesday", "count": 36 }, { "value": "wednesday", "count": 29 }, { "value": "thursday", "count": 33 }, { "value": "friday", "count": 18 }, { "value": "saturday", "count": 44 }, { "value": "sunday", "count": 12 } ], "time_block": [ { "value": "morning", "count": 41 }, { "value": "afternoon", "count": 39 }, { "value": "evening", "count": 28 }, { "value": "unspecified", "count": 4 } ], "price_band": [ { "value": "free", "count": 9 }, { "value": "under_20", "count": 14 }, { "value": "20_to_50", "count": 35 }, { "value": "50_to_100", "count": 22 }, { "value": "100_plus", "count": 17 }, { "value": "unspecified", "count": 15 } ], "age_band": [ { "value": "baby", "count": 14 }, { "value": "toddler", "count": 33 }, { "value": "young_kids", "count": 51 }, { "value": "bigger_kids", "count": 39 }, { "value": "unspecified", "count": 12 } ], "organization": { "distinct": 41, "top": [ { "id": "c45b9b3a-45ab-4f9c-b2e2-986dcb921f25", "name": "Example Arts Center", "count": 9 } ], "other_count": 97, "unspecified_count": 6 } }, "meta": { "limit": 0, "count": 0, "has_more": false, "next_cursor": null } } ``` (Numbers are illustrative.) Without `limit=0` you get a normal page in `data` and the same `facets` beside it, both from one request. ### How to read the counts - **`total`** is the number of listings that match your filters. It is the number you would reach by paging the list to the end. - **A listing is counted in every bucket it belongs to.** A class that meets Monday and Wednesday is in both; a listing with two categories is in both. So `category`, `age_band` and a class's `weekday` can add up to more than `total`. `time_block` and `price_band` put each listing in exactly one bucket. - **`unspecified`** counts listings that do not state that detail (no start time, no price, no age range). It appears only when it is above zero. - **Buckets.** `weekday` is the local day of an event, or each day a class meets. `time_block` is `morning` (before 12:00), `afternoon` (12:00 to before 17:00) or `evening` (17:00 on), from the local start time. `price_band` is `free` for exactly the listings `free=true` returns, so the bucket and the filter give the same number; every other listing is banded by its lowest stated price in US dollars: `under_20`, `20_to_50`, `50_to_100`, `100_plus`. A listing that is not free and states no price above zero is `unspecified`. `age_band` uses the same bands as `ages.bands` on a listing. - **`organization`** lists the twenty organizations with the most listings. `other_count` covers the rest, and `unspecified_count` the listings with no organization. - **Not every facet exists on every collection.** `weekday` is for events and classes; `time_block` and `price_band` are for events, classes and camps. Ask for one a collection has no data for and it is named in `facets.unavailable` instead of returning an empty list. Facets answer the filters you send, so a market overview near a point is the same request you would page, with `facets=` and `limit=0` added. Use the counts to draw the overview, then fetch a page only for the bucket the person opens. ## Limits - `facets=` is available on every plan and counts as **one request**, however many listings it covers. A facets-only request returns no records. - `page` and `cursor` do not change the counts. - The organizations list is capped at twenty. - Very large sets are counted up to a fixed bound. When a count covers only part of the set, or counting ran out of time, `facets.is_complete` is `false`. Narrow the filters (a metro-wide overview is comfortably inside the bound) and ask again. - `limit=0` is accepted only together with `facets`; on its own it is `400`. - `/search` does not take `facets`. ## Related - [Quickstart](https://api.tailend.dev/docs/quickstart.md) — filters, pagination and search. - [Pagination](https://api.tailend.dev/docs/pagination.md) — walking a list when you do want the records. - [Audience & versioning](https://api.tailend.dev/docs/audience-and-versioning.md) — why `adults` is left out by default. - [Plans & access](https://api.tailend.dev/docs/plans-and-access.md) — what each plan includes. --- # Shaping responses > Trim or extend what comes back with fields=, expand= and include=. Source: https://api.tailend.dev/docs/shaping-responses The default response for every event, class, camp and place is lean. Four parameters change what comes back: ```bash curl -H "X-API-Key: $TAILEND_API_KEY" --get https://api.tailend.dev/v1/philadelphia/events \ --data-urlencode "fields=name,place.name,price.is_free" \ --data-urlencode "expand=place" \ --data-urlencode "include=source" \ --data-urlencode "include_mode=lenient" ``` | Parameter | What it does | | --- | --- | | `fields=` | Comma list of dotted paths to keep (`name,place.name`). `id` and `type` are always kept. | | `expand=place,organization` | Attaches the full `Place`/`Organization` object under `expanded`, instead of just the reference. | | `include=source` | A paid add-on. Attaches `source: {url, crawled_at, verification}` to each item. Requires an entitled key. | | `include=forecast` | A paid event add-on. Adds a short outlook for eligible timed US events starting within seven days. Requires an entitled key. | | `include=related` | A paid add-on on the single read of an event, class or camp. Adds `related: {similar, from_organization, at_place}`. `400` anywhere else. Requires an entitled key. | | `include_mode=lenient` | Changes what happens when `include=` names something your key isn't entitled to: see below. | `fields=` and `expand=` compose: `fields=expanded.place.name` works once you've expanded `place`. ## When your key isn't entitled `include=` add-ons require a key entitled to them (see [Plans & access](https://api.tailend.dev/docs/plans-and-access.md)). Without one, the request fails: ```json { "error": { "code": "include_not_permitted", "message": "include=source is not available on this key's plan; retry without it, or add include_mode=lenient", "details": { "include": "source" }, "request_id": "req_2c6a19f8d0b34e77" } } ``` Add `include_mode=lenient` and the same request succeeds instead: `200`, with the block left off and the omission recorded in `notices[]`: ```json { "data": [ "…" ], "meta": { "…": "…" }, "notices": [ { "code": "include_not_permitted", "include": "source", "message": "…" } ] } ``` A `200` in lenient mode doesn't mean you got everything you asked for: always check `notices[]`. ## Related listings: `include=related` The "more like this" rows for a listing page, in one request: - `similar`: nearby listings of the same kind in the same Monday-to-Sunday week as this listing's date, in a matching category and for overlapping ages. Never this listing, its series, its organization or its place. - `from_organization`: the organization's next upcoming listings of the same type, one card per series. - `at_place`: other listings at the same place in the same week. Up to six cards each. Every card carries `id`, `slug`, `type`, `name`, the relevant occurrence (`starts_at`, `ends_at`, and the local dates `starts_on`, `ends_on`), `place`, `image_url`, `price` and `is_free`. A related card is always something the list endpoints would also return to your key. If a list cannot be produced it comes back empty with a `related_unavailable` notice; the listing itself is still returned. ## Forecasts: `include=forecast` Forecasts use degrees Fahrenheit and miles per hour. - Events within 48 hours receive hourly periods covering their start-to-end window; when no end is stated, the window is two hours. - Later events, up to seven days out, get a summary of the event's local calendar day. - Events without a time, coordinates, a US location, or a start within seven days omit `forecast`. - When forecasts are temporarily unavailable the block is omitted and `notices[]` carries `forecast_unavailable`. --- # Pagination > Walk a collection past the first page with an opaque cursor. Source: https://api.tailend.dev/docs/pagination Collections return a cursor; pass it back unchanged to get the next page. ```bash cursor="" while :; do page=$(curl -sS -H "X-API-Key: $TAILEND_API_KEY" \ --get "https://api.tailend.dev/v1/philadelphia/events" \ --data-urlencode "cursor=$cursor") echo "$page" | jq '.data[].name' cursor=$(echo "$page" | jq -r '.meta.next_cursor // empty') [ -z "$cursor" ] && break done ``` `meta.next_cursor` is `null` once there's nothing left; `meta.has_more` says the same thing without you having to check for `null`. ## What to do - Treat the cursor as opaque. Don't parse it, store fields out of it, or construct one by hand — a hand-built cursor is a `400`. - A cursor is bound to the request that issued it: same route, resource, and filters. Change a filter and start the traversal over. - `/v1/{metro}/around` pages each result group independently — a cursor from one group only advances that group. - For a traversal that must not miss a row, prefer `changed_since` (sync mode) over browse-mode paging; see the endpoint reference for the exact contract on each collection. --- # Keep your copy in sync > Mirror listings with the changes feed without missing an edit or a removal. Source: https://api.tailend.dev/docs/syncing-changes This page is for anyone mirroring events, classes, camps or places locally — a search index, a newsletter database, a cache in front of your own API — and needs to know about an add, an edit or a removal without re-fetching everything on a timer. `GET /v1/changes` is a single, ordered feed across every resource type in a metro, and it's the only place a removal shows up. ```mermaid flowchart TD A["Choose and persist a since floor, then walk the list endpoints"] --> B[Store since and cursor for this metro] B --> C["Poll GET /v1/changes?since=&cursor="] C --> F["Apply every item in order; confirm archived with a detail fetch"] F --> G["Adopt and persist next_cursor after the page, even when data is empty"] G --> D{has_more?} D -- no --> E[Wait before polling again] E --> C D -- yes --> C H["Periodically:\nre-walk the list endpoints"] --> I["Reconcile: upsert anything you're missing,\nremove anything no longer served"] I --> B ``` ## Bookkeeping - **One resume record per metro.** Persist both the fixed `since` value and the latest cursor. Reusing a cursor with a newly generated `since` after a restart can skip changes that happened while the process was down. - **The cursor is a decimal feed position, not an opaque token.** Store exactly what `meta.next_cursor` returns and pass it back as `cursor` on your next call; do not derive or increment it yourself. - **Persist the cursor only after the page is fully applied, including an empty page.** An empty page can still advance the cursor and report `has_more: true` when changes were filtered from the response. If your process dies partway through a page, you want to replay it, not skip past it. Since apply is idempotent (below), replaying a page you already partly applied is safe. - **Apply idempotently, keyed by `listing_id`.** Every item in the feed carries `listing_id` — upsert and remove against that key, not against `version_id` (which is only a feed position, not a stable identifier for the resource itself). - **A `404` when you fetch a changed listing means it's gone.** Between the feed telling you about a change and you fetching the resource, it can be removed again. Treat a `404` on the fetch exactly like an `archived` item: remove it, regardless of which `kind` sent you there. - **Poll on an interval, not in a tight loop.** Choose an interval that fits your freshness needs, and only wait once you've drained `has_more: false`. Back off on `429`/`503`: read `Retry-After` and wait that long before your next call, same as any other endpoint (see [Errors](https://api.tailend.dev/docs/errors.md)). - **Always resume from the cursor you were given** rather than assuming "now" is caught up. - **Reconcile periodically** by re-walking the list endpoints for each metro you sync, upserting everything they return and removing anything from your store that they no longer serve. This catches drift from a gap in your own polling without you having to trust the feed as your only source of truth forever. ## What each change `kind` means | `kind` | What happened | What to do | | --- | --- | --- | | `created` | A public listing or one of its public detail records was added | Fetch by `listing_id` and upsert | | `updated` | A public field changed that isn't covered by a more specific kind below | Fetch and upsert | | `date_changed` | A date or schedule field changed | Fetch and upsert | | `price_changed` | Price, tuition, or admission changed | Fetch and upsert | | `location_changed` | The place or address changed | Fetch and upsert | | `restored` | The listing became public; this includes the first publication of a listing initially created as a draft | Fetch and upsert | | `archived` | The listing left public visibility — unpublished, deactivated, or deleted | Confirm with a detail fetch; remove on `404` or an archived response, otherwise upsert the live representation | `fields` names any public fields represented by the change. It may be empty or populated for any kind, including `created` and `archived` — see the [API reference](https://api.tailend.dev/docs/api.md) for what each field name means. ## Example ```bash curl -sS -H "X-API-Key: $TJ_KEY" --get https://api.tailend.dev/v1/changes \ --data-urlencode "since=2026-09-10T00:00:00Z" \ --data-urlencode "metro=philadelphia" \ --data-urlencode "limit=3" ``` ```json { "data": [ { "version_id": 18231, "listing_id": "2f3c9d4e-3d2b-4b7d-9a3e-1b2c3d4e5f60", "slug": "storytime-in-the-park-2026-09-12", "type": "event", "metro": "philadelphia", "changed_at": "2026-09-10T06:12:05Z", "kind": "date_changed", "fields": ["starts_at"] }, { "version_id": 18235, "listing_id": "9cde4494-6c2a-4bfc-b15a-22edce13f3f7", "slug": "treehouse-world-fall-open-house", "type": "event", "metro": "philadelphia", "changed_at": "2026-09-10T08:41:19Z", "kind": "created", "fields": ["name", "status"] }, { "version_id": 18240, "listing_id": "4b6b9a1e-8f21-4a11-8f4c-6a1e2b3c4d5e", "slug": "toddler-tinker-time", "type": "class", "metro": "philadelphia", "changed_at": "2026-09-10T09:03:44Z", "kind": "archived", "fields": ["status"] } ], "meta": { "since": "2026-09-10T00:00:00.000Z", "metro": "philadelphia", "limit": 3, "count": 3, "has_more": true, "next_cursor": "18240", "generated_at": "2026-09-12T16:00:00.000Z" } } ``` ## A sync loop (Node 18+) Runnable as-is against a live key; swap the in-memory `store` `Map` for whatever you're actually persisting to. It covers the whole diagram above — initial load, poll, apply, persist the cursor, and a periodic reconciliation — for one metro; sync several by running one of these per metro. The list endpoints intentionally have narrower defaults than the changes feed: list reads apply a date window, a confidence floor, and exclude adult-only rows unless asked otherwise, while the feed is not filtered by those rules. The example makes its mirror scope explicit with `min_confidence=0`, `audience=all`, and a `from`/`to` window. Choose the window your product supports, use the same parameters for every initial load and reconciliation, and apply the same scope when processing feed items. ```javascript const BASE = "https://api.tailend.dev"; const STATE_FILE = new URL("./tailend-sync-state.json", import.meta.url); const KEY = process.env.TJ_KEY; const METRO = "philadelphia"; const PLURAL = { event: "events", class: "classes", camp: "camps", place: "places" }; const POLL_MS = Number(process.env.POLL_MS); const RECONCILE_MS = Number(process.env.RECONCILE_MS); if (!POLL_MS || !RECONCILE_MS) throw new Error("Set POLL_MS and RECONCILE_MS to positive millisecond intervals"); const { readFile, writeFile, rename } = await import("node:fs/promises"); const store = new Map(); // listing_id -> resource; persist this for real use const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function apiGet(path, params = {}) { const url = new URL(path, BASE); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null) url.searchParams.set(key, String(value)); } const res = await fetch(url, { headers: { "X-API-Key": KEY } }); if (res.status === 429 || res.status === 503) { const value = res.headers.get("retry-after"); const seconds = value && /^\d+$/.test(value) ? Number(value) : null; const dateDelay = value && seconds === null ? Date.parse(value) - Date.now() : null; await sleep(seconds !== null ? seconds * 1000 : Math.max(0, dateDelay ?? 5000)); return apiGet(path, params); } return res; } async function loadState() { try { return JSON.parse(await readFile(STATE_FILE, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } } async function saveState(state) { const temporary = new URL(`${STATE_FILE.pathname}.tmp`, "file://"); await writeFile(temporary, JSON.stringify(state)); await rename(temporary, STATE_FILE); } function nextCursor(current, meta) { return meta.next_cursor ?? current; } function scopeWindow() { const from = new Date().toISOString().slice(0, 10); const end = new Date(`${from}T00:00:00Z`); end.setUTCDate(end.getUTCDate() + 366); return { from, to: end.toISOString().slice(0, 10) }; } function listScope(type, cursor) { const window = scopeWindow(); return { cursor, min_confidence: 0, audience: "all", ...(type === "places" ? {} : window), }; } // Keep polling and list reconciliation inside the same mirror window. function inScope(listing) { if (listing.status === "archived" || listing.is_active === false) return false; const { from, to } = scopeWindow(); if (listing.type === "event") { // List windows use the event's metro-local calendar date, not its UTC // instant. Use the same field here so late-evening boundary events agree. const starts = listing.starts_local?.slice(0, 10); return starts ? starts >= from && starts <= to : false; } if (listing.type === "class") { if (listing.schedule?.type === "ongoing") return true; const starts = listing.term?.starts_on; const ends = listing.term?.ends_on; return (!starts || starts <= to) && (!ends || ends >= from); } if (listing.type === "camp") { return (!listing.starts_on || listing.starts_on <= to) && (!listing.ends_on || listing.ends_on >= from); } return listing.type === "place"; } // Walks every list endpoint for the metro, upserting what it finds. // Used both for the initial load and for periodic reconciliation. async function walkListings(metro) { const seen = new Set(); for (const type of Object.values(PLURAL)) { let cursor; do { const res = await apiGet(`/v1/${metro}/${type}`, listScope(type, cursor)); if (!res.ok) throw new Error(`list failed: ${res.status}`); const { data, meta } = await res.json(); for (const item of data) { store.set(item.id, item); seen.add(item.id); } cursor = meta.next_cursor ?? undefined; } while (cursor); } return seen; } async function reconcile(metro) { const seen = await walkListings(metro); for (const id of store.keys()) if (!seen.has(id)) store.delete(id); } async function applyChange(change) { const res = await apiGet(`/v1/${PLURAL[change.type]}/${change.listing_id}`); if (res.status === 404) { store.delete(change.listing_id); return; } if (!res.ok) throw new Error(`detail failed: ${res.status}`); const { data } = await res.json(); if (data.status === "archived" || data.is_active === false) { store.delete(change.listing_id); return; } if (inScope(data)) store.set(change.listing_id, data); else store.delete(change.listing_id); } async function initializeState() { let state = await loadState(); if (!state) { state = { since: new Date().toISOString(), cursor: null, baseline_complete: false }; await saveState(state); // establish the floor before the initial walk } if (!state.baseline_complete) { await walkListings(METRO); state.baseline_complete = true; await saveState(state); } return state; } async function pollChanges(state, maxPages = Infinity, run = (task) => task()) { let pages = 0; while (pages < maxPages) { const meta = await run(async () => { const res = await apiGet("/v1/changes", { since: state.since, cursor: state.cursor, metro: METRO, limit: 500 }); if (!res.ok) throw new Error(`changes failed: ${res.status}`); const page = await res.json(); for (const change of page.data) await applyChange(change); state.cursor = nextCursor(state.cursor, page.meta); // advance even when page.data is empty await saveState(state); return page.meta; }); pages += 1; if (!meta.has_more) await sleep(POLL_MS); } } async function main() { const state = await initializeState(); // Polling and reconciliation share this queue, so a reconcile cannot delete // an item that a concurrent poll has just added. let exclusive = Promise.resolve(); const serial = (task) => { const run = exclusive.then(task, task); exclusive = run.catch(() => {}); return run; }; setInterval(() => serial(() => reconcile(METRO)).catch((err) => console.error("reconcile failed", err)), RECONCILE_MS); await pollChanges(state, Infinity, serial); } main(); ``` ## Webhooks There's no push option today — `/v1/changes` is the way to keep a copy in sync. Change webhooks are coming, but there's no contract, payload shape, or date to build against yet; this page will cover them once there is. ## What to do - Key every apply by `listing_id`; don't use `version_id` for anything but the cursor. - Persist `since`, the cursor, and whether the initial baseline completed together. If startup stops after saving the floor, finish the baseline before polling on restart. Adopt `next_cursor` after every fully applied page, even when that page contains no items. - Confirm `archived` with a detail fetch. Remove on `404` or an archived representation; otherwise upsert the live representation. - Reconcile against the list endpoints on a schedule so a gap in your own polling can't drift your copy forever, and serialize it with polling. - See [Errors](https://api.tailend.dev/docs/errors.md) for `429`/`503` backoff, and the [API reference](https://api.tailend.dev/docs/api.md) for what each resource field means once you've fetched it. --- # Caching > Conditional requests with ETag and If-None-Match. Source: https://api.tailend.dev/docs/caching Every successful `/v1` response carries an `ETag`. Send it back as `If-None-Match` on your next request to the same resource, and an unchanged answer costs a `304` with no body: ```bash curl -sS -D - -o /dev/null -H "X-API-Key: $TAILEND_API_KEY" \ -H 'If-None-Match: W/"TD64uXep9BDu_tA9n6OF_Zwl3IA"' \ https://api.tailend.dev/v1/philadelphia/events # HTTP/1.1 304 Not Modified ``` Responses are `private` — cacheable only by you, never a shared cache — and vary by key. Follow `Cache-Control` for how long to hold a response before revalidating; `/v1/changes` is the one exception, always revalidate it rather than trusting a cached copy. ## What to do - Store the `ETag` alongside the response body and send it back as `If-None-Match` on your next request for the same resource. - A `304` still counts as a request — see [Limits & usage](https://api.tailend.dev/docs/limits-and-usage.md). - See the [API reference](https://api.tailend.dev/docs/api.md) for every header this API sends. --- # How we verify listings > Where a listing comes from, and what confidence and verified_at tell you. Source: https://api.tailend.dev/docs/how-we-verify This page is for anyone deciding how much to trust a listing before showing it to a user — what "verified" means here, where the numbers come from, and which fields to read if you want to show your own trust signal instead of just taking ours. ```bash curl -sS -H "X-API-Key: $TAILEND_API_KEY" --get https://api.tailend.dev/v1/philadelphia/events \ --data-urlencode "min_confidence=0.8" \ --data-urlencode "include=source" \ --data-urlencode "limit=1" ``` ```javascript const res = await fetch( "https://api.tailend.dev/v1/philadelphia/events?min_confidence=0.8&include=source&limit=1", { headers: { "X-API-Key": process.env.TAILEND_API_KEY } }, ); const { data } = await res.json(); console.log(data[0].confidence, data[0].verified_at, data[0].source.verification.status); ``` ## Where a listing comes from Every event, class, camp and place starts from an organizer's own page — their site, a calendar system, a listing directory. We don't invent dates, prices or descriptions: what you get back is what the source states, read into a consistent shape. When a value isn't on the source page, the field is `null`, not a guess. ## Re-checking Listings are re-checked against their source over time, prioritized around what's about to happen — an event coming up soon, a registration deadline, an enrollment window — rather than checked on a flat schedule regardless of date. There's no published SLA with a numeric guarantee yet, so this page won't quote a specific "checked every N hours" promise. What you can rely on today is the two fields below, which tell you — per listing, right now — whether and when it was last checked. ## The fields Every response carries two flat fields, on every resource, always: | Field | Meaning | | --- | --- | | `confidence` | 0–1 trust score. The same number `min_confidence` filters on | | `verified_at` | UTC instant of the last check against the source, or `null` if the listing hasn't been checked yet | `confidence` starts at a documented baseline for a newly parsed listing and becomes a verified score once a check has run; `verified_at` is `null` until then. Neither field tells you *why* — for that, ask for `include=source` (a paid add-on — see [Plans & access](https://api.tailend.dev/docs/plans-and-access.md)), which attaches: | Field | Meaning | | --- | --- | | `source.url` | The page the listing was parsed from | | `source.crawled_at` | When that page was last fetched | | `source.verification.status` | The outcome of the last check: `clear` (still matches), `changed` (source has changed since), `flagged` (a specific problem was found, evidenced), `unverifiable` (couldn't be confirmed either way), `unreachable` (source didn't respond), `gone` (source no longer has it), or `null` before any check | | `source.verification.basis` | `verified` once a check has produced the score, else `baseline` | | `source.verification.date_basis` | How the date was established: `stated` (the source gave it directly), `inferred` (derived, e.g. from a recurrence rule), `unevidenced`, or `unknown` | See the [API reference](https://api.tailend.dev/docs/api.md) for the full shape. ## Fields providers often leave unstated `null` on a value field means **not stated by the source**. It is never a "no": a class with `adult_participation: null` may or may not need a parent to stay, and `drop_in_price: null` does not mean drop-ins are unavailable. We only fill these from what the provider's own text states; we never work them out from dates, ages or category, and we leave a field empty rather than fill it with a value we are not sure is right. Some class fields are stated by far fewer providers than others. Plan your UI for `null` on these rather than treating it as missing data: | Field | What `null` means | Notes | | --- | --- | --- | | `program.adult_participation` | Not stated for this class | Values: `required`, `optional`, `drop_off`, `not_allowed`, `varies`. Many providers put this rule on an FAQ or policy page rather than on the class | | `tuition.drop_in_price` | No single-class price stated for everyone | Offered by a minority of providers; member-only or age-specific prices are not filled | | `tuition.trial_price` | No trial price stated | `0` is a free trial; conditional trials are not filled | | `term.total_classes` | No series length stated by the class's own text | Rolling, drop-in, open and year-round classes have none | | `registration.spots_remaining` | The provider does not publish a seat count | Rarely published; read `registration.status` instead. `registration.has_spots_available` is `true` unless we have read a full/closed statement, so it is not a guarantee of open seats | Schedule and price fields (`schedule.days`, `schedule.start_time`, `schedule.duration_minutes`, `tuition.amount`) are stated more often but still not by every provider, and follow the same rule: `null` is "not stated", not "none". ## `min_confidence` Every list endpoint drops rows below a confidence floor by default — you never have to ask for this, and you can always loosen or tighten it: | Resource | Default `min_confidence` | | --- | --- | | Places | `0.5` | | Events, classes, camps | `0.7` | Pass `min_confidence=0` to see everything regardless of score, or a higher value to see only what's cleared a stricter bar. `place_id`-scoped and `organization_id`-scoped lists use the same default as their resource. ## What "fresh" means, honestly Two different things get called "freshness," and it's worth keeping them apart when you're deciding what to show a user: - **When we last looked** — `verified_at` / `source.crawled_at`. This is a fact about our process, always available per listing. - **Whether what we found is still true** — `source.verification.status`. A `clear` result from three days ago is a stronger signal than a `changed` or `unverifiable` result from this morning. Neither one is a promise that seats are still open or a price hasn't changed since the last check — for time-sensitive facts like registration status, read the listing's own `registration`/`enrollment` fields and treat `verified_at` as "as of," not "guaranteed as of this second." ## What to do - Use `confidence`/`min_confidence` as your baseline trust filter; only reach for `include=source` when you want to show your own "last checked" or "verified" badge to a user. - Branch on `source.verification.status`, not on `verified_at` alone — a listing can be checked recently and still come back `unverifiable` or `flagged`. - Don't build on a specific re-check interval as a guarantee; build on the per-listing fields above, which are accurate regardless of how the underlying schedule evolves. --- # Audience & versioning > The family-safe default, and what v1 promises not to break. Source: https://api.tailend.dev/docs/audience-and-versioning ## Audience The API defaults to family-safe. Every event, class, camp, and place carries an `audience` field — `kids`, `families`, `teens`, `adults`, or `all_ages`. Adult-only content is excluded by default on every list endpoint; a detail read (`GET /v1/events/{id}`) always returns the item regardless of audience, so a permalink resolves either way. ```bash GET /v1/philadelphia/events # adults-only rows excluded GET /v1/philadelphia/events?audience=adults # only adults-only rows GET /v1/philadelphia/events?audience=all # every audience GET /v1/philadelphia/events?audience=teens,families # a specific combination ``` ## Versioning & stability The version lives in the path (`/v1/...`) only — no version header, no `Accept` override. **v1 is frozen**: every path, parameter, and response field named in the endpoint reference is a promise. New optional parameters, new response fields, and new `error.code` values are additive and may appear at any time — parse permissively. `x-stability: beta` or `experimental` on an operation or parameter marks the exceptions to the freeze; everything else unmarked is stable. ## Timestamp fields | Suffix | Meaning | | --- | --- | | `*_at` | UTC, RFC 3339 (`starts_at`, `created_at`) | | `*_local` | Naive local wall-clock time, no offset, read against the object's own `timezone` | | `*_on` | A calendar date | ## What to do - Pass `audience=all` (or name a specific audience) explicitly when your use case needs it — don't rely on the default staying family-safe forever without checking. - Don't build on a `beta` or `experimental` surface without watching for change notices; unmarked surface is safe to depend on. - Format `*_at` fields as UTC in your own storage, and convert to `*_local`'s timezone only for display. --- # Plans & access > What each plan includes, and what a 403 for a missing entitlement looks like. Source: https://api.tailend.dev/docs/plans-and-access Check here whether a feature is available on your key before you build on it, and what the API returns when it is not. Every endpoint's reference page shows its access requirement, and the [OpenAPI spec](https://api.tailend.dev/docs/openapi.json) records it as `x-access` (endpoints) and `x-access-addons` (`include=` values). ```bash curl -sS -H "X-API-Key: $TAILEND_API_KEY" --get https://api.tailend.dev/v1/philadelphia/events \ --data-urlencode "include=source" ``` ```javascript const res = await fetch("https://api.tailend.dev/v1/philadelphia/events?include=source", { headers: { "X-API-Key": process.env.TAILEND_API_KEY }, }); if (res.status === 403) { const { error } = await res.json(); console.log(error.code, error.details.upgrade_url); // "include_not_permitted", "https://tailend.dev/pricing" } ``` ## Plans A customer key is on one of three plans: `free`, `standard` or `partner`. See [Entitlements](#entitlements) below for what each one includes. > **TODO (owner):** add each plan's price, monthly request allowance and > per-minute rate limit, or link the pricing page. These docs do not state > numbers that are not published yet. ## Current truth | What | Access today | | --- | --- | | Existing `/v1` operations | `all` plans. | | Upcoming listing history | Feature `history`: included with partner; available as an add-on on standard; not available on free. | | Upcoming trends | Feature `trends`: included with partner; available as an add-on on standard; not available on free. | | Upcoming insights (beta) | Feature `insights`: included with partner; available as an add-on on standard; not available on free. | | `include=source` | Included with partner; available as an add-on on standard; not available on free. | | `include=related` | Included with partner; available as an add-on on standard; not available on free. Single-listing reads only. | `facets=` (counts over a whole filtered set) is available on every plan and counts as one request, however many listings it covers. This is a deliberate choice, not an oversight: facets are not a premium feature. To keep the service responsive, a burst of facet requests may be answered with `is_complete: false` (partial counts) rather than slowing other traffic. See [Market overview with facets](https://api.tailend.dev/docs/market-overview-with-facets.md). ## Include add-ons Ask for `include=source` without an entitled key and you get `403 include_not_permitted`, naming the add-on and `https://tailend.dev/pricing` in `details`. Add `include_mode=lenient` and the same request succeeds instead: `200`, with the block left off and the omission recorded in `notices[]`, including the same `upgrade_url`. See [Shaping responses](https://api.tailend.dev/docs/shaping-responses.md) for the full request/response shapes. ## Premium endpoints A key without the effective entitlement receives `403 feature_not_in_plan`; `details.feature` names the missing feature and `details.upgrade_url` is `https://tailend.dev/pricing`. ```json { "error": { "code": "feature_not_in_plan", "message": "feature 'history' is not included with this key's plan", "details": { "feature": "history", "upgrade_url": "https://tailend.dev/pricing" }, "request_id": "req_2c6a19f8d0b34e77" } } ``` There's no `include_mode=lenient` equivalent for a premium *endpoint* the way there is for a premium `include=` add-on — a `feature_not_in_plan` 403 is always hard. `include_mode=lenient` only ever softens an `include=` value on an operation you're already allowed to call (see [Include add-ons](#include-add-ons) above). ## Entitlements What a key can use is its plan's defaults plus any add-ons granted to that key. Partner includes `history`, `trends`, `insights` and `source`. Standard includes none of them by default, and each can be added to a key. Free cannot add premium features. When an add-on is removed from a key, requests that need it get the responses above: `403` for a premium endpoint, and for an `include=` add-on either `403` or, with `include_mode=lenient`, a `200` without that block. ## What to do - Check the badge on an operation or add-on before you build against it, but always branch on `error.code` at runtime. - Building against `include=source`? Confirm your key is entitled before you rely on it in production; use `include_mode=lenient` during development so a missing entitlement degrades instead of failing outright. - See [Limits & usage](https://api.tailend.dev/docs/limits-and-usage.md) for the separate question of *how much* you can call, as opposed to *what* you can call. --- # Limits & usage > The per-minute rate limit, the monthly quota, and the headers that report both. Source: https://api.tailend.dev/docs/limits-and-usage Two independent limits apply to every key. Both return `429`, told apart only by `error.code`: | Limit | Scope | On refusal | | --- | --- | --- | | Rate limit | Per key, per minute | `429 rate_limited`, short `Retry-After` — wait it out and retry | | Monthly quota | Per client, per UTC calendar month, across every key on the account | `429 quota_exceeded`, `Retry-After` points at the 1st of next month — don't retry sooner | A request refused by either limit isn't billed and doesn't consume the allowance that refused it. > **TODO (owner):** publish the per-minute limit and monthly allowance for each > plan here. Until then, read them from the headers below: they are always > authoritative for your key. ## Headers - `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`, and the equivalent `RateLimit-*` trio — the per-minute limit, on every response. - `X-Quota-Limit` / `X-Quota-Remaining` / `X-Quota-Reset` — the monthly allowance, only on a plan that has one. - `Retry-After` — seconds to wait, sent on both `429`s and on `503`. See the [API reference](https://api.tailend.dev/docs/api.md) for the headers on each response. ## Access Every endpoint and `include=` add-on shows its access requirement in the endpoint reference. `include=source` requires an entitled key: without one you get `403 include_not_permitted`, unless you add `include_mode=lenient` — see [Shaping responses](https://api.tailend.dev/docs/shaping-responses.md). ## What to do - Branch on `error.code`, not on the `429` status alone — the two limits need different responses. - Read `X-RateLimit-Remaining` before you burst a batch of requests, and back off before you hit zero rather than after. - A `304` still counts as a request against both limits. --- # Errors > The one error envelope, every error code, and whether to retry. Source: https://api.tailend.dev/docs/errors Every failure returns the same JSON envelope. Never an HTML page, never a bare string: ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` | Field | Meaning | | --- | --- | | `code` | One value from a closed list (below). Branch on this. | | `message` | A human-readable explanation. It can change; do not parse it. | | `details` | Optional machine-readable context, such as the parameter that failed or the scopes your key has. | | `request_id` | The id of this request. Quote it when you contact support. | ## Error codes Branch on `code`, not the status alone: `403` and `429` each cover codes with different remedies. | Status | `code` | Retry? | What to do | | --- | --- | --- | --- | | 400 | `bad_request` | No | Fix the request; `details` usually names the parameter | | 401 | `unauthorized` | No | Send a valid, unrevoked key in `X-API-Key` | | 403 | `forbidden` | No | Your key is not scoped for this metro or type; `details` lists what it can read | | 403 | `include_not_permitted` | No | Drop the `include=`, add `include_mode=lenient`, or use an entitled key | | 403 | `feature_not_in_plan` | No | The endpoint needs a feature your plan does not include; `details.feature` names it | | 404 | `not_found` | Only if the input changes | Check the metro, id or slug | | 405 | `method_not_allowed` | No | Use `GET`; the API is read-only | | 429 | `rate_limited` | Yes, after `Retry-After` seconds | Wait, then retry | | 429 | `quota_exceeded` | No | The month's allowance is spent; `Retry-After` points at the start of next month | | 500 | `internal` | Once, with backoff | If it recurs, contact support with the `request_id` | | 501 | `not_implemented` | No | This feature is not available here; do not build on it | | 503 | `unavailable` | Yes, after `Retry-After` | Back off and retry | New codes can be added over time, so treat an unknown `code` by its status. ## Handling errors in code ```javascript const res = await fetch(url, { headers: { "X-API-Key": process.env.TAILEND_API_KEY } }); if (!res.ok) { const { error } = await res.json(); if (error.code === "rate_limited" || error.code === "unavailable") { const wait = Number(res.headers.get("Retry-After") ?? 1); // wait `wait` seconds, then retry once } else { console.error(error.code, error.message, error.request_id); } } ``` ## `request_id` Every response, success or failure, carries a request id: in the `X-Request-Id` header, and in the body of an error. Log it next to your own request context and quote it when you contact support; it is how we find your request. ## Checklist - Check `error.code` before deciding whether to retry. - On a `429`, read `Retry-After` instead of guessing a backoff: the two codes mean very different waits. - Do not retry `400`, `401`, `403`, `404` or `501` unchanged. --- # API reference > Every endpoint, parameter, field and status code, generated from the OpenAPI spec. Source: https://api.tailend.dev/docs/api Each endpoint page below is generated from the [OpenAPI spec](https://api.tailend.dev/docs/openapi.json), so it always matches what the API does. Every page shows the parameters, the response fields, an example request and a sample body for each status code, and a Try it panel: try any request with sample data, no key needed, or paste a key to run it live. ## Base URL ```text https://api.tailend.dev ``` Every endpoint is a `GET` under `/v1`. The API is read-only. ## Authentication Send your API key in the `X-API-Key` header on every request. ```bash curl -H "X-API-Key: $TAILEND_API_KEY" https://api.tailend.dev/v1/metros ``` A missing or revoked key returns `401`; a key without access to a metro, type or add-on returns `403`. See [Authentication](https://api.tailend.dev/docs/authentication.md). ## Responses Lists return `{ "data": [...], "meta": {...} }`; single reads return `{ "data": {...} }`. Failures return one error envelope: ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` | Status | Means | | --- | --- | | `200` | Success | | `304` | Not modified, when you send `If-None-Match` ([Caching](https://api.tailend.dev/docs/caching.md)) | | `400` | The request is invalid; `details` names the parameter | | `401` | Missing, invalid or revoked key | | `403` | Your key is not entitled to this metro, type, add-on or feature | | `404` | Unknown metro, id or slug | | `405` | Not a `GET` | | `429` | Rate limit or monthly quota reached; read `error.code` and `Retry-After` | | `500` | Our error; retry once, then report the `request_id` | | `501` | Not available on this deployment; do not retry | | `503` | Temporarily unavailable; retry after `Retry-After` | Branch on `error.code`, not on the status. The full list is in [Errors](https://api.tailend.dev/docs/errors.md). ## Endpoints ### Metros - [GET /v1/metros](https://api.tailend.dev/docs/api/list-metros.md): List metros ### Search - [GET /v1/{metro}/search](https://api.tailend.dev/docs/api/search-listings.md): Search every listing collection ### Events - [GET /v1/{metro}/events](https://api.tailend.dev/docs/api/list-events.md): List events - [GET /v1/{metro}/events/calendar](https://api.tailend.dev/docs/api/get-event-calendar.md): Event counts per day - [GET /v1/events/{id}](https://api.tailend.dev/docs/api/get-event.md): Retrieve an event - [GET /v1/events/slug/{slug}](https://api.tailend.dev/docs/api/get-event-by-slug.md): Retrieve an event by slug - [GET /v1/places/{id}/events](https://api.tailend.dev/docs/api/list-place-events.md): List place events - [GET /v1/organizations/{id}/events](https://api.tailend.dev/docs/api/list-organization-events.md): List organization events ### Classes - [GET /v1/{metro}/classes](https://api.tailend.dev/docs/api/list-classes.md): List classes - [GET /v1/classes/{id}](https://api.tailend.dev/docs/api/get-class.md): Retrieve a class - [GET /v1/classes/slug/{slug}](https://api.tailend.dev/docs/api/get-class-by-slug.md): Retrieve a class by slug - [GET /v1/places/{id}/classes](https://api.tailend.dev/docs/api/list-place-classes.md): List place classes - [GET /v1/organizations/{id}/classes](https://api.tailend.dev/docs/api/list-organization-classes.md): List organization classes ### Camps - [GET /v1/{metro}/camps](https://api.tailend.dev/docs/api/list-camps.md): List camps - [GET /v1/camps/{id}](https://api.tailend.dev/docs/api/get-camp.md): Retrieve a camp - [GET /v1/camps/slug/{slug}](https://api.tailend.dev/docs/api/get-camp-by-slug.md): Retrieve a camp by slug - [GET /v1/places/{id}/camps](https://api.tailend.dev/docs/api/list-place-camps.md): List place camps - [GET /v1/organizations/{id}/camps](https://api.tailend.dev/docs/api/list-organization-camps.md): List organization camps ### Places - [GET /v1/{metro}/places](https://api.tailend.dev/docs/api/list-places.md): List places - [GET /v1/places/{id}](https://api.tailend.dev/docs/api/get-place.md): Retrieve a place - [GET /v1/places/slug/{slug}](https://api.tailend.dev/docs/api/get-place-by-slug.md): Retrieve a place by slug - [GET /v1/organizations/{id}/places](https://api.tailend.dev/docs/api/list-organization-places.md): List organization places ### Organizations - [GET /v1/organizations/{id}](https://api.tailend.dev/docs/api/get-organization.md): Retrieve an organization ### Directory - [GET /v1/{metro}/organizations](https://api.tailend.dev/docs/api/list-organizations.md): List organizations - [GET /v1/{metro}/neighborhoods](https://api.tailend.dev/docs/api/list-neighborhoods.md): List neighborhoods ### Address search - [GET /v1/{metro}/around](https://api.tailend.dev/docs/api/search-around-address.md): Search around an address ### Activity - [GET /v1/{metro}/activity](https://api.tailend.dev/docs/api/get-activity.md): Retrieve area activity - [GET /v1/{metro}/nearby](https://api.tailend.dev/docs/api/list-nearby.md): List nearby listings ### Coverage - [GET /v1/{metro}/coverage](https://api.tailend.dev/docs/api/get-metro-coverage.md): Retrieve metro coverage ### Changes - [GET /v1/listings/{id}/versions](https://api.tailend.dev/docs/api/list-listing-versions.md): List listing versions - [GET /v1/changes](https://api.tailend.dev/docs/api/list-changes.md): List changes --- # List metros > Active metros, with their slug, name, timezone and known aliases. Source: https://api.tailend.dev/docs/api/list-metros ```http GET https://api.tailend.dev/v1/metros X-API-Key: $TAILEND_API_KEY ``` Active metros, with their slug, name, timezone and known aliases. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/metros" ``` ## Responses ### 200 Metros ```json { "data": [ { "slug": "philadelphia", "name": "Philadelphia Area", "timezone": "America/New_York" } ] } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Search every listing collection > Searches published, active listings in one metro. Source: https://api.tailend.dev/docs/api/search-listings ```http GET https://api.tailend.dev/v1/{metro}/search X-API-Key: $TAILEND_API_KEY ``` Searches published, active listings in one metro. When semantic search is enabled, results match by meaning as well as keywords. Search applies the same visibility and filter rules as each list endpoint, so it never returns a listing that endpoint would hide. Results are ranked and paginated across collections. Recurring events use their next occurrence. **How words are matched.** The query is read as plain words: quotes, a leading `-` and `OR` are not interpreted. Different forms of a word match each other (`swim` finds `swimming`), and a short list of closely related family-activity words also counts (`pottery` finds `ceramics`, `baking` finds `cooking`). Question and filler words (`where can I`, `with`, `for`, `the`) are ignored and never change a score. Words that only name the kind of listing (`class`, `lessons`, `camp`) never decide whether a listing matches; they only lift a listing that has them in its name. **Ages and audiences.** An age in the query becomes an age filter: `5 year olds`, `ages 3-5`, `toddlers` (1 to 3), `baby` (0 to 1) and `teens` (13 to 18). The band that was applied is echoed in `meta.interpreted.age`. When the query gives an age, listings whose stated ages cover it rank above listings with no stated ages; listings whose ages exclude it are left out. An explicit `age` or `audience` parameter always wins: the age in the words is then not applied, and `meta.interpreted.age.overridden_by` says which parameter won. Time and price words (`tonight`, `saturday`, `morning`, `cheap`) are not filters yet: they never decide a match and are listed in `meta.ignored_words`. Audience words (`parents`, `kids`, `family`) do not narrow the search when other words are present, and are listed in `meta.ignored_words`; a title that names them still ranks a little higher. A query made only of such words is never refused: `Mommy and Me`, `kids` and `family` search for those words as written. A query with nothing searchable at all (`near me`, `where can I`) returns `400`. **How directly a result matches.** Every result carries `match`. `all_terms`: every word you typed is in the listing's name or category, or is prominent in the opening of its description. `related`: every word is covered that way, but at least one only through a closely related word. `partial`: only some of your words, or all of them only mentioned in passing further down a description (`food` in a park concert that sells snacks). Inside a level, a name match ranks above a category match, which ranks above a description match. By default only `all_terms` and `related` are returned, so a count reflects real matches; send `match=all_terms,related,partial` (or `match=partial` alone) to include the loose ones. `meta.match_counts` says how many results there are at each level, whatever `match` you chose, so a client can say "17 direct, 36 related, 1,378 loose". Results are ordered so that `all_terms` come before `related`, and those before `partial`, unless you choose another `sort`. `score` is an absolute 0 to 1 measure of how well one listing fits the words of the query. It is not scaled against the other results, so a weak best match no longer shows as 1, and the same listing gets the same score whatever else is returned. It is not a probability. `snippet` is a plain-text, unhighlighted excerpt of the listing's public description (or name when no description exists), at most 180 characters. `sort=relevance` is the default. `soonest` orders dated results by their next occurrence (including recurring events), with places and undated items after them. `nearest` orders by distance and requires `lat` + `lng` or `near_zip`. `newest` orders by `created_at`. Every order breaks ties by score, then id; cursors are bound to the sort and ranking mode. If semantic availability changes between pages, the API returns `400 bad_request` with `details.reason: cursor_expired`; restart the search without a cursor so results are never silently skipped. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `q` | string | yes | Natural-language or keyword query, read as plain words. | | `types` | array of string | no | Repeatable or comma-separated collections; defaults to all four. | | `sort` | string | no | Cross-collection ordering. | | `match` | array of string | no | Which match levels to return, as a comma-separated list of all_terms, related and partial. | | `from` | string (date) | no | Local calendar date (metro timezone), inclusive. | | `to` | string (date) | no | Local calendar date (metro timezone), inclusive; at most 366 days after from. | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `free` | boolean | no | Events, classes, and camps only; ignored for places. | | `max_price` | number | no | A budget. | | `has_time` | boolean | no | true keeps only events with a stated clock time. | | `recurring` | boolean | no | Filter on is_recurring. | | `weekday` | array of string | no | Keep rows on any of these local days. | | `season` | array of string | no | Events, classes and camps only. | | `occasion` | string | no | Events and classes tagged for a seasonal occasion. | | `team` | string | no | Events built around a local pro sports team (watch parties, game-day themed events, team or mascot appearances). | | `start_after` | string | no | Local start time at or after this, HH:MM (or HH:MM:SS). | | `start_before` | string | no | Local start time strictly before this, HH:MM; 09:00–12:00 is the morning and 12:00 belongs to the afternoon. | | `organization_id` | string (uuid) | no | Keep rows published by this organization (organization.id). | | `changed_since` | string (date-time) | no | Sync mode. | | `place_id` | string (uuid) | no | Keep rows held at this place (place.id). | | `openings` | boolean | no | Classes only. | | `schedule_day` | string | no | Classes only; keep classes meeting on this weekday. | | `enrollment_type` | string | no | Classes only; filter by enrollment model. | | `year` | integer | no | Camps only; program year. | | `overnight` | boolean | no | Camps only; filter overnight programs. | | `type_hint` | string | no | Places only; filter by place type. | | `has_birthday_parties` | boolean | no | Places only; filter advertised birthday-party offerings. | | `has_classes` | boolean | no | Places only; filter places offering classes. | | `indoor` | boolean | no | Events and classes only; see the indoor parameter on the event list. | | `outdoor` | boolean | no | Events and classes only; see the outdoor parameter on the event list. | | `sensory` | boolean | no | Events, classes and places; see the sensory parameter on the event list. | | `cursor` | string | no | Opaque continuation token, bound to the query, types, filters, and sort. | | `limit` | integer | no | Results per page. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/search?q=family%20story%20time" ``` ## Responses ### 200 A page spanning the selected collections in the requested sort order. ```json { "data": [ { "type": "event", "id": "b81a95d2-1d4e-46fd-9cff-322a41e429ad", "slug": "toddler-storytime", "name": "Toddler Storytime", "snippet": "Stories and songs for children ages 2–5.", "match": "all_terms", "score": 0.92, "starts_local": "2026-10-03T10:00:00" } ], "meta": { "query": "toddler story time", "applied_filters": { "audience": [ "not_adults" ], "by_type": { "events": { "from": "2026-09-28", "to": "2026-10-28", "min_confidence": 0.7 }, "classes": { "from": "2026-09-28", "to": null, "min_confidence": 0.7 }, "camps": { "from": "2026-09-28", "to": null, "min_confidence": 0.7 }, "places": { "min_confidence": 0.5 } } }, "defaults_applied": [ "from", "to", "min_confidence", "audience" ], "types": [ "events", "classes", "camps", "places" ], "ranking": "keyword", "ignored_words": [], "interpreted": { "age": { "min": 1, "max": 3, "from": "toddlers" } }, "match": [ "all_terms", "related" ], "match_counts": { "all_terms": 1, "related": 0, "partial": 14 }, "sort": "relevance", "limit": 20, "count": 1, "count_estimate": 1, "capped": false, "has_more": false, "next_cursor": null, "generated_at": "2026-09-28T14:30:00Z" } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List events > Published, active events in the metro, ordered by starts_at ascending (then id). Source: https://api.tailend.dev/docs/api/list-events ```http GET https://api.tailend.dev/v1/{metro}/events X-API-Key: $TAILEND_API_KEY ``` Published, active events in the metro, ordered by `starts_at` ascending (then `id`). `from`/`to` are **local dates** in the metro's timezone and compare against `starts_local`; `from` defaults to today, **`to` to `from` + 30 days**, so a list with no `to` covers one month. Rows below `min_confidence` (**default `0.7`**) are dropped (pass `0` to disable), and `adults` audiences are left out unless asked for. `meta.applied_filters` echoes the value of each of these in effect and `meta.defaults_applied` lists the ones that came from a default. `facets=` adds counts over the whole filtered set; see [Market overview with facets](https://api.tailend.dev/docs/market-overview-with-facets.md). `changed_since` switches to sync mode: rows with `updated_at > changed_since`, ordered by `updated_at, id`, paged with `cursor` instead of `page`. `occurrences=expand` lists every occurrence of a recurring event in the window, for calendar views; see the `occurrences` parameter. For per-day counts over a month, see `/v1/{metro}/events/calendar`. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `ids` | string | no | Fetch specific listings of this resource type in one call: up to 100 comma-separated listing UUIDs (ids=,). | | `from` | string (date) | no | Local calendar date (metro timezone), inclusive. | | `to` | string (date) | no | Local calendar date (metro timezone), inclusive; at most 366 days after from. | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `season` | array of string | no | Events, classes and camps only. | | `occasion` | string | no | Events and classes tagged for a seasonal occasion. | | `team` | string | no | Events built around a local pro sports team (watch parties, game-day themed events, team or mascot appearances). | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `place_id` | string (uuid) | no | Keep rows held at this place (place.id). | | `free` | boolean | no | Events, classes, and camps only; ignored for places. | | `has_time` | boolean | no | true keeps only events with a stated clock time. | | `recurring` | boolean | no | Filter on is_recurring. | | `occurrences` | string | no | expand lists one item per occurrence instead of one per event: a recurring series appears once for every occurrence that starts inside [from, to], each with the same id and its own occurrence block; a one-off event appears once, its occurrence built from its own times. | | `indoor` | boolean | no | Filter on is_indoor, a fact about the linked place. | | `outdoor` | boolean | no | Filter on is_outdoor, a fact about the linked place. | | `sensory` | boolean | no | Filter on is_sensory_friendly (on events and classes, the linked place's; on places, the place's own). | | `changed_since` | string (date-time) | no | Sync mode. | | `q` | string | no | Full-text search over the listing's name and search text (websearch syntax: storytime park, "drop in", art -camp). | | `weekday` | array of string | no | Keep rows on any of these local days. | | `start_after` | string | no | Local start time at or after this, HH:MM (or HH:MM:SS). | | `start_before` | string | no | Local start time strictly before this, HH:MM; 09:00–12:00 is the morning and 12:00 belongs to the afternoon. | | `max_price` | number | no | A budget. | | `organization_id` | string (uuid) | no | Keep rows published by this organization (organization.id). | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `facets` | array of string | no | Counts over every listing that matches the filters, not just this page, returned as a top-level facets object beside data and meta. | | `limit` | integer | no | Items per page, 1 to 200. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/events" ``` ## Responses ### 200 A page of events. ```json { "data": [ { "id": "b81a95d2-1d4e-46fd-9cff-322a41e429ad", "type": "event", "slug": "toddler-time-ages-5-and-under-toddler-time-observer-e50d61ea", "name": "Toddler Time (Ages 5 and Under) / Toddler Time Observer", "description": "Includes Treehouses and Free Play Areas for toddlers ages 5 and under.", "starts_local": "2026-09-14T09:30:00", "ends_local": null, "start_time": "09:30:00", "has_time": true, "has_date": true, "all_day": false, "timezone": "America/New_York", "starts_at": "2026-09-14T13:30:00.000Z", "ends_at": null, "is_recurring": true, "rrule": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "recurrence_description": null, "place": { "id": "9cde4494-6c2a-4bfc-b15a-22edce13f3f7", "slug": "treehouse-world-d34361f7", "name": "Treehouse World" }, "location": { "address": "1442 Phoenixville Pike, West Chester, PA 19380, USA", "street_address": "1442 Phoenixville Pike, West Chester, PA 19380, USA", "city": "West Chester", "state": "PA", "zip": "19380", "lat": 40.0240296, "lng": -75.5858949, "neighborhood": "Newtown Square", "neighborhood_slug": "newtown-square" }, "price": { "type": "fixed", "min": 12, "max": 12, "scope": "admission", "note": "$12.00", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 1, "max": 5, "text": null, "audience": "kids", "bands": [ "baby", "toddler", "young_kids" ] }, "audience": "kids", "traits": { "seasons": [ "fall" ] }, "categories": [ "social_play_spaces" ], "occasions": [], "teams": [], "registration_url": "https://treehouseworld.com/pages/admission-prices", "ticket_url": null, "registration_required": false, "is_sold_out": false, "image_url": null, "organization": { "id": "c45b9b3a-45ab-4f9c-b2e2-986dcb921f25", "name": "Treehouse World", "slug": "treehouseworldcom-a86bfd79" }, "confidence": 1, "verified_at": "2026-09-26T14:06:38.332Z", "metro": "philadelphia", "status": "published", "created_at": "2026-09-14T08:04:04.969Z", "updated_at": "2026-09-26T14:06:38.788Z", "links": { "self": "/v1/events/b81a95d2-1d4e-46fd-9cff-322a41e429ad", "place": "/v1/places/9cde4494-6c2a-4bfc-b15a-22edce13f3f7", "organization": "/v1/organizations/c45b9b3a-45ab-4f9c-b2e2-986dcb921f25" } } ], "meta": { "limit": 1, "count": 1, "has_more": false, "applied_filters": { "from": "2026-09-14", "to": "2026-10-14", "min_confidence": 0.7, "audience": [ "not_adults" ] }, "defaults_applied": [ "from", "to", "min_confidence", "audience" ] } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Event counts per day > For each local date in [from, to] (both required, at most 62 days inclusive), how many event occurrences start that day, how many of them are free, and how many fall in each category — every date included, with zeros. Source: https://api.tailend.dev/docs/api/get-event-calendar ```http GET https://api.tailend.dev/v1/{metro}/events/calendar X-API-Key: $TAILEND_API_KEY ``` For each local date in `[from, to]` (both required, at most 62 days inclusive), how many event occurrences start that day, how many of them are free, and how many fall in each category — every date included, with zeros. Occurrences are exactly the ones `/v1/{metro}/events` lists with `occurrences=expand` under the same filters, so a month view's counts always agree with the week or day list behind them. Takes the same filters as `/events`. `weekday`, `start_after`, `start_before` and `season` apply to each occurrence. `cursor`, `page`, `fields`, `expand`, `changed_since`, `q` and `facets` are not available. `include=forecast` (a paid add-on, entitled as on `/events`) adds a `forecast` to today and each of the next seven days. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `from` | string (date) | yes | First local date of the window, YYYY-MM-DD. | | `to` | string (date) | yes | Last local date of the window (inclusive), at most 61 days after from. | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `season` | array of string | no | Events, classes and camps only. | | `occasion` | string | no | Events and classes tagged for a seasonal occasion. | | `team` | string | no | Events built around a local pro sports team (watch parties, game-day themed events, team or mascot appearances). | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `place_id` | string (uuid) | no | Keep rows held at this place (place.id). | | `free` | boolean | no | Events, classes, and camps only; ignored for places. | | `has_time` | boolean | no | true keeps only events with a stated clock time. | | `recurring` | boolean | no | Filter on is_recurring. | | `weekday` | array of string | no | Keep rows on any of these local days. | | `start_after` | string | no | Local start time at or after this, HH:MM (or HH:MM:SS). | | `start_before` | string | no | Local start time strictly before this, HH:MM; 09:00–12:00 is the morning and 12:00 belongs to the afternoon. | | `max_price` | number | no | A budget. | | `organization_id` | string (uuid) | no | Keep rows published by this organization (organization.id). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/events/calendar?from=2026-10-01&to=2026-10-31" ``` ## Responses ### 200 One entry per date in the window. ```json { "data": [ { "date": "2026-10-05", "count": 41, "free_count": 22, "categories": { "storytime_books": 14, "arts_creative": 9, "sports_active_play": 6 } }, { "date": "2026-10-06", "count": 0, "free_count": 0, "categories": {} }, { "date": "2026-10-07", "count": 57, "free_count": 31, "categories": { "storytime_books": 19, "music_movement": 8 }, "forecast": { "summary": "Chance Showers", "temperature_f": { "min": 54, "max": 66 }, "precipitation_chance_max": 60, "wet_hours": [ [ 13, 16 ] ], "issued_at": "2026-10-05T14:00:00Z", "expires_at": "2026-10-05T15:00:00Z" } } ], "meta": { "metro": "philadelphia", "window": { "from": "2026-10-05", "to": "2026-10-07", "timezone": "America/New_York" }, "min_confidence": 0.7, "total": 98, "applied_filters": { "from": "2026-10-05", "to": "2026-10-07", "min_confidence": 0.7, "audience": [ "not_adults" ] }, "defaults_applied": [ "min_confidence", "audience" ], "generated_at": "2026-10-07T12:00:00.000Z" } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Retrieve an event > An event by id. Source: https://api.tailend.dev/docs/api/get-event ```http GET https://api.tailend.dev/v1/events/{id} X-API-Key: $TAILEND_API_KEY ``` An event by id. Published and active by default; archived rows answer `404` unless `include_archived=true`, and drafts never appear. The response carries `status`, so a permalink can render a past listing with a banner instead of a dead link. Removals are also visible on `/v1/changes`. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `include_archived` | boolean | no | Also serve a listing whose status is archived — a permalink that should still render, with a "this has passed" banner, rather than 404. | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/events/b81a95d2-1d4e-46fd-9cff-322a41e429ad" ``` ## Responses ### 200 The event ```json { "data": { "id": "b81a95d2-1d4e-46fd-9cff-322a41e429ad", "type": "event", "slug": "toddler-time-ages-5-and-under-toddler-time-observer-e50d61ea", "name": "Toddler Time (Ages 5 and Under) / Toddler Time Observer", "description": "Includes Treehouses and Free Play Areas for toddlers ages 5 and under.", "starts_local": "2026-09-14T09:30:00", "ends_local": null, "start_time": "09:30:00", "has_time": true, "has_date": true, "all_day": false, "timezone": "America/New_York", "starts_at": "2026-09-14T13:30:00.000Z", "ends_at": null, "is_recurring": true, "rrule": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "recurrence_description": null, "place": { "id": "9cde4494-6c2a-4bfc-b15a-22edce13f3f7", "slug": "treehouse-world-d34361f7", "name": "Treehouse World" }, "location": { "address": "1442 Phoenixville Pike, West Chester, PA 19380, USA", "street_address": "1442 Phoenixville Pike, West Chester, PA 19380, USA", "city": "West Chester", "state": "PA", "zip": "19380", "lat": 40.0240296, "lng": -75.5858949, "neighborhood": "Newtown Square", "neighborhood_slug": "newtown-square" }, "price": { "type": "fixed", "min": 12, "max": 12, "scope": "admission", "note": "$12.00", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 1, "max": 5, "text": null, "audience": "kids", "bands": [ "baby", "toddler", "young_kids" ] }, "audience": "kids", "traits": { "seasons": [ "fall" ] }, "categories": [ "social_play_spaces" ], "occasions": [], "teams": [], "registration_url": "https://treehouseworld.com/pages/admission-prices", "ticket_url": null, "registration_required": false, "is_sold_out": false, "image_url": null, "organization": { "id": "c45b9b3a-45ab-4f9c-b2e2-986dcb921f25", "name": "Treehouse World", "slug": "treehouseworldcom-a86bfd79" }, "confidence": 1, "verified_at": "2026-09-26T14:06:38.332Z", "metro": "philadelphia", "status": "published", "created_at": "2026-09-14T08:04:04.969Z", "updated_at": "2026-09-26T14:06:38.788Z", "links": { "self": "/v1/events/b81a95d2-1d4e-46fd-9cff-322a41e429ad", "place": "/v1/places/9cde4494-6c2a-4bfc-b15a-22edce13f3f7", "organization": "/v1/organizations/c45b9b3a-45ab-4f9c-b2e2-986dcb921f25" } } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Retrieve an event by slug > Returns the same event as /v1/events/{id}, addressed by slug instead of id. Source: https://api.tailend.dev/docs/api/get-event-by-slug ```http GET https://api.tailend.dev/v1/events/slug/{slug} X-API-Key: $TAILEND_API_KEY ``` Returns the same event as `/v1/events/{id}`, addressed by slug instead of id. `slug` is unique per (metro, type). If the key can see this slug in more than one metro, returns `400` with `details.metros`; pass `metro=` to disambiguate. `include_archived=true` returns an archived row. Returns 503 when this endpoint is unavailable. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | Listing slug — the segment a consumer already has in its own URLs. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | no | Which metro's row is meant, when a slug is not unique across the key's metros. | | `include_archived` | boolean | no | Also serve a listing whose status is archived — a permalink that should still render, with a "this has passed" banner, rather than 404. | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/events/slug/toddler-time-ages-5-and-under-toddler-time-observer-e50d61ea" ``` ## Responses ### 200 The event ```json { "data": { "id": "b81a95d2-1d4e-46fd-9cff-322a41e429ad", "type": "event", "slug": "toddler-time-ages-5-and-under-toddler-time-observer-e50d61ea", "name": "Toddler Time (Ages 5 and Under) / Toddler Time Observer", "description": "Includes Treehouses and Free Play Areas for toddlers ages 5 and under.", "starts_local": "2026-09-14T09:30:00", "ends_local": null, "start_time": "09:30:00", "has_time": true, "has_date": true, "all_day": false, "timezone": "America/New_York", "starts_at": "2026-09-14T13:30:00.000Z", "ends_at": null, "is_recurring": true, "rrule": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "recurrence_description": null, "place": { "id": "9cde4494-6c2a-4bfc-b15a-22edce13f3f7", "slug": "treehouse-world-d34361f7", "name": "Treehouse World" }, "location": { "address": "1442 Phoenixville Pike, West Chester, PA 19380, USA", "street_address": "1442 Phoenixville Pike, West Chester, PA 19380, USA", "city": "West Chester", "state": "PA", "zip": "19380", "lat": 40.0240296, "lng": -75.5858949, "neighborhood": "Newtown Square", "neighborhood_slug": "newtown-square" }, "price": { "type": "fixed", "min": 12, "max": 12, "scope": "admission", "note": "$12.00", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 1, "max": 5, "text": null, "audience": "kids", "bands": [ "baby", "toddler", "young_kids" ] }, "audience": "kids", "traits": { "seasons": [ "fall" ] }, "categories": [ "social_play_spaces" ], "occasions": [], "teams": [], "registration_url": "https://treehouseworld.com/pages/admission-prices", "ticket_url": null, "registration_required": false, "is_sold_out": false, "image_url": null, "organization": { "id": "c45b9b3a-45ab-4f9c-b2e2-986dcb921f25", "name": "Treehouse World", "slug": "treehouseworldcom-a86bfd79" }, "confidence": 1, "verified_at": "2026-09-26T14:06:38.332Z", "metro": "philadelphia", "status": "published", "created_at": "2026-09-14T08:04:04.969Z", "updated_at": "2026-09-26T14:06:38.788Z", "links": { "self": "/v1/events/b81a95d2-1d4e-46fd-9cff-322a41e429ad", "place": "/v1/places/9cde4494-6c2a-4bfc-b15a-22edce13f3f7", "organization": "/v1/organizations/c45b9b3a-45ab-4f9c-b2e2-986dcb921f25" } } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List place events > Same as GET /v1/{metro}/events?place_id={id}, addressed by the place instead of the metro. Source: https://api.tailend.dev/docs/api/list-place-events ```http GET https://api.tailend.dev/v1/places/{id}/events X-API-Key: $TAILEND_API_KEY ``` Same as `GET /v1/{metro}/events?place_id={id}`, addressed by the place instead of the metro. Resolves the place by id, `404`s if it does not exist, takes the metro from the place's own row, and delegates to the ordinary events list — every other list parameter (paging, `fields=`, `include=`, date windows, `min_confidence`, ...) keeps working exactly as it does there. Same auth, metro scope, rate limiting and metering as the list endpoint; a key without access to the place's metro gets the same `403` the list endpoint gives. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `from` | string (date) | no | Local calendar date (metro timezone), inclusive. | | `to` | string (date) | no | Local calendar date (metro timezone), inclusive; at most 366 days after from. | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `season` | array of string | no | Events, classes and camps only. | | `occasion` | string | no | Events and classes tagged for a seasonal occasion. | | `team` | string | no | Events built around a local pro sports team (watch parties, game-day themed events, team or mascot appearances). | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `free` | boolean | no | Events, classes, and camps only; ignored for places. | | `has_time` | boolean | no | true keeps only events with a stated clock time. | | `recurring` | boolean | no | Filter on is_recurring. | | `indoor` | boolean | no | Filter on is_indoor, a fact about the linked place. | | `outdoor` | boolean | no | Filter on is_outdoor, a fact about the linked place. | | `sensory` | boolean | no | Filter on is_sensory_friendly (on events and classes, the linked place's; on places, the place's own). | | `changed_since` | string (date-time) | no | Sync mode. | | `q` | string | no | Full-text search over the listing's name and search text (websearch syntax: storytime park, "drop in", art -camp). | | `weekday` | array of string | no | Keep rows on any of these local days. | | `start_after` | string | no | Local start time at or after this, HH:MM (or HH:MM:SS). | | `start_before` | string | no | Local start time strictly before this, HH:MM; 09:00–12:00 is the morning and 12:00 belongs to the afternoon. | | `max_price` | number | no | A budget. | | `organization_id` | string (uuid) | no | Keep rows published by this organization (organization.id). | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `facets` | array of string | no | Counts over every listing that matches the filters, not just this page, returned as a top-level facets object beside data and meta. | | `limit` | integer | no | Items per page, 1 to 200. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/places/b81a95d2-1d4e-46fd-9cff-322a41e429ad/events" ``` ## Responses ### 200 A page of events. ```json { "data": [ { "id": "b81a95d2-1d4e-46fd-9cff-322a41e429ad", "type": "event", "slug": "toddler-time-ages-5-and-under-toddler-time-observer-e50d61ea", "name": "Toddler Time (Ages 5 and Under) / Toddler Time Observer", "description": "Includes Treehouses and Free Play Areas for toddlers ages 5 and under.", "starts_local": "2026-09-14T09:30:00", "ends_local": null, "start_time": "09:30:00", "has_time": true, "has_date": true, "all_day": false, "timezone": "America/New_York", "starts_at": "2026-09-14T13:30:00.000Z", "ends_at": null, "is_recurring": true, "rrule": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "recurrence_description": null, "place": { "id": "9cde4494-6c2a-4bfc-b15a-22edce13f3f7", "slug": "treehouse-world-d34361f7", "name": "Treehouse World" }, "location": { "address": "1442 Phoenixville Pike, West Chester, PA 19380, USA", "street_address": "1442 Phoenixville Pike, West Chester, PA 19380, USA", "city": "West Chester", "state": "PA", "zip": "19380", "lat": 40.0240296, "lng": -75.5858949, "neighborhood": "Newtown Square", "neighborhood_slug": "newtown-square" }, "price": { "type": "fixed", "min": 12, "max": 12, "scope": "admission", "note": "$12.00", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 1, "max": 5, "text": null, "audience": "kids", "bands": [ "baby", "toddler", "young_kids" ] }, "audience": "kids", "traits": { "seasons": [ "fall" ] }, "categories": [ "social_play_spaces" ], "occasions": [], "teams": [], "registration_url": "https://treehouseworld.com/pages/admission-prices", "ticket_url": null, "registration_required": false, "is_sold_out": false, "image_url": null, "organization": { "id": "c45b9b3a-45ab-4f9c-b2e2-986dcb921f25", "name": "Treehouse World", "slug": "treehouseworldcom-a86bfd79" }, "confidence": 1, "verified_at": "2026-09-26T14:06:38.332Z", "metro": "philadelphia", "status": "published", "created_at": "2026-09-14T08:04:04.969Z", "updated_at": "2026-09-26T14:06:38.788Z", "links": { "self": "/v1/events/b81a95d2-1d4e-46fd-9cff-322a41e429ad", "place": "/v1/places/9cde4494-6c2a-4bfc-b15a-22edce13f3f7", "organization": "/v1/organizations/c45b9b3a-45ab-4f9c-b2e2-986dcb921f25" } } ], "meta": { "limit": 1, "count": 1, "has_more": false, "applied_filters": { "from": "2026-09-14", "to": "2026-10-14", "min_confidence": 0.7, "audience": [ "not_adults" ] }, "defaults_applied": [ "from", "to", "min_confidence", "audience" ] } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List organization events > Same as GET /v1/{metro}/events?organization_id={id}, addressed by the organization instead of the metro. Source: https://api.tailend.dev/docs/api/list-organization-events ```http GET https://api.tailend.dev/v1/organizations/{id}/events X-API-Key: $TAILEND_API_KEY ``` Same as `GET /v1/{metro}/events?organization_id={id}`, addressed by the organization instead of the metro. Resolves the organization by id, `404`s if it does not exist, and takes the metro from that row. An organization with live listings in more than one metro is a `400` asking for `metro=` to disambiguate, exactly like a slug that is not unique across the key's metros; give it and the ambiguity is resolved the same way. Every other list parameter keeps working. Same auth, metro scope, rate limiting and metering as the list endpoint; a key without access to the organization's metro gets the same `403` the list endpoint gives. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | no | Which metro's row is meant, when a slug is not unique across the key's metros. | | `from` | string (date) | no | Local calendar date (metro timezone), inclusive. | | `to` | string (date) | no | Local calendar date (metro timezone), inclusive; at most 366 days after from. | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `season` | array of string | no | Events, classes and camps only. | | `occasion` | string | no | Events and classes tagged for a seasonal occasion. | | `team` | string | no | Events built around a local pro sports team (watch parties, game-day themed events, team or mascot appearances). | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `place_id` | string (uuid) | no | Keep rows held at this place (place.id). | | `free` | boolean | no | Events, classes, and camps only; ignored for places. | | `has_time` | boolean | no | true keeps only events with a stated clock time. | | `recurring` | boolean | no | Filter on is_recurring. | | `indoor` | boolean | no | Filter on is_indoor, a fact about the linked place. | | `outdoor` | boolean | no | Filter on is_outdoor, a fact about the linked place. | | `sensory` | boolean | no | Filter on is_sensory_friendly (on events and classes, the linked place's; on places, the place's own). | | `changed_since` | string (date-time) | no | Sync mode. | | `q` | string | no | Full-text search over the listing's name and search text (websearch syntax: storytime park, "drop in", art -camp). | | `weekday` | array of string | no | Keep rows on any of these local days. | | `start_after` | string | no | Local start time at or after this, HH:MM (or HH:MM:SS). | | `start_before` | string | no | Local start time strictly before this, HH:MM; 09:00–12:00 is the morning and 12:00 belongs to the afternoon. | | `max_price` | number | no | A budget. | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `facets` | array of string | no | Counts over every listing that matches the filters, not just this page, returned as a top-level facets object beside data and meta. | | `limit` | integer | no | Items per page, 1 to 200. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/organizations/b81a95d2-1d4e-46fd-9cff-322a41e429ad/events" ``` ## Responses ### 200 A page of events. ```json { "data": [ { "id": "b81a95d2-1d4e-46fd-9cff-322a41e429ad", "type": "event", "slug": "toddler-time-ages-5-and-under-toddler-time-observer-e50d61ea", "name": "Toddler Time (Ages 5 and Under) / Toddler Time Observer", "description": "Includes Treehouses and Free Play Areas for toddlers ages 5 and under.", "starts_local": "2026-09-14T09:30:00", "ends_local": null, "start_time": "09:30:00", "has_time": true, "has_date": true, "all_day": false, "timezone": "America/New_York", "starts_at": "2026-09-14T13:30:00.000Z", "ends_at": null, "is_recurring": true, "rrule": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "recurrence_description": null, "place": { "id": "9cde4494-6c2a-4bfc-b15a-22edce13f3f7", "slug": "treehouse-world-d34361f7", "name": "Treehouse World" }, "location": { "address": "1442 Phoenixville Pike, West Chester, PA 19380, USA", "street_address": "1442 Phoenixville Pike, West Chester, PA 19380, USA", "city": "West Chester", "state": "PA", "zip": "19380", "lat": 40.0240296, "lng": -75.5858949, "neighborhood": "Newtown Square", "neighborhood_slug": "newtown-square" }, "price": { "type": "fixed", "min": 12, "max": 12, "scope": "admission", "note": "$12.00", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 1, "max": 5, "text": null, "audience": "kids", "bands": [ "baby", "toddler", "young_kids" ] }, "audience": "kids", "traits": { "seasons": [ "fall" ] }, "categories": [ "social_play_spaces" ], "occasions": [], "teams": [], "registration_url": "https://treehouseworld.com/pages/admission-prices", "ticket_url": null, "registration_required": false, "is_sold_out": false, "image_url": null, "organization": { "id": "c45b9b3a-45ab-4f9c-b2e2-986dcb921f25", "name": "Treehouse World", "slug": "treehouseworldcom-a86bfd79" }, "confidence": 1, "verified_at": "2026-09-26T14:06:38.332Z", "metro": "philadelphia", "status": "published", "created_at": "2026-09-14T08:04:04.969Z", "updated_at": "2026-09-26T14:06:38.788Z", "links": { "self": "/v1/events/b81a95d2-1d4e-46fd-9cff-322a41e429ad", "place": "/v1/places/9cde4494-6c2a-4bfc-b15a-22edce13f3f7", "organization": "/v1/organizations/c45b9b3a-45ab-4f9c-b2e2-986dcb921f25" } } ], "meta": { "limit": 1, "count": 1, "has_more": false, "applied_filters": { "from": "2026-09-14", "to": "2026-10-14", "min_confidence": 0.7, "audience": [ "not_adults" ] }, "defaults_applied": [ "from", "to", "min_confidence", "audience" ] } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List classes > Published, active classes. Source: https://api.tailend.dev/docs/api/list-classes ```http GET https://api.tailend.dev/v1/{metro}/classes X-API-Key: $TAILEND_API_KEY ``` Published, active classes. `from`/`to` select terms that **overlap** the window (`term.starts_on <= to AND (term.ends_on IS NULL OR term.ends_on >= from)`); ongoing programs (`schedule.type = 'ongoing'`) and terms with no dates always match. Unlike events, `to` has no default here: with `from` alone (default today) the window is open-ended, so next season's terms are listed. Rows below `min_confidence` (**default `0.7`**) and `adults` audiences are left out unless asked for; `meta.applied_filters` and `meta.defaults_applied` echo what was applied. `facets=` adds counts over the whole filtered set. Ordered by `term.starts_on` nulls last, then `name`. **Sections.** A program with `sections` is matched by one section at a time: the weekday (`schedule_day`, `weekday`), `age`, `start_after`/`start_before`, the dates and `season`, `free`/`max_price` and `openings=true` must all be satisfied by the same section. A Saturday section for ages 3-5 and a Sunday section for ages 9-12 do not match `schedule_day=saturday&age=10`. A program with no sections is judged on its own fields. Filters decide which programs are returned; they never trim a program's `sections`. **Grouping.** When a provider publishes one program as several listings, the list shows a single card for it, with the other listings under `sessions`. Cards are formed before paging, so a page never splits one and `limit` counts cards. `expand=sessions` returns every class on its own. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `ids` | string | no | Fetch specific listings of this resource type in one call: up to 100 comma-separated listing UUIDs (ids=,). | | `from` | string (date) | no | Local calendar date (metro timezone), inclusive. | | `to` | string (date) | no | Local calendar date (metro timezone), inclusive; at most 366 days after from. | | `starts_from` | string (date) | no | Classes only. | | `starts_to` | string (date) | no | Classes only. | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `season` | array of string | no | Events, classes and camps only. | | `occasion` | string | no | Events and classes tagged for a seasonal occasion. | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `place_id` | string (uuid) | no | Keep rows held at this place (place.id). | | `free` | boolean | no | Events, classes, and camps only; ignored for places. | | `openings` | boolean | no | Classes only. | | `indoor` | boolean | no | Filter on is_indoor, a fact about the linked place. | | `outdoor` | boolean | no | Filter on is_outdoor, a fact about the linked place. | | `sensory` | boolean | no | Filter on is_sensory_friendly (on events and classes, the linked place's; on places, the place's own). | | `schedule_day` | string | no | Keep classes that meet on this weekday. | | `enrollment_type` | string | no | Keep rows matching this enrollment type. | | `changed_since` | string (date-time) | no | Sync mode. | | `q` | string | no | Full-text search over the listing's name and search text (websearch syntax: storytime park, "drop in", art -camp). | | `weekday` | array of string | no | Keep rows on any of these local days. | | `start_after` | string | no | Local start time at or after this, HH:MM (or HH:MM:SS). | | `start_before` | string | no | Local start time strictly before this, HH:MM; 09:00–12:00 is the morning and 12:00 belongs to the afternoon. | | `max_price` | number | no | A budget. | | `organization_id` | string (uuid) | no | Keep rows published by this organization (organization.id). | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `facets` | array of string | no | Counts over every listing that matches the filters, not just this page, returned as a top-level facets object beside data and meta. | | `limit` | integer | no | Items per page, 1 to 200. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/classes" ``` ## Responses ### 200 A page of classes. ```json { "data": [ { "id": "c57d9bae-6479-41c5-9176-f314fa4d31f4", "type": "class", "slug": "kids-pottery-classes-b3d835f7", "name": "Kids Pottery Classes", "description": "6-week pottery classes for kids ages 8-16, focusing on wheel-throwing and hand-building techniques.", "program": { "enrollment_type": "enrollment", "skill_level_notes": "all levels", "adult_participation": null, "what_youll_learn": null, "prerequisites": null }, "term": { "name": null, "starts_on": "2026-09-29", "ends_on": "2026-12-15", "total_classes": null }, "schedule": { "type": "session", "days": [ "tuesday" ], "start_time": "16:00:00", "end_time": "18:00:00", "duration_minutes": 120, "is_recurring": true, "rrule": "FREQ=WEEKLY;BYDAY=TU", "notes": "Tuesdays (4:00 pm – 6:00 pm)", "timezone": "America/New_York" }, "tuition": { "amount": 185, "from": 185, "text": "$185 for 6-week session", "drop_in_price": null, "trial_price": null, "trial_notes": null, "currency": "USD" }, "price": { "type": "fixed", "min": 185, "max": 185, "scope": "admission", "note": "$185 for 6-week session", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 8, "max": 16, "text": "Early Elementary, Tweens, Teens", "audience": "kids", "bands": [ "bigger_kids" ] }, "audience": "teens", "traits": { "seasons": [ "fall", "winter" ] }, "place": { "id": "1af0824d-36cf-482d-a6d7-073343ae52ed", "slug": "earth-center-pottery-ebc6fdd7", "name": "Earth Center Pottery" }, "location": { "address": "10 Stable Mill Trl, Richboro, PA 18954, USA", "street_address": "10 Stable Mill Trl, Richboro, PA 18954, USA", "city": "Richboro", "state": "PA", "zip": "18954", "lat": 40.2226282, "lng": -74.9866812, "neighborhood": "Somerton", "neighborhood_slug": "somerton" }, "categories": [ "arts_creative" ], "occasions": [], "registration": { "url": "https://www.cognitoforms.com/TylerParkCenterForTheArts/KidsPotteryClasses", "status": "open", "opens_at": null, "spots_remaining": null, "has_spots_available": true, "waitlist_available": false, "sections_with_spots": 1 }, "sections_total": 1, "sections": [ { "id": "0b8a3f5e-52c4-4d64-9a0e-2f6d7c1e9a11", "name": "Tuesday afternoon", "schedule": { "days": [ "tuesday" ], "start_time": "16:00:00", "end_time": "18:00:00", "duration_minutes": 120, "rrule": "FREQ=WEEKLY;BYDAY=TU", "notes": "Tuesdays (4:00 pm – 6:00 pm)", "timezone": "America/New_York" }, "term": { "name": null, "starts_on": "2026-09-29", "ends_on": "2026-12-15", "total_classes": null }, "ages": { "min": 8, "max": 16, "text": null }, "tuition": { "amount": 185, "text": "$185 for 6-week session", "drop_in_price": null, "currency": "USD" }, "registration": { "url": "https://www.cognitoforms.com/TylerParkCenterForTheArts/KidsPotteryClasses", "status": "open", "spots_remaining": null, "has_spots_available": true, "waitlist_available": false }, "updated_at": "2026-09-26T12:01:08.782Z" } ], "sessions": [], "image_url": "https://earthcenterpottery.com/wp-content/uploads/2024/02/IMG_0318-scaled-e1707359564209-1024x566.jpg", "organization": { "id": "37bae48b-e86c-44a4-80d7-29fd9c34443c", "name": "Earth Center Pottery", "slug": "earthcenterpotterycom-f09a2fcc" }, "confidence": 0.45, "verified_at": "2026-09-26T12:00:21.748Z", "metro": "philadelphia", "status": "published", "created_at": "2026-07-17T16:01:41.805Z", "updated_at": "2026-09-26T12:01:08.782Z", "links": { "self": "/v1/classes/c57d9bae-6479-41c5-9176-f314fa4d31f4", "place": "/v1/places/1af0824d-36cf-482d-a6d7-073343ae52ed", "organization": "/v1/organizations/37bae48b-e86c-44a4-80d7-29fd9c34443c" } } ], "meta": { "limit": 1, "count": 1, "has_more": false, "applied_filters": { "from": "2026-09-14", "to": null, "min_confidence": 0.7, "audience": [ "not_adults" ] }, "defaults_applied": [ "from", "min_confidence", "audience" ] } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Retrieve a class > A class by id. Source: https://api.tailend.dev/docs/api/get-class ```http GET https://api.tailend.dev/v1/classes/{id} X-API-Key: $TAILEND_API_KEY ``` A class by id. Published and active by default; archived rows answer `404` unless `include_archived=true`, and drafts never appear. The response carries `status`. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `include_archived` | boolean | no | Also serve a listing whose status is archived — a permalink that should still render, with a "this has passed" banner, rather than 404. | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/classes/c57d9bae-6479-41c5-9176-f314fa4d31f4" ``` ## Responses ### 200 The class ```json { "data": { "id": "c57d9bae-6479-41c5-9176-f314fa4d31f4", "type": "class", "slug": "kids-pottery-classes-b3d835f7", "name": "Kids Pottery Classes", "description": "6-week pottery classes for kids ages 8-16, focusing on wheel-throwing and hand-building techniques.", "program": { "enrollment_type": "enrollment", "skill_level_notes": "all levels", "adult_participation": null, "what_youll_learn": null, "prerequisites": null }, "term": { "name": null, "starts_on": "2026-09-29", "ends_on": "2026-12-15", "total_classes": null }, "schedule": { "type": "session", "days": [ "tuesday" ], "start_time": "16:00:00", "end_time": "18:00:00", "duration_minutes": 120, "is_recurring": true, "rrule": "FREQ=WEEKLY;BYDAY=TU", "notes": "Tuesdays (4:00 pm – 6:00 pm)", "timezone": "America/New_York" }, "tuition": { "amount": 185, "from": 185, "text": "$185 for 6-week session", "drop_in_price": null, "trial_price": null, "trial_notes": null, "currency": "USD" }, "price": { "type": "fixed", "min": 185, "max": 185, "scope": "admission", "note": "$185 for 6-week session", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 8, "max": 16, "text": "Early Elementary, Tweens, Teens", "audience": "kids", "bands": [ "bigger_kids" ] }, "audience": "teens", "traits": { "seasons": [ "fall", "winter" ] }, "place": { "id": "1af0824d-36cf-482d-a6d7-073343ae52ed", "slug": "earth-center-pottery-ebc6fdd7", "name": "Earth Center Pottery" }, "location": { "address": "10 Stable Mill Trl, Richboro, PA 18954, USA", "street_address": "10 Stable Mill Trl, Richboro, PA 18954, USA", "city": "Richboro", "state": "PA", "zip": "18954", "lat": 40.2226282, "lng": -74.9866812, "neighborhood": "Somerton", "neighborhood_slug": "somerton" }, "categories": [ "arts_creative" ], "occasions": [], "registration": { "url": "https://www.cognitoforms.com/TylerParkCenterForTheArts/KidsPotteryClasses", "status": "open", "opens_at": null, "spots_remaining": null, "has_spots_available": true, "waitlist_available": false, "sections_with_spots": 1 }, "sections_total": 1, "sections": [ { "id": "0b8a3f5e-52c4-4d64-9a0e-2f6d7c1e9a11", "name": "Tuesday afternoon", "schedule": { "days": [ "tuesday" ], "start_time": "16:00:00", "end_time": "18:00:00", "duration_minutes": 120, "rrule": "FREQ=WEEKLY;BYDAY=TU", "notes": "Tuesdays (4:00 pm – 6:00 pm)", "timezone": "America/New_York" }, "term": { "name": null, "starts_on": "2026-09-29", "ends_on": "2026-12-15", "total_classes": null }, "ages": { "min": 8, "max": 16, "text": null }, "tuition": { "amount": 185, "text": "$185 for 6-week session", "drop_in_price": null, "currency": "USD" }, "registration": { "url": "https://www.cognitoforms.com/TylerParkCenterForTheArts/KidsPotteryClasses", "status": "open", "spots_remaining": null, "has_spots_available": true, "waitlist_available": false }, "updated_at": "2026-09-26T12:01:08.782Z" } ], "sessions": [], "image_url": "https://earthcenterpottery.com/wp-content/uploads/2024/02/IMG_0318-scaled-e1707359564209-1024x566.jpg", "organization": { "id": "37bae48b-e86c-44a4-80d7-29fd9c34443c", "name": "Earth Center Pottery", "slug": "earthcenterpotterycom-f09a2fcc" }, "confidence": 0.45, "verified_at": "2026-09-26T12:00:21.748Z", "metro": "philadelphia", "status": "published", "created_at": "2026-07-17T16:01:41.805Z", "updated_at": "2026-09-26T12:01:08.782Z", "links": { "self": "/v1/classes/c57d9bae-6479-41c5-9176-f314fa4d31f4", "place": "/v1/places/1af0824d-36cf-482d-a6d7-073343ae52ed", "organization": "/v1/organizations/37bae48b-e86c-44a4-80d7-29fd9c34443c" } } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Retrieve a class by slug > Returns the same class as /v1/classes/{id}, addressed by slug instead of id. Source: https://api.tailend.dev/docs/api/get-class-by-slug ```http GET https://api.tailend.dev/v1/classes/slug/{slug} X-API-Key: $TAILEND_API_KEY ``` Returns the same class as `/v1/classes/{id}`, addressed by slug instead of id. `slug` is unique per (metro, type). If the key can see this slug in more than one metro, returns `400` with `details.metros`; pass `metro=` to disambiguate. `include_archived=true` returns an archived row. Returns 503 when this endpoint is unavailable. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | Listing slug — the segment a consumer already has in its own URLs. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | no | Which metro's row is meant, when a slug is not unique across the key's metros. | | `include_archived` | boolean | no | Also serve a listing whose status is archived — a permalink that should still render, with a "this has passed" banner, rather than 404. | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/classes/slug/kids-pottery-classes-b3d835f7" ``` ## Responses ### 200 The class ```json { "data": { "id": "c57d9bae-6479-41c5-9176-f314fa4d31f4", "type": "class", "slug": "kids-pottery-classes-b3d835f7", "name": "Kids Pottery Classes", "description": "6-week pottery classes for kids ages 8-16, focusing on wheel-throwing and hand-building techniques.", "program": { "enrollment_type": "enrollment", "skill_level_notes": "all levels", "adult_participation": null, "what_youll_learn": null, "prerequisites": null }, "term": { "name": null, "starts_on": "2026-09-29", "ends_on": "2026-12-15", "total_classes": null }, "schedule": { "type": "session", "days": [ "tuesday" ], "start_time": "16:00:00", "end_time": "18:00:00", "duration_minutes": 120, "is_recurring": true, "rrule": "FREQ=WEEKLY;BYDAY=TU", "notes": "Tuesdays (4:00 pm – 6:00 pm)", "timezone": "America/New_York" }, "tuition": { "amount": 185, "from": 185, "text": "$185 for 6-week session", "drop_in_price": null, "trial_price": null, "trial_notes": null, "currency": "USD" }, "price": { "type": "fixed", "min": 185, "max": 185, "scope": "admission", "note": "$185 for 6-week session", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 8, "max": 16, "text": "Early Elementary, Tweens, Teens", "audience": "kids", "bands": [ "bigger_kids" ] }, "audience": "teens", "traits": { "seasons": [ "fall", "winter" ] }, "place": { "id": "1af0824d-36cf-482d-a6d7-073343ae52ed", "slug": "earth-center-pottery-ebc6fdd7", "name": "Earth Center Pottery" }, "location": { "address": "10 Stable Mill Trl, Richboro, PA 18954, USA", "street_address": "10 Stable Mill Trl, Richboro, PA 18954, USA", "city": "Richboro", "state": "PA", "zip": "18954", "lat": 40.2226282, "lng": -74.9866812, "neighborhood": "Somerton", "neighborhood_slug": "somerton" }, "categories": [ "arts_creative" ], "occasions": [], "registration": { "url": "https://www.cognitoforms.com/TylerParkCenterForTheArts/KidsPotteryClasses", "status": "open", "opens_at": null, "spots_remaining": null, "has_spots_available": true, "waitlist_available": false, "sections_with_spots": 1 }, "sections_total": 1, "sections": [ { "id": "0b8a3f5e-52c4-4d64-9a0e-2f6d7c1e9a11", "name": "Tuesday afternoon", "schedule": { "days": [ "tuesday" ], "start_time": "16:00:00", "end_time": "18:00:00", "duration_minutes": 120, "rrule": "FREQ=WEEKLY;BYDAY=TU", "notes": "Tuesdays (4:00 pm – 6:00 pm)", "timezone": "America/New_York" }, "term": { "name": null, "starts_on": "2026-09-29", "ends_on": "2026-12-15", "total_classes": null }, "ages": { "min": 8, "max": 16, "text": null }, "tuition": { "amount": 185, "text": "$185 for 6-week session", "drop_in_price": null, "currency": "USD" }, "registration": { "url": "https://www.cognitoforms.com/TylerParkCenterForTheArts/KidsPotteryClasses", "status": "open", "spots_remaining": null, "has_spots_available": true, "waitlist_available": false }, "updated_at": "2026-09-26T12:01:08.782Z" } ], "sessions": [], "image_url": "https://earthcenterpottery.com/wp-content/uploads/2024/02/IMG_0318-scaled-e1707359564209-1024x566.jpg", "organization": { "id": "37bae48b-e86c-44a4-80d7-29fd9c34443c", "name": "Earth Center Pottery", "slug": "earthcenterpotterycom-f09a2fcc" }, "confidence": 0.45, "verified_at": "2026-09-26T12:00:21.748Z", "metro": "philadelphia", "status": "published", "created_at": "2026-07-17T16:01:41.805Z", "updated_at": "2026-09-26T12:01:08.782Z", "links": { "self": "/v1/classes/c57d9bae-6479-41c5-9176-f314fa4d31f4", "place": "/v1/places/1af0824d-36cf-482d-a6d7-073343ae52ed", "organization": "/v1/organizations/37bae48b-e86c-44a4-80d7-29fd9c34443c" } } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List place classes > Same as GET /v1/{metro}/classes?place_id={id}, addressed by the place instead of the metro. Source: https://api.tailend.dev/docs/api/list-place-classes ```http GET https://api.tailend.dev/v1/places/{id}/classes X-API-Key: $TAILEND_API_KEY ``` Same as `GET /v1/{metro}/classes?place_id={id}`, addressed by the place instead of the metro. Resolves the place by id, `404`s if it does not exist, takes the metro from the place's own row, and delegates to the ordinary classes list — every other list parameter (paging, `fields=`, `include=`, date windows, `min_confidence`, ...) keeps working exactly as it does there. Same auth, metro scope, rate limiting and metering as the list endpoint; a key without access to the place's metro gets the same `403` the list endpoint gives. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `from` | string (date) | no | Local calendar date (metro timezone), inclusive. | | `to` | string (date) | no | Local calendar date (metro timezone), inclusive; at most 366 days after from. | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `season` | array of string | no | Events, classes and camps only. | | `occasion` | string | no | Events and classes tagged for a seasonal occasion. | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `free` | boolean | no | Events, classes, and camps only; ignored for places. | | `openings` | boolean | no | Classes only. | | `schedule_day` | string | no | Keep classes that meet on this weekday. | | `enrollment_type` | string | no | Keep rows matching this enrollment type. | | `changed_since` | string (date-time) | no | Sync mode. | | `q` | string | no | Full-text search over the listing's name and search text (websearch syntax: storytime park, "drop in", art -camp). | | `weekday` | array of string | no | Keep rows on any of these local days. | | `start_after` | string | no | Local start time at or after this, HH:MM (or HH:MM:SS). | | `start_before` | string | no | Local start time strictly before this, HH:MM; 09:00–12:00 is the morning and 12:00 belongs to the afternoon. | | `max_price` | number | no | A budget. | | `organization_id` | string (uuid) | no | Keep rows published by this organization (organization.id). | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `facets` | array of string | no | Counts over every listing that matches the filters, not just this page, returned as a top-level facets object beside data and meta. | | `limit` | integer | no | Items per page, 1 to 200. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/places/c57d9bae-6479-41c5-9176-f314fa4d31f4/classes" ``` ## Responses ### 200 A page of classes. ```json { "data": [ { "id": "c57d9bae-6479-41c5-9176-f314fa4d31f4", "type": "class", "slug": "kids-pottery-classes-b3d835f7", "name": "Kids Pottery Classes", "description": "6-week pottery classes for kids ages 8-16, focusing on wheel-throwing and hand-building techniques.", "program": { "enrollment_type": "enrollment", "skill_level_notes": "all levels", "adult_participation": null, "what_youll_learn": null, "prerequisites": null }, "term": { "name": null, "starts_on": "2026-09-29", "ends_on": "2026-12-15", "total_classes": null }, "schedule": { "type": "session", "days": [ "tuesday" ], "start_time": "16:00:00", "end_time": "18:00:00", "duration_minutes": 120, "is_recurring": true, "rrule": "FREQ=WEEKLY;BYDAY=TU", "notes": "Tuesdays (4:00 pm – 6:00 pm)", "timezone": "America/New_York" }, "tuition": { "amount": 185, "from": 185, "text": "$185 for 6-week session", "drop_in_price": null, "trial_price": null, "trial_notes": null, "currency": "USD" }, "price": { "type": "fixed", "min": 185, "max": 185, "scope": "admission", "note": "$185 for 6-week session", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 8, "max": 16, "text": "Early Elementary, Tweens, Teens", "audience": "kids", "bands": [ "bigger_kids" ] }, "audience": "teens", "traits": { "seasons": [ "fall", "winter" ] }, "place": { "id": "1af0824d-36cf-482d-a6d7-073343ae52ed", "slug": "earth-center-pottery-ebc6fdd7", "name": "Earth Center Pottery" }, "location": { "address": "10 Stable Mill Trl, Richboro, PA 18954, USA", "street_address": "10 Stable Mill Trl, Richboro, PA 18954, USA", "city": "Richboro", "state": "PA", "zip": "18954", "lat": 40.2226282, "lng": -74.9866812, "neighborhood": "Somerton", "neighborhood_slug": "somerton" }, "categories": [ "arts_creative" ], "occasions": [], "registration": { "url": "https://www.cognitoforms.com/TylerParkCenterForTheArts/KidsPotteryClasses", "status": "open", "opens_at": null, "spots_remaining": null, "has_spots_available": true, "waitlist_available": false, "sections_with_spots": 1 }, "sections_total": 1, "sections": [ { "id": "0b8a3f5e-52c4-4d64-9a0e-2f6d7c1e9a11", "name": "Tuesday afternoon", "schedule": { "days": [ "tuesday" ], "start_time": "16:00:00", "end_time": "18:00:00", "duration_minutes": 120, "rrule": "FREQ=WEEKLY;BYDAY=TU", "notes": "Tuesdays (4:00 pm – 6:00 pm)", "timezone": "America/New_York" }, "term": { "name": null, "starts_on": "2026-09-29", "ends_on": "2026-12-15", "total_classes": null }, "ages": { "min": 8, "max": 16, "text": null }, "tuition": { "amount": 185, "text": "$185 for 6-week session", "drop_in_price": null, "currency": "USD" }, "registration": { "url": "https://www.cognitoforms.com/TylerParkCenterForTheArts/KidsPotteryClasses", "status": "open", "spots_remaining": null, "has_spots_available": true, "waitlist_available": false }, "updated_at": "2026-09-26T12:01:08.782Z" } ], "sessions": [], "image_url": "https://earthcenterpottery.com/wp-content/uploads/2024/02/IMG_0318-scaled-e1707359564209-1024x566.jpg", "organization": { "id": "37bae48b-e86c-44a4-80d7-29fd9c34443c", "name": "Earth Center Pottery", "slug": "earthcenterpotterycom-f09a2fcc" }, "confidence": 0.45, "verified_at": "2026-09-26T12:00:21.748Z", "metro": "philadelphia", "status": "published", "created_at": "2026-07-17T16:01:41.805Z", "updated_at": "2026-09-26T12:01:08.782Z", "links": { "self": "/v1/classes/c57d9bae-6479-41c5-9176-f314fa4d31f4", "place": "/v1/places/1af0824d-36cf-482d-a6d7-073343ae52ed", "organization": "/v1/organizations/37bae48b-e86c-44a4-80d7-29fd9c34443c" } } ], "meta": { "limit": 1, "count": 1, "has_more": false, "applied_filters": { "from": "2026-09-14", "to": null, "min_confidence": 0.7, "audience": [ "not_adults" ] }, "defaults_applied": [ "from", "min_confidence", "audience" ] } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List organization classes > Same as GET /v1/{metro}/classes?organization_id={id}, addressed by the organization instead of the metro. Source: https://api.tailend.dev/docs/api/list-organization-classes ```http GET https://api.tailend.dev/v1/organizations/{id}/classes X-API-Key: $TAILEND_API_KEY ``` Same as `GET /v1/{metro}/classes?organization_id={id}`, addressed by the organization instead of the metro. Resolves the organization by id, `404`s if it does not exist, and takes the metro from that row. An organization with live listings in more than one metro is a `400` asking for `metro=` to disambiguate, exactly like a slug that is not unique across the key's metros; give it and the ambiguity is resolved the same way. Every other list parameter keeps working. Same auth, metro scope, rate limiting and metering as the list endpoint; a key without access to the organization's metro gets the same `403` the list endpoint gives. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | no | Which metro's row is meant, when a slug is not unique across the key's metros. | | `from` | string (date) | no | Local calendar date (metro timezone), inclusive. | | `to` | string (date) | no | Local calendar date (metro timezone), inclusive; at most 366 days after from. | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `season` | array of string | no | Events, classes and camps only. | | `occasion` | string | no | Events and classes tagged for a seasonal occasion. | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `place_id` | string (uuid) | no | Keep rows held at this place (place.id). | | `free` | boolean | no | Events, classes, and camps only; ignored for places. | | `openings` | boolean | no | Classes only. | | `indoor` | boolean | no | Filter on is_indoor, a fact about the linked place. | | `outdoor` | boolean | no | Filter on is_outdoor, a fact about the linked place. | | `sensory` | boolean | no | Filter on is_sensory_friendly (on events and classes, the linked place's; on places, the place's own). | | `schedule_day` | string | no | Keep classes that meet on this weekday. | | `enrollment_type` | string | no | Keep rows matching this enrollment type. | | `changed_since` | string (date-time) | no | Sync mode. | | `q` | string | no | Full-text search over the listing's name and search text (websearch syntax: storytime park, "drop in", art -camp). | | `weekday` | array of string | no | Keep rows on any of these local days. | | `start_after` | string | no | Local start time at or after this, HH:MM (or HH:MM:SS). | | `start_before` | string | no | Local start time strictly before this, HH:MM; 09:00–12:00 is the morning and 12:00 belongs to the afternoon. | | `max_price` | number | no | A budget. | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `facets` | array of string | no | Counts over every listing that matches the filters, not just this page, returned as a top-level facets object beside data and meta. | | `limit` | integer | no | Items per page, 1 to 200. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/organizations/c57d9bae-6479-41c5-9176-f314fa4d31f4/classes" ``` ## Responses ### 200 A page of classes. ```json { "data": [ { "id": "c57d9bae-6479-41c5-9176-f314fa4d31f4", "type": "class", "slug": "kids-pottery-classes-b3d835f7", "name": "Kids Pottery Classes", "description": "6-week pottery classes for kids ages 8-16, focusing on wheel-throwing and hand-building techniques.", "program": { "enrollment_type": "enrollment", "skill_level_notes": "all levels", "adult_participation": null, "what_youll_learn": null, "prerequisites": null }, "term": { "name": null, "starts_on": "2026-09-29", "ends_on": "2026-12-15", "total_classes": null }, "schedule": { "type": "session", "days": [ "tuesday" ], "start_time": "16:00:00", "end_time": "18:00:00", "duration_minutes": 120, "is_recurring": true, "rrule": "FREQ=WEEKLY;BYDAY=TU", "notes": "Tuesdays (4:00 pm – 6:00 pm)", "timezone": "America/New_York" }, "tuition": { "amount": 185, "from": 185, "text": "$185 for 6-week session", "drop_in_price": null, "trial_price": null, "trial_notes": null, "currency": "USD" }, "price": { "type": "fixed", "min": 185, "max": 185, "scope": "admission", "note": "$185 for 6-week session", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 8, "max": 16, "text": "Early Elementary, Tweens, Teens", "audience": "kids", "bands": [ "bigger_kids" ] }, "audience": "teens", "traits": { "seasons": [ "fall", "winter" ] }, "place": { "id": "1af0824d-36cf-482d-a6d7-073343ae52ed", "slug": "earth-center-pottery-ebc6fdd7", "name": "Earth Center Pottery" }, "location": { "address": "10 Stable Mill Trl, Richboro, PA 18954, USA", "street_address": "10 Stable Mill Trl, Richboro, PA 18954, USA", "city": "Richboro", "state": "PA", "zip": "18954", "lat": 40.2226282, "lng": -74.9866812, "neighborhood": "Somerton", "neighborhood_slug": "somerton" }, "categories": [ "arts_creative" ], "occasions": [], "registration": { "url": "https://www.cognitoforms.com/TylerParkCenterForTheArts/KidsPotteryClasses", "status": "open", "opens_at": null, "spots_remaining": null, "has_spots_available": true, "waitlist_available": false, "sections_with_spots": 1 }, "sections_total": 1, "sections": [ { "id": "0b8a3f5e-52c4-4d64-9a0e-2f6d7c1e9a11", "name": "Tuesday afternoon", "schedule": { "days": [ "tuesday" ], "start_time": "16:00:00", "end_time": "18:00:00", "duration_minutes": 120, "rrule": "FREQ=WEEKLY;BYDAY=TU", "notes": "Tuesdays (4:00 pm – 6:00 pm)", "timezone": "America/New_York" }, "term": { "name": null, "starts_on": "2026-09-29", "ends_on": "2026-12-15", "total_classes": null }, "ages": { "min": 8, "max": 16, "text": null }, "tuition": { "amount": 185, "text": "$185 for 6-week session", "drop_in_price": null, "currency": "USD" }, "registration": { "url": "https://www.cognitoforms.com/TylerParkCenterForTheArts/KidsPotteryClasses", "status": "open", "spots_remaining": null, "has_spots_available": true, "waitlist_available": false }, "updated_at": "2026-09-26T12:01:08.782Z" } ], "sessions": [], "image_url": "https://earthcenterpottery.com/wp-content/uploads/2024/02/IMG_0318-scaled-e1707359564209-1024x566.jpg", "organization": { "id": "37bae48b-e86c-44a4-80d7-29fd9c34443c", "name": "Earth Center Pottery", "slug": "earthcenterpotterycom-f09a2fcc" }, "confidence": 0.45, "verified_at": "2026-09-26T12:00:21.748Z", "metro": "philadelphia", "status": "published", "created_at": "2026-07-17T16:01:41.805Z", "updated_at": "2026-09-26T12:01:08.782Z", "links": { "self": "/v1/classes/c57d9bae-6479-41c5-9176-f314fa4d31f4", "place": "/v1/places/1af0824d-36cf-482d-a6d7-073343ae52ed", "organization": "/v1/organizations/37bae48b-e86c-44a4-80d7-29fd9c34443c" } } ], "meta": { "limit": 1, "count": 1, "has_more": false, "applied_filters": { "from": "2026-09-14", "to": null, "min_confidence": 0.7, "audience": [ "not_adults" ] }, "defaults_applied": [ "from", "min_confidence", "audience" ] } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List camps > Published, active camps, with their sessions. Source: https://api.tailend.dev/docs/api/list-camps ```http GET https://api.tailend.dev/v1/{metro}/camps X-API-Key: $TAILEND_API_KEY ``` Published, active camps, with their sessions. `from`/`to` select camps whose overall span (`starts_on`..`ends_on`) or any session overlaps the window; undated camps always match. As for classes, `to` has no default: `from` alone (default today) is open-ended. Rows below `min_confidence` (**default `0.7`**) and `adults` audiences are left out unless asked for; `meta.applied_filters` and `meta.defaults_applied` echo what was applied. `facets=` adds counts over the whole filtered set. Ordered by `starts_on` nulls last, then `name`. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `ids` | string | no | Fetch specific listings of this resource type in one call: up to 100 comma-separated listing UUIDs (ids=,). | | `from` | string (date) | no | Local calendar date (metro timezone), inclusive. | | `to` | string (date) | no | Local calendar date (metro timezone), inclusive; at most 366 days after from. | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `season` | array of string | no | Events, classes and camps only. | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `place_id` | string (uuid) | no | Keep rows held at this place (place.id). | | `free` | boolean | no | Events, classes, and camps only; ignored for places. | | `year` | integer | no | The camp's year. | | `overnight` | boolean | no | Keep only overnight camps. | | `changed_since` | string (date-time) | no | Sync mode. | | `q` | string | no | Full-text search over the listing's name and search text (websearch syntax: storytime park, "drop in", art -camp). | | `max_price` | number | no | A budget. | | `organization_id` | string (uuid) | no | Keep rows published by this organization (organization.id). | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `facets` | array of string | no | Counts over every listing that matches the filters, not just this page, returned as a top-level facets object beside data and meta. | | `limit` | integer | no | Items per page, 1 to 200. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/camps" ``` ## Responses ### 200 A page of camps. ```json { "data": [ { "id": "7fb56a77-8603-4c31-a07f-ef9d9e7f53e3", "type": "camp", "slug": "2026-summer-circus-camp-e49f4a3a", "name": "2026 Summer Circus Camp", "description": "The Philadelphia School of Circus Arts – Best of Philly Winner for “Kids Classes” – announces Summer Circus Camp 2026 for youth ages 4 – 15. Circus Camp returns with our 1-week camp program designed for campers with an emphasis on play and exploration in the exciting world of circus. Campers will learn static trapeze, aerial sling, aerial fabric, wire walking, juggling, plate-spinning, rolling globe, tumbling, unicycling, clowning and more. We can’t wait to share the excitement and challenge of the circus with kids in our full-day program!", "season": "summer", "year": 2026, "starts_on": "2026-06-01", "ends_on": "2026-06-05", "schedule": { "type": null, "is_full_day": true, "is_half_day_am": false, "is_half_day_pm": false, "day_start_time": "09:00:00", "day_end_time": "15:00:00", "extended_care": { "morning_start": null, "morning_price": null, "afternoon_end": null, "afternoon_price": null, "notes": "Before Care: $20/day; After Care: $35/day", "currency": "USD" }, "timezone": "America/New_York" }, "is_overnight": false, "is_travel": false, "is_specialty": false, "pricing_unit": "per_week", "price": { "type": "fixed", "min": 495, "max": 495, "scope": "admission", "note": "$495/week", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 4, "max": 15, "text": null, "audience": null, "bands": [ "young_kids", "bigger_kids" ] }, "audience": "teens", "traits": { "seasons": [ "summer" ] }, "place": { "id": "1730e947-3dd8-45ad-88f5-2fe7cdeec9e8", "slug": "philadelphia-school-of-circus-arts-62377244", "name": "Philadelphia School of Circus Arts" }, "location": { "address": "6452 Greene Street", "street_address": "6452 Greene Street", "city": "Philadelphia", "state": "PA", "zip": "19119", "lat": 40.0419162, "lng": -75.1896032, "neighborhood": "Northwest Philadelphia", "neighborhood_slug": null }, "categories": [ "sports_active_play" ], "registration": { "url": "https://portal.iclasspro.com/phillycircus/booking", "opens_on": null, "deadline_on": null, "early_bird_deadline_on": null, "has_spots_available": true, "sessions_with_spots": 1 }, "sessions": [ { "id": "4fbe88ce-c939-450e-92a0-e92c3f04ed6c", "name": "Summer Circus Camp 2026", "starts_on": "2026-06-01", "ends_on": "2026-06-05", "price": 495, "early_bird_price": null, "full_day_price": null, "half_day_price": null, "currency": "USD", "capacity": null, "spots_remaining": null, "status": "open", "theme": null, "notes": null } ], "image_url": "https://phillycircus.com/wp-content/uploads/PSCA-web-promo-01.jpg", "organization": { "id": "c6e39d31-abce-43f8-bccd-2cf5ce73a0ad", "name": "Philadelphia School of Circus Arts", "slug": "philadelphia-school-of-circus-arts" }, "confidence": 0.55, "verified_at": null, "metro": "philadelphia", "status": "published", "created_at": "2026-09-22T06:11:27.636Z", "updated_at": "2026-09-26T08:19:23.038Z", "links": { "self": "/v1/camps/7fb56a77-8603-4c31-a07f-ef9d9e7f53e3", "place": "/v1/places/1730e947-3dd8-45ad-88f5-2fe7cdeec9e8", "organization": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad" } } ], "meta": { "limit": 1, "count": 1, "has_more": false, "applied_filters": { "from": "2026-09-14", "to": null, "min_confidence": 0.7, "audience": [ "not_adults" ] }, "defaults_applied": [ "from", "min_confidence", "audience" ] } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Retrieve a camp > A camp by id. Source: https://api.tailend.dev/docs/api/get-camp ```http GET https://api.tailend.dev/v1/camps/{id} X-API-Key: $TAILEND_API_KEY ``` A camp by id. Published and active by default; archived rows answer `404` unless `include_archived=true`, and drafts never appear. The response carries `status`. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `include_archived` | boolean | no | Also serve a listing whose status is archived — a permalink that should still render, with a "this has passed" banner, rather than 404. | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/camps/7fb56a77-8603-4c31-a07f-ef9d9e7f53e3" ``` ## Responses ### 200 The camp ```json { "data": { "id": "7fb56a77-8603-4c31-a07f-ef9d9e7f53e3", "type": "camp", "slug": "2026-summer-circus-camp-e49f4a3a", "name": "2026 Summer Circus Camp", "description": "The Philadelphia School of Circus Arts – Best of Philly Winner for “Kids Classes” – announces Summer Circus Camp 2026 for youth ages 4 – 15. Circus Camp returns with our 1-week camp program designed for campers with an emphasis on play and exploration in the exciting world of circus. Campers will learn static trapeze, aerial sling, aerial fabric, wire walking, juggling, plate-spinning, rolling globe, tumbling, unicycling, clowning and more. We can’t wait to share the excitement and challenge of the circus with kids in our full-day program!", "season": "summer", "year": 2026, "starts_on": "2026-06-01", "ends_on": "2026-06-05", "schedule": { "type": null, "is_full_day": true, "is_half_day_am": false, "is_half_day_pm": false, "day_start_time": "09:00:00", "day_end_time": "15:00:00", "extended_care": { "morning_start": null, "morning_price": null, "afternoon_end": null, "afternoon_price": null, "notes": "Before Care: $20/day; After Care: $35/day", "currency": "USD" }, "timezone": "America/New_York" }, "is_overnight": false, "is_travel": false, "is_specialty": false, "pricing_unit": "per_week", "price": { "type": "fixed", "min": 495, "max": 495, "scope": "admission", "note": "$495/week", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 4, "max": 15, "text": null, "audience": null, "bands": [ "young_kids", "bigger_kids" ] }, "audience": "teens", "traits": { "seasons": [ "summer" ] }, "place": { "id": "1730e947-3dd8-45ad-88f5-2fe7cdeec9e8", "slug": "philadelphia-school-of-circus-arts-62377244", "name": "Philadelphia School of Circus Arts" }, "location": { "address": "6452 Greene Street", "street_address": "6452 Greene Street", "city": "Philadelphia", "state": "PA", "zip": "19119", "lat": 40.0419162, "lng": -75.1896032, "neighborhood": "Northwest Philadelphia", "neighborhood_slug": null }, "categories": [ "sports_active_play" ], "registration": { "url": "https://portal.iclasspro.com/phillycircus/booking", "opens_on": null, "deadline_on": null, "early_bird_deadline_on": null, "has_spots_available": true, "sessions_with_spots": 1 }, "sessions": [ { "id": "4fbe88ce-c939-450e-92a0-e92c3f04ed6c", "name": "Summer Circus Camp 2026", "starts_on": "2026-06-01", "ends_on": "2026-06-05", "price": 495, "early_bird_price": null, "full_day_price": null, "half_day_price": null, "currency": "USD", "capacity": null, "spots_remaining": null, "status": "open", "theme": null, "notes": null } ], "image_url": "https://phillycircus.com/wp-content/uploads/PSCA-web-promo-01.jpg", "organization": { "id": "c6e39d31-abce-43f8-bccd-2cf5ce73a0ad", "name": "Philadelphia School of Circus Arts", "slug": "philadelphia-school-of-circus-arts" }, "confidence": 0.55, "verified_at": null, "metro": "philadelphia", "status": "published", "created_at": "2026-09-22T06:11:27.636Z", "updated_at": "2026-09-26T08:19:23.038Z", "links": { "self": "/v1/camps/7fb56a77-8603-4c31-a07f-ef9d9e7f53e3", "place": "/v1/places/1730e947-3dd8-45ad-88f5-2fe7cdeec9e8", "organization": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad" } } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Retrieve a camp by slug > Returns the same camp as /v1/camps/{id}, addressed by slug instead of id. Source: https://api.tailend.dev/docs/api/get-camp-by-slug ```http GET https://api.tailend.dev/v1/camps/slug/{slug} X-API-Key: $TAILEND_API_KEY ``` Returns the same camp as `/v1/camps/{id}`, addressed by slug instead of id. `slug` is unique per (metro, type). If the key can see this slug in more than one metro, returns `400` with `details.metros`; pass `metro=` to disambiguate. `include_archived=true` returns an archived row. Returns 503 when this endpoint is unavailable. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | Listing slug — the segment a consumer already has in its own URLs. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | no | Which metro's row is meant, when a slug is not unique across the key's metros. | | `include_archived` | boolean | no | Also serve a listing whose status is archived — a permalink that should still render, with a "this has passed" banner, rather than 404. | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/camps/slug/2026-summer-circus-camp-e49f4a3a" ``` ## Responses ### 200 The camp ```json { "data": { "id": "7fb56a77-8603-4c31-a07f-ef9d9e7f53e3", "type": "camp", "slug": "2026-summer-circus-camp-e49f4a3a", "name": "2026 Summer Circus Camp", "description": "The Philadelphia School of Circus Arts – Best of Philly Winner for “Kids Classes” – announces Summer Circus Camp 2026 for youth ages 4 – 15. Circus Camp returns with our 1-week camp program designed for campers with an emphasis on play and exploration in the exciting world of circus. Campers will learn static trapeze, aerial sling, aerial fabric, wire walking, juggling, plate-spinning, rolling globe, tumbling, unicycling, clowning and more. We can’t wait to share the excitement and challenge of the circus with kids in our full-day program!", "season": "summer", "year": 2026, "starts_on": "2026-06-01", "ends_on": "2026-06-05", "schedule": { "type": null, "is_full_day": true, "is_half_day_am": false, "is_half_day_pm": false, "day_start_time": "09:00:00", "day_end_time": "15:00:00", "extended_care": { "morning_start": null, "morning_price": null, "afternoon_end": null, "afternoon_price": null, "notes": "Before Care: $20/day; After Care: $35/day", "currency": "USD" }, "timezone": "America/New_York" }, "is_overnight": false, "is_travel": false, "is_specialty": false, "pricing_unit": "per_week", "price": { "type": "fixed", "min": 495, "max": 495, "scope": "admission", "note": "$495/week", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 4, "max": 15, "text": null, "audience": null, "bands": [ "young_kids", "bigger_kids" ] }, "audience": "teens", "traits": { "seasons": [ "summer" ] }, "place": { "id": "1730e947-3dd8-45ad-88f5-2fe7cdeec9e8", "slug": "philadelphia-school-of-circus-arts-62377244", "name": "Philadelphia School of Circus Arts" }, "location": { "address": "6452 Greene Street", "street_address": "6452 Greene Street", "city": "Philadelphia", "state": "PA", "zip": "19119", "lat": 40.0419162, "lng": -75.1896032, "neighborhood": "Northwest Philadelphia", "neighborhood_slug": null }, "categories": [ "sports_active_play" ], "registration": { "url": "https://portal.iclasspro.com/phillycircus/booking", "opens_on": null, "deadline_on": null, "early_bird_deadline_on": null, "has_spots_available": true, "sessions_with_spots": 1 }, "sessions": [ { "id": "4fbe88ce-c939-450e-92a0-e92c3f04ed6c", "name": "Summer Circus Camp 2026", "starts_on": "2026-06-01", "ends_on": "2026-06-05", "price": 495, "early_bird_price": null, "full_day_price": null, "half_day_price": null, "currency": "USD", "capacity": null, "spots_remaining": null, "status": "open", "theme": null, "notes": null } ], "image_url": "https://phillycircus.com/wp-content/uploads/PSCA-web-promo-01.jpg", "organization": { "id": "c6e39d31-abce-43f8-bccd-2cf5ce73a0ad", "name": "Philadelphia School of Circus Arts", "slug": "philadelphia-school-of-circus-arts" }, "confidence": 0.55, "verified_at": null, "metro": "philadelphia", "status": "published", "created_at": "2026-09-22T06:11:27.636Z", "updated_at": "2026-09-26T08:19:23.038Z", "links": { "self": "/v1/camps/7fb56a77-8603-4c31-a07f-ef9d9e7f53e3", "place": "/v1/places/1730e947-3dd8-45ad-88f5-2fe7cdeec9e8", "organization": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad" } } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List place camps > Same as GET /v1/{metro}/camps?place_id={id}, addressed by the place instead of the metro. Source: https://api.tailend.dev/docs/api/list-place-camps ```http GET https://api.tailend.dev/v1/places/{id}/camps X-API-Key: $TAILEND_API_KEY ``` Same as `GET /v1/{metro}/camps?place_id={id}`, addressed by the place instead of the metro. Resolves the place by id, `404`s if it does not exist, takes the metro from the place's own row, and delegates to the ordinary camps list — every other list parameter (paging, `fields=`, `include=`, date windows, `min_confidence`, ...) keeps working exactly as it does there. Same auth, metro scope, rate limiting and metering as the list endpoint; a key without access to the place's metro gets the same `403` the list endpoint gives. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `from` | string (date) | no | Local calendar date (metro timezone), inclusive. | | `to` | string (date) | no | Local calendar date (metro timezone), inclusive; at most 366 days after from. | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `season` | array of string | no | Events, classes and camps only. | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `free` | boolean | no | Events, classes, and camps only; ignored for places. | | `year` | integer | no | The camp's year. | | `overnight` | boolean | no | Keep only overnight camps. | | `changed_since` | string (date-time) | no | Sync mode. | | `q` | string | no | Full-text search over the listing's name and search text (websearch syntax: storytime park, "drop in", art -camp). | | `max_price` | number | no | A budget. | | `organization_id` | string (uuid) | no | Keep rows published by this organization (organization.id). | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `facets` | array of string | no | Counts over every listing that matches the filters, not just this page, returned as a top-level facets object beside data and meta. | | `limit` | integer | no | Items per page, 1 to 200. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/places/7fb56a77-8603-4c31-a07f-ef9d9e7f53e3/camps" ``` ## Responses ### 200 A page of camps. ```json { "data": [ { "id": "7fb56a77-8603-4c31-a07f-ef9d9e7f53e3", "type": "camp", "slug": "2026-summer-circus-camp-e49f4a3a", "name": "2026 Summer Circus Camp", "description": "The Philadelphia School of Circus Arts – Best of Philly Winner for “Kids Classes” – announces Summer Circus Camp 2026 for youth ages 4 – 15. Circus Camp returns with our 1-week camp program designed for campers with an emphasis on play and exploration in the exciting world of circus. Campers will learn static trapeze, aerial sling, aerial fabric, wire walking, juggling, plate-spinning, rolling globe, tumbling, unicycling, clowning and more. We can’t wait to share the excitement and challenge of the circus with kids in our full-day program!", "season": "summer", "year": 2026, "starts_on": "2026-06-01", "ends_on": "2026-06-05", "schedule": { "type": null, "is_full_day": true, "is_half_day_am": false, "is_half_day_pm": false, "day_start_time": "09:00:00", "day_end_time": "15:00:00", "extended_care": { "morning_start": null, "morning_price": null, "afternoon_end": null, "afternoon_price": null, "notes": "Before Care: $20/day; After Care: $35/day", "currency": "USD" }, "timezone": "America/New_York" }, "is_overnight": false, "is_travel": false, "is_specialty": false, "pricing_unit": "per_week", "price": { "type": "fixed", "min": 495, "max": 495, "scope": "admission", "note": "$495/week", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 4, "max": 15, "text": null, "audience": null, "bands": [ "young_kids", "bigger_kids" ] }, "audience": "teens", "traits": { "seasons": [ "summer" ] }, "place": { "id": "1730e947-3dd8-45ad-88f5-2fe7cdeec9e8", "slug": "philadelphia-school-of-circus-arts-62377244", "name": "Philadelphia School of Circus Arts" }, "location": { "address": "6452 Greene Street", "street_address": "6452 Greene Street", "city": "Philadelphia", "state": "PA", "zip": "19119", "lat": 40.0419162, "lng": -75.1896032, "neighborhood": "Northwest Philadelphia", "neighborhood_slug": null }, "categories": [ "sports_active_play" ], "registration": { "url": "https://portal.iclasspro.com/phillycircus/booking", "opens_on": null, "deadline_on": null, "early_bird_deadline_on": null, "has_spots_available": true, "sessions_with_spots": 1 }, "sessions": [ { "id": "4fbe88ce-c939-450e-92a0-e92c3f04ed6c", "name": "Summer Circus Camp 2026", "starts_on": "2026-06-01", "ends_on": "2026-06-05", "price": 495, "early_bird_price": null, "full_day_price": null, "half_day_price": null, "currency": "USD", "capacity": null, "spots_remaining": null, "status": "open", "theme": null, "notes": null } ], "image_url": "https://phillycircus.com/wp-content/uploads/PSCA-web-promo-01.jpg", "organization": { "id": "c6e39d31-abce-43f8-bccd-2cf5ce73a0ad", "name": "Philadelphia School of Circus Arts", "slug": "philadelphia-school-of-circus-arts" }, "confidence": 0.55, "verified_at": null, "metro": "philadelphia", "status": "published", "created_at": "2026-09-22T06:11:27.636Z", "updated_at": "2026-09-26T08:19:23.038Z", "links": { "self": "/v1/camps/7fb56a77-8603-4c31-a07f-ef9d9e7f53e3", "place": "/v1/places/1730e947-3dd8-45ad-88f5-2fe7cdeec9e8", "organization": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad" } } ], "meta": { "limit": 1, "count": 1, "has_more": false, "applied_filters": { "from": "2026-09-14", "to": null, "min_confidence": 0.7, "audience": [ "not_adults" ] }, "defaults_applied": [ "from", "min_confidence", "audience" ] } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List organization camps > Same as GET /v1/{metro}/camps?organization_id={id}, addressed by the organization instead of the metro. Source: https://api.tailend.dev/docs/api/list-organization-camps ```http GET https://api.tailend.dev/v1/organizations/{id}/camps X-API-Key: $TAILEND_API_KEY ``` Same as `GET /v1/{metro}/camps?organization_id={id}`, addressed by the organization instead of the metro. Resolves the organization by id, `404`s if it does not exist, and takes the metro from that row. An organization with live listings in more than one metro is a `400` asking for `metro=` to disambiguate, exactly like a slug that is not unique across the key's metros; give it and the ambiguity is resolved the same way. Every other list parameter keeps working. Same auth, metro scope, rate limiting and metering as the list endpoint; a key without access to the organization's metro gets the same `403` the list endpoint gives. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | no | Which metro's row is meant, when a slug is not unique across the key's metros. | | `from` | string (date) | no | Local calendar date (metro timezone), inclusive. | | `to` | string (date) | no | Local calendar date (metro timezone), inclusive; at most 366 days after from. | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `season` | array of string | no | Events, classes and camps only. | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `place_id` | string (uuid) | no | Keep rows held at this place (place.id). | | `free` | boolean | no | Events, classes, and camps only; ignored for places. | | `year` | integer | no | The camp's year. | | `overnight` | boolean | no | Keep only overnight camps. | | `changed_since` | string (date-time) | no | Sync mode. | | `q` | string | no | Full-text search over the listing's name and search text (websearch syntax: storytime park, "drop in", art -camp). | | `max_price` | number | no | A budget. | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `facets` | array of string | no | Counts over every listing that matches the filters, not just this page, returned as a top-level facets object beside data and meta. | | `limit` | integer | no | Items per page, 1 to 200. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/organizations/7fb56a77-8603-4c31-a07f-ef9d9e7f53e3/camps" ``` ## Responses ### 200 A page of camps. ```json { "data": [ { "id": "7fb56a77-8603-4c31-a07f-ef9d9e7f53e3", "type": "camp", "slug": "2026-summer-circus-camp-e49f4a3a", "name": "2026 Summer Circus Camp", "description": "The Philadelphia School of Circus Arts – Best of Philly Winner for “Kids Classes” – announces Summer Circus Camp 2026 for youth ages 4 – 15. Circus Camp returns with our 1-week camp program designed for campers with an emphasis on play and exploration in the exciting world of circus. Campers will learn static trapeze, aerial sling, aerial fabric, wire walking, juggling, plate-spinning, rolling globe, tumbling, unicycling, clowning and more. We can’t wait to share the excitement and challenge of the circus with kids in our full-day program!", "season": "summer", "year": 2026, "starts_on": "2026-06-01", "ends_on": "2026-06-05", "schedule": { "type": null, "is_full_day": true, "is_half_day_am": false, "is_half_day_pm": false, "day_start_time": "09:00:00", "day_end_time": "15:00:00", "extended_care": { "morning_start": null, "morning_price": null, "afternoon_end": null, "afternoon_price": null, "notes": "Before Care: $20/day; After Care: $35/day", "currency": "USD" }, "timezone": "America/New_York" }, "is_overnight": false, "is_travel": false, "is_specialty": false, "pricing_unit": "per_week", "price": { "type": "fixed", "min": 495, "max": 495, "scope": "admission", "note": "$495/week", "unit": "per_session", "is_free": false, "currency": "USD" }, "ages": { "min": 4, "max": 15, "text": null, "audience": null, "bands": [ "young_kids", "bigger_kids" ] }, "audience": "teens", "traits": { "seasons": [ "summer" ] }, "place": { "id": "1730e947-3dd8-45ad-88f5-2fe7cdeec9e8", "slug": "philadelphia-school-of-circus-arts-62377244", "name": "Philadelphia School of Circus Arts" }, "location": { "address": "6452 Greene Street", "street_address": "6452 Greene Street", "city": "Philadelphia", "state": "PA", "zip": "19119", "lat": 40.0419162, "lng": -75.1896032, "neighborhood": "Northwest Philadelphia", "neighborhood_slug": null }, "categories": [ "sports_active_play" ], "registration": { "url": "https://portal.iclasspro.com/phillycircus/booking", "opens_on": null, "deadline_on": null, "early_bird_deadline_on": null, "has_spots_available": true, "sessions_with_spots": 1 }, "sessions": [ { "id": "4fbe88ce-c939-450e-92a0-e92c3f04ed6c", "name": "Summer Circus Camp 2026", "starts_on": "2026-06-01", "ends_on": "2026-06-05", "price": 495, "early_bird_price": null, "full_day_price": null, "half_day_price": null, "currency": "USD", "capacity": null, "spots_remaining": null, "status": "open", "theme": null, "notes": null } ], "image_url": "https://phillycircus.com/wp-content/uploads/PSCA-web-promo-01.jpg", "organization": { "id": "c6e39d31-abce-43f8-bccd-2cf5ce73a0ad", "name": "Philadelphia School of Circus Arts", "slug": "philadelphia-school-of-circus-arts" }, "confidence": 0.55, "verified_at": null, "metro": "philadelphia", "status": "published", "created_at": "2026-09-22T06:11:27.636Z", "updated_at": "2026-09-26T08:19:23.038Z", "links": { "self": "/v1/camps/7fb56a77-8603-4c31-a07f-ef9d9e7f53e3", "place": "/v1/places/1730e947-3dd8-45ad-88f5-2fe7cdeec9e8", "organization": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad" } } ], "meta": { "limit": 1, "count": 1, "has_more": false, "applied_filters": { "from": "2026-09-14", "to": null, "min_confidence": 0.7, "audience": [ "not_adults" ] }, "defaults_applied": [ "from", "min_confidence", "audience" ] } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List places > Published, active places, ordered by name. Source: https://api.tailend.dev/docs/api/list-places ```http GET https://api.tailend.dev/v1/{metro}/places X-API-Key: $TAILEND_API_KEY ``` Published, active places, ordered by `name`. `min_confidence` defaults to **0.5** (events, classes and camps default to `0.7`), and `adults` audiences are left out unless asked for; `meta.applied_filters` and `meta.defaults_applied` echo what was applied. `facets=` adds counts over the whole filtered set. `type_hint=`/`place_type=` are the same filter under two spellings; `place_type=` is the deprecated one. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `ids` | string | no | Fetch specific listings of this resource type in one call: up to 100 comma-separated listing UUIDs (ids=,). | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `type_hint` | string | no | Filters on the place's type (see Place.type_hint). | | `place_type` | string | no | The deprecated spelling of type_hint. | | `has_birthday_parties` | boolean | no | | | `has_classes` | boolean | no | | | `sensory` | boolean | no | Filter on is_sensory_friendly (on events and classes, the linked place's; on places, the place's own). | | `changed_since` | string (date-time) | no | Sync mode. | | `q` | string | no | Full-text search over the listing's name and search text (websearch syntax: storytime park, "drop in", art -camp). | | `organization_id` | string (uuid) | no | Keep rows published by this organization (organization.id). | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `facets` | array of string | no | Counts over every listing that matches the filters, not just this page, returned as a top-level facets object beside data and meta. | | `limit` | integer | no | Items per page, 1 to 200. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/places" ``` ## Responses ### 200 A page of places. ```json { "data": [ { "id": "1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e", "type": "place", "slug": "philly-art-center-queen-village", "name": "Philly Art Center - Queen Village", "description": "Philly Art Center's Queen Village studio offers classes, camps, and after-school programs for kids from infancy through tweens, plus adult classes in drawing, painting, printmaking, fibers, and clay. Programs include Creative Playgroup + Story Time (drop-in for 0-30 months), adult+child music and art classes, drop-off kids art classes (ages 2+), tween classes, summer camps (ages 4-13), vacation camps (ages 5-12), and after-school art with free school pick-up. Process over product philosophy — all materials included.", "type_hint": "other", "type_hint_notes": "Art studio / creative learning center", "location": { "address": "514 Bainbridge St, Philadelphia, PA 19147, USA", "street_address": "514 Bainbridge St, Philadelphia, PA 19147, USA", "city": "Philadelphia", "state": "PA", "zip": "19147", "lat": 39.9408481, "lng": -75.1516337, "neighborhood": "Queen Village", "neighborhood_slug": "queen-village" }, "hours": { "monday": "Closed", "tuesday": "Class hours vary", "wednesday": "Class hours vary", "thursday": "Class hours vary", "friday": "Class hours vary", "saturday": "Class hours vary", "sunday": "Closed", "notes": "Hours vary by class schedule. Check phillyartcenter.captyn.com for current session times.", "exceptions": null }, "admission": { "type": "paid", "child_price": null, "adult_price": null, "infant_age_cutoff": null, "notes": "Varies by program. Tuition assistance available. 10% household discount.", "currency": "USD" }, "membership": { "available": false, "price_monthly": null, "price_annual": null, "notes": null, "currency": "USD" }, "birthday_parties": { "available": false, "min_guests": null, "max_guests": null, "age_range": null, "booking_url": null, "notes": null }, "classes": { "available": true, "types": [ "art", "music", "playgroup", "after school art" ], "age_range": "0-12 years", "schedule_notes": "Fall Registration is LIVE!", "registration_url": "https://phillyartcenter.com/kids-classes/" }, "amenities": { "has_outdoor_space": false, "has_parking": false, "parking_notes": "Street parking on Bainbridge St and surrounding blocks.", "has_wifi": false, "socks_required": false, "outside_food_allowed": true, "stroller_policy": null }, "ages": { "min": 0, "max": 17, "text": "Playgroup: 0-30 months. Adult+child classes: 3-36 months. Kids classes: ages 2+. Tween classes available. Summer camps: 4-13. Vacation camps: 5-12.", "audience": null, "bands": [ "baby", "toddler", "young_kids", "bigger_kids" ] }, "audience": "teens", "categories": [ "arts_creative" ], "image_url": "https://a.mpcdn.io/pac/2023/04/pac-social-share.jpg?auto=compress%2Cformat&ixlib=php-3.3.1", "images": [ "https://mpcdn.imgix.net/pac/26/04/web-banner_fnl-copy-2.png?auto=compress%2Cformat&ixlib=php-3.3.1", "https://mpcdn.imgix.net/pac/26/07/fall-classes_final-1.png?auto=compress%2Cformat&ixlib=php-3.3.1", "https://a.mpcdn.io/pac/2023/03/testimonial-bg.jpg?auto=compress%2Cformat&ixlib=php-3.3.1" ], "website_url": "https://phillyartcenter.com/", "organization": { "id": null, "name": null, "slug": null }, "confidence": 0.7, "verified_at": null, "metro": "philadelphia", "status": "published", "created_at": "2026-02-18T19:16:17.504Z", "updated_at": "2026-09-21T18:07:17.391Z", "links": { "self": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e", "events": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/events", "classes": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/classes", "camps": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/camps" } } ], "meta": { "limit": 1, "count": 1, "has_more": false, "applied_filters": { "min_confidence": 0.5, "audience": [ "not_adults" ] }, "defaults_applied": [ "min_confidence", "audience" ] } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Retrieve a place > A place by id. Source: https://api.tailend.dev/docs/api/get-place ```http GET https://api.tailend.dev/v1/places/{id} X-API-Key: $TAILEND_API_KEY ``` A place by id. Published and active by default; archived rows answer `404` unless `include_archived=true`, and drafts never appear. The response carries `status`. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `include_archived` | boolean | no | Also serve a listing whose status is archived — a permalink that should still render, with a "this has passed" banner, rather than 404. | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e" ``` ## Responses ### 200 The place ```json { "data": { "id": "1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e", "type": "place", "slug": "philly-art-center-queen-village", "name": "Philly Art Center - Queen Village", "description": "Philly Art Center's Queen Village studio offers classes, camps, and after-school programs for kids from infancy through tweens, plus adult classes in drawing, painting, printmaking, fibers, and clay. Programs include Creative Playgroup + Story Time (drop-in for 0-30 months), adult+child music and art classes, drop-off kids art classes (ages 2+), tween classes, summer camps (ages 4-13), vacation camps (ages 5-12), and after-school art with free school pick-up. Process over product philosophy — all materials included.", "type_hint": "other", "type_hint_notes": "Art studio / creative learning center", "location": { "address": "514 Bainbridge St, Philadelphia, PA 19147, USA", "street_address": "514 Bainbridge St, Philadelphia, PA 19147, USA", "city": "Philadelphia", "state": "PA", "zip": "19147", "lat": 39.9408481, "lng": -75.1516337, "neighborhood": "Queen Village", "neighborhood_slug": "queen-village" }, "hours": { "monday": "Closed", "tuesday": "Class hours vary", "wednesday": "Class hours vary", "thursday": "Class hours vary", "friday": "Class hours vary", "saturday": "Class hours vary", "sunday": "Closed", "notes": "Hours vary by class schedule. Check phillyartcenter.captyn.com for current session times.", "exceptions": null }, "admission": { "type": "paid", "child_price": null, "adult_price": null, "infant_age_cutoff": null, "notes": "Varies by program. Tuition assistance available. 10% household discount.", "currency": "USD" }, "membership": { "available": false, "price_monthly": null, "price_annual": null, "notes": null, "currency": "USD" }, "birthday_parties": { "available": false, "min_guests": null, "max_guests": null, "age_range": null, "booking_url": null, "notes": null }, "classes": { "available": true, "types": [ "art", "music", "playgroup", "after school art" ], "age_range": "0-12 years", "schedule_notes": "Fall Registration is LIVE!", "registration_url": "https://phillyartcenter.com/kids-classes/" }, "amenities": { "has_outdoor_space": false, "has_parking": false, "parking_notes": "Street parking on Bainbridge St and surrounding blocks.", "has_wifi": false, "socks_required": false, "outside_food_allowed": true, "stroller_policy": null }, "ages": { "min": 0, "max": 17, "text": "Playgroup: 0-30 months. Adult+child classes: 3-36 months. Kids classes: ages 2+. Tween classes available. Summer camps: 4-13. Vacation camps: 5-12.", "audience": null, "bands": [ "baby", "toddler", "young_kids", "bigger_kids" ] }, "audience": "teens", "categories": [ "arts_creative" ], "image_url": "https://a.mpcdn.io/pac/2023/04/pac-social-share.jpg?auto=compress%2Cformat&ixlib=php-3.3.1", "images": [ "https://mpcdn.imgix.net/pac/26/04/web-banner_fnl-copy-2.png?auto=compress%2Cformat&ixlib=php-3.3.1", "https://mpcdn.imgix.net/pac/26/07/fall-classes_final-1.png?auto=compress%2Cformat&ixlib=php-3.3.1", "https://a.mpcdn.io/pac/2023/03/testimonial-bg.jpg?auto=compress%2Cformat&ixlib=php-3.3.1" ], "website_url": "https://phillyartcenter.com/", "organization": { "id": null, "name": null, "slug": null }, "confidence": 0.7, "verified_at": null, "metro": "philadelphia", "status": "published", "created_at": "2026-02-18T19:16:17.504Z", "updated_at": "2026-09-21T18:07:17.391Z", "links": { "self": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e", "events": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/events", "classes": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/classes", "camps": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/camps" } } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Retrieve a place by slug > Returns the same place as /v1/places/{id}, addressed by slug instead of id. Source: https://api.tailend.dev/docs/api/get-place-by-slug ```http GET https://api.tailend.dev/v1/places/slug/{slug} X-API-Key: $TAILEND_API_KEY ``` Returns the same place as `/v1/places/{id}`, addressed by slug instead of id. `slug` is unique per (metro, type). If the key can see this slug in more than one metro, returns `400` with `details.metros`; pass `metro=` to disambiguate. `include_archived=true` returns an archived row. Returns 503 when this endpoint is unavailable. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | Listing slug — the segment a consumer already has in its own URLs. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | no | Which metro's row is meant, when a slug is not unique across the key's metros. | | `include_archived` | boolean | no | Also serve a listing whose status is archived — a permalink that should still render, with a "this has passed" banner, rather than 404. | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/places/slug/philly-art-center-queen-village" ``` ## Responses ### 200 The place ```json { "data": { "id": "1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e", "type": "place", "slug": "philly-art-center-queen-village", "name": "Philly Art Center - Queen Village", "description": "Philly Art Center's Queen Village studio offers classes, camps, and after-school programs for kids from infancy through tweens, plus adult classes in drawing, painting, printmaking, fibers, and clay. Programs include Creative Playgroup + Story Time (drop-in for 0-30 months), adult+child music and art classes, drop-off kids art classes (ages 2+), tween classes, summer camps (ages 4-13), vacation camps (ages 5-12), and after-school art with free school pick-up. Process over product philosophy — all materials included.", "type_hint": "other", "type_hint_notes": "Art studio / creative learning center", "location": { "address": "514 Bainbridge St, Philadelphia, PA 19147, USA", "street_address": "514 Bainbridge St, Philadelphia, PA 19147, USA", "city": "Philadelphia", "state": "PA", "zip": "19147", "lat": 39.9408481, "lng": -75.1516337, "neighborhood": "Queen Village", "neighborhood_slug": "queen-village" }, "hours": { "monday": "Closed", "tuesday": "Class hours vary", "wednesday": "Class hours vary", "thursday": "Class hours vary", "friday": "Class hours vary", "saturday": "Class hours vary", "sunday": "Closed", "notes": "Hours vary by class schedule. Check phillyartcenter.captyn.com for current session times.", "exceptions": null }, "admission": { "type": "paid", "child_price": null, "adult_price": null, "infant_age_cutoff": null, "notes": "Varies by program. Tuition assistance available. 10% household discount.", "currency": "USD" }, "membership": { "available": false, "price_monthly": null, "price_annual": null, "notes": null, "currency": "USD" }, "birthday_parties": { "available": false, "min_guests": null, "max_guests": null, "age_range": null, "booking_url": null, "notes": null }, "classes": { "available": true, "types": [ "art", "music", "playgroup", "after school art" ], "age_range": "0-12 years", "schedule_notes": "Fall Registration is LIVE!", "registration_url": "https://phillyartcenter.com/kids-classes/" }, "amenities": { "has_outdoor_space": false, "has_parking": false, "parking_notes": "Street parking on Bainbridge St and surrounding blocks.", "has_wifi": false, "socks_required": false, "outside_food_allowed": true, "stroller_policy": null }, "ages": { "min": 0, "max": 17, "text": "Playgroup: 0-30 months. Adult+child classes: 3-36 months. Kids classes: ages 2+. Tween classes available. Summer camps: 4-13. Vacation camps: 5-12.", "audience": null, "bands": [ "baby", "toddler", "young_kids", "bigger_kids" ] }, "audience": "teens", "categories": [ "arts_creative" ], "image_url": "https://a.mpcdn.io/pac/2023/04/pac-social-share.jpg?auto=compress%2Cformat&ixlib=php-3.3.1", "images": [ "https://mpcdn.imgix.net/pac/26/04/web-banner_fnl-copy-2.png?auto=compress%2Cformat&ixlib=php-3.3.1", "https://mpcdn.imgix.net/pac/26/07/fall-classes_final-1.png?auto=compress%2Cformat&ixlib=php-3.3.1", "https://a.mpcdn.io/pac/2023/03/testimonial-bg.jpg?auto=compress%2Cformat&ixlib=php-3.3.1" ], "website_url": "https://phillyartcenter.com/", "organization": { "id": null, "name": null, "slug": null }, "confidence": 0.7, "verified_at": null, "metro": "philadelphia", "status": "published", "created_at": "2026-02-18T19:16:17.504Z", "updated_at": "2026-09-21T18:07:17.391Z", "links": { "self": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e", "events": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/events", "classes": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/classes", "camps": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/camps" } } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List organization places > Same as GET /v1/{metro}/places?organization_id={id}, addressed by the organization instead of the metro. Source: https://api.tailend.dev/docs/api/list-organization-places ```http GET https://api.tailend.dev/v1/organizations/{id}/places X-API-Key: $TAILEND_API_KEY ``` Same as `GET /v1/{metro}/places?organization_id={id}`, addressed by the organization instead of the metro. Resolves the organization by id, `404`s if it does not exist, and takes the metro from that row. An organization with live listings in more than one metro is a `400` asking for `metro=` to disambiguate, exactly like a slug that is not unique across the key's metros; give it and the ambiguity is resolved the same way. Every other list parameter keeps working. Same auth, metro scope, rate limiting and metering as the list endpoint; a key without access to the organization's metro gets the same `403` the list endpoint gives. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | no | Which metro's row is meant, when a slug is not unique across the key's metros. | | `min_confidence` | number | no | Drop rows whose confidence is below this. | | `category` | array of string | no | Keep rows whose categories contain any of these. | | `audience` | array of string | no | Keep rows whose audience is one of these. | | `age` | integer | no | A child's age in years. | | `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). | | `lat` | number | no | Center latitude for a radius search. | | `lng` | number | no | Center longitude for a radius search. | | `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). | | `zip` | array of string | no | Beta — may not be enabled for all keys yet. | | `near_zip` | string | no | Beta — may not be enabled for all keys yet. | | `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. | | `type_hint` | string | no | Filters on the place's type (see Place.type_hint). | | `place_type` | string | no | The deprecated spelling of type_hint. | | `has_birthday_parties` | boolean | no | | | `has_classes` | boolean | no | | | `sensory` | boolean | no | Filter on is_sensory_friendly (on events and classes, the linked place's; on places, the place's own). | | `changed_since` | string (date-time) | no | Sync mode. | | `q` | string | no | Full-text search over the listing's name and search text (websearch syntax: storytime park, "drop in", art -camp). | | `fields` | array of string | no | Keep only these dotted paths on each item (name,place.name,price.is_free); id and type are always kept so a trimmed item stays addressable. | | `expand` | array of string | no | Attach the full Place and/or the Organization behind each item under expanded, one extra read per expansion per page (not per row). | | `include` | array of string | no | Opt into a block the lean default response leaves out. | | `include_mode` | string | no | How an include= the key is not entitled to is handled. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `facets` | array of string | no | Counts over every listing that matches the filters, not just this page, returned as a top-level facets object beside data and meta. | | `limit` | integer | no | Items per page, 1 to 200. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/organizations/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/places" ``` ## Responses ### 200 A page of places. ```json { "data": [ { "id": "1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e", "type": "place", "slug": "philly-art-center-queen-village", "name": "Philly Art Center - Queen Village", "description": "Philly Art Center's Queen Village studio offers classes, camps, and after-school programs for kids from infancy through tweens, plus adult classes in drawing, painting, printmaking, fibers, and clay. Programs include Creative Playgroup + Story Time (drop-in for 0-30 months), adult+child music and art classes, drop-off kids art classes (ages 2+), tween classes, summer camps (ages 4-13), vacation camps (ages 5-12), and after-school art with free school pick-up. Process over product philosophy — all materials included.", "type_hint": "other", "type_hint_notes": "Art studio / creative learning center", "location": { "address": "514 Bainbridge St, Philadelphia, PA 19147, USA", "street_address": "514 Bainbridge St, Philadelphia, PA 19147, USA", "city": "Philadelphia", "state": "PA", "zip": "19147", "lat": 39.9408481, "lng": -75.1516337, "neighborhood": "Queen Village", "neighborhood_slug": "queen-village" }, "hours": { "monday": "Closed", "tuesday": "Class hours vary", "wednesday": "Class hours vary", "thursday": "Class hours vary", "friday": "Class hours vary", "saturday": "Class hours vary", "sunday": "Closed", "notes": "Hours vary by class schedule. Check phillyartcenter.captyn.com for current session times.", "exceptions": null }, "admission": { "type": "paid", "child_price": null, "adult_price": null, "infant_age_cutoff": null, "notes": "Varies by program. Tuition assistance available. 10% household discount.", "currency": "USD" }, "membership": { "available": false, "price_monthly": null, "price_annual": null, "notes": null, "currency": "USD" }, "birthday_parties": { "available": false, "min_guests": null, "max_guests": null, "age_range": null, "booking_url": null, "notes": null }, "classes": { "available": true, "types": [ "art", "music", "playgroup", "after school art" ], "age_range": "0-12 years", "schedule_notes": "Fall Registration is LIVE!", "registration_url": "https://phillyartcenter.com/kids-classes/" }, "amenities": { "has_outdoor_space": false, "has_parking": false, "parking_notes": "Street parking on Bainbridge St and surrounding blocks.", "has_wifi": false, "socks_required": false, "outside_food_allowed": true, "stroller_policy": null }, "ages": { "min": 0, "max": 17, "text": "Playgroup: 0-30 months. Adult+child classes: 3-36 months. Kids classes: ages 2+. Tween classes available. Summer camps: 4-13. Vacation camps: 5-12.", "audience": null, "bands": [ "baby", "toddler", "young_kids", "bigger_kids" ] }, "audience": "teens", "categories": [ "arts_creative" ], "image_url": "https://a.mpcdn.io/pac/2023/04/pac-social-share.jpg?auto=compress%2Cformat&ixlib=php-3.3.1", "images": [ "https://mpcdn.imgix.net/pac/26/04/web-banner_fnl-copy-2.png?auto=compress%2Cformat&ixlib=php-3.3.1", "https://mpcdn.imgix.net/pac/26/07/fall-classes_final-1.png?auto=compress%2Cformat&ixlib=php-3.3.1", "https://a.mpcdn.io/pac/2023/03/testimonial-bg.jpg?auto=compress%2Cformat&ixlib=php-3.3.1" ], "website_url": "https://phillyartcenter.com/", "organization": { "id": null, "name": null, "slug": null }, "confidence": 0.7, "verified_at": null, "metro": "philadelphia", "status": "published", "created_at": "2026-02-18T19:16:17.504Z", "updated_at": "2026-09-21T18:07:17.391Z", "links": { "self": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e", "events": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/events", "classes": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/classes", "camps": "/v1/places/1aed87ca-ab4f-4fc0-baaa-8c6ebeefa10e/camps" } } ], "meta": { "limit": 1, "count": 1, "has_more": false, "applied_filters": { "min_confidence": 0.5, "audience": [ "not_adults" ] }, "defaults_applied": [ "min_confidence", "audience" ] } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Retrieve an organization > The organization behind a listing's organization reference. Source: https://api.tailend.dev/docs/api/get-organization ```http GET https://api.tailend.dev/v1/organizations/{id} X-API-Key: $TAILEND_API_KEY ``` The organization behind a listing's `organization` reference. Scope is checked against the metros the organization has live listings in. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad" ``` ## Responses ### 200 The organization ```json { "data": { "id": "c6e39d31-abce-43f8-bccd-2cf5ce73a0ad", "name": "Philadelphia School of Circus Arts", "slug": "philadelphia-school-of-circus-arts", "organization_type": "recreation", "website_url": "https://phillycircus.com", "city": "Philadelphia", "state": "PA", "is_verified": false, "links": { "self": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad", "events": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad/events", "classes": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad/classes", "camps": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad/camps", "places": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad/places" }, "metro": "philadelphia", "live": { "listings": 19, "events": 4, "classes": 11, "camps": 4, "places": 0 }, "updated_at": "2026-09-26T13:45:37.633Z" } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List organizations > Returns every organization with at least one published, active listing in the metro, with live counts by type. Source: https://api.tailend.dev/docs/api/list-organizations ```http GET https://api.tailend.dev/v1/{metro}/organizations X-API-Key: $TAILEND_API_KEY ``` Returns every organization with at least one published, active listing in the metro, with live counts by type. Without `q`, ordered by `name` then `id`, keyset-paged like the collections; the cursor is bound to `q` and `type`. **Finding a business.** Send `q` to look one up by what a person would type: its name, its website address (`designgym.com`), or a pasted URL (`https://www.designgym.com/classes`). Casing, punctuation and spacing are ignored, as are endings such as `LLC`, `Inc`, `The`, `& Co` and `.com`/`.co`/`.org`/`.net`/`.io`/`.us`, and `design gym` finds `designgym`. A small typo (`desing gym`) still finds it. Both the organization's name and its website addresses are searched, whether or not `website_url` is filled in. With `q`, every result carries `score` and `match`, and results are ordered by `score` (highest first), then `name`, then `id`. `score` is 0 to 1, higher is closer, and is the same for one organization whatever else is returned, so it can be shown as a confidence. `match` says how it was found: `exact_name` or `exact_domain` (the whole name or address was typed), `name` or `domain` (the same words once spelling, spacing and endings are set aside, or the start or a word of the name), or `fuzzy` (a small typo away). A `domain` match on a different ending (`acme.org` for `acme.com`) is a suggestion to confirm. Pages on shared platforms (social pages, booking widgets, website builders) are never `exact_domain`. A `q` made only of words like `the` or `and`, or of endings like `llc` or `.com`, returns an empty list with `meta.hint`. `page`, `limit` and `cursor` work as usual; a cursor follows the same ranked order. `website_url` is the organization's own website. When none is recorded it is the address of the site the organization itself runs, if we have one, and stays `null` when there is nothing trustworthy to show. Returns 501 when this endpoint is unavailable. Organization detail with its metros list is at `/v1/organizations/{id}`; listings themselves are the collections filtered with `organization_id=`. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `q` | string | no | Find a business by what a person would type: its name, its website address or a pasted URL. | | `type` | string | no | Exact match on the organization's type. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `limit` | integer | no | | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/organizations" ``` ## Responses ### 200 OK ```json { "data": [ { "id": "c6e39d31-abce-43f8-bccd-2cf5ce73a0ad", "name": "Philadelphia School of Circus Arts", "slug": "philadelphia-school-of-circus-arts", "organization_type": "recreation", "website_url": "https://phillycircus.com", "city": "Philadelphia", "state": "PA", "is_verified": false, "links": { "self": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad", "events": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad/events", "classes": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad/classes", "camps": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad/camps", "places": "/v1/organizations/c6e39d31-abce-43f8-bccd-2cf5ce73a0ad/places" }, "metro": "philadelphia", "live": { "listings": 19, "events": 4, "classes": 11, "camps": 4, "places": 0 }, "updated_at": "2026-09-26T13:45:37.633Z" } ], "meta": { "metro": "example", "limit": 1, "count": 1, "has_more": false } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List neighborhoods > Returns the metro's neighborhoods with live listing counts by type, including zero-count rows. Source: https://api.tailend.dev/docs/api/list-neighborhoods ```http GET https://api.tailend.dev/v1/{metro}/neighborhoods X-API-Key: $TAILEND_API_KEY ``` Returns the metro's neighborhoods with live listing counts by type, including zero-count rows. `slug` is the value `neighborhood=` takes on the collection endpoints. Ordered by `name` then `id`; the default page size is `limit=200`. Returns 501 when this endpoint is unavailable. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `q` | string | no | Find a business by what a person would type: its name, its website address or a pasted URL. | | `cursor` | string | no | Opaque cursor from meta.next_cursor. | | `page` | integer | no | 1-based page number (browse mode). | | `limit` | integer | no | | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/neighborhoods" ``` ## Responses ### 200 OK ```json { "data": [ { "id": "9a9a9a9a-0000-4000-8000-000000000001", "name": "University City", "slug": "university-city", "aliases": [ "UCity" ], "lat": 39.95, "lng": -75.2, "metro": "philadelphia", "live": { "listings": 40, "events": 30, "classes": 5, "camps": 2, "places": 3 } } ], "meta": { "metro": "example", "limit": 1, "count": 1, "has_more": false } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Search around an address > Takes an address (or lat/lng, resolved the same way) and returns everything nearby, grouped the way a person reads a neighborhood rather than as one flat relevance list. Source: https://api.tailend.dev/docs/api/search-around-address ```http GET https://api.tailend.dev/v1/{metro}/around X-API-Key: $TAILEND_API_KEY ``` Takes an **address** (or `lat`/`lng`, resolved the same way) and returns everything nearby, grouped the way a person reads a neighborhood rather than as one flat relevance list. `/nearby` is the point-only sibling: a plain distance-ordered list, no address resolution, no grouping — use it when the caller already has coordinates. One request returns: * `location` — the resolved address, so you can see which reading of an ambiguous address was used; * `activity` — a header: score, percentile, one sentence, components; * `results` — five ranked groups, each item carrying `distance_m` and `walk_minutes`; * `facets` — counts by type, age band, free/paid and distance ring over the whole circle; * `coverage` — how many nearby rows are excluded for lacking coordinates. Check `coverage` before treating an empty group as a signal: an empty group with `coverage.hidden_nearby > 0` means those rows are not visible to this endpoint, not that the area has nothing happening. The groups: `happening_soon` (dated events in the window), `recurring_programs` (weekly/monthly series and drop-in classes), `classes_enrolling` (term classes), `camps`, and `places` (places with no date of their own). `happening_soon` and `recurring_programs` overlap by design; each item's `also_in` names the other groups it appears in. Results are ordered for relevance to the requested point, window and filters. Diagnostic ranking details are available only to internal keys. `limit` is **per group**. Each group returns its own `next_cursor` (`":"`); pass it as `cursor` to continue that one group. The window is `from`/`to`, local calendar dates in the metro's timezone: `from` defaults to today, `to` to `from` + 30 days, `to` is inclusive of that whole local day, and the span is at most 366 days. Unlike the collection endpoints, this route never returns an instant before now — a `from` of today (or earlier) starts at the present, not local midnight. `meta.window` echoes the resolved dates. `min_confidence` uses the default shown in this operation. Collection endpoints may use different documented defaults. Returns 503 when this endpoint is unavailable. **Beta — may not be enabled for all keys yet.** If location resolution is unavailable, returns `503` (`"geocoding unavailable"`) rather than guessing. `data.location.source` says how the location was resolved (see `ResolvedLocation`). Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `lat` | number | no | Latitude of the point to score. | | `lng` | number | no | Longitude of the point to score. | | `address` | string | no | A street address to resolve server-side instead of sending a point. | | `radius_m` | integer | no | Radius in metres. | | `from` | string (date) | no | Local calendar date (metro timezone), inclusive. | | `to` | string (date) | no | Local calendar date (metro timezone), inclusive; at most 366 days after from. | | `ages` | array of string | no | Age bands to favour. | | `free` | boolean | no | Events, classes, and camps only; ignored for places. | | `types` | array of string | no | Restrict to these listing types. | | `min_confidence` | number | no | Minimum row confidence. | | `limit` | integer | no | Maximum items per group, not per response. | | `cursor` | string | no | A group's next_cursor from the previous response: ":". | | `include` | array of string | no | Opt into a block the lean default response leaves out. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/around" ``` ## Responses ### 200 The resolved location, the activity header, grouped results, facets and coverage. ```json { "data": { "location": { "lat": 1, "lng": 1, "source": "cache" }, "results": { "happening_soon": { "total": 1, "returned": 1, "items": [ { "listing_id": "00000000-0000-4000-8000-000000000001", "type": "event", "name": "example", "distance_m": 1, "walk_minutes": 1, "score": 1 } ] }, "recurring_programs": { "total": 1, "returned": 1, "items": [ { "listing_id": "00000000-0000-4000-8000-000000000001", "type": "event", "name": "example", "distance_m": 1, "walk_minutes": 1, "score": 1 } ] }, "classes_enrolling": { "total": 1, "returned": 1, "items": [ { "listing_id": "00000000-0000-4000-8000-000000000001", "type": "event", "name": "example", "distance_m": 1, "walk_minutes": 1, "score": 1 } ] }, "camps": { "total": 1, "returned": 1, "items": [ { "listing_id": "00000000-0000-4000-8000-000000000001", "type": "event", "name": "example", "distance_m": 1, "walk_minutes": 1, "score": 1 } ] }, "places": { "total": 1, "returned": 1, "items": [ { "listing_id": "00000000-0000-4000-8000-000000000001", "type": "event", "name": "example", "distance_m": 1, "walk_minutes": 1, "score": 1 } ] } }, "facets": { "by_type": {}, "by_age_band": {}, "by_price": { "free": 1, "paid": 1 }, "free_share": 1, "by_distance_ring": [ { "label": "within_400m", "max_m": 1, "count": 1 } ] }, "coverage": { "hidden_nearby": 1, "unplaceable_in_metro": 1, "reason": "example" } }, "meta": { "metro": "example", "radius_m": 1, "window": { "from": "2026-09-24", "to": "2026-09-24", "timezone": "example" }, "limit": 1 } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Retrieve area activity > Returns a single comparable activity score for a point (unlike /around, which resolves an address and returns grouped, ranked results — this endpoint's job is the one number, not a result set), the components behind it, and the specific listings nearby. Source: https://api.tailend.dev/docs/api/get-activity ```http GET https://api.tailend.dev/v1/{metro}/activity X-API-Key: $TAILEND_API_KEY ``` Returns a single comparable activity score for a **point** (unlike `/around`, which resolves an **address** and returns grouped, ranked results — this endpoint's job is the one number, not a result set), the components behind it, and the specific listings nearby. Give **either** `lat` + `lng` **or** `address`. An `address` is resolved to a point server-side and biased to the metro. A request that exceeds the key's geocoding allowance returns `429` naming the reset. `data.resolved_address` reports the address that was used and how it was resolved (see `ResolvedLocation`); it is `null` for the `lat`/`lng` form. Counted rows are published, active, and coordinate-bearing, excluding a source marked `gone`, anything quarantined, and coordinates that are an approximate resolution rather than a known place. Classes and camps whose term has already ended are not counted: `activity_score` reflects what is ahead, not what has happened. `activity_score` is 0-100 and absolute, so it is comparable between metros and over time. `percentile` ranks the same point against the metro's reference distribution; it is `null` until comparison data is available. Diagnostic details are available only to internal keys. Returns 503 when this endpoint, or address resolution for the `address` form, is unavailable. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `lat` | number | no | Latitude of the point to score. | | `lng` | number | no | Longitude of the point to score. | | `address` | string | no | A street address to resolve server-side instead of sending a point. | | `radius_m` | integer | no | Radius in metres. | | `days` | integer | no | How far ahead the event window reaches, in days. | | `include` | array of string | no | Opt into a block the lean default response leaves out. | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/activity" ``` ## Responses ### 200 The activity of the point. ```json { "data": { "point": { "lat": 1, "lng": 1 }, "radius_m": 1, "days": 1, "components": { "events_30d": 1, "events_per_week": 1, "recurring_programs": 1, "classes": 1, "camps": 1, "places_total": 1, "places_by_type": {} }, "activity_score": 1, "summary": "example", "evidence": [ { "listing_id": "00000000-0000-4000-8000-000000000001", "type": "event", "name": "example", "distance_m": 1 } ] }, "meta": { "metro": "example", "radius_m": 1, "days": 1, "generated_at": "2026-09-24T12:00:00.000Z" } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List nearby listings > Takes a point (lat/lng) and returns a flat, distance-ordered list — no address resolution and no grouping, unlike /around, which takes an address and returns grouped, ranked results. Source: https://api.tailend.dev/docs/api/list-nearby ```http GET https://api.tailend.dev/v1/{metro}/nearby X-API-Key: $TAILEND_API_KEY ``` Takes a **point** (`lat`/`lng`) and returns a flat, distance-ordered list — no address resolution and no grouping, unlike `/around`, which takes an **address** and returns grouped, ranked results. Use this when the caller already has coordinates; use `/around` when the caller has an address. Returns published, active rows with coordinates within `radius_m` of `lat`/`lng`, nearest first, with the distance in metres on every row. Only rows with something still ahead of them: places always, events that have not started, and classes and camps whose term has not ended. Paged by `cursor` only (there is no `page`); `meta.next_cursor` is `":"`, the key of the last row returned, so the page cannot drift as rows are written. Results are confined to `{metro}`; a point outside it returns an empty page rather than the neighboring metro's rows. `lat`/`lng` choose where in the metro to look, never which metro to look in. Use `types` to narrow; each type requested must be in the key's `scopes.types`. Returns 503 when this endpoint is unavailable. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `lat` | number | yes | Latitude of the point to search around. | | `lng` | number | yes | Longitude of the point to search around. | | `radius_m` | integer | no | Radius in metres. | | `types` | array of string | no | Restrict to these listing types. | | `cursor` | string | no | meta.next_cursor from the previous page: ":". | | `limit` | integer | no | | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/nearby" ``` ## Responses ### 200 A page of nearby items, nearest first. ```json { "data": [ { "listing_id": "00000000-0000-4000-8000-000000000001", "type": "event", "name": "example", "distance_m": 1 } ], "meta": { "limit": 1, "count": 1, "has_more": false } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Retrieve metro coverage > Returns coverage as a buyer would judge it for a metro: live counts by type, how far ahead events run, how much of what is currently served is confirmed current, and how many rows carry an organization, neighborhood, place or coordinates. Source: https://api.tailend.dev/docs/api/get-metro-coverage ```http GET https://api.tailend.dev/v1/{metro}/coverage X-API-Key: $TAILEND_API_KEY ``` Returns coverage as a buyer would judge it for a metro: live counts by type, how far ahead events run, how much of what is currently served is confirmed current, and how many rows carry an organization, neighborhood, place or coordinates. Numbers are computed from the same rows the collection endpoints serve. Responses may be cached according to `Cache-Control`; every key entitled to the metro sees the same report. Returns 501 when this endpoint is unavailable. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/philadelphia/coverage" ``` ## Responses ### 200 The report ```json { "data": { "metro": "philadelphia", "timezone": "America/New_York", "counts": { "event": 1583, "class": 648, "camp": 88, "place": 983, "ongoing": 16 }, "events": { "future": 950, "next_7_days": 165, "next_30_days": 500, "future_recurring": 460, "horizon": "2027-06-01T00:00:00Z" }, "freshness": { "confirmed_last_7d_pct": 62.3, "confirmed_last_30d_pct": 84.1, "upcoming_48h_confirmed_12h_pct": 58, "median_source_age_days": 8.8 }, "fields": { "total": 2335, "with_organization": 2167, "with_neighborhood": 1380, "with_place": 1823, "with_coordinates": 1340 }, "organizations": 357, "sources": { "total": 357 }, "generated_at": "2026-09-13T12:00:00Z" } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List listing versions > Returns a privacy-safe summary of every change one listing has had, newest first. Source: https://api.tailend.dev/docs/api/list-listing-versions ```http GET https://api.tailend.dev/v1/listings/{id}/versions X-API-Key: $TAILEND_API_KEY ``` Returns a privacy-safe summary of every change one listing has had, newest first. Each item contains only its UTC time, a closed change kind, and the names of changed public fields. Values, internal actor names, notes, and pipeline attributes are never returned. History is still available after a listing is hard-deleted. Pages with `cursor` set to the last `version_id` seen (`id < cursor`). A key not scoped to the listing's metro or type returns `404`, never `403`. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Listing id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `cursor` | string | no | The last version_id seen (meta.next_cursor); the page continues with older versions (id < cursor). | | `limit` | integer | no | | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/listings/{id}/versions" ``` ## Responses ### 200 A page of versions ```json { "data": [ { "changed_at": "2026-09-10T06:12:05Z", "kind": "date_changed", "fields": [ "starts_at" ] } ], "meta": { "listing_id": "00000000-0000-4000-8000-000000000001", "limit": 1, "count": 1, "has_more": false } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 404 No published, active resource with that id (or unknown metro). ```json { "error": { "code": "not_found", "message": "unknown metro 'boston'", "request_id": "req_7431b51e10ec2b77" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # List changes > Returns every public-field change to a listing since since, oldest first. Source: https://api.tailend.dev/docs/api/list-changes ```http GET https://api.tailend.dev/v1/changes X-API-Key: $TAILEND_API_KEY ``` Returns every public-field change to a listing since `since`, oldest first. To sync incrementally, keep the last `version_id` you saw and pass it as `cursor`. Removals (`status` leaving `published`, `is_active` becoming `false`, or a hard delete) appear here even though the resource itself returns `404`. Each item exposes only the change time, a closed change kind, and allowlisted public field names. Values, actors, pipeline attributes, and rows that touch only internal fields are never returned. Read freshness from `source.crawled_at` (`include=source`) on the resource. Authorization: send your key in the `X-API-Key` header. Available on every plan. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `since` | string (date-time) | yes | Return changes with changed_at strictly after this instant. | | `metro` | string | no | Restrict to one metro slug (must be in the key's scope). | | `type` | string | no | Restrict to one listing type. | | `cursor` | string | no | This is not an opaque cursor: pass back the decimal string returned by meta.next_cursor, and the feed resumes strictly after it. | | `limit` | integer | no | | ## Example request ```bash curl -sS \ -H "X-API-Key: $TAILEND_API_KEY" \ "https://api.tailend.dev/v1/changes?since=2026-10-01T00%3A00%3A00Z" ``` ## Responses ### 200 A page of changes ```json { "data": [ { "version_id": 18231, "listing_id": "2f3c9d4e-3d2b-4b7d-9a3e-1b2c3d4e5f60", "slug": "storytime-in-the-park-2026-09-12", "type": "event", "metro": "philadelphia", "changed_at": "2026-09-10T06:12:05Z", "kind": "date_changed", "fields": [ "starts_at" ] } ], "meta": { "limit": 1, "count": 1, "has_more": false } } ``` ### 400 Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered. ```json { "error": { "code": "bad_request", "message": "radius_m: must be at least 100", "details": { "param": "radius_m" }, "request_id": "req_dfbc3295a8382b05" } } ``` ### 401 Missing, unknown or revoked X-API-Key. ```json { "error": { "code": "unauthorized", "message": "missing X-API-Key header", "request_id": "req_90e63908273f6bff" } } ``` ### 403 Two different reasons share this status. ```json { "error": { "code": "forbidden", "message": "key is not scoped to metro 'boston'", "details": { "metros": [ "philadelphia" ] }, "request_id": "req_9e7a750acef48ab2" } } ``` ### 405 The path exists; the method does not. ```json { "error": { "code": "method_not_allowed", "message": "POST is not allowed on this path; this API is read-only", "details": { "allow": [ "GET", "HEAD", "OPTIONS" ] }, "request_id": "req_01J9Z3" } } ``` ### 429 A limit was reached. ```json { "error": { "code": "rate_limited", "message": "3 requests per minute exceeded", "request_id": "req_06bfd3c2d5e8aa9f" } } ``` ### 500 A bug on our side. ```json { "error": { "code": "internal", "message": "internal error", "request_id": "req_01J9Z3" } } ``` ### 501 This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured. ```json { "error": { "code": "not_implemented", "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help", "request_id": "req_01J9Z3" } } ``` ### 503 A transient outage: a dependency could not be reached or did not answer in time. ```json { "error": { "code": "unavailable", "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After", "request_id": "req_01J9Z3" } } ``` --- # Changelog > Customer-facing changes to the Tailend API, newest first. Source: https://api.tailend.dev/docs/changelog What changed in the Tailend API, for people calling it. Additive changes (new optional parameters, new fields) can ship at any time; see [Audience & versioning](https://api.tailend.dev/docs/audience-and-versioning.md) for what v1 promises not to break. ## 2026-10-13 ### Changed: Try any request from the API reference Every endpoint in the API reference now has a Try it panel. Without a key it shows a sample response; paste a key to send the request live and see the status, headers and body. The standalone playground is retired, and its links forward to the reference. ### Added: A status page `https://api.tailend.dev/status` is now a page: overall status, API and docs status, data freshness per city, 90-day API uptime and past incidents. Programs keep getting JSON from the same URL (any `Accept` other than `text/html`), and `/status.json` is the JSON explicitly. Both refresh once a minute. - JSON: `meta` gains `status`, `components`, `uptime` and `incidents`. - Changed: `meta.unavailable[]` entries are now `{ what, reason }` in plain words, and `last_successful_ingest.source` describes where the time comes from instead of naming internal objects. ## 2026-10-12 ### Added: Teams on events Events carry `teams`: the local pro sports teams an event is built around (watch parties, game-day themed events, team or mascot appearances). It is always an array, empty when none. Values today: `eagles`, `phillies`, `sixers`, `flyers`, `union`. ```bash curl -G -H "X-API-Key: $TAILEND_API_KEY" --data "team=eagles,phillies" \ https://api.tailend.dev/v1/philadelphia/events ``` - `team=` works on `/v1/{metro}/events`, the events routes under places and organizations, `/events/calendar` and `/search`. - One value or a comma-separated list, matching any. - An unknown value is a `400` that lists the allowed ones. - On `/search`, `team=` limits the results to events. - Available on every plan. ## 2026-10-10 ### Changed: `q` also finds organization and place names `q` on `/v1/{metro}/events`, `/classes`, `/camps`, `/places` and `/search` now also matches the name of the organization behind a listing and the place it happens at, tolerating spacing, punctuation and small typos. Searching for an organization's name now finds its listings even when their own names do not mention it. ## 2026-10-08 ### Added: Related listings with `include=related` `GET /v1/events/{id}`, `/v1/classes/{id}` and `/v1/camps/{id}` (and their `slug` forms) accept `include=related`, which adds `related: {similar, from_organization, at_place}`: up to six compact cards each, so a listing page gets its "more like this" rows in one request. - Related cards are listings the list endpoints would also return to your key. - An add-on entitled like `include=source` (partner plans, or granted on a standard key). - `400` on lists, search and places. - If a list cannot be produced it comes back empty with a `related_unavailable` notice. See [Shaping responses](https://api.tailend.dev/docs/shaping-responses.md#related-listings-includerelated). ## 2026-10-07 ### Changed: Adult-only listings report `adults` in `ages.audience` A listing flagged as adult-only content now reports `adults` in `ages.audience` as well as `audience`, whatever its source page's wording. Lists already left these out by default. ### Fixed: `ages.min` is `null` when ages are not stated `ages.min` is `null` when a listing does not state its ages, instead of `0`. A stated `0` (for example 0 to 5) is unchanged, and `age=` still keeps listings that do not state their ages. ## 2026-10-06 ### Added: Event calendar counts `GET /v1/{metro}/events/calendar?from=…&to=…` returns, for every date in a window of up to 62 days, how many occurrences start that day, how many are free, and a count per category. The counts match the expanded event list exactly. ```bash curl -G -H "X-API-Key: $TAILEND_API_KEY" \ --data "from=2026-11-01" --data "to=2026-11-30" \ https://api.tailend.dev/v1/philadelphia/events/calendar ``` With `include=forecast` on eligible paid plans, today and the next seven days also carry a day forecast. ### Added: One item per occurrence with `occurrences=expand` `GET /v1/{metro}/events?occurrences=expand&from=…&to=…` lists one item per occurrence, for calendar views: a recurring event appears on every day it happens in the window (up to 31 days), each item carrying an `occurrence` block with its own start, end and position in the series. Local times hold across daylight-saving changes. Without `occurrences=expand`, `/events` is unchanged. ### Added: Fetch listings by id with `ids=` `/v1/{metro}/events`, `/classes`, `/camps` and `/places` accept `ids=` (up to 100 listing ids, comma-separated) to fetch specific listings in one call. Results come back in the order given, with no date window and no paging. Ids that are unknown, of another type, or no longer active are listed in `meta.missing_ids`. ### Added: Classes starting in a date range `/v1/{metro}/classes` accepts `starts_from` and `starts_to` (local dates, inclusive, either alone) to keep classes whose term starts in the range, for "classes starting this week". For programs with `sections`, any section starting in the range matches, and `sections` are returned whole. ### Added: Indoor, outdoor and sensory-friendly facts Events and classes carry `is_indoor`, `is_outdoor`, `good_for_rainy` and `is_sensory_friendly`, facts about the place they happen at; places carry `is_sensory_friendly`. `null` means the place does not say. - Filter with `indoor=`, `outdoor=` and `sensory=`. - A listing whose value is unknown matches neither `true` nor `false`. ## 2026-10-05 ### Added: Classes with sections Classes can list their variants as `sections` (days and times, age groups, terms, each with its own tuition and availability). - Class filters for weekday, age, time, dates and price match a single section at once. - `openings=true` keeps programs with open seats. - Programs published as several listings appear as one card, with the others under `sessions`; `expand=sessions` returns every class on its own. - Section changes show up as updates in `/v1/changes` and the per-listing history. See [Classes with sections](https://api.tailend.dev/docs/class-sections.md). ### Added: Event forecasts with `include=forecast` Events can opt into `include=forecast` on eligible paid plans. Timed US events in the next seven days receive either event-hour detail or a local-day summary. Ineligible events omit the field; temporary availability problems are reported through `notices[]`. See [Shaping responses](https://api.tailend.dev/docs/shaping-responses.md#forecasts-includeforecast). ## 2026-09-30 ### Added: Facets Add `facets=` to an events, classes, camps or places list to get counts by `category`, `weekday`, `time_block`, `price_band`, `age_band` and `organization` over every listing that matches the filters, not just the page. `limit=0` returns the counts alone. Available on every plan and counted as one request. ```bash curl -G -H "X-API-Key: $TAILEND_API_KEY" \ --data "facets=category,weekday" --data "limit=0" \ https://api.tailend.dev/v1/philadelphia/events ``` See [Market overview with facets](https://api.tailend.dev/docs/market-overview-with-facets.md). ### Added: Responses say which filters they used Every list response (events, classes, camps, places, search, nearby and around) carries `meta.applied_filters`, the value in effect for each filter, and `meta.defaults_applied`, the names that came from a default rather than your request. A smaller-than-expected result can now be explained from the response itself. - Events still cover the next 30 days by default. - `min_confidence` still defaults to `0.7` (`0.5` on places). - `adults` audiences are still left out, echoed as `audience: ["not_adults"]`, a value you can send back as `audience=not_adults`. ### Changed: Search understands word forms, related words and ages On `GET /v1/{metro}/search`: - `swim` finds `swimming`, and closely related family-activity words count: `pottery` finds `ceramics`, `baking` finds `cooking`. - Question and filler words (`where can I`, `with`, `for`) no longer pull in unrelated listings or change a score. - `5 year olds`, `ages 3-5`, `toddlers`, `baby` and `teens` narrow by age; the band applied is echoed in `meta.interpreted`. An `age` or `audience` you send yourself wins. - Time and price words (`tonight`, `saturday`, `cheap`) are not filters yet; they are listed in `meta.ignored_words`. See [Search](https://api.tailend.dev/docs/search.md). ### Added: Match levels on search results Each search result carries `match`: `all_terms`, `related` or `partial`. The `match` parameter selects which levels are returned (loose `partial` matches are now opt-in), and `meta.match_counts` counts every level whatever you selected. Search cursors from before this change are no longer accepted; restart the search without the cursor. ### Changed: Search `score` is absolute `score` on search results was scaled so the best result in each search read 1. It is now a 0 to 1 measure of how well one listing fits the query's words, the same whatever else is returned, and comparable between queries. ### Changed: Finding an organization works the way people type On `GET /v1/{metro}/organizations`, `q` finds a business by its name, its website address or a pasted URL, ignoring casing, punctuation, spacing and endings such as `LLC`, `Inc`, `& Co` and `.com`. `design gym`, `Design Gym` and `designgym.com` all find `designgym.co`, and a small typo still finds it. - Results carry `score` (0 to 1) and `match` (`exact_name`, `exact_domain`, `name`, `domain` or `fuzzy`), best match first. - A query of only `the`, `and`, `llc`, `.com` and the like returns an empty list with a `meta.hint`. - Organization-search cursors from before this change are no longer accepted. See [Finding a business](https://api.tailend.dev/docs/finding-a-business.md). ### Changed: `website_url` is filled in more often When an organization has no website recorded, `website_url` now shows the address of the site the organization itself runs, if we have one. ### Changed: Clearer meaning for class fields a provider may not state The descriptions for `program.adult_participation`, `term.total_classes`, `tuition.drop_in_price`, `tuition.trial_price`, `tuition.trial_notes`, `registration.spots_remaining` and `registration.has_spots_available` now say what `null` means: not stated by the source, never "no". The response shape is unchanged. ## 2026-09-28 ### Added: Unified listing search `GET /v1/{metro}/search` searches events, classes, camps and places with the same visibility and filter rules as the individual list endpoints. ```bash curl -G -H "X-API-Key: $TAILEND_API_KEY" --data-urlencode "q=story time" \ https://api.tailend.dev/v1/philadelphia/search ``` Very broad queries may set `meta.capped: true`; add filters or a more specific `q` to narrow them. See [Search](https://api.tailend.dev/docs/search.md). ### Added: Recurring events listed by their next occurrence A recurring event's `occurrence.next_starts_at` and `next_starts_local` give the next live date, and `from`/`to` windows on `GET /v1/{metro}/events` include a series by that next occurrence rather than only its original start date. A series with no more occurrences stops appearing in lists; its permalink still resolves by `id` or `slug`. See [Recurring events](https://api.tailend.dev/docs/recurring-events.md). ### Added: Plan-level access for premium features A key's effective entitlement is its plan's defaults plus whatever is granted on the key itself. A premium endpoint without the entitlement answers `403 feature_not_in_plan`, distinct from `include_not_permitted`. `include=source` follows the same rule. Both `403 include_not_permitted` and its `include_mode=lenient` notice now carry an upgrade link. See [Plans & access](https://api.tailend.dev/docs/plans-and-access.md). ### Changed: Places default to `min_confidence=0.5` Events, classes and camps keep the `0.7` default. Pass `min_confidence=0.7` on a places call for the previous behaviour. ## 2026-09-27 ### Fixed: Events with no date return `null`, not an empty string `starts_at` and `starts_local` are `null` when an event has no date, and a new `has_date` boolean tells you which case you're in without parsing either field. If your code assumed `starts_at` was always a non-empty string, treat `null` the way you'd already have treated `""`. ### Fixed: Listing version history shows public change facts only Each item from `GET /v1/listings/{id}/versions` contains `changed_at`, a closed `kind`, and public field names only. Raw changed values are no longer returned. ### Fixed: `source.verification.status` lists its real values With `include=source`, the documented values are `clear`, `changed`, `flagged`, `unverifiable`, `unreachable` and `gone`. Two values that never occur were removed. ## Earlier ### Changed: Vocabulary cleanup before launch `organizer` is `organization` everywhere, a "venue" is a `place`, `include=provenance` is `include=source`, distances are metres only, and `GET /v1/{metro}/vitality` is `GET /v1/{metro}/activity`. ### Added: Audience on every listing Every event, class, camp and place carries `audience` (`kids`, `families`, `teens`, `adults`, `all_ages`). Adult-only content is excluded by default on list endpoints; pass `audience=all` to include it. ### Added: Relationship routes List a place's or organization's events, classes and camps directly (`/v1/places/{id}/events`, and similarly for organizations), with every other list parameter still available. ### Added: Seasons, links and images - `traits.seasons` on events, classes and camps, with a matching `season=` filter. - `links` on every resource: relative paths to the resource and its related collections. - `images` on places: more photos beyond `image_url`. ### Changed: Lean default responses Every resource carries a flat `confidence` and `verified_at`. Where the data came from, and detailed verification status, moved behind `include=source`; add `include_mode=lenient` to get a `200` instead of a `403` when your key isn't entitled. ### Changed: New API key format Keys are issued as `tl__`. Existing keys keep working.