Overview

A read-only JSON API over a curated database of events in Amsterdam. Thirteen ingest adapters are read on a schedule, their records deduplicated against one another and sorted into twelve categories, and the result is served as one paginated feed.

The base URL is https://events.jetskibay.com and every route lives under /v1. Every response is JSON, including every error. Nothing here writes to the catalogue: the only two non-GET routes mint and revoke your own key.

RouteDoesCosts
GET /v1Service index: version, catalogue counts, trial terms, endpoint listfree
GET /v1/sourcesThe source / adapter catalogue, with last-run healthfree
GET /v1/categoriesThe twelve categoriesfree
GET /v1/eventsThe feed, filtered and cursor-paginated1 request
GET /v1/events/{id}One event by id1 request
POST /v1/keysMint a key for an email addressfree
GET /v1/keys/currentAllowance and expiry of the key on the requestfree
DELETE /v1/keys/currentRevoke that key immediatelyfree

Only the two /v1/events routes are metered. Discovery, key management and the service index are free, so a client can find out what it is allowed to ask for before it spends anything asking.

Authentication

Send the key on every metered request, either way — pick one, they are equivalent:

headers
x-api-key: evk_9f2c41a8b03d5e6712c4f890
# or
Authorization: Bearer evk_9f2c41a8b03d5e6712c4f890

A request with no credential, or one we cannot use, is a 401. Those responses carry a challenge header, so a generic HTTP client can recognise the failure without reading the body:

401 unauthorized
www-authenticate: Bearer realm="Jetski Bay Events API"

{ "error": "No API key on the request.", "code": "missing_api_key" }

Every error in this API has the same two-key shape: error is a sentence you can put in front of a person, code is the stable string you branch on. Branch on code; the wording of error is allowed to improve.

Never put the key in a URL. It belongs in a header. A key in a query string ends up in browser history, proxy logs, and the Referer of every link a page like this one contains.

Keys & the trial

A trial key is worth 10 requests and expires 24 hours after it is minted. There is no card, no account, and no approval step — one POST and you are holding a key.

POST/v1/keys free

Takes a JSON body with one field, email. Returns the key.

request
curl -s -X POST https://events.jetskibay.com/v1/keys \
  -H "content-type: application/json" \
  -d '{"email":"you@company.com"}'
201 created
{
  "status": "created",
  "message": "Save this key now. It is shown once and only its hash is stored.",
  "apiKey": "evk_9f2c41a8b03d5e6712c4f890a17b3d55",
  "key": {
    "keyPrefix": "evk_9f2c41a…",
    "tier": "trial",
    "requestLimit": 10,
    "requestCount": 0,
    "requestsRemaining": 10,
    "createdAt": "2026-09-12T09:12:44.000Z",
    "expiresAt": "2026-09-13T09:12:44.000Z",
    "lastUsedAt": null
  }
}

apiKey is the secret. key is the metadata about it, and is the same object GET /v1/keys/current returns — only apiKey is ever unrecoverable.

That response is the only copy of the key. This host has no mail binding, so there is no email to fall back on: the plaintext key crosses the wire once, in that one response body, and is never retrievable afterwards. We store a hash of it, which is enough to check a key and to count it down, and not enough to show it to you again.

So: read it out of that response and put it somewhere you keep secrets. Treat the response body itself the way you would treat a password — do not log it, do not print it in CI output, do not leave it in a shell history. Losing it is not a disaster, because minting again is free and carries your remaining allowance over; but there is no "resend".

This is an acceptable trade for a read-only API over cached public data. It would not be an acceptable trade for an API that spends money, and we would not make it there.

Minting again for the same address

Ask a second time with the same email while the first key is still alive and three things happen, in this order:

The point of that design: re-requesting is a recovery mechanism, not a way to farm quota. Losing a key costs nothing, asking twice buys nothing.

Mint failures

Service index

GET/v1 free

The self-describing root. It carries the API version, what is in the catalogue, the terms of a trial key, and the list of endpoints — so a client can discover the current shape of the service instead of hard-coding this page.

200 ok
{
  "service": "Jetski Bay Events API",
  "version": "v1",
  "catalogue": {
    "city": "Amsterdam",
    "sources": 13,
    "categories": 12
  },
  "trial": {
    "request_limit": 10,
    "expires_in_hours": 24
  },
  "endpoints": [
    "GET /v1/sources",
    "GET /v1/categories",
    "GET /v1/events",
    "GET /v1/events/{id}",
    "POST /v1/keys",
    "GET /v1/keys/current",
    "DELETE /v1/keys/current"
  ]
}

Sources

GET/v1/sources free

The ingest catalogue. Each entry is one adapter — a scraper or a feed reader pointed at one site — with the key that ?source= accepts, a human name, a description of what it covers, and the health of its last run.

