Skip to content
Passavo
Menu

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  1. 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.