Introduction
festiware API v2 — project-scoped REST API for People, Groups, Ticket Types, and workflow-step transitions. Authentication is OAuth 2.0 Authorization Code grant via Laravel Passport.
Terminology: Group == Application
This API calls the resource a Group — the same entity the festiware backend
shows as Gruppe. For backwards compatibility the wire format still uses the
historic application spelling, and that spelling will not change.
| Concept | Wire identifier |
|---|---|
| Group | GET /projects/{project}/applications, application_type_id |
| Group type | GET /application-types, application_type |
| Sideload flags | withApplications, withSubordinatedApplications |
| Permissions | all_persons_and_applications_see / _manage / _administrate |
Read every application* identifier below as "group".
Connecting your integration (e.g. n8n)
Complete this once per integration. After Step 4 the credential auto-renews — no further manual work.
Prerequisites
- A festiware instance with backend admin access
- An OAuth 2.0 client (n8n, custom integration, postman, etc.)
Step 1 — Create an API User
In the festiware backend: API Users → Create API User
- Enter a descriptive name (e.g.
N8N — Bucht der Träumer) - Assign the permissions the integration needs (e.g.
all_persons_and_applications_administrate,ticketcode_administrate) - Copy the password shown — it is displayed once. Hand the email + password to whoever will complete Step 4.
Step 2 — Issue an OAuth Client
In the backend: open the API user → Actions → Create OAuth Client
| Field | Value |
|---|---|
| Client Name | e.g. N8N Production — Bucht der Träumer |
| Redirect URI | https://{n8n-instance}/rest/oauth2-credential/callback |
Click Run Action. Copy the client_id and client_secret shown — they are displayed once.
Step 3 — Configure the n8n credential
In n8n: Credentials → New → OAuth2 API
| Field | Value |
|---|---|
| Grant Type | Authorization Code |
| Authorization URL | https://{instance}.festiwa.re/oauth/authorize |
| Access Token URL | https://{instance}.festiwa.re/oauth/token |
| Client ID | from Step 2 |
| Client Secret | from Step 2 |
| Scope | (leave empty) |
Click Connect. A browser window opens.
Step 4 — Complete browser authorization (one-time)
The user from Step 1 logs in:
- Enter email + password → Login
- The consent screen shows the client name → Authorize
- Browser closes → n8n shows Connected
This step runs once. n8n stores the refresh token and auto-renews from this point.
Step 5 — Token lifecycle
- Access tokens expire after 1 hour
- n8n uses the refresh token to mint a new access token automatically — no manual action
- Refresh tokens are valid for 1 year
- If the client secret is compromised: revoke the client in the backend (API user → Actions → Revoke Client), issue a new one, and reconnect in n8n
Step 6 — First API call
In an n8n HTTP Request node:
Method: GET
URL: https://{instance}.festiwa.re/api/v2/projects/{project_id}/people
Auth: Predefined Credential Type → OAuth2 (the credential from Step 3)
Expected: HTTP 200 with a paginated People list.
Connecting without n8n (Postman, scripts, custom code)
The credentials from Steps 1–2 are a standard OAuth 2.0 Authorization Code client — nothing about them is n8n-specific. Any tool or library that supports this grant works: Postman, Insomnia, or your own code. Steps 3–6 above are simply the n8n flavour of the same flow.
Understanding the Redirect URI
This is the single most common point of confusion, so to be clear:
- The redirect URI does not have to point to your tool, your server, or anything reachable at all. It is only the address the browser is sent to after a successful login — tools like Postman intercept that redirect locally and never actually load the URL.
- The only hard requirement: the redirect/callback URI configured in your tool must exactly match the one registered on the OAuth client (Step 2) — same scheme, host, path, no extra trailing slash.
- On a mismatch the login fails with a redirect-URI error. Fix it on either side: change the callback URL in your tool, or update the client's redirect URI in the backend under the API user.
Postman setup
- Import or create a request/collection and open the Authorization tab → Type OAuth 2.0.
- Configure:
| Field | Value |
|---|---|
| Grant Type | Authorization Code |
| Callback URL | the Redirect URI registered on the client (Step 2) |
| Auth URL | https://{instance}.festiwa.re/oauth/authorize |
| Access Token URL | https://{instance}.festiwa.re/oauth/token |
| Client ID / Client Secret | from Step 2 |
| Scope | (leave empty) |
| Client Authentication | Send client credentials in body |
- Leave "Authorize using browser" switched OFF. When it is on, Postman replaces your callback URL with its own relay (
https://oauth.pstmn.io/v1/callback), which won't match the registered redirect URI — the login then always fails. With the switch off, Postman opens its own login popup and intercepts the redirect regardless of where it points. - Click Get New Access Token, log in with the API-user email + password from Step 1, and approve the consent screen. Postman stores the token and attaches it as a Bearer header.
Token lifecycle without auto-refresh
Unlike n8n, Postman and plain scripts do not renew tokens by themselves:
- The access token expires after 1 hour. In Postman, simply click Get New Access Token again.
- In your own code, store the
refresh_tokenfrom the token response and exchange it before expiry:
curl -X POST https://{instance}.festiwa.re/oauth/token \
-d grant_type=refresh_token \
-d refresh_token={refresh_token} \
-d client_id={client_id} \
-d client_secret={client_secret}
- Refresh tokens are valid for 1 year; after that, run the browser authorization once more.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Login form says "not an API user" | The customer signed in with their own festiware account | Use the API-user credentials from Step 1 |
| n8n shows "redirect URI mismatch" | The redirect URI in the backend doesn't match n8n's callback URL | Update the client in the backend with n8n's exact callback URL |
| Postman: login fails / redirect mismatch although the callback URL looks right | "Authorize using browser" is ON — Postman silently forces its oauth.pstmn.io relay as callback |
Switch "Authorize using browser" off and get the token again |
| API calls return 403 | The API user lacks the required permission | Add the permission in the backend → API User → Edit |
| n8n shows "disconnected" after weeks/months | Refresh token expired (1 year) or the client was revoked | Issue a new client and reconnect |
Authenticating requests
To authenticate requests, include an Authorization header with the value "Bearer {ACCESS_TOKEN}".
All authenticated endpoints are marked with a requires authentication badge in the documentation below.
Obtain a token via OAuth 2.0 Authorization Code grant. See the connection guide in the introduction for the full backend → OAuth client → n8n setup. The returned access_token must be sent as a Bearer token in the Authorization header.
People
List person types.
requires authentication
Returns every person type defined on the instance, paginated. Global reference data; not project-scoped.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/person-types?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/person-types"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/person-types';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (500):
Show headers
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
{
"message": "An error occurred and was reported automatically.<br>(Event ID: 3aa100cff3f24384aabf026ce0816397)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Paginated list of Persons in a project.
requires authentication
Required permission: all_persons_and_applications_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/people?email=anna%40example.com&updated_since=2026-04-30T00%3A00%3A00%2B00%3A00&person_type=volunteer&step=acc_buildup&workflow_id=209&withJourney=&group=812&withGroups=&per_page=25" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"per_page\": 16,
\"email\": \"zbailey@example.net\",
\"updated_since\": \"2026-10-28T22:23:53+01:00\",
\"person_type\": \"architecto\",
\"step\": \"architecto\",
\"workflow_id\": 16,
\"withJourney\": true,
\"group\": 22,
\"withGroups\": true
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/people"
);
const params = {
"email": "anna@example.com",
"updated_since": "2026-04-30T00:00:00+00:00",
"person_type": "volunteer",
"step": "acc_buildup",
"workflow_id": "209",
"withJourney": "0",
"group": "812",
"withGroups": "0",
"per_page": "25",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"per_page": 16,
"email": "zbailey@example.net",
"updated_since": "2026-10-28T22:23:53+01:00",
"person_type": "architecto",
"step": "architecto",
"workflow_id": 16,
"withJourney": true,
"group": 22,
"withGroups": true
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'email' => 'anna@example.com',
'updated_since' => '2026-04-30T00:00:00+00:00',
'person_type' => 'volunteer',
'step' => 'acc_buildup',
'workflow_id' => '209',
'withJourney' => '0',
'group' => '812',
'withGroups' => '0',
'per_page' => '25',
],
'json' => [
'per_page' => 16,
'email' => 'zbailey@example.net',
'updated_since' => '2026-10-28T22:23:53+01:00',
'person_type' => 'architecto',
'step' => 'architecto',
'workflow_id' => 16,
'withJourney' => true,
'group' => 22,
'withGroups' => true,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"data": {
"firstname": "Anna",
"email": "anna@example.com"
},
"calculated_data": {},
"person_types": [
"volunteer"
],
"created_at": "2026-04-30T11:24:53+00:00",
"updated_at": "2026-04-30T11:24:53+00:00",
"related": {
"teams": [
405,
426
],
"team_roles": [
12
],
"groups_owned": [],
"groups_member": [
3310
],
"events": [
881
],
"tickets": [],
"orders": [],
"person_access_types": [
4
],
"coupons": [],
"nfc_tags": [],
"contracts": [
3021
]
}
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (200, With journey (?withJourney=1)):
{
"data": [
{
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"data": {
"firstname": "Anna"
},
"calculated_data": {},
"person_types": [
"volunteer"
],
"created_at": "2026-04-30T11:24:53+00:00",
"updated_at": "2026-04-30T11:24:53+00:00",
"current_steps": [
{
"workflow_id": 209,
"step": {
"id": 1904,
"shortname": "acc_buildup",
"name": "2a) Akkreditierung Aufbau"
},
"entered_at": "2026-05-01T09:00:00+00:00"
}
],
"journey": [
{
"step": {
"id": 1904,
"shortname": "acc_buildup"
},
"registered_at": "2026-05-01T09:00:00+00:00",
"user_id": null
}
]
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (403, Filter on a hidden field):
{
"error": {
"code": "filter_not_allowed",
"message": "Filter 'email' is not allowed: it searches data.email, which data qualification 'Personal Data' protects on a person form of this project, and your user does not hold it. Remove the email parameter and find the person another way (e.g. by id).",
"errors": {
"email": [
"Filter 'email' is not allowed: it searches data.email, which data qualification 'Personal Data' protects on a person form of this project, and your user does not hold it. Remove the email parameter and find the person another way (e.g. by id)."
]
}
}
}
Example response (422, Invalid query parameter):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"per_page": [
"The per page must not be greater than 100."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create a new Person in a project.
requires authentication
Required permission: all_persons_and_applications_administrate
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/people" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"id\": \"architecto\",
\"person_types\": [
\"volunteer\"
],
\"act_as_backend_creation\": false,
\"data\": {
\"firstname\": \"Anna\",
\"email\": \"anna@example.com\"
}
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/people"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"id": "architecto",
"person_types": [
"volunteer"
],
"act_as_backend_creation": false,
"data": {
"firstname": "Anna",
"email": "anna@example.com"
}
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'id' => 'architecto',
'person_types' => [
'volunteer',
],
'act_as_backend_creation' => false,
'data' => [
'firstname' => 'Anna',
'email' => 'anna@example.com',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (201, Created):
{
"data": {
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"data": {
"firstname": "Anna",
"email": "anna@example.com"
},
"calculated_data": {},
"person_types": [
"volunteer"
],
"created_at": "2026-04-30T11:24:53+00:00",
"updated_at": "2026-04-30T11:24:53+00:00"
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (409, DuplicateId):
{
"error": {
"code": "duplicate_id",
"message": "A Person with this id already exists."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"data": [
"The data field is required."
],
"person_types": [
"The person_types field is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show a single Person by UUID.
requires authentication
Required permission: all_persons_and_applications_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/people/architecto" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/architecto';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"data": {
"firstname": "Anna",
"email": "anna@example.com"
},
"calculated_data": {},
"person_types": [
"volunteer"
],
"created_at": "2026-04-30T11:24:53+00:00",
"updated_at": "2026-04-30T11:24:53+00:00"
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Person not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update a Person.
requires authentication
Both PUT and PATCH route here. The behavioural switch is replaceMissing:
- PUT → replaceMissing=true (full replace; absent
datakeys cleared) - PATCH → replaceMissing=false (sparse merge; absent keys preserved)
Both verbs require an updated_at body field (ISO 8601) matching the Person's
current updated_at; mismatch returns 409 with error.errors.current.updated_at.
Required permission: all_persons_and_applications_manage
Example request:
curl --request PUT \
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"person_types\": [
\"architecto\"
],
\"act_as_backend_creation\": false,
\"data\": [],
\"updated_at\": \"2026-04-30T11:24:53+00:00\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"person_types": [
"architecto"
],
"act_as_backend_creation": false,
"data": [],
"updated_at": "2026-04-30T11:24:53+00:00"
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/architecto';
$response = $client->put(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'person_types' => [
'architecto',
],
'act_as_backend_creation' => false,
'data' => [],
'updated_at' => '2026-04-30T11:24:53+00:00',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"data": {
"firstname": "Anna",
"email": "anna@example.com"
},
"calculated_data": {},
"person_types": [
"volunteer"
],
"created_at": "2026-04-30T11:24:53+00:00",
"updated_at": "2026-04-30T12:00:00+00:00"
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Person not found."
}
}
Example response (409, OptimisticLockFailed):
{
"error": {
"code": "optimistic_lock_failed",
"message": "The Person was modified after your last read.",
"errors": {
"current": {
"updated_at": "2026-04-30T11:24:53+00:00"
}
}
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"updated_at": [
"The updated_at field is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Soft-delete a Person.
requires authentication
Requires explicit confirm: true in the body. Missing or false confirmation
returns 422 and the Person remains intact. Soft-deleted Persons return 404
from all subsequent v2 endpoints.
Required permission: all_persons_and_applications_administrate
Example request:
curl --request DELETE \
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"confirm\": true
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"confirm": true
};
fetch(url, {
method: "DELETE",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/architecto';
$response = $client->delete(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'confirm' => true,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (204, SoftDeleted):
Empty response
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Person not found."
}
}
Example response (409, PersonHasContracts):
{
"error": {
"code": "person_has_contracts",
"message": "Person has signed contracts and cannot be deleted."
}
}
Example response (409, PersonHasInvoices):
{
"error": {
"code": "person_has_invoices",
"message": "Person has linked invoices and cannot be deleted."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"confirm": [
"The confirm field must be accepted."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Advance many Persons in one request.
requires authentication
force=false(default): every id is pre-validated; if ANY would fail, the response is 400bulk_validation_failedwith afailed_idsarray and no Person is transitioned.force=true: every id is processed; failures are reported per-entity inside a 200 response. Sync/async dispatch still follows ChangeWorkflowStepJob::$maxModelsForAsync (default 25); above that threshold the response is 202dispatched.
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/people/transitions/bulk" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"ids\": [
\"uuid-1\",
\"uuid-2\"
],
\"to_step_id\": 42,
\"to_step_name\": \"APPROVED\",
\"person_type\": \"volunteer\",
\"workflow_id\": 5,
\"note\": \"Approved.\",
\"act_as_backend_creation\": false,
\"dry_run\": false,
\"force\": false
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/people/transitions/bulk"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"ids": [
"uuid-1",
"uuid-2"
],
"to_step_id": 42,
"to_step_name": "APPROVED",
"person_type": "volunteer",
"workflow_id": 5,
"note": "Approved.",
"act_as_backend_creation": false,
"dry_run": false,
"force": false
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/transitions/bulk';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'ids' => [
'uuid-1',
'uuid-2',
],
'to_step_id' => 42,
'to_step_name' => 'APPROVED',
'person_type' => 'volunteer',
'workflow_id' => 5,
'note' => 'Approved.',
'act_as_backend_creation' => false,
'dry_run' => false,
'force' => false,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, SyncBulk):
{
"data": {
"01a1e3a6-efbf-4413-9db1-f36e60489254": {
"status": "success",
"previous_step_id": 41,
"current_step_id": 42,
"triggers_fired": 3
},
"0298f01b-aa11-4d3c-bb22-cc33dd44ee55": {
"status": "skipped",
"reason": "already_in_step",
"previous_step_id": 42
}
}
}
Example response (202, AsyncDispatched):
{
"status": "dispatched",
"count": 50
}
Example response (400, BulkValidationFailed):
{
"error": {
"code": "bulk_validation_failed",
"message": "One or more transitions are not possible. Use force=true to process the eligible entries.",
"failed_ids": [
{
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"reason": "already_in_step",
"previous_step_id": 42
},
{
"id": "0298f01b-aa11-4d3c-bb22-cc33dd44ee55",
"reason": "person_not_found",
"previous_step_id": null
}
]
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "scope_missing",
"message": "Token does not carry all_persons_and_applications_manage."
}
}
Example response (422, InvalidPayload):
{
"error": {
"code": "invalid_payload",
"message": "The given data was invalid.",
"errors": {
"ids": [
"The ids field is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Advance a single Person to a new workflow step.
requires authentication
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/transitions" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"to_step_id\": 42,
\"to_step_name\": \"APPROVED\",
\"person_type\": \"volunteer\",
\"workflow_id\": 5,
\"note\": \"Approved via n8n.\",
\"act_as_backend_creation\": false,
\"dry_run\": false
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/transitions"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"to_step_id": 42,
"to_step_name": "APPROVED",
"person_type": "volunteer",
"workflow_id": 5,
"note": "Approved via n8n.",
"act_as_backend_creation": false,
"dry_run": false
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/transitions';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'to_step_id' => 42,
'to_step_name' => 'APPROVED',
'person_type' => 'volunteer',
'workflow_id' => 5,
'note' => 'Approved via n8n.',
'act_as_backend_creation' => false,
'dry_run' => false,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, Transitioned):
{
"data": {
"person_id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"previous_workflow_step": {
"id": 41,
"shortname": "PENDING",
"name": "Pending"
},
"current_workflow_step": {
"id": 42,
"shortname": "APPROVED",
"name": "Approved",
"entered_at": "2026-05-13T10:00:00+00:00"
},
"triggers_fired": 3,
"note_saved": true
}
}
Example response (200, DryRun):
{
"data": {
"person_id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"previous_workflow_step": {
"id": 41,
"shortname": "PENDING",
"name": "Pending"
},
"current_workflow_step": {
"id": 42,
"shortname": "APPROVED",
"name": "Approved",
"entered_at": "2026-05-13T10:00:00+00:00"
},
"triggers_fired": 0,
"note_saved": false,
"dry_run": true
}
}
Example response (400, AlreadyInStep):
{
"error": {
"code": "already_in_step",
"message": "The target is already in workflow step APPROVED."
}
}
Example response (400, StepNotInWorkflow):
{
"error": {
"code": "step_not_in_workflow",
"message": "The target step does not belong to the identified workflow."
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "scope_missing",
"message": "Token does not carry all_persons_and_applications_manage."
}
}
Example response (404, PersonNotFound):
{
"error": {
"code": "person_not_found",
"message": "Person not found in this project."
}
}
Example response (404, WorkflowNotFound):
{
"error": {
"code": "workflow_not_found",
"message": "Workflow could not be resolved from the supplied disambiguation."
}
}
Example response (422, InvalidPayload):
{
"error": {
"code": "invalid_payload",
"message": "One of `to_step_id` or `to_step_name` is required.",
"errors": {
"to_step": [
"One of `to_step_id` or `to_step_name` is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Groups
Groups — the resource the festiware backend shows as Gruppen.
For backwards compatibility the wire format keeps the historic application
spelling and will not change: route segment applications, payload keys
application_type / application_type_id / subordinated_applications,
sideload flags withApplications / withSubordinatedApplications, and the
permissions all_persons_and_applications_*. Read each of them as "group".
List group types.
requires authentication
Returns every group type defined on the instance, paginated. Global reference data; not project-scoped.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/application-types?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/application-types"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/application-types';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (500):
Show headers
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
{
"message": "An error occurred and was reported automatically.<br>(Event ID: 00ced9cdf5a240ac9b9dcdc6ebbdbd71)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Paginated list of Groups in a project.
requires authentication
Required permission: all_persons_and_applications_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/applications?application_type=volunteer&person_id=01a1e3a6-efbf-4413-9db1-f36e60489254&step=applied&updated_since=2026-05-07T00%3A00%3A00%2B00%3A00&per_page=25&withOwner=&withMembersCollection=&withSubordinatedApplications=&withJourney=&withEvents=&withEventAttendees=&withTickets=&withTranslatedValues=de%0A%0AEvery+Group+also+carries+an+always-on+%60related%60+block+%28no+flag+needed%29%3A+ID-only%0Aarrays+of+linked+resources+%E2%80%94+events%2C+teams%2C+ticket_types%2C+subordinated_applications%2C%0Acontracts+%E2%80%94+for+follow-up+requests.+%60owner%60+and+%60members%60+are+omitted+from+%60related%60%0Aas+they+are+already+top-level+%28person_id%2C+members%5B%5D%29." \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"per_page\": 16,
\"application_type\": \"architecto\",
\"person_id\": \"a4855dc5-0acb-33c3-b921-f4291f719ca0\",
\"step\": \"architecto\",
\"updated_since\": \"2026-10-28T22:23:53+01:00\",
\"withOwner\": false,
\"withMembersCollection\": true,
\"withSubordinatedApplications\": true,
\"withJourney\": true,
\"withEvents\": false,
\"withEventAttendees\": false,
\"withTickets\": false,
\"withTranslatedValues\": \"architecto\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/applications"
);
const params = {
"application_type": "volunteer",
"person_id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"step": "applied",
"updated_since": "2026-05-07T00:00:00+00:00",
"per_page": "25",
"withOwner": "0",
"withMembersCollection": "0",
"withSubordinatedApplications": "0",
"withJourney": "0",
"withEvents": "0",
"withEventAttendees": "0",
"withTickets": "0",
"withTranslatedValues": "de
Every Group also carries an always-on `related` block (no flag needed): ID-only
arrays of linked resources — events, teams, ticket_types, subordinated_applications,
contracts — for follow-up requests. `owner` and `members` are omitted from `related`
as they are already top-level (person_id, members[]).",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"per_page": 16,
"application_type": "architecto",
"person_id": "a4855dc5-0acb-33c3-b921-f4291f719ca0",
"step": "architecto",
"updated_since": "2026-10-28T22:23:53+01:00",
"withOwner": false,
"withMembersCollection": true,
"withSubordinatedApplications": true,
"withJourney": true,
"withEvents": false,
"withEventAttendees": false,
"withTickets": false,
"withTranslatedValues": "architecto"
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'application_type' => 'volunteer',
'person_id' => '01a1e3a6-efbf-4413-9db1-f36e60489254',
'step' => 'applied',
'updated_since' => '2026-05-07T00:00:00+00:00',
'per_page' => '25',
'withOwner' => '0',
'withMembersCollection' => '0',
'withSubordinatedApplications' => '0',
'withJourney' => '0',
'withEvents' => '0',
'withEventAttendees' => '0',
'withTickets' => '0',
'withTranslatedValues' => 'de
Every Group also carries an always-on `related` block (no flag needed): ID-only
arrays of linked resources — events, teams, ticket_types, subordinated_applications,
contracts — for follow-up requests. `owner` and `members` are omitted from `related`
as they are already top-level (person_id, members[]).',
],
'json' => [
'per_page' => 16,
'application_type' => 'architecto',
'person_id' => 'a4855dc5-0acb-33c3-b921-f4291f719ca0',
'step' => 'architecto',
'updated_since' => '2026-10-28T22:23:53+01:00',
'withOwner' => false,
'withMembersCollection' => true,
'withSubordinatedApplications' => true,
'withJourney' => true,
'withEvents' => false,
'withEventAttendees' => false,
'withTickets' => false,
'withTranslatedValues' => 'architecto',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"application_type_id": 3,
"application_type": {
"id": 3,
"shortname": "volunteer"
},
"person_id": null,
"members": [],
"current_step": {
"id": 42,
"shortname": "APPROVED"
},
"data": {
"shirt_size": "M"
},
"created_at": "2026-05-07T11:24:53+00:00",
"updated_at": "2026-05-07T11:24:53+00:00",
"anonymized_at": null,
"deactivated_at": null,
"deleted_at": null,
"related": {
"events": [
881
],
"teams": [
405
],
"ticket_types": [],
"subordinated_applications": [],
"contracts": []
}
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (422, Invalid query parameter):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"per_page": [
"The per page must not be greater than 100."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create a new Group in a project.
requires authentication
Required permission: all_persons_and_applications_administrate
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/applications" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"application_type\": \"volunteer\",
\"person_id\": \"01a1e3a6-efbf-4413-9db1-f36e60489254\",
\"data\": {
\"shirt_size\": \"M\",
\"dietary_pref\": \"vegan\"
}
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/applications"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"application_type": "volunteer",
"person_id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"data": {
"shirt_size": "M",
"dietary_pref": "vegan"
}
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'application_type' => 'volunteer',
'person_id' => '01a1e3a6-efbf-4413-9db1-f36e60489254',
'data' => [
'shirt_size' => 'M',
'dietary_pref' => 'vegan',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (201, Created):
{
"data": {
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"application_type_id": 3,
"application_type": {
"id": 3,
"shortname": "volunteer"
},
"person_id": null,
"members": [],
"current_step": {
"id": 40,
"shortname": "PENDING"
},
"data": {
"shirt_size": "M",
"dietary_pref": "vegan"
},
"calculated_data": {},
"created_at": "2026-05-07T11:24:53+00:00",
"updated_at": "2026-05-07T11:24:53+00:00",
"anonymized_at": null,
"deactivated_at": null,
"deleted_at": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (409, DuplicateId):
{
"error": {
"code": "duplicate",
"message": "A Group with this id already exists."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"application_type": [
"The application_type field is required."
],
"data": [
"The data field must be an array."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show a single Group by UUID.
requires authentication
Same sideload flags (withOwner, withMembersCollection, withJourney,
withEvents, withEventAttendees, withTickets, withSubordinatedApplications,
withTranslatedValues) are supported here as on the list endpoint.
Required permission: all_persons_and_applications_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/applications/16?withOwner=&withMembersCollection=&withJourney=&withEvents=&withEventAttendees=&withTickets=&withSubordinatedApplications=&withTranslatedValues=de" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16"
);
const params = {
"withOwner": "0",
"withMembersCollection": "0",
"withJourney": "0",
"withEvents": "0",
"withEventAttendees": "0",
"withTickets": "0",
"withSubordinatedApplications": "0",
"withTranslatedValues": "de",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'withOwner' => '0',
'withMembersCollection' => '0',
'withJourney' => '0',
'withEvents' => '0',
'withEventAttendees' => '0',
'withTickets' => '0',
'withSubordinatedApplications' => '0',
'withTranslatedValues' => 'de',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"application_type_id": 3,
"application_type": {
"id": 3,
"shortname": "volunteer"
},
"person_id": null,
"members": [],
"current_step": {
"id": 42,
"shortname": "APPROVED"
},
"data": {
"shirt_size": "M"
},
"calculated_data": {},
"created_at": "2026-05-07T11:24:53+00:00",
"updated_at": "2026-05-07T11:24:53+00:00",
"anonymized_at": null,
"deactivated_at": null,
"deleted_at": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update a Group (PUT = full replace, PATCH = sparse merge).
requires authentication
Both PUT and PATCH route here; replaceMissing is derived from the HTTP method.
applicationType (immutable) and step_id / workflow_step_id (transitions
belong to a separate endpoint) are rejected if present in the body.
members[] writes return 422 — manage members in the festiware backend or via v1.
Required permission: all_persons_and_applications_manage
Example request:
curl --request PUT \
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"data\": [],
\"updated_at\": \"2026-05-07T11:24:53+00:00\",
\"person_id\": \"architecto\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"data": [],
"updated_at": "2026-05-07T11:24:53+00:00",
"person_id": "architecto"
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16';
$response = $client->put(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'data' => [],
'updated_at' => '2026-05-07T11:24:53+00:00',
'person_id' => 'architecto',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"application_type_id": 3,
"application_type": {
"id": 3,
"shortname": "volunteer"
},
"person_id": null,
"members": [],
"current_step": {
"id": 42,
"shortname": "APPROVED"
},
"data": {
"shirt_size": "L"
},
"calculated_data": {},
"created_at": "2026-05-07T11:24:53+00:00",
"updated_at": "2026-05-07T12:00:00+00:00",
"anonymized_at": null,
"deactivated_at": null,
"deleted_at": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Example response (409, OptimisticLockFailed):
{
"error": {
"code": "optimistic_lock_failed",
"message": "The Group was modified by someone else; reload and retry.",
"errors": {
"current": {
"updated_at": "2026-05-07T11:24:53+00:00"
}
}
}
}
Example response (422, GroupTypeImmutable):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"application_type": [
"The application_type cannot be changed once set."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Soft-delete a Group.
requires authentication
Requires explicit confirm: true. Missing/false confirmation returns 422 and
the Group remains intact. Soft-deleted Groups return 404 from
all subsequent v2 endpoints.
Required permission: all_persons_and_applications_administrate
Example request:
curl --request DELETE \
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"confirm\": true
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"confirm": true
};
fetch(url, {
method: "DELETE",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16';
$response = $client->delete(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'confirm' => true,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (204, SoftDeleted):
Empty response
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"confirm": [
"The confirm field must be accepted."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Advance many Groups in one request.
requires authentication
force=false(default): every id is pre-validated; if ANY would fail, the response is 400bulk_validation_failedwith afailed_idsarray and no Group is transitioned.force=true: every id is processed; failures are reported per-entity inside a 200 response. Sync/async dispatch still follows ChangeWorkflowStepJob::$maxModelsForAsync (default 25); above that threshold the response is 202dispatched.
Bulk dry_run ABOVE the sync threshold returns 400
dry_run_async_unsupported — the preview cannot be produced asynchronously.
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/applications/transitions/bulk" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"ids\": [
4711,
4712
],
\"to_step_id\": 42,
\"to_step_name\": \"APPROVED\",
\"application_type\": \"vendor\",
\"workflow_id\": 5,
\"note\": \"Approved.\",
\"act_as_backend_creation\": false,
\"dry_run\": false,
\"force\": false
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/applications/transitions/bulk"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"ids": [
4711,
4712
],
"to_step_id": 42,
"to_step_name": "APPROVED",
"application_type": "vendor",
"workflow_id": 5,
"note": "Approved.",
"act_as_backend_creation": false,
"dry_run": false,
"force": false
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/transitions/bulk';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'ids' => [
4711,
4712,
],
'to_step_id' => 42,
'to_step_name' => 'APPROVED',
'application_type' => 'vendor',
'workflow_id' => 5,
'note' => 'Approved.',
'act_as_backend_creation' => false,
'dry_run' => false,
'force' => false,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, SyncBulk):
{
"data": {
"4711": {
"status": "success",
"previous_step_id": 41,
"current_step_id": 42,
"triggers_fired": 3
},
"4712": {
"status": "skipped",
"reason": "already_in_step",
"previous_step_id": 42
}
}
}
Example response (202, AsyncDispatched):
{
"status": "dispatched",
"count": 50
}
Example response (400, BulkValidationFailed):
{
"error": {
"code": "bulk_validation_failed",
"message": "One or more transitions are not possible. Use force=true to process the eligible entries.",
"failed_ids": [
{
"id": 4712,
"reason": "already_in_step",
"previous_step_id": 42
},
{
"id": 4713,
"reason": "application_not_found",
"previous_step_id": null
}
]
}
}
Example response (400, DryRunAsyncUnsupported):
{
"error": {
"code": "dry_run_async_unsupported",
"message": "Bulk dry_run is not supported for batches above the sync threshold."
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You do not have permission to perform this action."
}
}
Example response (422, InvalidPayload):
{
"error": {
"code": "invalid_payload",
"message": "The given data was invalid.",
"errors": {
"ids": [
"The ids field is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Advance a single Group to a new workflow step.
requires authentication
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16/transitions" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"to_step_id\": 42,
\"to_step_name\": \"APPROVED\",
\"application_type\": \"vendor\",
\"workflow_id\": 5,
\"note\": \"Approved via n8n.\",
\"act_as_backend_creation\": false,
\"dry_run\": false
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16/transitions"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"to_step_id": 42,
"to_step_name": "APPROVED",
"application_type": "vendor",
"workflow_id": 5,
"note": "Approved via n8n.",
"act_as_backend_creation": false,
"dry_run": false
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16/transitions';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'to_step_id' => 42,
'to_step_name' => 'APPROVED',
'application_type' => 'vendor',
'workflow_id' => 5,
'note' => 'Approved via n8n.',
'act_as_backend_creation' => false,
'dry_run' => false,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, Transitioned):
{
"data": {
"application_id": 4711,
"previous_workflow_step": {
"id": 41,
"shortname": "PENDING",
"name": "Pending"
},
"current_workflow_step": {
"id": 42,
"shortname": "APPROVED",
"name": "Approved",
"entered_at": "2026-05-25T10:00:00+00:00"
},
"triggers_fired": 3,
"note_saved": true
}
}
Example response (200, DryRun):
{
"data": {
"application_id": 4711,
"previous_workflow_step": {
"id": 41,
"shortname": "PENDING",
"name": "Pending"
},
"current_workflow_step": {
"id": 42,
"shortname": "APPROVED",
"name": "Approved",
"entered_at": "2026-05-25T10:00:00+00:00"
},
"triggers_fired": 0,
"note_saved": false,
"dry_run": true
}
}
Example response (400, AlreadyInStep):
{
"error": {
"code": "already_in_step",
"message": "The target is already in workflow step APPROVED."
}
}
Example response (400, StepNotInWorkflow):
{
"error": {
"code": "step_not_in_workflow",
"message": "The target step does not belong to the identified workflow."
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You do not have permission to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (404, WorkflowNotFound):
{
"error": {
"code": "workflow_not_found",
"message": "Workflow could not be resolved from the supplied disambiguation."
}
}
Example response (422, InvalidPayload):
{
"error": {
"code": "invalid_payload",
"message": "One of `to_step_id` or `to_step_name` is required.",
"errors": {
"to_step": [
"One of `to_step_id` or `to_step_name` is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Form Attachments
Upload and clear the file/image fields of a Group or a Person.
Every other data.* field is written through the Group/Person update endpoints, but an
upload field cannot be: its value is a stored path, and a JSON body carries no file. These
endpoints take the file itself (multipart/form-data, one file per request) and write the
resulting path into data.{field} for you.
The payload names neither a model type nor a type id — the URL says which record it is, and the record's own type(s) decide which form definition declares the field.
Download the file stored in a Group's form field.
requires authentication
Streams the file with its stored Content-Type and Content-Disposition: attachment.
No signed or public URL is handed out: the file comes through this authenticated request.
404 when the field is unknown, is not a file/image field, is empty, or its file is gone, and when field-level access (data qualification) hides the field from you — the same fields the backend hides.
Required permission: all_persons_and_applications_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
[binary file content]
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NoFile):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Download the file stored in a Person's form field.
requires authentication
Same behaviour as the Group download. The field counts as declared when any of the Person's types declares it.
Required permission: all_persons_and_applications_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
[binary file content]
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NoFile):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Upload a file into a Group's form field.
requires authentication
multipart/form-data, one file per request, part name file. Replaces whatever the
field held before; the previously stored file is deleted once the new path is committed,
unless another record still references it.
Size and type are validated against the same limits the frontend forms use — image
fields accept image formats only, file fields the instance's upload allowlist.
HEIC/HEIF uploads are converted to JPEG before validation, as everywhere else.
Required permission: all_persons_and_applications_manage
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "file=@/tmp/phpedU7gw" const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('file', document.querySelector('input[name="file"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'multipart/form-data',
'Accept' => 'application/json',
],
'multipart' => [
[
'name' => 'file',
'contents' => fopen('/tmp/phpedU7gw', 'r')
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"application_type_id": 3,
"data": {
"passport_scan": "form-attachments/01a1e3a6-efbf-4413-9db1-f36e60489254/8f14e45fceea167a5a36dedd4bea2543.jpg"
},
"created_at": "2026-05-07T11:24:53+00:00",
"updated_at": "2026-05-07T12:00:00+00:00"
}
}
Example response (401, Unauthorized):
{
"message": "Unauthenticated."
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, UnknownFieldOrGroup):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (422, NotAnUploadField):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"field": [
"The field 'shirt_size' does not take a file upload."
]
}
}
}
Example response (422, ProtectedByDataQualification):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"field": [
"Field 'passport_scan': you may not upload or clear its file — your user holds data qualification 'Personal Data' read-only. Do not retry; this needs a user with write access to it."
]
}
}
}
Example response (422, FileRejected):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"file": [
"The file is too large. A maximum of 20 MB is allowed."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Clear a Group's file/image field.
requires authentication
Removes the field from data and deletes the stored file (kept if another record still
references it). Clearing a field that is already empty is a no-op and still returns 200,
so retries are safe.
Required permission: all_persons_and_applications_manage
Example request:
curl --request DELETE \
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan';
$response = $client->delete(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"application_type_id": 3,
"data": {},
"created_at": "2026-05-07T11:24:53+00:00",
"updated_at": "2026-05-07T12:00:00+00:00"
}
}
Example response (401, Unauthorized):
{
"message": "Unauthenticated."
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, UnknownFieldOrGroup):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (422, NotAnUploadField):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"field": [
"The field 'shirt_size' does not take a file upload."
]
}
}
}
Example response (422, ProtectedByDataQualification):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"field": [
"Field 'passport_scan': you may not upload or clear its file — your user holds data qualification 'Personal Data' read-only. Do not retry; this needs a user with write access to it."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Upload a file into a Person's form field.
requires authentication
Same contract as the Group endpoint. A Person carries several person types, each with its own form definition; the field is accepted when any of them declares it.
Required permission: all_persons_and_applications_manage
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "file=@/tmp/phppbdDWp" const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('file', document.querySelector('input[name="file"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'multipart/form-data',
'Accept' => 'application/json',
],
'multipart' => [
[
'name' => 'file',
'contents' => fopen('/tmp/phppbdDWp', 'r')
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"data": {
"photo": "form-attachments/01a1e3a6-efbf-4413-9db1-f36e60489254/8f14e45fceea167a5a36dedd4bea2543.jpg"
},
"person_types": [
"volunteer"
],
"related": {
"teams": [],
"groups_owned": [],
"groups_member": [],
"events": [],
"tickets": [],
"orders": []
},
"created_at": "2026-04-30T11:24:53+00:00",
"updated_at": "2026-04-30T12:00:00+00:00"
}
}
Example response (401, Unauthorized):
{
"message": "Unauthenticated."
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, UnknownFieldOrPerson):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (422, NotAnUploadField):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"field": [
"The field 'firstname' does not take a file upload."
]
}
}
}
Example response (422, ProtectedByDataQualification):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"field": [
"Field 'passport_scan': you may not upload or clear its file — your user holds data qualification 'Personal Data' read-only. Do not retry; this needs a user with write access to it."
]
}
}
}
Example response (422, FileRejected):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"file": [
"This file type is not allowed."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Clear a Person's file/image field.
requires authentication
Required permission: all_persons_and_applications_manage
Example request:
curl --request DELETE \
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo';
$response = $client->delete(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"project_id": 1,
"data": {},
"person_types": [
"volunteer"
],
"related": {
"teams": [],
"groups_owned": [],
"groups_member": [],
"events": [],
"tickets": [],
"orders": []
},
"created_at": "2026-04-30T11:24:53+00:00",
"updated_at": "2026-04-30T12:00:00+00:00"
}
}
Example response (401, Unauthorized):
{
"message": "Unauthenticated."
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, UnknownFieldOrPerson):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (422, NotAnUploadField):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"field": [
"The field 'firstname' does not take a file upload."
]
}
}
}
Example response (422, ProtectedByDataQualification):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"field": [
"Field 'passport_scan': you may not upload or clear its file — your user holds data qualification 'Personal Data' read-only. Do not retry; this needs a user with write access to it."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Ticket Types
Paginated list of Ticket Types in a project.
requires authentication
Required permission: ticketcode_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/ticket-types?per_page=25" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/ticket-types"
);
const params = {
"per_page": "25",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/ticket-types';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 161,
"name": "Shuttle Berlin",
"shortname": "BFST",
"description": "Bus shuttle from Berlin Ostbahnhof",
"issue": 100,
"available_in_backend": true,
"ticket_category_id": 7,
"created_at": "2026-05-07T11:24:53+00:00",
"updated_at": "2026-05-07T11:24:53+00:00"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create a new Ticket Type in a project.
requires authentication
Required permission: ticketcode_administrate
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/ticket-types" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "name=Shuttle Berlin"\
--form "shortname=BFST"\
--form "description=Bus shuttle from Berlin Ostbahnhof to the festival site."\
--form "issue=100"\
--form "available_in_backend=1"\
--form "ticket_category_id=7"\
--form "additional_html=<p>Welcome!</p>"\
--form "ticket_background=@/tmp/phpzo3m7B" const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/ticket-types"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('name', 'Shuttle Berlin');
body.append('shortname', 'BFST');
body.append('description', 'Bus shuttle from Berlin Ostbahnhof to the festival site.');
body.append('issue', '100');
body.append('available_in_backend', '1');
body.append('ticket_category_id', '7');
body.append('additional_html', '<p>Welcome!</p>');
body.append('ticket_background', document.querySelector('input[name="ticket_background"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/ticket-types';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'multipart/form-data',
'Accept' => 'application/json',
],
'multipart' => [
[
'name' => 'name',
'contents' => 'Shuttle Berlin'
],
[
'name' => 'shortname',
'contents' => 'BFST'
],
[
'name' => 'description',
'contents' => 'Bus shuttle from Berlin Ostbahnhof to the festival site.'
],
[
'name' => 'issue',
'contents' => '100'
],
[
'name' => 'available_in_backend',
'contents' => '1'
],
[
'name' => 'ticket_category_id',
'contents' => '7'
],
[
'name' => 'additional_html',
'contents' => '<p>Welcome!</p>'
],
[
'name' => 'ticket_background',
'contents' => fopen('/tmp/phpzo3m7B', 'r')
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (201, Created):
{
"id": 161,
"name": "Shuttle Berlin",
"shortname": "BFST",
"description": null,
"issue": 100,
"available_in_backend": true,
"ticket_category_id": null,
"created_at": "2026-05-07T11:24:53+00:00",
"updated_at": "2026-05-07T11:24:53+00:00"
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"name": [
"The name field is required."
],
"shortname": [
"The shortname must be 2-4 alphanumeric characters."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show a single Ticket Type by id.
requires authentication
Required permission: ticketcode_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/ticket-types/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/ticket-types/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/ticket-types/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"id": 161,
"name": "Shuttle Berlin",
"shortname": "BFST",
"description": "Bus shuttle from Berlin Ostbahnhof",
"issue": 100,
"available_in_backend": true,
"ticket_category_id": 7,
"created_at": "2026-05-07T11:24:53+00:00",
"updated_at": "2026-05-07T11:24:53+00:00"
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Ticket Type not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update a Ticket Type (PATCH = sparse merge; PUT = full replace).
requires authentication
Required permission: ticketcode_administrate
Example request:
curl --request PUT \
"https://{instance}.festiwa.re/api/v2/projects/564/ticket-types/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "name=Shuttle Berlin"\
--form "shortname=BFST"\
--form "description=Bus shuttle from Berlin Ostbahnhof to the festival site."\
--form "issue=100"\
--form "available_in_backend=1"\
--form "ticket_category_id=7"\
--form "additional_html=<p>Welcome!</p>"\
--form "ticket_background=@/tmp/phpCeMk0A" const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/ticket-types/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('name', 'Shuttle Berlin');
body.append('shortname', 'BFST');
body.append('description', 'Bus shuttle from Berlin Ostbahnhof to the festival site.');
body.append('issue', '100');
body.append('available_in_backend', '1');
body.append('ticket_category_id', '7');
body.append('additional_html', '<p>Welcome!</p>');
body.append('ticket_background', document.querySelector('input[name="ticket_background"]').files[0]);
fetch(url, {
method: "PUT",
headers,
body,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/ticket-types/16';
$response = $client->put(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'multipart/form-data',
'Accept' => 'application/json',
],
'multipart' => [
[
'name' => 'name',
'contents' => 'Shuttle Berlin'
],
[
'name' => 'shortname',
'contents' => 'BFST'
],
[
'name' => 'description',
'contents' => 'Bus shuttle from Berlin Ostbahnhof to the festival site.'
],
[
'name' => 'issue',
'contents' => '100'
],
[
'name' => 'available_in_backend',
'contents' => '1'
],
[
'name' => 'ticket_category_id',
'contents' => '7'
],
[
'name' => 'additional_html',
'contents' => '<p>Welcome!</p>'
],
[
'name' => 'ticket_background',
'contents' => fopen('/tmp/phpCeMk0A', 'r')
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"id": 161,
"name": "Shuttle Berlin (updated)",
"shortname": "BFST",
"description": "Updated description.",
"issue": 150,
"available_in_backend": true,
"ticket_category_id": 7,
"created_at": "2026-05-07T11:24:53+00:00",
"updated_at": "2026-05-07T12:00:00+00:00"
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Ticket Type not found."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"shortname": [
"The shortname must be 2-4 alphanumeric characters."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete a Ticket Type. Refuses with 409 if the type still has issued ticket codes or offers attached.
requires authentication
Required permission: ticketcode_administrate
Example request:
curl --request DELETE \
"https://{instance}.festiwa.re/api/v2/projects/564/ticket-types/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/ticket-types/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/ticket-types/16';
$response = $client->delete(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (204, Deleted):
Empty response
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Ticket Type not found."
}
}
Example response (409, HasTicketCodes):
{
"error": {
"code": "ticket_type_has_codes",
"message": "This ticket type has issued ticket codes and cannot be deleted."
}
}
Example response (409, HasOffers):
{
"error": {
"code": "ticket_type_has_offers",
"message": "This ticket type has offers attached and cannot be deleted."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Ticket Codes
Paginated list of Ticket Codes in a project.
requires authentication
Each item nests its ticket_type (id, shortname) and its owner
(id, firstname, lastname, email); owner is null when the code
is unowned.
status is the code's current status, the same value v1 returns as
current_ticket_code_status.ticket_status: pending (not yet activated),
valid, redeemed (scanned at the gate) or revoked (annulled). It is null
for the rare code without any status row; such a code never opens a gate.
status_changed_at is when the current status was set.
All filters combine (AND).
Required permission: ticketcode_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes?per_page=25&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00&ticket_type=VIP&status=redeemed" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"per_page\": 16,
\"updated_since\": \"2026-10-28T22:23:53+01:00\",
\"ticket_type\": \"n\",
\"status\": \"revoked\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes"
);
const params = {
"per_page": "25",
"updated_since": "2026-06-01T00:00:00+00:00",
"ticket_type": "VIP",
"status": "redeemed",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"per_page": 16,
"updated_since": "2026-10-28T22:23:53+01:00",
"ticket_type": "n",
"status": "revoked"
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'updated_since' => '2026-06-01T00:00:00+00:00',
'ticket_type' => 'VIP',
'status' => 'redeemed',
],
'json' => [
'per_page' => 16,
'updated_since' => '2026-10-28T22:23:53+01:00',
'ticket_type' => 'n',
'status' => 'revoked',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": "9b1c4d2e-0000-4000-8000-000000000001",
"code": "ABC123",
"ticket_type": {
"id": 42,
"shortname": "VIP"
},
"owner": {
"id": "7a2f4d2e-0000-4000-8000-000000000002",
"firstname": "Anna",
"lastname": "Meyer",
"email": "anna@example.com"
},
"status": "redeemed",
"status_changed_at": "2026-06-02T18:30:00+00:00",
"created_at": "2026-06-01T10:00:00+00:00",
"updated_at": "2026-06-01T10:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"status": [
"The selected status is invalid."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show one Ticket Code, including its current status.
requires authentication
Same shape as an item of the list.
Required permission: ticketcode_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes/architecto" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes/architecto"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes/architecto';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": "9b1c4d2e-0000-4000-8000-000000000001",
"code": "ABC123",
"ticket_type": {
"id": 42,
"shortname": "VIP"
},
"owner": null,
"status": "valid",
"status_changed_at": "2026-06-01T10:00:00+00:00",
"created_at": "2026-06-01T10:00:00+00:00",
"updated_at": "2026-06-01T10:00:00+00:00"
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Orders
Paginated list of Orders in a project.
requires authentication
Each order carries currency, total_price, total_discount and total_donation —
major units, rounded to 2 decimals, the same values as the backend's order detail.
current_status is the status shortname (OPEN, PAID, DELIVERED, CANCELLED,
CONFLICTING, RETURNREQUESTED, RETURNED, SWAPPED). All filters combine (AND).
Required permission: order_see; withTicketCodes additionally ticketcode_see.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/orders?per_page=25&status=PAID%2CDELIVERED&created_from=2026-06-01T00%3A00%3A00%2B02%3A00&created_until=2026-07-01T00%3A00%3A00%2B02%3A00&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00&withPositions=&withTicketCodes=" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"per_page\": 16,
\"status\": \"architecto\",
\"statuses\": [
\"RETURNED\"
],
\"created_from\": \"2026-10-28T22:23:53+01:00\",
\"created_until\": \"2026-10-28T22:23:53+01:00\",
\"updated_since\": \"2026-10-28T22:23:53+01:00\",
\"withPositions\": false,
\"withTicketCodes\": false
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/orders"
);
const params = {
"per_page": "25",
"status": "PAID,DELIVERED",
"created_from": "2026-06-01T00:00:00+02:00",
"created_until": "2026-07-01T00:00:00+02:00",
"updated_since": "2026-06-01T00:00:00+00:00",
"withPositions": "0",
"withTicketCodes": "0",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"per_page": 16,
"status": "architecto",
"statuses": [
"RETURNED"
],
"created_from": "2026-10-28T22:23:53+01:00",
"created_until": "2026-10-28T22:23:53+01:00",
"updated_since": "2026-10-28T22:23:53+01:00",
"withPositions": false,
"withTicketCodes": false
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/orders';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'status' => 'PAID,DELIVERED',
'created_from' => '2026-06-01T00:00:00+02:00',
'created_until' => '2026-07-01T00:00:00+02:00',
'updated_since' => '2026-06-01T00:00:00+00:00',
'withPositions' => '0',
'withTicketCodes' => '0',
],
'json' => [
'per_page' => 16,
'status' => 'architecto',
'statuses' => [
'RETURNED',
],
'created_from' => '2026-10-28T22:23:53+01:00',
'created_until' => '2026-10-28T22:23:53+01:00',
'updated_since' => '2026-10-28T22:23:53+01:00',
'withPositions' => false,
'withTicketCodes' => false,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": "3f8a4d2e-0000-4000-8000-000000000001",
"order_number": "2026-000123",
"current_status": "PAID",
"firstname": "Anna",
"lastname": "Meyer",
"email": "anna@example.com",
"created_at": "2026-06-01T10:00:00+00:00",
"updated_at": "2026-06-01T10:00:00+00:00",
"currency": "EUR",
"total_price": 89,
"total_discount": 10,
"total_donation": 5
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (200, With positions (?withPositions=1)):
{
"data": [
{
"id": "3f8a4d2e-0000-4000-8000-000000000001",
"order_number": "2026-000123",
"current_status": "PAID",
"firstname": "Anna",
"lastname": "Meyer",
"email": "anna@example.com",
"created_at": "2026-06-01T10:00:00+00:00",
"updated_at": "2026-06-01T10:00:00+00:00",
"currency": "EUR",
"total_price": 89,
"total_discount": 10,
"total_donation": 5,
"positions": [
{
"id": 501,
"offer": {
"id": 300,
"shortname": "weekend"
},
"coupon": null,
"amount": 2,
"price": 47,
"total_price": 94,
"tax": 19,
"currency": "EUR",
"is_donation": false
}
]
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"statuses.0": [
"The selected statuses.0 is invalid."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show one Order with positions, payments and its invoice reference.
requires authentication
Adds billing data, payment_type, amount_received / amount_open, positions,
payments (the order's transactions that are not ignored — the same set
amount_received is summed from) and invoice (id, invoice_number, or null).
Required permission: order_see; withTicketCodes additionally ticketcode_see.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/orders/architecto?withTicketCodes=" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/orders/architecto"
);
const params = {
"withTicketCodes": "0",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/orders/architecto';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'withTicketCodes' => '0',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": "3f8a4d2e-0000-4000-8000-000000000001",
"order_number": "2026-000123",
"current_status": "PAID",
"firstname": "Anna",
"lastname": "Meyer",
"email": "anna@example.com",
"created_at": "2026-06-01T10:00:00+00:00",
"updated_at": "2026-06-01T10:00:00+00:00",
"currency": "EUR",
"total_price": 89,
"total_discount": 10,
"total_donation": 5,
"positions": [
{
"id": 501,
"offer": {
"id": 300,
"shortname": "weekend"
},
"coupon": null,
"amount": 2,
"price": 47,
"total_price": 94,
"tax": 19,
"currency": "EUR",
"is_donation": false
},
{
"id": 502,
"offer": null,
"coupon": {
"id": 12,
"code": "BUS-7F3K9A"
},
"amount": 1,
"price": -10,
"total_price": -10,
"tax": 0,
"currency": "EUR",
"is_donation": false
},
{
"id": 503,
"offer": null,
"coupon": null,
"amount": 1,
"price": 5,
"total_price": 5,
"tax": 0,
"currency": "EUR",
"is_donation": true
}
],
"person_id": "7a2f4d2e-0000-4000-8000-000000000002",
"company": null,
"address": "Hauptstr. 1",
"address2": null,
"zip": "04109",
"city": "Leipzig",
"country": "DE",
"vat_id": null,
"payment_type": "mollie",
"amount_received": 89,
"amount_open": 0,
"payments": [
{
"id": 7001,
"transaction_number": "tr_abc123",
"transaction_type": "payment",
"payment_method": "creditcard",
"status": "paid",
"balance": 89,
"currency": "EUR",
"registered_at": "2026-06-01T10:01:00+00:00",
"created_at": "2026-06-01T10:01:00+00:00"
}
],
"invoice": {
"id": 880,
"invoice_number": "RE-2026-0042"
}
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Offers
Money fields are JSON numbers: price is the gross amount in the project's
currency (the currency is a per-project setting, not part of the response),
and tax is a rate as a decimal (0.19 = 19%), not a computed amount.
Paginated list of OfferCategories in a project.
requires authentication
Sort: order ASC (admin-curated shop display order), id ASC tiebreaker.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/offer-categories?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offer-categories"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offer-categories';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 42,
"project_id": 5,
"name": "Outbound",
"shortname": "outbound",
"description": "Bus shuttles leaving the festival",
"order": 1,
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create an OfferCategory in a project.
requires authentication
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/offer-categories" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Outbound\",
\"shortname\": \"outbound\",
\"description\": \"Bus shuttles leaving the festival.\",
\"name_translations\": {
\"de\": \"Hinfahrt\",
\"en\": \"Outbound\"
},
\"description_translations\": {
\"de\": \"Busse ab dem Festival.\",
\"en\": \"Bus shuttles leaving the festival.\"
}
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offer-categories"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Outbound",
"shortname": "outbound",
"description": "Bus shuttles leaving the festival.",
"name_translations": {
"de": "Hinfahrt",
"en": "Outbound"
},
"description_translations": {
"de": "Busse ab dem Festival.",
"en": "Bus shuttles leaving the festival."
}
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offer-categories';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Outbound',
'shortname' => 'outbound',
'description' => 'Bus shuttles leaving the festival.',
'name_translations' => [
'de' => 'Hinfahrt',
'en' => 'Outbound',
],
'description_translations' => [
'de' => 'Busse ab dem Festival.',
'en' => 'Bus shuttles leaving the festival.',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (201, Created):
{
"id": 42,
"project_id": 5,
"name": "Outbound",
"shortname": "outbound",
"description": null,
"order": 0,
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:00:00+00:00"
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"name": [
"The name field is required."
],
"shortname": [
"The shortname field is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show a single OfferCategory.
requires authentication
?withOffers=1 adds the legacy combined payload — each offer with
its offerable (TicketType) inlined, mirroring v1's
with('offers.offerable'). Unpaginated. The new
/offer-categories/{category}/offers sub-resource is the
best-practice path for new integrators.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16?withOffers=" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16"
);
const params = {
"withOffers": "0",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'withOffers' => '0',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"id": 42,
"project_id": 5,
"name": "Outbound",
"shortname": "outbound",
"description": "Bus shuttles leaving the festival",
"order": 1,
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:00:00+00:00"
}
Example response (200, OK (withOffers=1)):
{
"id": 42,
"project_id": 5,
"name": "Outbound",
"shortname": "outbound",
"description": "Bus shuttles leaving the festival",
"order": 1,
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:00:00+00:00",
"offers": [
{
"id": 300,
"shortname": "shuttle-bln",
"description": "Bus shuttle from Berlin",
"price": 2500,
"tax": 19,
"issue": 100,
"ticket_count": 1,
"available_from": "2026-06-01T00:00:00+00:00",
"available_to": "2026-06-20T00:00:00+00:00",
"published_from": null,
"published_to": null,
"sort_order": 1,
"offerable": {
"id": 161,
"name": "Shuttle Berlin",
"shortname": "BFST",
"description": "Bus shuttle from Berlin Ostbahnhof",
"issue": 100,
"available_in_backend": true,
"ticket_category_id": 7,
"created_at": "2026-05-07T11:24:53+00:00",
"updated_at": "2026-05-07T11:24:53+00:00"
}
}
]
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update an OfferCategory. Both PUT and PATCH route here.
requires authentication
Optimistic lock via updated_at. Mismatch → 409
optimistic_lock_failed. PUT requires every editable field;
PATCH treats omissions as preserve-existing.
Example request:
curl --request PUT \
"https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Outbound\",
\"shortname\": \"outbound\",
\"description\": \"Bus shuttles leaving the festival.\",
\"order\": 2,
\"name_translations\": {
\"de\": \"Hinfahrt\",
\"en\": \"Outbound\"
},
\"description_translations\": {
\"de\": \"Busse ab dem Festival.\",
\"en\": \"Bus shuttles leaving the festival.\"
},
\"updated_at\": \"2026-06-23T09:00:00+00:00\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Outbound",
"shortname": "outbound",
"description": "Bus shuttles leaving the festival.",
"order": 2,
"name_translations": {
"de": "Hinfahrt",
"en": "Outbound"
},
"description_translations": {
"de": "Busse ab dem Festival.",
"en": "Bus shuttles leaving the festival."
},
"updated_at": "2026-06-23T09:00:00+00:00"
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16';
$response = $client->put(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Outbound',
'shortname' => 'outbound',
'description' => 'Bus shuttles leaving the festival.',
'order' => 2,
'name_translations' => [
'de' => 'Hinfahrt',
'en' => 'Outbound',
],
'description_translations' => [
'de' => 'Busse ab dem Festival.',
'en' => 'Bus shuttles leaving the festival.',
],
'updated_at' => '2026-06-23T09:00:00+00:00',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"id": 42,
"project_id": 5,
"name": "Outbound (updated)",
"shortname": "outbound",
"description": "Bus shuttles leaving the festival",
"order": 2,
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T12:00:00+00:00"
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (409, OptimisticLockFailed):
{
"error": {
"code": "optimistic_lock_failed",
"message": "The resource was modified after your last read. Re-fetch and retry.",
"errors": {
"current": {
"updated_at": "2026-06-23T12:00:00+00:00"
}
}
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"updated_at": [
"The updated_at field is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Soft-delete an OfferCategory.
requires authentication
Requires {"confirm": true} body. Returns 409
offer_category_has_offers (with count in the message) if the
category still has offers attached.
Example request:
curl --request DELETE \
"https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"confirm\": true
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"confirm": true
};
fetch(url, {
method: "DELETE",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16';
$response = $client->delete(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'confirm' => true,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (204, Deleted):
Empty response
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (409, HasOffers):
{
"error": {
"code": "offer_category_has_offers",
"message": "This offer category cannot be deleted because offers are still attached. (3)"
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"confirm": [
"The confirm field must be accepted."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Paginated list of OfferableOptions in a project.
requires authentication
Optional filter by offerable_id + offerable_type. The polymorphic
relation has no project_id column on the join — scoping is enforced
by filtering against TicketTypes belonging to the URL's {project}.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/offerable-options?offerable_id=16&offerable_type=architecto&per_page=25" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offerable-options"
);
const params = {
"offerable_id": "16",
"offerable_type": "architecto",
"per_page": "25",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offerable-options';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'offerable_id' => '16',
'offerable_type' => 'architecto',
'per_page' => '25',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 88,
"name": "Early bird seat",
"description": "Discounted seat for early buyers",
"issue": 50,
"overbook_allowance": 0,
"active": true,
"offerable_id": 161,
"offerable_type": "App\\TicketType",
"valid_from": "2026-06-01T00:00:00+00:00",
"valid_to": "2026-06-20T00:00:00+00:00",
"sort_order": 1,
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create an OfferableOption attached to a TicketType.
requires authentication
Resolves offerable_id × offerable_type, then verifies the
offerable belongs to the URL's {project} via
assertBelongsToProject() — cross-project returns 404 (same guard
used for URL-bound cross-tenant checks).
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/offerable-options" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"offerable_id\": 161,
\"offerable_type\": \"App\\\\TicketType\",
\"name\": \"Early bird seat\",
\"description\": \"Discounted seat for early buyers.\",
\"issue\": 50,
\"overbook_allowance\": 16,
\"active\": true,
\"valid_from\": \"2026-06-01\",
\"valid_to\": \"2026-06-20\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offerable-options"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"offerable_id": 161,
"offerable_type": "App\\TicketType",
"name": "Early bird seat",
"description": "Discounted seat for early buyers.",
"issue": 50,
"overbook_allowance": 16,
"active": true,
"valid_from": "2026-06-01",
"valid_to": "2026-06-20"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offerable-options';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'offerable_id' => 161,
'offerable_type' => 'App\\TicketType',
'name' => 'Early bird seat',
'description' => 'Discounted seat for early buyers.',
'issue' => 50,
'overbook_allowance' => 16,
'active' => true,
'valid_from' => '2026-06-01',
'valid_to' => '2026-06-20',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (201, Created):
{
"id": 88,
"name": "Early bird seat",
"description": "Discounted seat for early buyers",
"issue": 50,
"overbook_allowance": 0,
"active": true,
"offerable_id": 161,
"offerable_type": "App\\TicketType",
"valid_from": "2026-06-01T00:00:00+00:00",
"valid_to": "2026-06-20T00:00:00+00:00",
"sort_order": 0,
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:00:00+00:00"
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"offerable_id": [
"The offerable_id field is required."
],
"name": [
"The name field is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show a single OfferableOption.
requires authentication
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/offerable-options/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offerable-options/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offerable-options/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"id": 88,
"name": "Early bird seat",
"description": "Discounted seat for early buyers",
"issue": 50,
"overbook_allowance": 0,
"active": true,
"offerable_id": 161,
"offerable_type": "App\\TicketType",
"valid_from": "2026-06-01T00:00:00+00:00",
"valid_to": "2026-06-20T00:00:00+00:00",
"sort_order": 1,
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:00:00+00:00"
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update an OfferableOption. Both PUT and PATCH route here.
requires authentication
offerable_id and offerable_type are prohibited on update —
re-parenting is not supported. Optimistic lock via updated_at.
Example request:
curl --request PUT \
"https://{instance}.festiwa.re/api/v2/projects/564/offerable-options/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Early bird seat\",
\"description\": \"Discounted seat for early buyers.\",
\"issue\": 75,
\"overbook_allowance\": 16,
\"active\": true,
\"valid_from\": \"2026-06-01\",
\"valid_to\": \"2026-06-20\",
\"updated_at\": \"2026-06-23T09:00:00+00:00\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offerable-options/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Early bird seat",
"description": "Discounted seat for early buyers.",
"issue": 75,
"overbook_allowance": 16,
"active": true,
"valid_from": "2026-06-01",
"valid_to": "2026-06-20",
"updated_at": "2026-06-23T09:00:00+00:00"
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offerable-options/16';
$response = $client->put(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Early bird seat',
'description' => 'Discounted seat for early buyers.',
'issue' => 75,
'overbook_allowance' => 16,
'active' => true,
'valid_from' => '2026-06-01',
'valid_to' => '2026-06-20',
'updated_at' => '2026-06-23T09:00:00+00:00',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"id": 88,
"name": "Early bird seat (updated)",
"description": "Discounted seat for early buyers",
"issue": 75,
"overbook_allowance": 0,
"active": true,
"offerable_id": 161,
"offerable_type": "App\\TicketType",
"valid_from": "2026-06-01T00:00:00+00:00",
"valid_to": "2026-06-20T00:00:00+00:00",
"sort_order": 1,
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T12:00:00+00:00"
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (409, OptimisticLockFailed):
{
"error": {
"code": "optimistic_lock_failed",
"message": "The resource was modified after your last read. Re-fetch and retry.",
"errors": {
"current": {
"updated_at": "2026-06-23T12:00:00+00:00"
}
}
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"offerable_id": [
"The offerable_id field is prohibited."
],
"updated_at": [
"The updated_at field is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Soft-delete an OfferableOption.
requires authentication
Requires {"confirm": true} body. Returns 409
offerable_option_has_order_positions (with count in the message)
if the option still has order_positions attached.
Example request:
curl --request DELETE \
"https://{instance}.festiwa.re/api/v2/projects/564/offerable-options/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"confirm\": true
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offerable-options/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"confirm": true
};
fetch(url, {
method: "DELETE",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offerable-options/16';
$response = $client->delete(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'confirm' => true,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (204, Deleted):
Empty response
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (409, HasOrderPositions):
{
"error": {
"code": "offerable_option_has_order_positions",
"message": "This option cannot be deleted because order positions still reference it. (5)"
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"confirm": [
"The confirm field must be accepted."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Paginated list of Offers in a given OfferCategory.
requires authentication
Best-practice sub-resource path. Each offer carries its offerable
(TicketType) inlined by default — no opt-in flag. Mirrors Tier 2's
/event-plans/{plan}/events pattern. The legacy
/offer-categories/{category}?withOffers=1 endpoint provides the
same data unpaginated for v1 callers.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16/offers?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16/offers"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16/offers';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 300,
"shortname": "shuttle-bln",
"description": "Bus shuttle from Berlin",
"price": 2500,
"tax": 19,
"issue": 100,
"ticket_count": 1,
"available_from": "2026-06-01T00:00:00+00:00",
"available_to": "2026-06-20T00:00:00+00:00",
"published_from": null,
"published_to": null,
"sort_order": 1,
"offerable": {
"id": 161,
"name": "Shuttle Berlin",
"shortname": "BFST",
"description": "Bus shuttle from Berlin Ostbahnhof",
"issue": 100,
"available_in_backend": true,
"ticket_category_id": 7,
"created_at": "2026-05-07T11:24:53+00:00",
"updated_at": "2026-05-07T11:24:53+00:00"
}
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Paginated list of Offers in a project.
requires authentication
Admin surface: hidden and unpublished offers are included.
Each item carries its offerable (ticket type) and offer_category.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/offers?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offers"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offers';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 311,
"shortname": "Camping Club Hamburg",
"name_translations": null,
"description": null,
"price": 0,
"tax": 0,
"issue": 0,
"ticket_count": 1,
"hidden_link": "club-hamburg",
"image_url": "https://instance.example/storage/api-uploads/offers/1753364000_logo.png",
"email_product": true,
"max_order_amount": 8,
"required_ticket_types": [],
"required_person_types": [],
"no_dynamic_showing": false,
"available_from": null,
"available_to": null,
"published_from": null,
"published_to": null,
"sort_order": 1,
"updated_at": "2026-07-24T09:00:00+00:00",
"offerable": {
"id": 208,
"name": "Camping Club Hamburg",
"shortname": "CCHH",
"description": null,
"capacity": 0,
"valid_from": null,
"valid_to": null,
"available_in_backend": true,
"category_id": null,
"ticket_background_url": null,
"additional_html": null,
"created_at": "2026-07-24T09:00:00+00:00",
"updated_at": "2026-07-24T09:00:00+00:00"
},
"offer_category": {
"id": 91,
"project_id": 5,
"name": "Camping",
"shortname": "camping",
"description": null,
"order": 0,
"created_at": "2026-07-24T09:00:00+00:00",
"updated_at": "2026-07-24T09:00:00+00:00"
}
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create an Offer (composite).
requires authentication
Atomic: the ticket type is created from ticket_type[...] XOR
referenced via ticket_type_id; the category is referenced via
offer_category_id XOR found-or-created from offer_category[...]
(keyed on shortname + project). Any failure leaves zero rows and
zero stored files. Content type: multipart/form-data.
The 201 response carries hidden_link and image_url — consumers
(n8n) build the individualized shop link without a second request.
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/offers" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "shortname=Camping Club Hamburg"\
--form "name[de]=Camping Club Hamburg"\
--form "name[en]=Camping Club Hamburg"\
--form "description=Includes breakfast"\
--form "description_translations[de]=Inkl. Frühstück"\
--form "description_translations[en]=Includes breakfast"\
--form "price=0"\
--form "tax=0"\
--form "issue=27"\
--form "hidden_link=club-hamburg"\
--form "hide_show_all_link=1"\
--form "email_product="\
--form "max_order_amount=16"\
--form "published_from=2026-10-28T22:23:53"\
--form "published_to=2052-11-20"\
--form "available_from=2026-10-28T22:23:53"\
--form "available_to=2052-11-20"\
--form "required_ticket_types[]=16"\
--form "required_person_types[]=16"\
--form "no_dynamic_showing=1"\
--form "ticket_type_id=208"\
--form "ticket_type[name]=Camping Club Hamburg"\
--form "ticket_type[shortname]=CCHH"\
--form "ticket_type[description]=Eius et animi quos velit et."\
--form "ticket_type[issue]=60"\
--form "ticket_type[available_in_backend]="\
--form "ticket_type[ticket_category_id]=16"\
--form "ticket_type[valid_from]=2022-11-22"\
--form "ticket_type[valid_to]=2052-11-20"\
--form "ticket_type[additional_html]=<p>Welcome, Club Hamburg!</p>"\
--form "offer_category_id=91"\
--form "offer_category[name]=Camping"\
--form "offer_category[shortname]=camping"\
--form "offer_category[description]=Eius et animi quos velit et."\
--form "tax_rules[][layout]=architecto"\
--form "tax_rules[][key]=architecto"\
--form "tax_rules[][attributes][label]=n"\
--form "tax_rules[][attributes][percent]=1"\
--form "tax_rules[][attributes][rate]=1"\
--form "image=@/tmp/phpYaW5Yk" \
--form "ticket_type[ticket_background]=@/tmp/phpIU9fZs" const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offers"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('shortname', 'Camping Club Hamburg');
body.append('name[de]', 'Camping Club Hamburg');
body.append('name[en]', 'Camping Club Hamburg');
body.append('description', 'Includes breakfast');
body.append('description_translations[de]', 'Inkl. Frühstück');
body.append('description_translations[en]', 'Includes breakfast');
body.append('price', '0');
body.append('tax', '0');
body.append('issue', '27');
body.append('hidden_link', 'club-hamburg');
body.append('hide_show_all_link', '1');
body.append('email_product', '');
body.append('max_order_amount', '16');
body.append('published_from', '2026-10-28T22:23:53');
body.append('published_to', '2052-11-20');
body.append('available_from', '2026-10-28T22:23:53');
body.append('available_to', '2052-11-20');
body.append('required_ticket_types[]', '16');
body.append('required_person_types[]', '16');
body.append('no_dynamic_showing', '1');
body.append('ticket_type_id', '208');
body.append('ticket_type[name]', 'Camping Club Hamburg');
body.append('ticket_type[shortname]', 'CCHH');
body.append('ticket_type[description]', 'Eius et animi quos velit et.');
body.append('ticket_type[issue]', '60');
body.append('ticket_type[available_in_backend]', '');
body.append('ticket_type[ticket_category_id]', '16');
body.append('ticket_type[valid_from]', '2022-11-22');
body.append('ticket_type[valid_to]', '2052-11-20');
body.append('ticket_type[additional_html]', '<p>Welcome, Club Hamburg!</p>');
body.append('offer_category_id', '91');
body.append('offer_category[name]', 'Camping');
body.append('offer_category[shortname]', 'camping');
body.append('offer_category[description]', 'Eius et animi quos velit et.');
body.append('tax_rules[][layout]', 'architecto');
body.append('tax_rules[][key]', 'architecto');
body.append('tax_rules[][attributes][label]', 'n');
body.append('tax_rules[][attributes][percent]', '1');
body.append('tax_rules[][attributes][rate]', '1');
body.append('image', document.querySelector('input[name="image"]').files[0]);
body.append('ticket_type[ticket_background]', document.querySelector('input[name="ticket_type[ticket_background]"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offers';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'multipart/form-data',
'Accept' => 'application/json',
],
'multipart' => [
[
'name' => 'shortname',
'contents' => 'Camping Club Hamburg'
],
[
'name' => 'name[de]',
'contents' => 'Camping Club Hamburg'
],
[
'name' => 'name[en]',
'contents' => 'Camping Club Hamburg'
],
[
'name' => 'description',
'contents' => 'Includes breakfast'
],
[
'name' => 'description_translations[de]',
'contents' => 'Inkl. Frühstück'
],
[
'name' => 'description_translations[en]',
'contents' => 'Includes breakfast'
],
[
'name' => 'price',
'contents' => '0'
],
[
'name' => 'tax',
'contents' => '0'
],
[
'name' => 'issue',
'contents' => '27'
],
[
'name' => 'hidden_link',
'contents' => 'club-hamburg'
],
[
'name' => 'hide_show_all_link',
'contents' => '1'
],
[
'name' => 'email_product',
'contents' => ''
],
[
'name' => 'max_order_amount',
'contents' => '16'
],
[
'name' => 'published_from',
'contents' => '2026-10-28T22:23:53'
],
[
'name' => 'published_to',
'contents' => '2052-11-20'
],
[
'name' => 'available_from',
'contents' => '2026-10-28T22:23:53'
],
[
'name' => 'available_to',
'contents' => '2052-11-20'
],
[
'name' => 'required_ticket_types[]',
'contents' => '16'
],
[
'name' => 'required_person_types[]',
'contents' => '16'
],
[
'name' => 'no_dynamic_showing',
'contents' => '1'
],
[
'name' => 'ticket_type_id',
'contents' => '208'
],
[
'name' => 'ticket_type[name]',
'contents' => 'Camping Club Hamburg'
],
[
'name' => 'ticket_type[shortname]',
'contents' => 'CCHH'
],
[
'name' => 'ticket_type[description]',
'contents' => 'Eius et animi quos velit et.'
],
[
'name' => 'ticket_type[issue]',
'contents' => '60'
],
[
'name' => 'ticket_type[available_in_backend]',
'contents' => ''
],
[
'name' => 'ticket_type[ticket_category_id]',
'contents' => '16'
],
[
'name' => 'ticket_type[valid_from]',
'contents' => '2022-11-22'
],
[
'name' => 'ticket_type[valid_to]',
'contents' => '2052-11-20'
],
[
'name' => 'ticket_type[additional_html]',
'contents' => '<p>Welcome, Club Hamburg!</p>'
],
[
'name' => 'offer_category_id',
'contents' => '91'
],
[
'name' => 'offer_category[name]',
'contents' => 'Camping'
],
[
'name' => 'offer_category[shortname]',
'contents' => 'camping'
],
[
'name' => 'offer_category[description]',
'contents' => 'Eius et animi quos velit et.'
],
[
'name' => 'tax_rules[][layout]',
'contents' => 'architecto'
],
[
'name' => 'tax_rules[][key]',
'contents' => 'architecto'
],
[
'name' => 'tax_rules[][attributes][label]',
'contents' => 'n'
],
[
'name' => 'tax_rules[][attributes][percent]',
'contents' => '1'
],
[
'name' => 'tax_rules[][attributes][rate]',
'contents' => '1'
],
[
'name' => 'image',
'contents' => fopen('/tmp/phpYaW5Yk', 'r')
],
[
'name' => 'ticket_type[ticket_background]',
'contents' => fopen('/tmp/phpIU9fZs', 'r')
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (201, Created):
{
"data": {
"id": 311,
"shortname": "Camping Club Hamburg",
"price": 0,
"tax": 0,
"hidden_link": "club-hamburg",
"required_ticket_types": [
208
],
"required_person_types": [],
"no_dynamic_showing": true,
"image_url": "https://instance.example/storage/api-uploads/offers/1753364000_logo.png",
"offerable": {
"id": 208,
"shortname": "CCHH",
"ticket_background_url": "https://instance.example/storage/api-uploads/ticket-backgrounds/1753364000_bg.jpg",
"additional_html": "<p>Welcome!</p>"
},
"offer_category": {
"id": 91,
"name": "Camping",
"shortname": "camping"
}
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"ticket_type.shortname": [
"The ticket type.shortname field format is invalid."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show a single Offer.
requires authentication
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/offers/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offers/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offers/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": 311,
"shortname": "Camping Club Hamburg",
"price": 0,
"hidden_link": "club-hamburg",
"required_ticket_types": [
208
],
"required_person_types": [],
"no_dynamic_showing": true,
"image_url": null,
"offerable": {
"id": 208,
"shortname": "CCHH"
},
"offer_category": {
"id": 91,
"shortname": "camping"
}
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update an Offer (offer fields only). Both PUT and PATCH route here.
requires authentication
Optimistic lock via updated_at — mismatch → 409
optimistic_lock_failed. Omitted keys keep their current value on both
verbs; PUT additionally requires every mandatory field (shortname,
price, tax, issue, email_product), PATCH requires none of them.
Ticket-type fields are rejected with 422 — change them via the
ticket-types endpoint. Image changes: multipart POST + _method=PATCH.
Shop visibility gates (required_ticket_types, required_person_types,
no_dynamic_showing) are accepted here and on store.
Example request:
curl --request PUT \
"https://{instance}.festiwa.re/api/v2/projects/564/offers/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "shortname=Camping Club Hamburg"\
--form "price=0"\
--form "tax=0"\
--form "issue=0"\
--form "email_product=1"\
--form "name[de]=Camping Club Hamburg"\
--form "description=Includes breakfast and dinner"\
--form "description_translations[de]=Inkl. Frühstück und Abendessen"\
--form "description_translations[en]=Includes breakfast and dinner"\
--form "hidden_link=club-hamburg-2027"\
--form "hide_show_all_link=1"\
--form "max_order_amount=16"\
--form "published_from=2026-10-28T22:23:53"\
--form "published_to=2052-11-20"\
--form "available_from=2026-10-28T22:23:53"\
--form "available_to=2052-11-20"\
--form "offer_category_id=91"\
--form "required_ticket_types[]=16"\
--form "required_person_types[]=16"\
--form "no_dynamic_showing=1"\
--form "updated_at=2026-07-24T09:00:00+00:00"\
--form "image=@/tmp/phpsv3DqL" const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offers/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('shortname', 'Camping Club Hamburg');
body.append('price', '0');
body.append('tax', '0');
body.append('issue', '0');
body.append('email_product', '1');
body.append('name[de]', 'Camping Club Hamburg');
body.append('description', 'Includes breakfast and dinner');
body.append('description_translations[de]', 'Inkl. Frühstück und Abendessen');
body.append('description_translations[en]', 'Includes breakfast and dinner');
body.append('hidden_link', 'club-hamburg-2027');
body.append('hide_show_all_link', '1');
body.append('max_order_amount', '16');
body.append('published_from', '2026-10-28T22:23:53');
body.append('published_to', '2052-11-20');
body.append('available_from', '2026-10-28T22:23:53');
body.append('available_to', '2052-11-20');
body.append('offer_category_id', '91');
body.append('required_ticket_types[]', '16');
body.append('required_person_types[]', '16');
body.append('no_dynamic_showing', '1');
body.append('updated_at', '2026-07-24T09:00:00+00:00');
body.append('image', document.querySelector('input[name="image"]').files[0]);
fetch(url, {
method: "PUT",
headers,
body,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offers/16';
$response = $client->put(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'multipart/form-data',
'Accept' => 'application/json',
],
'multipart' => [
[
'name' => 'shortname',
'contents' => 'Camping Club Hamburg'
],
[
'name' => 'price',
'contents' => '0'
],
[
'name' => 'tax',
'contents' => '0'
],
[
'name' => 'issue',
'contents' => '0'
],
[
'name' => 'email_product',
'contents' => '1'
],
[
'name' => 'name[de]',
'contents' => 'Camping Club Hamburg'
],
[
'name' => 'description',
'contents' => 'Includes breakfast and dinner'
],
[
'name' => 'description_translations[de]',
'contents' => 'Inkl. Frühstück und Abendessen'
],
[
'name' => 'description_translations[en]',
'contents' => 'Includes breakfast and dinner'
],
[
'name' => 'hidden_link',
'contents' => 'club-hamburg-2027'
],
[
'name' => 'hide_show_all_link',
'contents' => '1'
],
[
'name' => 'max_order_amount',
'contents' => '16'
],
[
'name' => 'published_from',
'contents' => '2026-10-28T22:23:53'
],
[
'name' => 'published_to',
'contents' => '2052-11-20'
],
[
'name' => 'available_from',
'contents' => '2026-10-28T22:23:53'
],
[
'name' => 'available_to',
'contents' => '2052-11-20'
],
[
'name' => 'offer_category_id',
'contents' => '91'
],
[
'name' => 'required_ticket_types[]',
'contents' => '16'
],
[
'name' => 'required_person_types[]',
'contents' => '16'
],
[
'name' => 'no_dynamic_showing',
'contents' => '1'
],
[
'name' => 'updated_at',
'contents' => '2026-07-24T09:00:00+00:00'
],
[
'name' => 'image',
'contents' => fopen('/tmp/phpsv3DqL', 'r')
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": 311,
"shortname": "Camping Club Hamburg",
"hidden_link": "club-hamburg-2027",
"updated_at": "2026-07-24T12:00:00+00:00"
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (409, OptimisticLockFailed):
{
"error": {
"code": "optimistic_lock_failed",
"message": "The resource was modified after your last read. Re-fetch and retry.",
"errors": {
"current": {
"updated_at": "2026-07-24T12:00:00+00:00"
}
}
}
}
Example response (422, TicketTypeFieldsRejected):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"ticket_type": [
"Ticket type fields cannot be changed through the offer. Please use the ticket-types endpoint."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Soft-delete an Offer — the offer only, never the ticket type.
requires authentication
Requires {"confirm": true} body. Deletion goes through the model
(identical to backend deletion), so sales history, coupon links and
rebooking links survive by design. To also remove an orphaned ticket
type, call DELETE /ticket-types/{id} separately — it refuses with 409
while the type is still in use.
Example request:
curl --request DELETE \
"https://{instance}.festiwa.re/api/v2/projects/564/offers/3" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"confirm\": true
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/offers/3"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"confirm": true
};
fetch(url, {
method: "DELETE",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offers/3';
$response = $client->delete(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'confirm' => true,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (204, Deleted):
Empty response
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"confirm": [
"The confirm field must be accepted."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Coupons
Paginated list of coupons in a project, including their redemption status.
requires authentication
Every item has the same shape as the show endpoint: order_amount (orders placed on the
coupon), redeemed and is_valid. Use it for reconciliation and export; walk the pages
with updated_since for incremental syncs.
Required permission: coupon_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/coupons?per_page=25&active=1&redeemed=&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"per_page\": 16,
\"active\": false,
\"redeemed\": false,
\"updated_since\": \"2026-10-28T22:23:53+01:00\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/coupons"
);
const params = {
"per_page": "25",
"active": "1",
"redeemed": "0",
"updated_since": "2026-06-01T00:00:00+00:00",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"per_page": 16,
"active": false,
"redeemed": false,
"updated_since": "2026-10-28T22:23:53+01:00"
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/coupons';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'active' => '1',
'redeemed' => '0',
'updated_since' => '2026-06-01T00:00:00+00:00',
],
'json' => [
'per_page' => 16,
'active' => false,
'redeemed' => false,
'updated_since' => '2026-10-28T22:23:53+01:00',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 12,
"project_id": 5,
"code": "BUS-7F3K9A",
"type": "relative",
"value": 100,
"max_units": 1,
"active": true,
"is_singleuse": true,
"uses_reserved_contingent": false,
"valid_from": null,
"valid_until": null,
"person_id": null,
"offer_ids": [
300,
301
],
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:00:00+00:00",
"order_amount": 0,
"redeemed": false,
"is_valid": true
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"redeemed": [
"The redeemed field must be true or false."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create a coupon in a project.
requires authentication
Generates a unique code server-side and returns the coupon with its linked offers.
Restrict the coupon to specific products via offer_ids; omit them to apply to all.
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/coupons" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"type\": \"relative\",
\"value\": 100,
\"max_units\": 1,
\"is_singleuse\": true,
\"active\": true,
\"uses_reserved_contingent\": true,
\"valid_from\": \"2026-06-01T00:00:00+00:00\",
\"valid_until\": \"2026-06-30T00:00:00+00:00\",
\"code_prefix\": \"BUS\",
\"offer_ids\": [
16
],
\"person_id\": \"9b1c8a7e-4f3d-4c2a-9e1b-2f5a6c7d8e9f\",
\"code\": \"BURTON-MUC\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/coupons"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"type": "relative",
"value": 100,
"max_units": 1,
"is_singleuse": true,
"active": true,
"uses_reserved_contingent": true,
"valid_from": "2026-06-01T00:00:00+00:00",
"valid_until": "2026-06-30T00:00:00+00:00",
"code_prefix": "BUS",
"offer_ids": [
16
],
"person_id": "9b1c8a7e-4f3d-4c2a-9e1b-2f5a6c7d8e9f",
"code": "BURTON-MUC"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/coupons';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'type' => 'relative',
'value' => 100.0,
'max_units' => 1,
'is_singleuse' => true,
'active' => true,
'uses_reserved_contingent' => true,
'valid_from' => '2026-06-01T00:00:00+00:00',
'valid_until' => '2026-06-30T00:00:00+00:00',
'code_prefix' => 'BUS',
'offer_ids' => [
16,
],
'person_id' => '9b1c8a7e-4f3d-4c2a-9e1b-2f5a6c7d8e9f',
'code' => 'BURTON-MUC',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (201, Created):
{
"id": 12,
"project_id": 5,
"code": "BUS-7F3K9A",
"type": "relative",
"value": 100,
"active": true,
"is_singleuse": true,
"valid_from": null,
"valid_until": null,
"person_id": null,
"offer_ids": [
300,
301
],
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:00:00+00:00"
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"type": [
"The type field is required."
],
"value": [
"The value field is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show a coupon, including its redemption status.
requires authentication
order_amount is the number of orders placed on the coupon, redeemed is a
convenience boolean, and is_valid reflects the model's own validity check
(active + within the validity window + not a spent single-use coupon).
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/coupons/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/coupons/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/coupons/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": 12,
"project_id": 5,
"code": "BUS-7F3K9A",
"type": "relative",
"value": 100,
"active": true,
"is_singleuse": true,
"valid_from": null,
"valid_until": null,
"person_id": null,
"offer_ids": [
300,
301
],
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:00:00+00:00",
"order_amount": 0,
"redeemed": false,
"is_valid": true
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update a coupon (deactivate / reactivate).
requires authentication
Toggles active only. Deactivation is the storno path and works even for an
already-redeemed coupon — such a coupon cannot be deleted (its order positions
pin it via a RESTRICT foreign key), but it can always be switched inactive.
Example request:
curl --request PATCH \
"https://{instance}.festiwa.re/api/v2/projects/564/coupons/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"active\": false,
\"max_units\": 1,
\"uses_reserved_contingent\": true
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/coupons/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"active": false,
"max_units": 1,
"uses_reserved_contingent": true
};
fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/coupons/16';
$response = $client->patch(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'active' => false,
'max_units' => 1,
'uses_reserved_contingent' => true,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": 12,
"project_id": 5,
"code": "BUS-7F3K9A",
"type": "relative",
"value": 100,
"active": false,
"is_singleuse": true,
"valid_from": null,
"valid_until": null,
"person_id": null,
"offer_ids": [
300,
301
],
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:10:00+00:00",
"order_amount": 0,
"redeemed": false,
"is_valid": false
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"active": [
"The active field is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Scheduler
List scheduler plans visible to the authenticated caller.
requires authentication
Paginated list filtered by SchedulerPlan::scopeVisibleToUser($user) —
plans with view_roles are restricted to users whose roles match;
plans with no view_roles fall back to the scheduler_see permission.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/event-plans?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/event-plans"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/event-plans';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (500):
Show headers
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
{
"message": "An error occurred and was reported automatically.<br>(Event ID: f093e7dd023f49bc8e86225f789eaab0)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show a single scheduler plan.
requires authentication
Default returns plan scalars only — no events key on the response.
Set ?withEvents=1 to receive the full v1-equivalent shape: events
embedded as nested array, each event with area, jobs, attendees,
applications populated. For paginated event reads, use the dedicated
/event-plans/{plan}/events sub-resource instead.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/event-plans/16?withEvents=" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/event-plans/16"
);
const params = {
"withEvents": "0",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/event-plans/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'withEvents' => '0',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (500):
Show headers
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
{
"message": "An error occurred and was reported automatically.<br>(Event ID: 5971f7f2797f4a09b70b5904a47405f1)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List events in a scheduler plan.
requires authentication
Paginated. Default emission per event: area and jobs as full nested
resources; attendees and applications as UUID arrays. Upgrade
attendees / applications to full nested Person / Group resources
via the opt-in ?withAttendees=1 / ?withApplications=1 flags
(mirrors ApplicationsController's sideload pattern).
403 if the caller cannot view the plan (canUserView). 404 if the
plan does not belong to the URL's project.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/event-plans/16/events?withAttendees=&withApplications=&per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/event-plans/16/events"
);
const params = {
"withAttendees": "0",
"withApplications": "0",
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/event-plans/16/events';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'withAttendees' => '0',
'withApplications' => '0',
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (500):
Show headers
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
{
"message": "An error occurred and was reported automatically.<br>(Event ID: e4a6fca54f2041c48927671b77ada771)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List areas in a project.
requires authentication
Paginated list of areas scoped to the project.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/areas?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/areas"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/areas';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (500):
Show headers
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
{
"message": "An error occurred and was reported automatically.<br>(Event ID: 3390d145dae94eeaa645fe0ac987e865)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List events scheduled in an area.
requires authentication
Paginated. Each event carries area, jobs, attendees, applications
eager-loaded inline (mirrors the v1 emission).
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/areas/16/events?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/areas/16/events"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/areas/16/events';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (500):
Show headers
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
{
"message": "An error occurred and was reported automatically.<br>(Event ID: 166d2b9a16144d5a9ff03a75ee659246)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List scheduler event types in a project.
requires authentication
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/event-types?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/event-types"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/event-types';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (500):
Show headers
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
{
"message": "An error occurred and was reported automatically.<br>(Event ID: 0cd1a332b1cf402c827f93ad3c6c611c)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Supply
Paginated list of supplies (resources) in a project.
requires authentication
A supply is the type of equipment ("Label printer"), a supply item is the single
physical device. category_ids lets a client group the list without a second call.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/supplies?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/supplies"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/supplies';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 7,
"project_id": 5,
"name": "Label printer",
"description": "Zebra label printers",
"image_url": null,
"requires_existing_stock": true,
"allow_partial_duration": false,
"orderable_from": null,
"orderable_to": null,
"category_ids": [
2
],
"created_at": "2026-07-27T09:00:00+00:00",
"updated_at": "2026-07-27T09:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Paginated list of supply categories that carry at least one supply of this project.
requires authentication
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/supply-categories?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/supply-categories"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/supply-categories';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 2,
"name": "Technology",
"description": "Printers, scanners, radios",
"image_url": null,
"supply_ids": [
7,
8
],
"created_at": "2026-07-27T09:00:00+00:00",
"updated_at": "2026-07-27T09:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Paginated list of the single devices belonging to one supply.
requires authentication
latest_tracking_event tells a client who currently holds the device.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/supplies/16/items?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/supplies/16/items"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/supplies/16/items';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 17,
"supply_id": 7,
"name": "Zebra ZD421 #3",
"description": null,
"serial_number": "ZD421-0099123",
"tag_number": "A1B2C3D4",
"owner_type": "team",
"owner_id": "4",
"owner_name": "Crew Technik",
"available_from": null,
"available_to": null,
"image_url": null,
"label_url": "https://demo.festiwa.re/supply-label/5/7/17.pdf",
"latest_tracking_event": null,
"created_at": "2026-07-27T09:00:00+00:00",
"updated_at": "2026-07-27T09:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Register a single device on a supply.
requires authentication
This is the write half of the "register device" kiosk flow: pick a resource, name the
device, let the server assign a scannable tag, optionally set an owner, then print the
label from label_url.
Tag assignment: send tag_number to use your own code, send nothing to have one
generated (this bypasses the project setting "generate tag numbers"), or send
generate_tag: false to create an item without a tag.
Owner: either owner_type + owner_id, or owner_person_tag_number for the scan
path. The owner must belong to the same project.
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/supplies/16/items" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Zebra ZD421 #3\",
\"description\": \"Label printer, 300 dpi\",
\"serial_number\": \"ZD421-0099123\",
\"tag_number\": \"A1B2C3D4\",
\"generate_tag\": true,
\"owner_type\": \"team\",
\"owner_id\": \"17\",
\"owner_person_tag_number\": \"0010000101\",
\"available_from\": \"2026-08-01T08:00:00+02:00\",
\"available_to\": \"2026-08-10T20:00:00+02:00\",
\"image_url\": \"https:\\/\\/storage.example.com\\/supply\\/zd421.jpg\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/supplies/16/items"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Zebra ZD421 #3",
"description": "Label printer, 300 dpi",
"serial_number": "ZD421-0099123",
"tag_number": "A1B2C3D4",
"generate_tag": true,
"owner_type": "team",
"owner_id": "17",
"owner_person_tag_number": "0010000101",
"available_from": "2026-08-01T08:00:00+02:00",
"available_to": "2026-08-10T20:00:00+02:00",
"image_url": "https:\/\/storage.example.com\/supply\/zd421.jpg"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/supplies/16/items';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Zebra ZD421 #3',
'description' => 'Label printer, 300 dpi',
'serial_number' => 'ZD421-0099123',
'tag_number' => 'A1B2C3D4',
'generate_tag' => true,
'owner_type' => 'team',
'owner_id' => '17',
'owner_person_tag_number' => '0010000101',
'available_from' => '2026-08-01T08:00:00+02:00',
'available_to' => '2026-08-10T20:00:00+02:00',
'image_url' => 'https://storage.example.com/supply/zd421.jpg',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (201, Created):
{
"data": {
"id": 17,
"supply_id": 7,
"name": "Zebra ZD421 #3",
"description": null,
"serial_number": "ZD421-0099123",
"tag_number": "A1B2C3D4",
"owner_type": "team",
"owner_id": "4",
"owner_name": "Crew Technik",
"available_from": null,
"available_to": null,
"image_url": null,
"label_url": "https://demo.festiwa.re/supply-label/5/7/17.pdf",
"latest_tracking_event": null,
"created_at": "2026-07-27T09:00:00+00:00",
"updated_at": "2026-07-27T09:00:00+00:00"
}
}
Example response (400, InvalidScan):
{
"error": {
"code": "bad_request",
"message": "Dieser Pass ist nicht mehr aktuell."
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"tag_number": [
"The tag number has already been taken."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show a single device by id, without knowing its supply.
requires authentication
The kiosk scans a tag, gets the id from the tracking response, and re-reads the item here to refresh owner and holder.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/supply-items/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/supply-items/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/supply-items/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": 17,
"supply_id": 7,
"name": "Zebra ZD421 #3",
"description": null,
"serial_number": "ZD421-0099123",
"tag_number": "A1B2C3D4",
"owner_type": "team",
"owner_id": "4",
"owner_name": "Crew Technik",
"available_from": null,
"available_to": null,
"image_url": null,
"label_url": "https://demo.festiwa.re/supply-label/5/7/17.pdf",
"latest_tracking_event": null,
"created_at": "2026-07-27T09:00:00+00:00",
"updated_at": "2026-07-27T09:00:00+00:00"
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Book a tracking event for one or many devices: hand out, take back, send to maintenance.
requires authentication
One event is written per item, so a kiosk can scan a whole box of radios and hand them
to one person in a single call. issued, received and transferred need a person
(by id or by scan); every other event type is a stock movement and works without one.
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/supply-tracking-events" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"event_type\": \"issued\",
\"supply_item_ids\": [
16
],
\"supply_item_tag_numbers\": [
\"architecto\"
],
\"person_id\": \"550e8400-e29b-41d4-a716-446655440000\",
\"person_tag_number\": \"0010000101\",
\"supply_order_id\": 42,
\"area_id\": 3,
\"stock_location\": \"Warehouse A, Shelf 3\",
\"geolocation\": \"51.3397,12.3731\",
\"w3w\": \"filled.count.soap\",
\"annotations\": \"Cracked housing, sent to repair\",
\"image_url\": \"https:\\/\\/storage.example.com\\/tracking\\/img001.jpg\",
\"tracked_at\": \"2026-07-15T10:30:00+02:00\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/supply-tracking-events"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"event_type": "issued",
"supply_item_ids": [
16
],
"supply_item_tag_numbers": [
"architecto"
],
"person_id": "550e8400-e29b-41d4-a716-446655440000",
"person_tag_number": "0010000101",
"supply_order_id": 42,
"area_id": 3,
"stock_location": "Warehouse A, Shelf 3",
"geolocation": "51.3397,12.3731",
"w3w": "filled.count.soap",
"annotations": "Cracked housing, sent to repair",
"image_url": "https:\/\/storage.example.com\/tracking\/img001.jpg",
"tracked_at": "2026-07-15T10:30:00+02:00"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/supply-tracking-events';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'event_type' => 'issued',
'supply_item_ids' => [
16,
],
'supply_item_tag_numbers' => [
'architecto',
],
'person_id' => '550e8400-e29b-41d4-a716-446655440000',
'person_tag_number' => '0010000101',
'supply_order_id' => 42,
'area_id' => 3,
'stock_location' => 'Warehouse A, Shelf 3',
'geolocation' => '51.3397,12.3731',
'w3w' => 'filled.count.soap',
'annotations' => 'Cracked housing, sent to repair',
'image_url' => 'https://storage.example.com/tracking/img001.jpg',
'tracked_at' => '2026-07-15T10:30:00+02:00',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (201, Created):
{
"data": [
{
"id": 901,
"supply_item_id": 17,
"supply_order_id": null,
"event_type": "issued",
"person_id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": 3,
"area_id": null,
"stock_location": null,
"geolocation": null,
"w3w": null,
"annotations": null,
"image_url": null,
"tracked_at": "2026-07-27T09:00:00+00:00",
"created_at": "2026-07-27T09:00:00+00:00",
"updated_at": "2026-07-27T09:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (400, InvalidScan):
{
"error": {
"code": "bad_request",
"message": "Dieser Pass ist nicht mehr aktuell."
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"person_tag_number": [
"The person tag number field is required."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Teams
Paginated list of teams in a project.
requires authentication
Added for the supply kiosk's owner picker ("this device belongs to team X"); it is a plain project-scoped list and useful to any client that needs team names and ids.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/teams?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/teams"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/teams';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 4,
"project_id": 5,
"name": "Crew Technik",
"shortname": "tech",
"description": null,
"email": null,
"team_type_id": 2,
"team_id": null,
"order": 100,
"created_at": "2026-07-27T09:00:00+00:00",
"updated_at": "2026-07-27T09:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Access Control
Check a scanned person against this device's access gates.
requires authentication
The "Scan Access" page of the check-in app. Send the scanned NFC chip UID or pass QR
value as code, or a person_id for a manual lookup. The answer is always HTTP 200 —
an unknown or superseded badge is a scan outcome, not a request error.
status is one of:
granted— the person holds at least one configured gatedenied— the person holds none of them; compareaccess_groupsagainstrequired_gatesidentified— no gate is configured for this project on this user, so no verdict is rendered. This is the identify-only mode ("whose wristband is this?"), not an error.tag_devalidated— the badge exists but was superseded (pass reprinted) or deleted. Show this differently from a denial: the person is usually entitled, the badge is stale.unknown— nothing resolves for this code
Which person fields appear is an instance setting (access_scan_show_*); disabled
fields are absent from the payload entirely rather than nulled.
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/access-checks" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"code\": \"04A1B2C3D4E5F6\",
\"person_id\": \"550e8400-e29b-41d4-a716-446655440000\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/access-checks"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"code": "04A1B2C3D4E5F6",
"person_id": "550e8400-e29b-41d4-a716-446655440000"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/access-checks';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'code' => '04A1B2C3D4E5F6',
'person_id' => '550e8400-e29b-41d4-a716-446655440000',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, Granted):
{
"data": {
"status": "granted",
"person": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"firstname": "Alex",
"lastname": "Rieger",
"fullname": "Alex Rieger",
"person_types": [
"Crew"
],
"access_groups": [
{
"id": 104,
"name": "Backstage",
"shortname": "BS"
}
]
},
"matched_gates": [
{
"id": 104,
"project_id": 7,
"name": "Backstage",
"shortname": "BS",
"level": "access_group"
}
],
"required_gates": [
{
"id": 104,
"project_id": 7,
"name": "Backstage",
"shortname": "BS",
"level": "access_group"
}
],
"fields": [
"person_types",
"access_groups"
]
}
}
Example response (200, Denied):
{
"data": {
"status": "denied",
"person": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"firstname": "Alex",
"lastname": "Rieger",
"fullname": "Alex Rieger",
"person_types": [
"Visitor"
],
"access_groups": [
{
"id": 99,
"name": "Camping",
"shortname": "CA"
}
]
},
"matched_gates": [],
"required_gates": [
{
"id": 104,
"project_id": 7,
"name": "Backstage",
"shortname": "BS",
"level": "access_group"
}
],
"fields": [
"person_types",
"access_groups"
]
}
}
Example response (200, Devalidated):
{
"data": {
"status": "tag_devalidated",
"person": null,
"matched_gates": [],
"required_gates": [],
"fields": [
"person_types",
"access_groups"
]
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"code": [
"The code field is required when person id is not present."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Offline dataset for the "Scan Access" page: every scannable person of a project with their codes and access groups.
requires authentication
This is a full snapshot, paginated — not a delta. Deliberately so: a revoked access group or a devalidated badge is a deletion, and an incremental feed that only ships changed rows would leave the removed ones sitting in the device's IndexedDB, letting somebody through a gate they were taken off hours ago. Getting deletions right needs tombstones on three tables; until then the honest primitive is a snapshot the device swaps in wholesale.
To avoid re-downloading it every minute, poll {@see version()} — one cheap request that returns the dataset's fingerprint — and only refetch when it changed.
codes holds every value that resolves to this person when scanned: valid NFC chip
UIDs plus the numbers of printed passes (a pass whose QR was never mirrored into
nfc_tags still resolves server-side, so it must resolve offline too).
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/access-holders?per_page=100&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/access-holders"
);
const params = {
"per_page": "100",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/access-holders';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '100',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"firstname": "Alex",
"lastname": "Rieger",
"fullname": "Alex Rieger",
"person_types": [
"Crew"
],
"access_groups": [
{
"id": 104,
"name": "Backstage",
"shortname": "BS"
}
],
"access_group_ids": [
104
],
"codes": [
"04A1B2C3D4E5F6",
"0070000123"
]
}
],
"meta": {
"current_page": 1,
"per_page": 100,
"total": 1,
"last_page": 1,
"version": "7:2026-08-01T18:32:18+00:00",
"fields": [
"person_types",
"access_groups"
]
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Fingerprint of the current dataset — poll this instead of refetching the snapshot.
requires authentication
The value is opaque; compare it for equality and nothing else. It changes whenever a person, an access-group assignment, a badge or a pass in this project changes.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/access-holders/version" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/access-holders/version"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/access-holders/version';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"version": "7:2026-08-01T18:32:18+00:00",
"total": 842
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Projects
List projects the authenticated caller can access.
requires authentication
Filtered by ProjectContext::getAllowedProjectIdsForUser(). Callers without
an explicit user_projects restriction receive every project on the instance.
Use any returned id as the {project} parameter for project-nested v2 endpoints.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects?per_page=25&page=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects"
);
const params = {
"per_page": "25",
"page": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'page' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (500):
Show headers
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
{
"message": "An error occurred and was reported automatically.<br>(Event ID: e2dfc879cc34450a9ed77b97e671c4c3)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Comments
Paginated comments of a Group, oldest first.
requires authentication
Same shape and rules as the Person comments.
Required permission: all_persons_and_applications_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/applications/16/comments?per_page=25&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"per_page\": 16,
\"updated_since\": \"2026-10-28T22:23:53+01:00\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/applications/16/comments"
);
const params = {
"per_page": "25",
"updated_since": "2026-06-01T00:00:00+00:00",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"per_page": 16,
"updated_since": "2026-10-28T22:23:53+01:00"
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16/comments';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'updated_since' => '2026-06-01T00:00:00+00:00',
],
'json' => [
'per_page' => 16,
'updated_since' => '2026-10-28T22:23:53+01:00',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 8813,
"text": "Stage plan received.",
"author": {
"id": 3,
"name": "Backoffice"
},
"source": "user",
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:00:00+00:00",
"edited_at": null
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"updated_since": [
"The updated since field must match the format Y-m-d\\TH:i:sP."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Paginated comments of a Person, oldest first.
requires authentication
author is the backend user who wrote the comment ({id, name}), or null for
system comments. source is user or system accordingly. A note saved with a
workflow transition carries the acting user and reads user; it cannot be told apart
from a comment typed in the backend. Soft-deleted comments are not listed.
Required permission: all_persons_and_applications_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/comments?per_page=25&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"per_page\": 16,
\"updated_since\": \"2026-10-28T22:23:53+01:00\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/comments"
);
const params = {
"per_page": "25",
"updated_since": "2026-06-01T00:00:00+00:00",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"per_page": 16,
"updated_since": "2026-10-28T22:23:53+01:00"
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/comments';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'updated_since' => '2026-06-01T00:00:00+00:00',
],
'json' => [
'per_page' => 16,
'updated_since' => '2026-10-28T22:23:53+01:00',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 8812,
"text": "Approved via n8n.",
"author": {
"id": 14,
"name": "n8n Integration"
},
"source": "user",
"created_at": "2026-06-23T09:00:00+00:00",
"updated_at": "2026-06-23T09:00:00+00:00",
"edited_at": null
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"updated_since": [
"The updated since field must match the format Y-m-d\\TH:i:sP."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Contracts
Paginated list of contracts in a project.
requires authentication
subject ({type: person|application, id}) is what the contract is about; the ids in
related.contracts of people and groups are these subjects' contracts, so ?person= and
?application= return exactly those. status is the contract state (draft, approved,
signed, set_without_signature, canceled, …). Content, signatures and signing IPs are
not part of the API. All filters combine (AND).
Required permission: contract_manage
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/contracts?per_page=25&status=signed%2Cset_without_signature&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00&person=01a1e3a6-efbf-4413-9db1-f36e60489254&application=812&partner=01a1e3a6-efbf-4413-9db1-f36e60489254" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"per_page\": 16,
\"status\": \"architecto\",
\"statuses\": [
\"draft\"
],
\"updated_since\": \"2026-10-28T22:23:53+01:00\",
\"person\": \"a4855dc5-0acb-33c3-b921-f4291f719ca0\",
\"application\": 66,
\"partner\": \"977e5426-8d13-3824-86aa-b092f8ae52c5\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/contracts"
);
const params = {
"per_page": "25",
"status": "signed,set_without_signature",
"updated_since": "2026-06-01T00:00:00+00:00",
"person": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"application": "812",
"partner": "01a1e3a6-efbf-4413-9db1-f36e60489254",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"per_page": 16,
"status": "architecto",
"statuses": [
"draft"
],
"updated_since": "2026-10-28T22:23:53+01:00",
"person": "a4855dc5-0acb-33c3-b921-f4291f719ca0",
"application": 66,
"partner": "977e5426-8d13-3824-86aa-b092f8ae52c5"
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/contracts';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'status' => 'signed,set_without_signature',
'updated_since' => '2026-06-01T00:00:00+00:00',
'person' => '01a1e3a6-efbf-4413-9db1-f36e60489254',
'application' => '812',
'partner' => '01a1e3a6-efbf-4413-9db1-f36e60489254',
],
'json' => [
'per_page' => 16,
'status' => 'architecto',
'statuses' => [
'draft',
],
'updated_since' => '2026-10-28T22:23:53+01:00',
'person' => 'a4855dc5-0acb-33c3-b921-f4291f719ca0',
'application' => 66,
'partner' => '977e5426-8d13-3824-86aa-b092f8ae52c5',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 326,
"contract_number": "V-2026-0042",
"status": "signed",
"contract_type": {
"id": 4,
"shortname": "ARTIST",
"name": "Artist contract"
},
"subject": {
"type": "application",
"id": "812"
},
"partner_id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"owner_id": "7a2f4d2e-0000-4000-8000-000000000002",
"pdf_available": true,
"sent_at": "2026-05-02T10:00:00+02:00",
"approved_at": "2026-05-01T09:00:00+02:00",
"partner_feedback_at": null,
"owner_signed_at": "2026-05-03T12:00:00+02:00",
"partner_signed_at": "2026-05-02T18:00:00+02:00",
"owner_set_at": "2026-05-03T12:00:00+02:00",
"owner_declined_at": null,
"partner_declined_at": null,
"owner_revoked_at": null,
"partner_revoked_at": null,
"canceled_at": null,
"paid_at": null,
"owner_fulfilled_at": null,
"partner_fulfilled_at": null,
"fulfilled_at": null,
"created_at": "2026-05-01T09:00:00+02:00",
"updated_at": "2026-05-03T12:00:00+02:00"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"statuses.0": [
"The selected statuses.0 is invalid."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show one contract, including its state and the timestamps of every step.
requires authentication
Required permission: contract_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/contracts/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/contracts/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/contracts/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": 326,
"contract_number": "V-2026-0042",
"status": "signed",
"contract_type": {
"id": 4,
"shortname": "ARTIST",
"name": "Artist contract"
},
"subject": {
"type": "application",
"id": "812"
},
"partner_id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"owner_id": "7a2f4d2e-0000-4000-8000-000000000002",
"pdf_available": true,
"sent_at": "2026-05-02T10:00:00+02:00",
"approved_at": "2026-05-01T09:00:00+02:00",
"partner_feedback_at": null,
"owner_signed_at": "2026-05-03T12:00:00+02:00",
"partner_signed_at": "2026-05-02T18:00:00+02:00",
"owner_set_at": "2026-05-03T12:00:00+02:00",
"owner_declined_at": null,
"partner_declined_at": null,
"owner_revoked_at": null,
"partner_revoked_at": null,
"canceled_at": null,
"paid_at": null,
"owner_fulfilled_at": null,
"partner_fulfilled_at": null,
"fulfilled_at": null,
"created_at": "2026-05-01T09:00:00+02:00",
"updated_at": "2026-05-03T12:00:00+02:00"
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Download the stored PDF of a finalised contract.
requires authentication
Only contracts the owner has finalised (signed or set) carry a stored PDF — the legally
relevant document, never re-rendered. 404 for drafts and other contracts without one
(pdf_available: false). Nothing is rendered or saved by this call.
Required permission: contract_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/contracts/16/pdf" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/contracts/16/pdf"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/contracts/16/pdf';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
[binary PDF]
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Form Definitions
Validate a form definition.
requires authentication
Runs the deterministic rule set (docs/form-definition-validator-rules.md, ids FD-*) against
the stored form definition and returns every finding:
errors— would block saving (reject)warnings— saving works, but something is probably not what the author intendeddata_impact— needsconfirm_data_loss=trueon a write
Each finding names its rule, the field and a JSON path into the definition. Findings on
a stored definition describe the current state; data-impact findings appear when a change is
checked (dry run of an update).
Required permission: formdefinition_see, plus access to the form's person or group type.
Rate limited per user (api-v2-config-read).
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16/validation" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16/validation"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16/validation';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"form_definition_id": 42,
"valid": false,
"requires_confirm_data_loss": false,
"errors": [
{
"rule": "FD-L03",
"severity": "reject",
"field": null,
"path": "data.settings.app.triggers.shirt_size.conditions.0.field",
"message": "A condition of the visibility rule of \"shirt_size\" refers to \"merch\", which is not a field of this form."
}
],
"warnings": [
{
"rule": "FD-C04",
"severity": "warn",
"field": "shirt_size",
"path": "data.fields.shirt_size.label",
"message": "The label has no text for the active language(s): en."
}
],
"data_impact": []
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Example response (429, TooManyRequests):
{
"error": {
"code": "too_many_requests",
"message": "Too many requests."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List the form definitions of a project.
requires authentication
Only forms of the person and group types the user may see (as in the backend). data is
left out; add withData=1 to get it.
Required permission: formdefinition_see. Rate limited per user (api-v2-config-read).
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/form-definitions?model=person&withData=&per_page=25" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"model\": \"group\",
\"withData\": false
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/form-definitions"
);
const params = {
"model": "person",
"withData": "0",
"per_page": "25",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"model": "group",
"withData": false
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/form-definitions';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'model' => 'person',
'withData' => '0',
'per_page' => '25',
],
'json' => [
'model' => 'group',
'withData' => false,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 42,
"project_id": 5,
"name": "Crew",
"description": null,
"model": "person",
"type_shortname": "CrewPerson",
"related_form_id": null,
"url_shortname": "crew-2026",
"tab_label": {
"de": "Crew",
"en": "Crew"
},
"published": true,
"published_from": null,
"published_to": null,
"created_at": "2026-01-10T09:00:00+00:00",
"updated_at": "2026-10-08T09:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 25,
"total": 1
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (429, TooManyRequests):
{
"error": {
"code": "too_many_requests",
"message": "Too many requests."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show a form definition.
requires authentication
Everything the write endpoint takes, in the stored format: data (fields + settings),
texts per language, publication window — plus the read-only functions and
calculation (null, or the state of a running recalculation of the calculated fields).
Required permission: formdefinition_see, plus access to the form's person or group type.
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": 42,
"project_id": 5,
"name": "Crew",
"description": null,
"model": "person",
"type_shortname": "CrewPerson",
"related_form_id": null,
"url_shortname": "crew-2026",
"tab_label": {
"de": "Crew",
"en": "Crew"
},
"published": true,
"published_from": null,
"published_to": null,
"created_at": "2026-01-10T09:00:00+00:00",
"updated_at": "2026-10-08T09:00:00+00:00",
"html_intro": {
"de": "<p>Hallo</p>"
},
"success_response": null,
"unpublished_content": null,
"functions": null,
"calculation": null,
"data": {
"fields": {
"firstname": {
"id": "868f3b29-26d0-4cc9-b377-82751e94b758",
"name": "firstname",
"type": "text",
"label": {
"de": "Vorname",
"en": "First name"
},
"views": [
"index",
"detail",
"edit",
"app"
],
"tab": "name_and_contact"
}
},
"settings": {
"app": {
"triggers": []
},
"tabOrder": [
"name_and_contact"
]
}
}
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update a form definition.
requires authentication
PUT and PATCH both route here; PUT requires name, tab_label and data, PATCH keeps
what it omits. data is replaced as a whole.
Every write runs the form definition validator (docs/form-definition-validator-rules.md):
- What you change must be clean: every error on a field, settings section or attribute the
request changes → 422
form_definition_invalid, nothing saved — old errors in it included, so legacy flaws are fixed on the way. Errors elsewhere that the stored version already had (preexisting: true) are reported but do not block. Each finding names the rule, the JSON path, what is wrong and, inhint, how to fix it. data_impact→ 422confirm_data_loss_requiredunlessconfirm_data_loss=true.dry_run=true→ 200 with the report andwould_save, nothing saved.- Twig code of calculated fields may only transform and format values (rule FD-L13).
- Changing data qualification (
dqt_ids) of a field, or deleting a protected field, needs the admin role (rule FD-X02).
When the request changes the fields and the calculated fields of this form are being
recalculated, it answers 409 calculation_in_progress with the progress and a
Retry-After header (rule FD-X01) — a save would abort and restart the calculation. A dry
run still works.
Every save is written to the form's change log (who, when, before/after), like an edit in the backend. Renamed fields move their values. Calculated fields recalculate in the background only when the request changes the fields; editing attributes alone (name, texts, publication) does not recalculate.
model and to_type (the form's type) cannot be changed.
Required permission: formdefinition_manage, plus access to the form's person or group
type. Rate limited per user (api-v2-config-write).
Example request:
curl --request PUT \
"https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Crew registration\",
\"description\": \"Main crew form\",
\"tab_label\": {
\"de\": \"Crew\",
\"en\": \"Crew\"
},
\"url_shortname\": \"crew-2026\",
\"published\": true,
\"published_from\": \"2026-03-01T00:00:00+01:00\",
\"published_to\": \"2026-06-30T23:59:00+02:00\",
\"html_intro\": {
\"de\": \"<p>Willkommen!<\\/p>\",
\"en\": \"<p>Welcome!<\\/p>\"
},
\"success_response\": {
\"de\": \"<p>Danke!<\\/p>\",
\"en\": \"<p>Thanks!<\\/p>\"
},
\"unpublished_content\": {
\"de\": \"<p>Geschlossen.<\\/p>\",
\"en\": \"<p>Closed.<\\/p>\"
},
\"data\": {
\"fields\": {
\"firstname\": {
\"id\": \"868f3b29-26d0-4cc9-b377-82751e94b758\",
\"name\": \"firstname\",
\"type\": \"text\",
\"label\": {
\"de\": \"Vorname\",
\"en\": \"First name\"
},
\"views\": [
\"index\",
\"detail\",
\"edit\",
\"app\"
],
\"tab\": \"name_and_contact\",
\"searchable\": true
}
},
\"settings\": {
\"app\": {
\"triggers\": []
},
\"tabOrder\": [
\"name_and_contact\"
]
}
},
\"updated_at\": \"2026-10-08T09:00:00+00:00\",
\"dry_run\": true,
\"confirm_data_loss\": false
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Crew registration",
"description": "Main crew form",
"tab_label": {
"de": "Crew",
"en": "Crew"
},
"url_shortname": "crew-2026",
"published": true,
"published_from": "2026-03-01T00:00:00+01:00",
"published_to": "2026-06-30T23:59:00+02:00",
"html_intro": {
"de": "<p>Willkommen!<\/p>",
"en": "<p>Welcome!<\/p>"
},
"success_response": {
"de": "<p>Danke!<\/p>",
"en": "<p>Thanks!<\/p>"
},
"unpublished_content": {
"de": "<p>Geschlossen.<\/p>",
"en": "<p>Closed.<\/p>"
},
"data": {
"fields": {
"firstname": {
"id": "868f3b29-26d0-4cc9-b377-82751e94b758",
"name": "firstname",
"type": "text",
"label": {
"de": "Vorname",
"en": "First name"
},
"views": [
"index",
"detail",
"edit",
"app"
],
"tab": "name_and_contact",
"searchable": true
}
},
"settings": {
"app": {
"triggers": []
},
"tabOrder": [
"name_and_contact"
]
}
},
"updated_at": "2026-10-08T09:00:00+00:00",
"dry_run": true,
"confirm_data_loss": false
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16';
$response = $client->put(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Crew registration',
'description' => 'Main crew form',
'tab_label' => [
'de' => 'Crew',
'en' => 'Crew',
],
'url_shortname' => 'crew-2026',
'published' => true,
'published_from' => '2026-03-01T00:00:00+01:00',
'published_to' => '2026-06-30T23:59:00+02:00',
'html_intro' => [
'de' => '<p>Willkommen!</p>',
'en' => '<p>Welcome!</p>',
],
'success_response' => [
'de' => '<p>Danke!</p>',
'en' => '<p>Thanks!</p>',
],
'unpublished_content' => [
'de' => '<p>Geschlossen.</p>',
'en' => '<p>Closed.</p>',
],
'data' => [
'fields' => [
'firstname' => [
'id' => '868f3b29-26d0-4cc9-b377-82751e94b758',
'name' => 'firstname',
'type' => 'text',
'label' => [
'de' => 'Vorname',
'en' => 'First name',
],
'views' => [
'index',
'detail',
'edit',
'app',
],
'tab' => 'name_and_contact',
'searchable' => true,
],
],
'settings' => [
'app' => [
'triggers' => [],
],
'tabOrder' => [
'name_and_contact',
],
],
],
'updated_at' => '2026-10-08T09:00:00+00:00',
'dry_run' => true,
'confirm_data_loss' => false,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, Saved):
{
"data": {
"id": 42,
"project_id": 5,
"name": "Crew",
"model": "person",
"type_shortname": "CrewPerson",
"updated_at": "2026-10-08T09:05:00+00:00",
"calculation": null,
"data": {
"fields": {},
"settings": {}
}
},
"validation": {
"valid": true,
"requires_confirm_data_loss": false,
"errors": [],
"warnings": [],
"data_impact": []
}
}
Example response (200, DryRun):
{
"data": {
"dry_run": true,
"would_save": false,
"validation": {
"valid": true,
"requires_confirm_data_loss": true,
"errors": [],
"warnings": [],
"data_impact": [
{
"rule": "FD-D01",
"severity": "confirm",
"field": "diet",
"path": "data.fields.diet",
"message": "The field holds values that would become unreachable.",
"impact": {
"affected_count": 12,
"sample_ids": [
101,
102,
103,
104,
105
],
"change": "removed",
"recommended_action": "Hide the field instead of removing it."
}
}
]
}
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Example response (409, OptimisticLockFailed):
{
"error": {
"code": "optimistic_lock_failed",
"message": "The resource was modified after your last read. Re-fetch and retry.",
"errors": {
"current": {
"updated_at": "2026-10-08T09:05:00+00:00"
}
}
}
}
Example response (409, CalculationInProgress):
{
"error": {
"code": "calculation_in_progress",
"message": "The calculated fields of this form are being recalculated (running, 400 of 1390). Retry after the recalculation has finished.",
"errors": {
"calculation": {
"state": "running",
"since": "2026-10-08T09:01:00+00:00",
"done": 400,
"total": 1390
}
}
}
}
Example response (422, Invalid):
{
"error": {
"code": "form_definition_invalid",
"message": "The form definition was not saved: 1 error(s).",
"errors": {
"validation": {
"valid": false,
"requires_confirm_data_loss": false,
"errors": [
{
"rule": "FD-L03",
"severity": "reject",
"path": "data.settings.app.triggers.shirt_size.conditions.0.field",
"message": "A condition of the visibility rule of \"shirt_size\" refers to \"merch\", which is not a field of this form."
}
],
"warnings": [],
"data_impact": []
}
}
}
}
Example response (422, ConfirmDataLoss):
{
"error": {
"code": "confirm_data_loss_required",
"message": "The change affects values that records already hold.",
"errors": {
"validation": {
"valid": true,
"requires_confirm_data_loss": true,
"errors": [],
"warnings": [],
"data_impact": []
}
}
}
}
Example response (429, TooManyRequests):
{
"error": {
"code": "too_many_requests",
"message": "Too many requests."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Invoices
Paginated list of invoices, cancellations and credit notes of people, groups and orders.
requires authentication
invoiceable names the Person, Group or Order the document belongs to ({type, id}), so a
failed person deletion (409 person_has_invoices) can be traced to its documents with
?person=. status is the document state: DRAFT, OPEN, PARTIALLY_PAID, PAID,
VOIDED, CREDITED, DONE, PAIDOUT. Money in major units, rounded to the currency.
Order invoices that do not exist yet are not created here. All filters combine (AND).
Required permission: invoice_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/invoices?per_page=25&status=OPEN%2CPARTIALLY_PAID&document_type=invoice&created_from=2026-06-01T00%3A00%3A00%2B02%3A00&created_until=2026-07-01T00%3A00%3A00%2B02%3A00&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00&person=01a1e3a6-efbf-4413-9db1-f36e60489254&application=812&order=3f8a4d2e-0000-4000-8000-000000000001" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"per_page\": 16,
\"status\": \"architecto\",
\"statuses\": [
\"PAID\"
],
\"document_type\": \"cancellation\",
\"created_from\": \"2026-10-28T22:23:53+01:00\",
\"created_until\": \"2026-10-28T22:23:53+01:00\",
\"updated_since\": \"2026-10-28T22:23:53+01:00\",
\"person\": \"a4855dc5-0acb-33c3-b921-f4291f719ca0\",
\"application\": 66,
\"order\": \"977e5426-8d13-3824-86aa-b092f8ae52c5\"
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/invoices"
);
const params = {
"per_page": "25",
"status": "OPEN,PARTIALLY_PAID",
"document_type": "invoice",
"created_from": "2026-06-01T00:00:00+02:00",
"created_until": "2026-07-01T00:00:00+02:00",
"updated_since": "2026-06-01T00:00:00+00:00",
"person": "01a1e3a6-efbf-4413-9db1-f36e60489254",
"application": "812",
"order": "3f8a4d2e-0000-4000-8000-000000000001",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"per_page": 16,
"status": "architecto",
"statuses": [
"PAID"
],
"document_type": "cancellation",
"created_from": "2026-10-28T22:23:53+01:00",
"created_until": "2026-10-28T22:23:53+01:00",
"updated_since": "2026-10-28T22:23:53+01:00",
"person": "a4855dc5-0acb-33c3-b921-f4291f719ca0",
"application": 66,
"order": "977e5426-8d13-3824-86aa-b092f8ae52c5"
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/invoices';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
'status' => 'OPEN,PARTIALLY_PAID',
'document_type' => 'invoice',
'created_from' => '2026-06-01T00:00:00+02:00',
'created_until' => '2026-07-01T00:00:00+02:00',
'updated_since' => '2026-06-01T00:00:00+00:00',
'person' => '01a1e3a6-efbf-4413-9db1-f36e60489254',
'application' => '812',
'order' => '3f8a4d2e-0000-4000-8000-000000000001',
],
'json' => [
'per_page' => 16,
'status' => 'architecto',
'statuses' => [
'PAID',
],
'document_type' => 'cancellation',
'created_from' => '2026-10-28T22:23:53+01:00',
'created_until' => '2026-10-28T22:23:53+01:00',
'updated_since' => '2026-10-28T22:23:53+01:00',
'person' => 'a4855dc5-0acb-33c3-b921-f4291f719ca0',
'application' => 66,
'order' => '977e5426-8d13-3824-86aa-b092f8ae52c5',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 880,
"invoice_number": "RE-2026-0042",
"document_type": "invoice",
"status": "OPEN",
"invoiceable": {
"type": "application",
"id": "812"
},
"invoice_type": {
"id": 3,
"name": "Exhibitor fee"
},
"original_invoice_id": null,
"currency": "EUR",
"total_amount": 238,
"total_net": 200,
"total_tax": 38,
"paid_amount": 0,
"open_amount": 238,
"delivered_date": "2026-07-12T00:00:00+02:00",
"created_at": "2026-06-02T10:00:00+02:00",
"updated_at": "2026-06-02T10:00:00+02:00"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
},
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (422, ValidationFailed):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"statuses.0": [
"The selected statuses.0 is invalid."
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show one invoice with billing address, payment terms and positions.
requires authentication
Required permission: invoice_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/invoices/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/invoices/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/invoices/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": 880,
"invoice_number": "RE-2026-0042",
"document_type": "invoice",
"status": "OPEN",
"invoiceable": {
"type": "application",
"id": "812"
},
"invoice_type": {
"id": 3,
"name": "Exhibitor fee"
},
"original_invoice_id": null,
"currency": "EUR",
"total_amount": 238,
"total_net": 200,
"total_tax": 38,
"paid_amount": 0,
"open_amount": 238,
"delivered_date": "2026-07-12T00:00:00+02:00",
"created_at": "2026-06-02T10:00:00+02:00",
"updated_at": "2026-06-02T10:00:00+02:00",
"billing_address": "Example GmbH, Hauptstr. 1, 04109 Leipzig, DE",
"payment_terms": "14 days net",
"positions": [
{
"shortname": "Stand fee",
"price": 200,
"amount": 1,
"unit": "",
"totalPrice": 238,
"totalTax": 38,
"totalNet": 200,
"currency": "EUR",
"tax": 19,
"service_type": "default",
"reverse_charge_applies": false
}
]
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Download the invoice as PDF.
requires authentication
Rendered on request from the stored invoice, the same document the backend's "download invoice" actions produce (with the e-invoice XML embedded when the project's export setting says so). Nothing is created or saved. 404 when the invoice has nothing to render it for — a person/group invoice without recipient person, or an order invoice whose order is gone.
Required permission: invoice_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/invoices/16/pdf" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/invoices/16/pdf"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/invoices/16/pdf';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
[binary PDF]
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You are not authorized to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Workflows
Export a workflow as a portable payload.
requires authentication
Serialises the workflow, its steps and triggers into a project-agnostic payload that uses shortnames instead of project-local ids. Feed the result straight into the import endpoint.
Required permission:
workflow_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/workflows/16/export" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/workflows/16/export"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/workflows/16/export';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, Exported):
{
"data": {
"version": 1,
"workflow_shortname": "onboarding",
"type_shortname": "artist",
"type_model": "person",
"name": "Onboarding",
"steps": [
{
"shortname": "NEW",
"is_start": true,
"triggers": [
{
"action_type": "notify_user",
"active": true,
"data": {
"notifiable_id": "crew@example.com"
}
}
]
}
]
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You do not have permission to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Import a workflow into a project.
requires authentication
Resolves every reference in the payload against the target project. If everything resolves the
workflow is created (or replaces a same-shortname workflow with no started journey) with all
triggers on. A missing dependency is a blocker: the import is refused (422) and nothing is
written — unless deactivate_unresolved is set, which commits with the affected triggers
switched off (listed in the report). A problem on a start or endpoint step is always a
showstopper and cannot be waived.
Required permission:
workflow_manage
Example request:
curl --request POST \
"https://{instance}.festiwa.re/api/v2/projects/564/workflows/import" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"payload\": {
\"workflow_shortname\": \"onboarding\",
\"type_shortname\": \"artist\",
\"steps\": [
[]
]
},
\"target_type\": \"artist\",
\"deactivate_unresolved\": false
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/workflows/import"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"payload": {
"workflow_shortname": "onboarding",
"type_shortname": "artist",
"steps": [
[]
]
},
"target_type": "artist",
"deactivate_unresolved": false
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/workflows/import';
$response = $client->post(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'payload' => [
'workflow_shortname' => 'onboarding',
'type_shortname' => 'artist',
'steps' => [
[],
],
],
'target_type' => 'artist',
'deactivate_unresolved' => false,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, Imported):
{
"data": {
"committed": true,
"workflow_id": 42,
"steps_created": 3,
"triggers_created": 5,
"deactivated_triggers": [],
"issues": []
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You do not have permission to perform this action."
}
}
Example response (422, Blockers):
{
"error": {
"code": "import_has_blockers",
"message": "The workflow has unresolved references. Prepare the target or re-send with deactivate_unresolved to import the affected triggers switched off.",
"report": {
"can_commit": false,
"issues": [
{
"severity": "blocker",
"code": "reference_unresolved",
"step_shortname": "NEW",
"field": "area",
"token": "mainstage"
}
]
}
}
}
Example response (422, Showstoppers):
{
"error": {
"code": "import_has_showstoppers",
"message": "The workflow cannot be imported. Resolve the showstoppers in the report first.",
"report": {
"can_commit": false,
"issues": [
{
"severity": "showstopper",
"code": "workflow_type_mismatch",
"token": "artist"
}
]
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List a workflow's triggers.
requires authentication
Lists every trigger of the workflow's steps. Filter by on/off with active to find the
triggers an import switched off, then complete them via the update endpoint.
Required permission:
workflow_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/workflows/16/triggers?active=1" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/workflows/16/triggers"
);
const params = {
"active": "1",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/workflows/16/triggers';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'active' => '1',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, Listed):
{
"data": [
{
"id": 77,
"name": "Notify crew",
"action_type": "notify_user",
"triggers_on": [
"enter"
],
"active": false,
"observed_id": 42,
"data": {
"notifiable_id": null
}
}
]
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You do not have permission to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update a workflow trigger.
requires authentication
Fix and reactivate a trigger after an import (merge missing values into data, set active),
or edit its content: texts, conditions, name, delay. Scoped to the project; only triggers of a
workflow step.
Every request runs the trigger validator (docs/workflow-trigger-validator-rules.md, WT-*):
Twig syntax with line numbers, unknown placeholders, an allow-list of Twig constructs, the
condition tree, references to deleted records, the delay range. What you send must be clean,
including errors it already had; errors the stored trigger already had in parts you do not
send (preexisting: true) do not block. Switching a trigger on validates all of it. Any error
→ 422 workflow_trigger_invalid; every finding names the rule, the path, what is wrong and a
hint how to fix it. {"active": false} alone always saves (emergency stop). dry_run=true
returns the report without saving. Unknown placeholders that are filled at run time can be
accepted with force=true (softens WT-06 only) — every forced save is logged. Every save is
written to the trigger's change log.
Required permission:
workflow_manage
Example request:
curl --request PATCH \
"https://{instance}.festiwa.re/api/v2/projects/564/workflow-triggers/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Welcome mail\",
\"active\": true,
\"data\": {
\"subject\": {
\"de\": \"Willkommen {{ firstname }}\",
\"en\": \"Welcome {{ firstname }}\"
}
},
\"conditions\": {
\"and\": [
{
\"field\": \"data.shirt_size\",
\"operator\": \"==\",
\"value\": \"m\"
}
]
},
\"delay_minutes\": 30,
\"updated_at\": \"2026-10-08T09:00:00+00:00\",
\"dry_run\": true,
\"force\": false
}"
const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/workflow-triggers/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Welcome mail",
"active": true,
"data": {
"subject": {
"de": "Willkommen {{ firstname }}",
"en": "Welcome {{ firstname }}"
}
},
"conditions": {
"and": [
{
"field": "data.shirt_size",
"operator": "==",
"value": "m"
}
]
},
"delay_minutes": 30,
"updated_at": "2026-10-08T09:00:00+00:00",
"dry_run": true,
"force": false
};
fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify(body),
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/workflow-triggers/16';
$response = $client->patch(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Welcome mail',
'active' => true,
'data' => [
'subject' => [
'de' => 'Willkommen {{ firstname }}',
'en' => 'Welcome {{ firstname }}',
],
],
'conditions' => [
'and' => [
[
'field' => 'data.shirt_size',
'operator' => '==',
'value' => 'm',
],
],
],
'delay_minutes' => 30,
'updated_at' => '2026-10-08T09:00:00+00:00',
'dry_run' => true,
'force' => false,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, Updated):
{
"data": {
"id": 77,
"name": "Notify crew",
"action_type": "notify_user",
"triggers_on": [
"enter"
],
"active": true,
"observed_id": 42,
"data": {
"notifiable_id": 123
},
"conditions": null,
"delay_minutes": null,
"updated_at": "2026-10-08T09:05:00+00:00"
},
"validation": {
"valid": true,
"requires_confirm_data_loss": false,
"errors": [],
"warnings": [],
"data_impact": []
}
}
Example response (200, DryRun):
{
"data": {
"dry_run": true,
"would_save": false,
"validation": {
"valid": false,
"requires_confirm_data_loss": false,
"errors": [
{
"rule": "WT-05",
"severity": "reject",
"field": "content",
"path": "data.content.de",
"message": "WT-05 at data.content.de: Twig syntax error in line 3: Unclosed \"if\".",
"hint": "Fix the Twig in line 3: every {% if %} / {% for %} needs its {% endif %} / {% endfor %}, every \"{{\" its \"}}\", every \"{%\" its \"%}\", every quote its closing quote."
}
],
"warnings": [],
"data_impact": []
}
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You do not have permission to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Example response (409, OptimisticLockFailed):
{
"error": {
"code": "optimistic_lock_failed",
"message": "The resource was modified after your last read. Re-fetch and retry.",
"errors": {
"current": {
"updated_at": "2026-10-08T09:05:00+00:00"
}
}
}
}
Example response (422, Invalid):
{
"error": {
"code": "workflow_trigger_invalid",
"message": "The trigger was not saved: 1 blocking error(s). Every entry in error.errors.validation.errors names the rule, the path, what is wrong and a \"hint\" how to fix it; entries with \"preexisting\": true do not block. Deactivating the trigger ({\"active\": false}) is always possible.",
"errors": {
"validation": {
"valid": false,
"requires_confirm_data_loss": false,
"errors": [
{
"rule": "WT-07",
"severity": "reject",
"field": "content",
"path": "data.content.de",
"message": "WT-07 at data.content.de, line 1: The function `config_get()` is not allowed in templates: templates may only transform and format values, not read server state or run code.",
"hint": "Allowed functions: …"
}
],
"warnings": [],
"data_impact": []
}
}
}
}
Example response (429, TooManyRequests):
{
"error": {
"code": "too_many_requests",
"message": "Too many requests."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List the workflows of a project.
requires authentication
Only workflows of the person and group types the user may see (as in the backend).
Required permission:
workflow_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/workflows?per_page=25" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/workflows"
);
const params = {
"per_page": "25",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/workflows';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'per_page' => '25',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": [
{
"id": 42,
"project_id": 2,
"name": "Crew",
"shortname": "crew",
"description": null,
"type": {
"id": 7,
"shortname": "CrewPerson",
"model": "person"
},
"created_at": "2026-01-10T09:00:00+00:00",
"updated_at": "2026-10-08T09:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 25,
"total": 1
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You do not have permission to perform this action."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show a workflow with its steps.
requires authentication
Steps in their order, each with the ids of its triggers.
Required permission:
workflow_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/workflows/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/workflows/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/workflows/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": 42,
"project_id": 2,
"name": "Crew",
"shortname": "crew",
"description": null,
"type": {
"id": 7,
"shortname": "CrewPerson",
"model": "person"
},
"created_at": "2026-01-10T09:00:00+00:00",
"updated_at": "2026-10-08T09:00:00+00:00",
"steps": [
{
"id": 5,
"name": "Applied",
"shortname": "applied",
"description": null,
"is_start": true,
"is_endpoint": false,
"inactive": false,
"previous_id": null,
"trigger_ids": [
77,
78
]
}
]
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You do not have permission to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Show a workflow trigger.
requires authentication
The full trigger: texts (data.subject, data.content), recipients and other settings in
data, run conditions, delay_minutes, active, and updated_at for the optimistic lock.
Required permission:
workflow_see
Example request:
curl --request GET \
--get "https://{instance}.festiwa.re/api/v2/projects/564/workflow-triggers/16" \
--header "Authorization: Bearer {ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://{instance}.festiwa.re/api/v2/projects/564/workflow-triggers/16"
);
const headers = {
"Authorization": "Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/workflow-triggers/16';
$response = $client->get(
$url,
[
'headers' => [
'Authorization' => 'Bearer {ACCESS_TOKEN}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));Example response (200, OK):
{
"data": {
"id": 77,
"name": "Welcome mail",
"action_type": "notify_self",
"triggers_on": [
"enter"
],
"active": true,
"observed_id": 42,
"data": {
"subject": {
"de": "Willkommen {{ firstname }}"
},
"content": {
"de": "<p>Hallo {{ firstname }}</p>"
}
},
"conditions": null,
"delay_minutes": null,
"updated_at": "2026-10-08T09:00:00+00:00"
}
}
Example response (401, Unauthorized):
{
"error": {
"code": "unauthorized",
"message": "Authentication required."
}
}
Example response (403, Forbidden):
{
"error": {
"code": "permission_denied",
"message": "You do not have permission to perform this action."
}
}
Example response (404, NotFound):
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.