The last-run health is the part worth wiring into your own monitoring. If a venue's gigs stop appearing in your product, the honest first question is whether that source last succeeded, and this route answers it without a support email.

200 ok
{
  "sources": [
    {
      "key": "cineville",
      "name": "Cineville Movies",
      "description": "Films & screenings for the coming week — cineville.nl",
      "last_run": {
        "status": "ok",
        "ran_at": "2026-09-12T06:00:12+02:00",
        "fetched": 184,
        "inserted": 9
      }
    }
  ]
}
KeyNameCovers
cinevilleCineville MoviesFilms & screenings for the coming week — cineville.nl
uitkijkDe Uitkijk MoviesFilms not already covered by Cineville — uitkijk.nl
google-eventsGoogle EventsAmsterdam events via SerpAPI
gemeentebladGemeenteblad PermitsAnnounced event permits — officielebekendmakingen.nl
eventbriteEventbriteAmsterdam events — eventbrite.nl
activiteitenActiviteiten AmsterdamCity activities — activiteiten.amsterdam.nl
modMusic of Our DesireConcerts — events.musicofourdesire.com
jazzinJazz in AmsterdamLive music & jazz gigs — jazzin.amsterdam/jazz
jazzin-theaterJazzin TheatreTheatre performances — jazzin.amsterdam/theater
jazzin-clubJazzin ClubClub nights — jazzin.amsterdam/club
jazzin-filmJazzin FilmScreenings not already covered by Cineville — jazzin.amsterdam/film
jazzin-independentJazzin IndependentGrassroots & independent venues — jazzin.amsterdam/independent
marriottMarriott EventsHotel events — event.marriott.com

Treat this table as a snapshot and the endpoint as the truth — adapters get added, and the endpoint is what tells you.

Categories

GET/v1/categories free

The twelve values ?category= accepts, which is also the complete set of values the category field can hold. It is a closed vocabulary, not a tag cloud: every record has exactly one, and nothing outside this list is ever assigned.

music film theatre festival market sport expo nightlife family food street_event other
200 ok
{
  "categories": [
    "music", "film", "theatre", "festival",
    "market", "sport", "expo", "nightlife",
    "family", "food", "street_event", "other"
  ]
}
An unknown category is a 400, not an empty page. ?category=concerts returns {"error": …, "code": "unknown_category"} rather than zero results. A silently empty page is a typo you find in a week, when someone asks why the music tab has been blank since Tuesday. A 400 is a typo you find in a second.

List events

GET/v1/events 1 request

The feed. Takes the nine query parameters in the filter reference, returns a page of event records ordered by starts_at ascending, plus an opaque cursor for the page after it.

request
curl -s https://events.jetskibay.com/v1/events \
  -H "x-api-key: $EVENTS_KEY" \
  --get \
  -d "category=music" \
  -d "from=2026-09-12" \
  -d "to=2026-09-19" \
  -d "limit=2"
200 ok
{
  "events": [
    {
      "id": "7b3f1a2c-9e04-4d61-8a77-2c1b5f0e9d33",
      "title": "Nubya Garcia",
      "description": "The London saxophonist brings her quartet…",
      "category": "music",
      "type": "event",
      "starts_at": "2026-09-12T20:30:00+02:00",
      "ends_at": "2026-09-12T23:00:00+02:00",
      "is_all_day": false,
      "venue_name": "Paradiso",
      "address": "Weteringschans 6-8",
      "city": "Amsterdam",
      "url": "https://www.paradiso.nl/en/program/nubya-garcia/…",
      "image_url": "https://…/nubya.jpg",
      "price_min": 24.5,
      "price_max": 24.5,
      "is_free": false
    },
    { /* … */ }
  ],
  "cursor": "b2Zmc2V0OjMyfHN0YXJ0czoyMDI2LTA5LTEz"
}

When there is no page after this one, cursor is null. An empty events array with a null cursor is a legitimate answer: it means nothing in the catalogue matches, not that something went wrong.

One event

GET/v1/events/{id} 1 request

Fetches a single record by its id — the UUID from a list page. Returns the same event record, unwrapped.

request
curl -s https://events.jetskibay.com/v1/events/7b3f1a2c-9e04-4d61-8a77-2c1b5f0e9d33 \
  -H "x-api-key: $EVENTS_KEY"

An id we do not have is a 404 with code: "not_found" — JSON, like everything else. Ids are not permanent forever: a record reprocessed from its raw source can be replaced, so a stored id that stops resolving is a normal thing to handle, not an error to alert on.

Key routes

GET/v1/keys/current free

