Build your own integration
Your catalogue, your availability, your orders, your tickets and your scans can be fetched by another program too, and it can create orders itself. Here is how that works — first in plain words, then with the code.
- Base address
- https://passavo.eu/api/v1
- Current version
- 2026-09-20
- The description itself
- openapi.json
What is an API, and do you need one?
An API is a door at the back of your account. Where you click in your admin screen, a program can use that door to read your data or write to it — with nobody sitting at a screen.
You only need one when you want something the admin screen does not do: figures in your own system, availability on your own website, tickets from a till you already have. If you can click it, clicking is faster.
Whoever builds that integration — your web builder, your accounting package, somebody who can program — needs two things from you: the address below and a key you create yourself.
What you can build with it
Availability on your own site
Show on your own home page which tours still have room this week, in your own design, with figures that are correct at that very moment.
Feed your bookkeeping
Let your accountant or your accounting package fetch last month’s sales themselves, instead of sending an export every month.
A screen at the entrance
A tablet in the hall showing which time slot is about to start and how many places are still free. One call a minute is enough.
Sell from your own system
Create an order from the program you already work in, send the buyer a payment link, and hear through a webhook that they have paid.
The API is part of the Pro plan
You create keys under Settings → API access. That screen appears in your menu as soon as your organisation is on that plan; you switch plans yourself in your admin screen, under Subscription.
See the plans →Try it safely first
Next to a normal key (pv_live_) you can create a test key (pv_test_). It works with your real catalogue, time slots and capacity, and can create orders too. Such a test order never goes through a payment provider — a test page plays out the payment —, does not count towards your revenue and is cleaned up after 24 hours. Until then it holds real places, so preferably work with a product you create for the purpose. Anything outside the sandbox, such as managing webhooks, we refuse for a test key with the code sandbox.
Quick start in five steps
From nothing to an integration that knows a payment has come in. Each step takes minutes, not days.
-
Create a key
Open Settings → API access in your admin screen and create a key with only the rights you need. You see the full token once; keep it the way you keep a password.
-
Make your first call
Request GET /me with your key in the Authorization header. You get back your organisation, your plan and the rights of your key. If this works, the rest works too.
-
Create an order
Post the lines you want to sell to POST /orders, with an Idempotency-Key — it is required here. If your connection breaks halfway, the same call repeated does not produce a second order. The reserved_until field tells you until when the places are held for you.
-
Send the buyer to the payment link
Request the payment link with POST /orders/{id}/checkout, together with the address where the buyer lands after paying. Send the buyer to that link or show it in your own page. The payment runs through the provider you have already connected; as soon as it is in, the tickets are created and sent.
-
Let a webhook tell you
Set up an address on your own site and register it. As soon as something is paid, cancelled or scanned, you get it there — you do not have to keep asking whether anything has happened.
curl
curl "https://passavo.eu/api/v1/me" \
-H "Authorization: Bearer pv_live_YOUR_API_KEY" \
-H "Accept: application/json"
PHP
use Illuminate\Support\Facades\Http;
$response = Http::acceptJson()
->withToken('pv_live_YOUR_API_KEY')
->get('https://passavo.eu/api/v1/me');
$data = $response->json('data');
JavaScript
const response = await fetch('https://passavo.eu/api/v1/me', {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: 'Bearer pv_live_YOUR_API_KEY',
},
});
const { data } = await response.json();
Python
import requests
headers = {"Accept": "application/json", "Authorization": "Bearer pv_live_YOUR_API_KEY"}
response = requests.get("https://passavo.eu/api/v1/me", headers=headers)
data = response.json()["data"]
Authentication
Every request carries your key as a bearer token. A key belongs to one organisation: that organisation is the whole world of that request, and you can never accidentally fetch somebody else’s data with it.
Authorization: Bearer pv_live_YOUR_API_KEY
A normal key starts with pv_live_, a sandbox key with pv_test_. That prefix is not a secret but a help: whoever sees it in a log knows at once where it comes from.
The full token exists for one moment, at creation. After that we only keep an irreversible fingerprint (sha256) of it. Lost really means lost: revoke the key and create a new one.
Rights
A key without rights may do nothing. That is deliberately the opposite of what you would expect: these keys are handed out by customers to third-party tools, and then the default should be "nothing" and not "everything".
- read
- Read everything: your venues, products, prices, availability, discounts, gift vouchers and season passes, as well as your orders, tickets, buyers and the scans at the entrance.
- write
- Create orders, check them out, cancel and refund them; void or resend tickets; check visitors in; issue gift vouchers and season passes.
- webhooks
- Manage your own webhook destinations.
- external_payment
- Mark an order that costs money as paid without a payment provider, for example after a bank transfer or a cash payment (POST /orders/{id}/mark-paid). A separate right next to write, so you only give it to a till or bookkeeping system you manage yourself. Confirming an order of zero euros works without it.
When something goes wrong
Every error has the same shape, with a code that is fixed and a text that is translated. Read the code and not the text: the text may be rewritten, the code never.
{
"error": {
"code": "validation_failed",
"message": "…",
"details": {}
}
}
| Code | Status | When |
|---|---|---|
| unauthenticated | 401 | Missing, unknown, revoked or expired key. |
| forbidden | 403 | The key is valid, but may not do this. |
| plan_required | 403 | This organisation’s plan does not include the API. |
| sandbox | 403 | A test key is trying something outside the sandbox, such as managing webhooks. |
| not_found | 404 | Does not exist, or does not exist within this organisation. |
| validation_failed | 422 | The request itself is wrong: an unknown filter, an invalid date. |
| rate_limited | 429 | Too many calls in one minute. |
| conflict | 409 | The same Idempotency-Key was already used for something else. |
| server_error | 500 | Something went wrong on our side. |
| slot_unavailable | 409 | The time slot is full, has already started or is unknown, or none was chosen while the product requires one. |
| sold_out | 409 | The product is sold out or no longer on sale. |
| discount_invalid | 422 | The discount code does not exist, is used up, or does not apply here. |
| payment_provider_missing | 409 | The organisation has no payment provider connected. |
| not_cancellable | 409 | The order cannot be cancelled in its current state. |
| not_refundable | 409 | There is nothing (left) to refund on this order, or it was paid outside the payment provider. |
| check_in_duplicate | 409 | This code has already been checked in. |
| check_in_invalid | 404 | This code is unknown. |
| check_in_wrong_day | 409 | This code is valid, but not today. |
| check_in_cancelled | 409 | This code belongs to a cancelled or refunded ticket. |
A row from another organisation always returns not_found and never a message saying you are not allowed in. Were that difference visible, anyone with a valid key could work out how many products the neighbour has by walking through numbers.
Paging, sorting and filtering
Lists arrive one page at a time. You do not ask for page three but for what comes after the previous page: the response carries a cursor, and you send it along to read on. That way a list never misses rows when something is added while you are paging.
| Parameter | What it does |
|---|---|
| page[size] | How many rows per page; 25 by default, 100 at most. |
| page[cursor] | The next_cursor from the previous response. If it is empty, that was the last page. |
| sort | What to sort on. A dash in front reverses the order. |
| filter[…] | Filter on a field the endpoint allows. |
| include | Include relations in the same response, separated by commas. |
A parameter the endpoint does not know is an error, not a silence. Whoever mistypes the name of a filter should hear about it — and not unknowingly get the whole list back unfiltered.
There is deliberately no total in the response: at tens of thousands of rows that count costs more than the page itself.
{
"data": [ … ],
"meta": { "next_cursor": "…" }
}
Send twice, run once
Send an Idempotency-Key along with every call that creates or changes something — a random unique string. If the same call comes in again with the same key, you get exactly the same answer back without anything happening a second time. On POST /orders it is required.
Idempotency-Key: 6f1c0b4a-6b2f-4a1b-9c7e-1f2d3e4a5b6c
The same key with different content gives conflict and nothing happens. A stored key lives 24 hours, and applies per organisation.
Versions
The version is in the path (/api/v1) and only changes on a break; a second version then runs beside it and not through it. Every small addition is dated in the Passavo-Version header, currently 2026-09-20.
What may be added within this version: a field, an endpoint, a new sort or filter value, a new error code. So build your integration so that an unknown field does not knock it over.
What never happens within this version: removing or renaming a field, changing a type, giving a code another meaning, or making a parameter required.
Rate limit
A key may make 120 calls per minute. The limit applies per key and not per address, so that two customers behind the same cloud provider do not get in each other’s way.
Every response tells you where you stand in X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. If you go over, you get rate_limited with a Retry-After alongside; then simply wait that many seconds.
Code examples
The same request in four languages, per group of endpoints. Replace YOUR_API_KEY with your own key — and never put it in code a visitor can download.
Account
Who am I and what may I do.
GET /api/v1/me
curl
curl "https://passavo.eu/api/v1/me" \
-H "Authorization: Bearer pv_live_YOUR_API_KEY" \
-H "Accept: application/json"
PHP
use Illuminate\Support\Facades\Http;
$response = Http::acceptJson()
->withToken('pv_live_YOUR_API_KEY')
->get('https://passavo.eu/api/v1/me');
$data = $response->json('data');
JavaScript
const response = await fetch('https://passavo.eu/api/v1/me', {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: 'Bearer pv_live_YOUR_API_KEY',
},
});
const { data } = await response.json();
Python
import requests
headers = {"Accept": "application/json", "Authorization": "Bearer pv_live_YOUR_API_KEY"}
response = requests.get("https://passavo.eu/api/v1/me", headers=headers)
data = response.json()["data"]
Catalogue
Locations, products and prices.
GET /api/v1/events
curl
curl "https://passavo.eu/api/v1/events" \
-H "Authorization: Bearer pv_live_YOUR_API_KEY" \
-H "Accept: application/json"
PHP
use Illuminate\Support\Facades\Http;
$response = Http::acceptJson()
->withToken('pv_live_YOUR_API_KEY')
->get('https://passavo.eu/api/v1/events');
$data = $response->json('data');
JavaScript
const response = await fetch('https://passavo.eu/api/v1/events', {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: 'Bearer pv_live_YOUR_API_KEY',
},
});
const { data } = await response.json();
Python
import requests
headers = {"Accept": "application/json", "Authorization": "Bearer pv_live_YOUR_API_KEY"}
response = requests.get("https://passavo.eu/api/v1/events", headers=headers)
data = response.json()["data"]
Availability
Time slots and free places.
GET /api/v1/products/{id}/slots
curl
curl "https://passavo.eu/api/v1/products/12/slots" \
-H "Authorization: Bearer pv_live_YOUR_API_KEY" \
-H "Accept: application/json"
PHP
use Illuminate\Support\Facades\Http;
$response = Http::acceptJson()
->withToken('pv_live_YOUR_API_KEY')
->get('https://passavo.eu/api/v1/products/12/slots');
$data = $response->json('data');
JavaScript
const response = await fetch('https://passavo.eu/api/v1/products/12/slots', {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: 'Bearer pv_live_YOUR_API_KEY',
},
});
const { data } = await response.json();
Python
import requests
headers = {"Accept": "application/json", "Authorization": "Bearer pv_live_YOUR_API_KEY"}
response = requests.get("https://passavo.eu/api/v1/products/12/slots", headers=headers)
data = response.json()["data"]
Sales
Discounts, gift vouchers and season passes.
GET /api/v1/discounts
curl
curl "https://passavo.eu/api/v1/discounts" \
-H "Authorization: Bearer pv_live_YOUR_API_KEY" \
-H "Accept: application/json"
PHP
use Illuminate\Support\Facades\Http;
$response = Http::acceptJson()
->withToken('pv_live_YOUR_API_KEY')
->get('https://passavo.eu/api/v1/discounts');
$data = $response->json('data');
JavaScript
const response = await fetch('https://passavo.eu/api/v1/discounts', {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: 'Bearer pv_live_YOUR_API_KEY',
},
});
const { data } = await response.json();
Python
import requests
headers = {"Accept": "application/json", "Authorization": "Bearer pv_live_YOUR_API_KEY"}
response = requests.get("https://passavo.eu/api/v1/discounts", headers=headers)
data = response.json()["data"]
Orders
Create, check out, cancel and refund orders.
GET /api/v1/orders
curl
curl "https://passavo.eu/api/v1/orders" \
-H "Authorization: Bearer pv_live_YOUR_API_KEY" \
-H "Accept: application/json"
PHP
use Illuminate\Support\Facades\Http;
$response = Http::acceptJson()
->withToken('pv_live_YOUR_API_KEY')
->get('https://passavo.eu/api/v1/orders');
$data = $response->json('data');
JavaScript
const response = await fetch('https://passavo.eu/api/v1/orders', {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: 'Bearer pv_live_YOUR_API_KEY',
},
});
const { data } = await response.json();
Python
import requests
headers = {"Accept": "application/json", "Authorization": "Bearer pv_live_YOUR_API_KEY"}
response = requests.get("https://passavo.eu/api/v1/orders", headers=headers)
data = response.json()["data"]
Tickets
Fetch, void and resend tickets.
GET /api/v1/tickets
curl
curl "https://passavo.eu/api/v1/tickets" \
-H "Authorization: Bearer pv_live_YOUR_API_KEY" \
-H "Accept: application/json"
PHP
use Illuminate\Support\Facades\Http;
$response = Http::acceptJson()
->withToken('pv_live_YOUR_API_KEY')
->get('https://passavo.eu/api/v1/tickets');
$data = $response->json('data');
JavaScript
const response = await fetch('https://passavo.eu/api/v1/tickets', {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: 'Bearer pv_live_YOUR_API_KEY',
},
});
const { data } = await response.json();
Python
import requests
headers = {"Accept": "application/json", "Authorization": "Bearer pv_live_YOUR_API_KEY"}
response = requests.get("https://passavo.eu/api/v1/tickets", headers=headers)
data = response.json()["data"]
Entrance
Checking in at the entrance.
GET /api/v1/check-ins
curl
curl "https://passavo.eu/api/v1/check-ins" \
-H "Authorization: Bearer pv_live_YOUR_API_KEY" \
-H "Accept: application/json"
PHP
use Illuminate\Support\Facades\Http;
$response = Http::acceptJson()
->withToken('pv_live_YOUR_API_KEY')
->get('https://passavo.eu/api/v1/check-ins');
$data = $response->json('data');
JavaScript
const response = await fetch('https://passavo.eu/api/v1/check-ins', {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: 'Bearer pv_live_YOUR_API_KEY',
},
});
const { data } = await response.json();
Python
import requests
headers = {"Accept": "application/json", "Authorization": "Bearer pv_live_YOUR_API_KEY"}
response = requests.get("https://passavo.eu/api/v1/check-ins", headers=headers)
data = response.json()["data"]
Customers
Buyers, summarised from the orders.
GET /api/v1/customers
curl
curl "https://passavo.eu/api/v1/customers" \
-H "Authorization: Bearer pv_live_YOUR_API_KEY" \
-H "Accept: application/json"
PHP
use Illuminate\Support\Facades\Http;
$response = Http::acceptJson()
->withToken('pv_live_YOUR_API_KEY')
->get('https://passavo.eu/api/v1/customers');
$data = $response->json('data');
JavaScript
const response = await fetch('https://passavo.eu/api/v1/customers', {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: 'Bearer pv_live_YOUR_API_KEY',
},
});
const { data } = await response.json();
Python
import requests
headers = {"Accept": "application/json", "Authorization": "Bearer pv_live_YOUR_API_KEY"}
response = requests.get("https://passavo.eu/api/v1/customers", headers=headers)
data = response.json()["data"]
Meta
The description of the API itself.
GET /api/v1/openapi.json
curl
curl "https://passavo.eu/api/v1/openapi.json" \
-H "Accept: application/json"
PHP
use Illuminate\Support\Facades\Http;
$response = Http::acceptJson()
->get('https://passavo.eu/api/v1/openapi.json');
$data = $response->json('data');
JavaScript
const response = await fetch('https://passavo.eu/api/v1/openapi.json', {
method: 'GET',
headers: {
Accept: 'application/json',
},
});
const { data } = await response.json();
Python
import requests
headers = {"Accept": "application/json"}
response = requests.get("https://passavo.eu/api/v1/openapi.json", headers=headers)
data = response.json()["data"]
Webhooks
Messages we send to your server.
GET /api/v1/webhook-deliveries
curl
curl "https://passavo.eu/api/v1/webhook-deliveries" \
-H "Authorization: Bearer pv_live_YOUR_API_KEY" \
-H "Accept: application/json"
PHP
use Illuminate\Support\Facades\Http;
$response = Http::acceptJson()
->withToken('pv_live_YOUR_API_KEY')
->get('https://passavo.eu/api/v1/webhook-deliveries');
$data = $response->json('data');
JavaScript
const response = await fetch('https://passavo.eu/api/v1/webhook-deliveries', {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: 'Bearer pv_live_YOUR_API_KEY',
},
});
const { data } = await response.json();
Python
import requests
headers = {"Accept": "application/json", "Authorization": "Bearer pv_live_YOUR_API_KEY"}
response = requests.get("https://passavo.eu/api/v1/webhook-deliveries", headers=headers)
data = response.json()["data"]
Webhooks
A webhook is the reverse of a call: instead of you asking again and again whether payment has come in, we send a message to an address of yours the moment it happens. That saves thousands of calls a day and you know right away instead of on the next round.
You manage your destinations in your admin screen under Settings → API access, Webhooks tab, or through the API with a key carrying the webhooks right. Every destination has its own secret; you use it to check that a message really came from us.
Checking the signature
Every message carries the Passavo-Signature header in the form t=<unix>,v1=<hmac-sha256>. t is the moment we signed; v1 is an HMAC-SHA256 with that destination’s secret over the timestamp, a dot and the raw body. Work it out yourself and compare in constant time — an ordinary comparison betrays through the time it takes how many characters were right. Also reject a message whose t differs from your own clock by more than 300 seconds: that way nobody can replay an intercepted message later.
Verify against the RAW body, before your framework turns it into json. Rebuilding json puts keys in another order and then no signature matches any more.
curl
# De handtekening narekenen vanaf de opdrachtregel.
# T is de waarde van t= uit de header Passavo-Signature,
# het resultaat hoort gelijk te zijn aan de waarde van v1=.
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET"
PHP
// De RUWE body, niet $request->all().
$body = $request->getContent();
// "t=1758355200,v1=9f86d0…" uit elkaar halen.
$delen = [];
foreach (explode(',', (string) $request->header('Passavo-Signature')) as $stuk) {
[$sleutel, $waarde] = array_pad(explode('=', trim($stuk), 2), 2, '');
$delen[$sleutel] = $waarde;
}
$t = $delen['t'] ?? '';
$verwacht = hash_hmac('sha256', $t . '.' . $body, $secret);
// hash_equals vergelijkt in constante tijd; de tijdstempel
// houdt een opgevangen bericht tegen dat later opnieuw komt.
if (! ctype_digit($t) || abs(time() - (int) $t) > 300 || ! hash_equals($verwacht, $delen['v1'] ?? '')) {
abort(400);
}
JavaScript
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: de ruwe body als string, bv. via express.raw().
const header = request.headers['passavo-signature'] ?? '';
const delen = Object.fromEntries(header.split(',').map((s) => s.trim().split('=', 2)));
const verwacht = createHmac('sha256', secret).update(delen.t + '.' + rawBody).digest('hex');
const gekregen = delen.v1 ?? '';
const vers = /^\d+$/.test(delen.t ?? '')
&& Math.abs(Date.now() / 1000 - Number(delen.t)) <= 300;
const geldig = vers
&& verwacht.length === gekregen.length
&& timingSafeEqual(Buffer.from(verwacht), Buffer.from(gekregen));
Python
import hashlib, hmac, time
# raw_body: de ruwe body als bytes, bv. request.get_data() in Flask.
header = request.headers.get("Passavo-Signature", "")
delen = dict(stuk.strip().split("=", 1) for stuk in header.split(",") if "=" in stuk)
t = delen.get("t", "")
verwacht = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
# compare_digest vergelijkt in constante tijd.
geldig = (t.isdigit()
and abs(time.time() - int(t)) <= 300
and hmac.compare_digest(verwacht, delen.get("v1", "")))
The full list of events and their contents is in the reference. →
What changes
Every change worth mentioning gets a new dated version. If your integration stays on an older date, it keeps working — the date only says which behaviour it was built against.
-
2026-09-20
The first version
Keys with rights, a sandbox, one response format and cursor paging. Reading: your organisation, venues, products, time slots, events, discounts, gift vouchers and season passes, orders, tickets, buyers and scans. Writing: creating, checking out, cancelling and refunding orders, voiding tickets, checking visitors in, issuing gift vouchers and season passes. And outgoing webhooks, signed per destination.