deduplicated
Thirteen feeds, one truth
The same concert arriving from a venue calendar, an aggregator and a ticket page becomes one record. You do not write the matching logic.
Amsterdam · read-only · JSON
Thirteen ingest sources — cinema programmes, ticket sites, venue calendars, city permits — read on a schedule, deduplicated against each other, categorised, and served as a single paginated endpoint. You write one integration instead of thirteen scrapers.
# every music event in the next week, three at a time 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=3" # { # "events": [ # { "title": "Nubya Garcia", # "category": "music", # "starts_at": "2026-09-12T20:30:00+02:00", # "venue_name": "Paradiso", # "price_min": 24.5, "is_free": false }, … # ], # "cursor": "b2Zmc2V0OjMy…" # }
What is in the data
Not one row per listing. A film with twenty screenings, a gig that three ticket sites all announced, a market that also appears as a city permit — those collapse into the records a person would call one thing, with a real start time, a real venue, and a price when a price exists.
deduplicated
The same concert arriving from a venue calendar, an aggregator and a ticket page becomes one record. You do not write the matching logic.
categorised
Every record carries exactly one of twelve categories. It is a fixed vocabulary, so ?category= is a filter you can build a UI on rather than a guess.
resolved
starts_at is ISO 8601 with an Amsterdam offset — not "Friday evening". venue_name and address are filled where the source gave them.
honest
price_min, image_url, ends_at are nullable and often null. Nothing is invented to fill a column.
{
"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
}
Every field, with its type and when it is null: the event record.
Narrowing it
Date range, category, source, city, free text, price-free, page size, cursor. Ask for an unknown category and you get a 400 with a named error — not an empty page that looks like a quiet Tuesday.
The twelve categories
# free, family-friendly, this weekend curl -s https://events.jetskibay.com/v1/events \ -H "x-api-key: $EVENTS_KEY" \ --get \ -d "category=family" \ -d "is_free=true" \ -d "from=2026-09-12" \ -d "to=2026-09-14"
const params = new URLSearchParams({ category: "family", is_free: "true", from: "2026-09-12", to: "2026-09-14", limit: "50", }); const page = await fetch( `https://events.jetskibay.com/v1/events?${params}`, { headers: { "x-api-key": process.env.EVENTS_KEY } }, ).then((r) => r.json()); for (const event of page.events) { console.log(event.starts_at, event.title, event.venue_name); }
import os, requests page = requests.get( "https://events.jetskibay.com/v1/events", headers={"x-api-key": os.environ["EVENTS_KEY"]}, params={ "category": "family", "is_free": "true", "from": "2026-09-12", "to": "2026-09-14", }, ).json() for event in page["events"]: print(event["starts_at"], event["title"], event["venue_name"])
Types and accepted values for all nine: the filter reference.
Where it comes from
Every record came in through exactly one adapter, and ?source= filters on which. GET /v1/sources returns this catalogue live, each entry carrying the health of its last ingest run — so a source that has gone quiet is visible to you, not just to us. No key needed for that route.
Before you build
Both are deliberate, both are cheap to handle, and both are much cheaper to handle now than after your integration is written.
The key is returned in the response, not emailed. This host has no mail binding, so POST /v1/keys hands you the plaintext key in its own reply. That is the only time it exists anywhere you can read it — we keep a hash, so it cannot be looked up again, only replaced.
Which means: capture it from that one response, and treat the response body the way you would treat a password. It is an acceptable trade for a read-only API over cached public data. It would not be for an API that spends money.
Pagination is cursor-based, not page or offset. The ingest cron rewrites the table underneath you, so a reader walking ?page=2, 3, 4 would silently skip rows that moved up and repeat rows that moved down.
GET /v1/events returns an opaque cursor; pass it back as ?cursor= to get the page after it. Do not parse it, build one, or store it as a bookmark — its contents are ours to change. When there is no next page, there is no cursor.
The playground walks two pages with the real cursor, if you would rather see it than read it.
Get started
Give an email address, get a key good for 10 requests that expires in 24 hours. Asking again for the same address carries whatever allowance is left onto the new key and revokes the old one — so losing a key costs nothing, and asking twice buys nothing.
curl -s -X POST https://events.jetskibay.com/v1/keys \ -H "content-type: application/json" \ -d '{"email":"you@company.com"}' # { # "key": "evk_9f2c41a8b03d5e6712c4f890…", ← the only copy # "request_limit": 10, # "requests_remaining": 10, # "expires_at": "2026-09-13T09:12:44.000Z" # }