Reports what is left of the key on the request. Free, so a client can check its allowance without spending any of it — which is the point: checking your quota should never be the thing that exhausts it.

200 ok
{
  "key": {
    "keyPrefix": "evk_9f2c41a…",
    "tier": "trial",
    "requestLimit": 10,
    "requestCount": 4,
    "requestsRemaining": 6,
    "createdAt": "2026-09-12T09:12:44.000Z",
    "expiresAt": "2026-09-13T09:12:44.000Z",
    "lastUsedAt": "2026-09-12T11:41:02.000Z"
  },
  "valid": true,
  "reason": ""
}

valid is the single field worth branching on. When it is false, reason carries the same code the metered routes would have returned — expired_api_key, revoked_api_key, quota_exhausted — so a client can tell a dead key from a spent one without burning a request to find out.

DELETE/v1/keys/current free

Revokes the key on the request, immediately and permanently. Use it the moment a key lands somewhere it should not be — a commit, a screenshot, a pasted log. The revoked key returns 401 revoked_api_key from then on; minting a new one for the same address gives you the remaining allowance back.

request
curl -s -X DELETE https://events.jetskibay.com/v1/keys/current \
  -H "x-api-key: $EVENTS_KEY"

Filter reference

Nine query parameters on GET /v1/events. All are optional; all combine with AND. Anything not listed here is ignored rather than rejected, so an extra cache-busting parameter will not break a request.

Parameter Type Accepted values Default
from string ISO 8601 date (2026-09-12) or datetime (2026-09-12T18:00:00+02:00). Inclusive lower bound on starts_at. A bare date is read as midnight, Amsterdam time. now
to string Same formats as from. Exclusive upper bound on starts_at. A bare date therefore excludes that day — pass the day after the last one you want. open-ended
category enum One of music, film, theatre, festival, market, sport, expo, nightlife, family, food, street_event, other. Anything else is 400 unknown_category. all
source enum A key from GET /v1/sourcescineville, eventbrite, jazzin-club, and the rest. all
city string Matched case-insensitively against the record's city. The catalogue is Amsterdam, so this is mostly here for the day it is not. all
q string Free text, matched against title, description and venue_name. Substring matching, not a query language — no operators, no quoting, no wildcards. none
is_free boolean true or false. Note that is_free on a record is nullable: is_free=false means "known to cost money", not "not known to be free". either
limit integer Page size, 1100. Out-of-range values are clamped, not rejected. 50
cursor string Opaque. The cursor value from the previous page's response, passed back verbatim. See cursor pagination. first page

Two things about the date window

Cursor pagination

There is no page parameter and no offset, and that is deliberate rather than unfinished.

The table moves underneath you. The ingest cron rewrites the catalogue six times a day: new records are inserted, duplicates collapse into one another, and finished events drop out. A reader walking ?offset=0, 50, 100 across that is not reading a stable list. Rows that shift up are skipped entirely; rows that shift down are returned twice. You get a feed with holes in it and no error anywhere to explain them.

A cursor names a position in the result rather than a count of rows already seen, so a page is still the page after the last one you read even if the table changed in between.

The contract

A worked example: two pages

Ask for two music events, then ask for the two after them.

page 1 · request
curl -s https://events.jetskibay.com/v1/events \
  -H "x-api-key: $EVENTS_KEY" \
  --get -d "category=music" -d "limit=2"
page 1 · 200 ok
{
  "events": [
    { "id": "7b3f1a2c-…", "title": "Nubya Garcia",
      "starts_at": "2026-09-12T20:30:00+02:00" },
    { "id": "1d09c4e7-…", "title": "Altin Gün",
      "starts_at": "2026-09-12T21:00:00+02:00" }
  ],
  "cursor": "b2Zmc2V0OjJ8c3RhcnRzOjIwMjYtMDktMTI"   ← send this back
}
page 2 · request — same filters, plus the cursor
curl -s https://events.jetskibay.com/v1/events \
  -H "x-api-key: $EVENTS_KEY" \
  --get -d "category=music" -d "limit=2" \
  --data-urlencode "cursor=b2Zmc2V0OjJ8c3RhcnRzOjIwMjYtMDktMTI"
page 2 · 200 ok — the last page
{
  "events": [
    { "id": "9a55e310-…", "title": "Jazz at the Bimhuis",
      "starts_at": "2026-09-13T20:00:00+02:00" }
  ],
  "cursor": null   ← nothing after this
}

--data-urlencode rather than -d for the cursor: it is an opaque string that may contain characters a query string cares about, and encoding it yourself is one more thing to get wrong.

Walking the whole feed

