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.
| Route | Does | Costs |
|---|---|---|
GET /v1 | Service index: version, catalogue counts, trial terms, endpoint list | free |
GET /v1/sources | The source / adapter catalogue, with last-run health | free |
GET /v1/categories | The twelve categories | free |
GET /v1/events | The feed, filtered and cursor-paginated | 1 request |
GET /v1/events/{id} | One event by id | 1 request |
POST /v1/keys | Mint a key for an email address | free |
GET /v1/keys/current | Allowance and expiry of the key on the request | free |
DELETE /v1/keys/current | Revoke that key immediately | free |
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:
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:
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.
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.
curl -s -X POST https://events.jetskibay.com/v1/keys \ -H "content-type: application/json" \ -d '{"email":"you@company.com"}'
{
"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 remaining allowance is carried over — if you had used four of ten, the new key starts with six.
- The old key is revoked. Any client still holding it starts getting
401 revoked_api_key. - The 24-hour clock is not reset by carrying allowance over; a fresh expiry is issued with the new key.
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
400 invalid_email— the address is missing or not parseable.429 mint_limit_reached— too many mints from one address or one network in a short window. Wait, or use the key you already have.
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.
{
"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.
{
"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
}
}
]
}
| Key | Name | Covers |
|---|---|---|
cineville | Cineville Movies | Films & screenings for the coming week — cineville.nl |
uitkijk | De Uitkijk Movies | Films not already covered by Cineville — uitkijk.nl |
google-events | Google Events | Amsterdam events via SerpAPI |
gemeenteblad | Gemeenteblad Permits | Announced event permits — officielebekendmakingen.nl |
eventbrite | Eventbrite | Amsterdam events — eventbrite.nl |
activiteiten | Activiteiten Amsterdam | City activities — activiteiten.amsterdam.nl |
mod | Music of Our Desire | Concerts — events.musicofourdesire.com |
jazzin | Jazz in Amsterdam | Live music & jazz gigs — jazzin.amsterdam/jazz |
jazzin-theater | Jazzin Theatre | Theatre performances — jazzin.amsterdam/theater |
jazzin-club | Jazzin Club | Club nights — jazzin.amsterdam/club |
jazzin-film | Jazzin Film | Screenings not already covered by Cineville — jazzin.amsterdam/film |
jazzin-independent | Jazzin Independent | Grassroots & independent venues — jazzin.amsterdam/independent |
marriott | Marriott Events | Hotel 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.
{
"categories": [
"music", "film", "theatre", "festival",
"market", "sport", "expo", "nightlife",
"family", "food", "street_event", "other"
]
}
?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.
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"
{
"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.
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.
{
"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.
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/sources — cineville, 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, 1–100. 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
- The feed is forward-looking. With no
from, you get events from now onward; the past is not served, sofromis for pushing the window later, not for reaching backwards. - Bounds are on
starts_at, not on the run of the event. A festival that started yesterday and ends on Sunday will not appear in a window that begins today.
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 page response carries
cursor. Pass it back as?cursor=to get the next page. - The cursor is opaque. Do not parse it, decode it, construct one, or infer anything from its contents. Its encoding is an implementation detail and will change without a version bump. The only correct thing to do with a cursor is send it back.
- When
cursorisnull, you are on the last page. That, and not an emptyeventsarray, is how a walk ends. - Keep the rest of the query identical when you pass a cursor. A cursor belongs to the filter set that produced it; changing
categorymid-walk is a new query and should start without a cursor. - Cursors are not bookmarks. They are good for walking a result now, not for storing and resuming next week.
A worked example: two pages
Ask for two music events, then ask for the two after them.
curl -s https://events.jetskibay.com/v1/events \ -H "x-api-key: $EVENTS_KEY" \ --get -d "category=music" -d "limit=2"
{
"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
}
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"
{
"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
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.
| Field | Type | Meaning |
|---|---|---|
id | string | UUID. Stable while the record lives; a reprocess from raw can replace it. |
title | string | The name of the thing. Never null. |
description | string | A paragraph about it, resolved from the source page. Never null; can be short. |
category | enum | Exactly one of the twelve. |
type | enum | How it entered the catalogue: movie, permit, event, or mail. Provenance, not genre — category is the genre. |
starts_at | string | ISO 8601 with an Amsterdam offset, e.g. 2026-09-12T20:30:00+02:00. Never null; the feed is sorted on it. |
ends_at | string · null | Same format. Null whenever the source did not publish an end time, which is often. |
is_all_day | boolean | True 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_name | string · null | The place, as the source named it. Not normalised against a venue list. |
address | string · null | Street address where one was published. No coordinates. |
city | string | "Amsterdam" for essentially everything today. |
url | string · null | The true source: the venue, organiser or ticket page. Where you send someone to book. |
image_url | string · null | A poster or photo on the source's own host. We do not proxy, resize, or promise it still resolves. |
price_min | number · null | Cheapest published ticket, in euros. Null means unknown, not zero. |
price_max | number · null | Dearest published ticket, in euros. Equal to price_min for single-price events. |
is_free | boolean · null | True when the source said free. Null when it said nothing — which is not the same as false. |
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.
| Status | Code | What happened, and what to do |
|---|---|---|
| 400 | invalid_email |
The email on a mint request is missing or not an address. Fix it and retry. |
| 400 | unknown_category |
?category= is not one of the twelve. Almost always a typo or a plural. Compare against GET /v1/categories. |
| 400 | unknown_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. |
| 401 | missing_api_key |
Neither x-api-key nor Authorization: Bearer was sent. Response carries www-authenticate: Bearer realm="Jetski Bay Events API". |
| 401 | invalid_api_key |
The credential is not a key we issued. Check for a truncated paste or a stray newline. |
| 401 | expired_api_key |
Past its 24 hours. Mint another; the allowance you had left comes with it. |
| 401 | revoked_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. |
| 404 | not_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. |
| 429 | quota_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. |
| 429 | mint_limit_reached |
Too many key mints from one address or network in a short window. Not a quota problem — a "stop asking" problem. |
| 429 | allowance_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
- 10 requests on a trial key, 24 hours from minting, whichever runs out first.
- Only
/v1/eventsand/v1/events/{id}are metered. One successful call, one request off the allowance./v1,/v1/sources,/v1/categoriesand all three key routes are free and unlimited by quota. - A page is a request, however big it is. Walking ten pages of fifty costs ten; asking for one page of a hundred costs one. Set
limitto what you actually need. - A request we never ran is not charged. A
400from a bad filter and a401from a bad key are both rejected before any query runs, so they cost nothing — you cannot burn an allowance on a typo. Anything5xxis refunded for the same reason: our failure, not your request. - A
404from/v1/events/{id}is charged, because looking is the work: there is no way to learn that an id is absent without asking the database, and a free miss would make enumerating ids cheaper than reading the feed. A404on an unrecognised path is free — that one is answered by the router, not the database. - Re-minting carries the remainder over from a key that is still live, and revokes it. Revoking first instead simply starts you at a full ten — the address is not verified here, so it cannot be the thing an allowance is pooled against without letting a stranger spend yours. The mint caps are what actually bound this, and they are per address and per network.
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.