javascript
async function allEvents(filters, key) {
  const out = [];
  let cursor = null;

  do {
    const params = new URLSearchParams({ ...filters, limit: "100" });
    if (cursor) params.set("cursor", cursor);

    const response = await fetch(
      `https://events.jetskibay.com/v1/events?${params}`,
      { headers: { "x-api-key": key } },
    );
    if (!response.ok) {
      const { code } = await response.json();
      throw new Error(code);   // branch on code, not on the message
    }

    const page = await response.json();
    out.push(...page.events);
    cursor = page.cursor;        // null ends the walk
  } while (cursor);

  return out;
}

Remember that each turn of that loop costs one request. On a trial key, ten turns is the whole allowance — so raise limit before you raise your patience.

The event record

One record is one thing that is happening: a screening, a gig, a market, a permitted street event. Sixteen fields, eight of them nullable. Nothing is invented to fill a column — where the sources did not say, the field is null.

FieldTypeMeaning
idstringUUID. Stable while the record lives; a reprocess from raw can replace it.
titlestringThe name of the thing. Never null.
descriptionstringA paragraph about it, resolved from the source page. Never null; can be short.
categoryenumExactly one of the twelve.
typeenumHow it entered the catalogue: movie, permit, event, or mail. Provenance, not genre — category is the genre.
starts_atstringISO 8601 with an Amsterdam offset, e.g. 2026-09-12T20:30:00+02:00. Never null; the feed is sorted on it.
ends_atstring · nullSame format. Null whenever the source did not publish an end time, which is often.
is_all_daybooleanTrue for things with a date but no meaningful clock time — a market, a permitted street closure. When true, read the time part of starts_at as filler.
venue_namestring · nullThe place, as the source named it. Not normalised against a venue list.
addressstring · nullStreet address where one was published. No coordinates.
citystring"Amsterdam" for essentially everything today.
urlstring · nullThe true source: the venue, organiser or ticket page. Where you send someone to book.
image_urlstring · nullA poster or photo on the source's own host. We do not proxy, resize, or promise it still resolves.
price_minnumber · nullCheapest published ticket, in euros. Null means unknown, not zero.
price_maxnumber · nullDearest published ticket, in euros. Equal to price_min for single-price events.
is_freeboolean · nullTrue when the source said free. Null when it said nothing — which is not the same as false.
Three fields are three different kinds of "we do not know". price_min: null means no price was published. is_free: null means nobody said either way. is_free: false with price_min: null means it costs money and we could not find out how much. Rendering all three as "Free" is the bug this table exists to prevent.

Errors

Every failure is { "error": "…", "code": "…" } with an accurate HTTP status. error is a sentence fit to show a person; code is the stable identifier to branch on.

StatusCodeWhat happened, and what to do
400invalid_email The email on a mint request is missing or not an address. Fix it and retry.
400unknown_category ?category= is not one of the twelve. Almost always a typo or a plural. Compare against GET /v1/categories.
400unknown_source ?source= is not one of the adapters we ingest from. Validated for the same reason as category: a source that does not exist would otherwise return an empty page that looks like a quiet night. Compare against GET /v1/sources.
401missing_api_key Neither x-api-key nor Authorization: Bearer was sent. Response carries www-authenticate: Bearer realm="Jetski Bay Events API".
401invalid_api_key The credential is not a key we issued. Check for a truncated paste or a stray newline.
401expired_api_key Past its 24 hours. Mint another; the allowance you had left comes with it.
401revoked_api_key Revoked — either by DELETE /v1/keys/current, or by a later mint for the same address. If you did not revoke it, someone minted with your email.
404not_found No event with that id, or no such route under /v1. A JSON body, not an HTML error page — a mistyped path fails the same way a mistyped id does.
429quota_exhausted The key's requests are spent. Mint a new key for a new allowance, or wait for the trial to lapse and start again.
429mint_limit_reached Too many key mints from one address or network in a short window. Not a quota problem — a "stop asking" problem.
429allowance_exhausted Not returned by this API. It exists on the sibling Content API, where keys are mailed and the allowance is pooled per address. Here the key comes back in the response, so anyone could type anyone's address — pooling would let a stranger exhaust your allowance instead of their own. Each mint gets its own full allowance; the mint caps are the bound.

Anything 5xx is ours and carries the same body shape. Retry a 5xx with backoff; do not retry a 4xx, because nothing about the request will have changed.

Quota & limits

Check what is left at any time, for free, with GET /v1/keys/current.

Reasonable use

The catalogue is public data about public events, cached and cleaned. Cache it on your side, honour the url field by sending people to the venue rather than around it, and do not present a record as a booking. If a trial allowance is not enough for what you are building, the shape of the conversation is "here is what we would build" — start it at hello@jetskibay.com.


Ready to try it against the live service? The playground mints a key and walks a real cursor-paginated query in the browser.