MENU navbar-image

Introduction

festiware API v2 — project-scoped REST API for People, Applications, Ticket Types, and workflow-step transitions. Authentication is OAuth 2.0 Authorization Code grant via Laravel Passport.

Looking for legacy v1 reference? It's at /v1.


Connecting your integration (e.g. n8n)

Complete this once per integration. After Step 4 the credential auto-renews — no further manual work.

Prerequisites


Step 1 — Create an API User

In the festiware backend: API Users → Create API User


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:

  1. Enter email + password → Login
  2. The consent screen shows the client name → Authorize
  3. Browser closes → n8n shows Connected

This step runs once. n8n stores the refresh token and auto-renews from this point.


Step 5 — Token lifecycle


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:

Postman setup

  1. Import or create a request/collection and open the Authorization tab → Type OAuth 2.0.
  2. 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
  1. 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.
  2. 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:

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}

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: 1e9595cf08c84d00a6656157a35f6c3f)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
 

Request      

GET api/v2/person-types

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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=&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-08-19T22:44:41+02:00\",
    \"person_type\": \"architecto\",
    \"step\": \"architecto\",
    \"workflow_id\": 16,
    \"withJourney\": false
}"
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",
    "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-08-19T22:44:41+02:00",
    "person_type": "architecto",
    "step": "architecto",
    "workflow_id": 16,
    "withJourney": 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/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',
            'per_page' => '25',
        ],
        'json' => [
            'per_page' => 16,
            'email' => 'zbailey@example.net',
            'updated_since' => '2026-08-19T22:44:41+02:00',
            'person_type' => 'architecto',
            'step' => 'architecto',
            'workflow_id' => 16,
            'withJourney' => false,
        ],
    ]
);
$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 (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."
            ]
        }
    }
}
 

Request      

GET api/v2/projects/{project_id}/people

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

email   string  optional    

Optional exact-match filter against data.email. Example: anna@example.com

updated_since   string  optional    

Optional ISO 8601 timestamp; returns Persons modified at or after this moment. Example: 2026-04-30T00:00:00+00:00

person_type   string  optional    

Optional PersonType shortname filter. Example: volunteer

step   string  optional    

Optional. Filter by the Person's CURRENT workflow-step shortname. LIMITATION: "current" is the Person's latest step OVERALL, not per workflow — for a Person in several workflows an older step in one workflow is not matched while a newer step in another is active. Pass workflow_id to scope, and note it still filters on the latest-overall status. Single-workflow Persons (the norm) are unaffected. Example: acc_buildup

workflow_id   integer  optional    

Optional. Narrow the step filter to one Workflow — needed when a Person is in several. Example: 209

withJourney   boolean  optional    

Optional. Sideload current_steps (latest step per workflow) and journey (full step history) on each Person. Default false. Example: false

per_page   integer  optional    

Page size, clamped to 100. Example: 25

Body Parameters

per_page   integer  optional    

Must be at least 1. Example: 16

email   string  optional    

Must not be greater than 255 characters. Example: zbailey@example.net

updated_since   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-08-19T22:44:41+02:00

person_type   string  optional    

The shortname of an existing record in the types table. Example: architecto

step   string  optional    

Example: architecto

workflow_id   integer  optional    

The id of an existing record in the workflows table. Example: 16

withJourney   boolean  optional    

Example: false

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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/people

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Body Parameters

id   string  optional    

Optional caller-supplied UUID. Auto-generated if omitted; rejected with 409 if already used. Example: architecto

person_types   string[]     

Array of PersonType shortnames. At least one.

act_as_backend_creation   boolean  optional    

If true, the write is treated as backend-originated by downstream WorkflowTrigger evaluation. Default: false. Example: false

data   object     

Map of custom-data fields. Scalar values only.

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/people/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   string     

The ID of the person. Example: architecto

project   integer     

Project id. Example: 16

person   string     

Person UUID. Example: architecto

Update a Person.

requires authentication

Both PUT and PATCH route here. The behavioural switch is replaceMissing:

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."
            ]
        }
    }
}
 

Request      

PUT api/v2/projects/{project_id}/people/{id}

PATCH api/v2/projects/{project_id}/people/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   string     

The ID of the person. Example: architecto

project   integer     

Project id. Example: 16

person   string     

Person UUID. Example: architecto

Body Parameters

person_types   string[]  optional    

Optional array of PersonType shortnames. If present, REPLACES existing assignments. If omitted, pivot is unchanged.

act_as_backend_creation   boolean  optional    

When true, downstream WorkflowTrigger evaluation treats this write as backend-originated (matches create/v1 semantics) — triggers flagged skip_after_backend_creation do NOT fire. Used so API/n8n write-backs (e.g. relaying coupon codes onto a Person) don't re-trigger the outbound webhook. Defaults to false. The legacy camelCase alias actAsBackendCreation is accepted (snake-case wins on collision). Example: false

data   object     

Map of custom-data fields (scalar values only).

updated_at   string     

ISO 8601 timestamp of the last known version (optimistic-lock token). Example: 2026-04-30T11:24:53+00:00

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."
            ]
        }
    }
}
 

Request      

DELETE api/v2/projects/{project_id}/people/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   string     

The ID of the person. Example: architecto

project   integer     

Project id. Example: 16

person   string     

Person UUID. Example: architecto

Body Parameters

confirm   boolean     

Must be exactly true to confirm deletion. Example: true

Advance many Persons in one request.

requires authentication

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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/people/transitions/bulk

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Body Parameters

ids   string[]     

Array of Person UUIDs to transition.

to_step_id   integer  optional    

Target WorkflowStep id (one-of with to_step_name). Example: 42

to_step_name   string  optional    

Target WorkflowStep shortname (one-of with to_step_id). Example: APPROVED

person_type   string  optional    

PersonType shortname (one-of with workflow_id). Example: volunteer

workflow_id   integer  optional    

Workflow id (one-of with person_type; wins on collision). Example: 5

note   string  optional    

Optional audit note. Saved as a Comment on each transitioned Person. Example: Approved.

act_as_backend_creation   boolean  optional    

See single-endpoint docs. Default: false. Example: false

dry_run   boolean  optional    

If true, no DB writes and no triggers fire — the response reports per-id intended outcomes. Default: false. Example: false

force   boolean  optional    

If true, partial failures are graceful; if false, any failure aborts the batch with 400. Default: false.

Required permission: all_persons_and_applications_manage Example: false

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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/people/{person_id}/transitions

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

person_id   string     

The ID of the person. Example: architecto

project   integer     

Project id. Example: 16

person   string     

Person UUID. Example: architecto

Body Parameters

to_step_id   integer  optional    

Target WorkflowStep id (one-of with to_step_name; wins on collision). Example: 42

to_step_name   string  optional    

Target WorkflowStep shortname, case-insensitive (one-of with to_step_id). Example: APPROVED

person_type   string  optional    

PersonType shortname (one-of with workflow_id). Example: volunteer

workflow_id   integer  optional    

Workflow id (one-of with person_type; wins on collision). Example: 5

note   string  optional    

Optional audit note saved as a Comment on the Person. Example: Approved via n8n.

act_as_backend_creation   boolean  optional    

If true, triggers with skip_after_backend_creation=true do not fire. Default: false. CamelCase alias actAsBackendCreation accepted. Example: false

dry_run   boolean  optional    

If true, validate and report the intended outcome without writing to the DB and without firing triggers. Default: false.

Required permission: all_persons_and_applications_manage Example: false

Applications

List application types.

requires authentication

Returns every application 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: 92296ff13fbc46419102a3ca9ed34cb6)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
 

Request      

GET api/v2/application-types

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

Paginated list of Applications 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+Application+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-08-19T22:44:41+02:00\",
    \"withOwner\": true,
    \"withMembersCollection\": true,
    \"withSubordinatedApplications\": true,
    \"withJourney\": true,
    \"withEvents\": false,
    \"withEventAttendees\": true,
    \"withTickets\": true,
    \"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 Application 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-08-19T22:44:41+02:00",
    "withOwner": true,
    "withMembersCollection": true,
    "withSubordinatedApplications": true,
    "withJourney": true,
    "withEvents": false,
    "withEventAttendees": true,
    "withTickets": true,
    "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 Application 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-08-19T22:44:41+02:00',
            'withOwner' => true,
            'withMembersCollection' => true,
            'withSubordinatedApplications' => true,
            'withJourney' => true,
            'withEvents' => false,
            'withEventAttendees' => true,
            'withTickets' => true,
            '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."
            ]
        }
    }
}
 

Request      

GET api/v2/projects/{project_id}/applications

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

application_type   string  optional    

Optional ApplicationType shortname filter. Example: volunteer

person_id   string  optional    

Optional Person UUID filter. Example: 01a1e3a6-efbf-4413-9db1-f36e60489254

step   string  optional    

Optional current workflow step shortname filter (read-only — does not transition). Example: applied

updated_since   string  optional    

Optional ISO 8601 timestamp; returns Applications modified at or after this moment. Example: 2026-05-07T00:00:00+00:00

per_page   integer  optional    

Page size, clamped to 100. Example: 25

withOwner   boolean  optional    

Sideload: inline the owner as a nested PersonV2Resource at owner. Default false. Example: false

withMembersCollection   boolean  optional    

Sideload: replace the members[] UUID array with nested PersonV2Resources. Default false. Example: false

withSubordinatedApplications   boolean  optional    

Sideload: inline subordinated applications (depth 1) at subordinated_applications. Default false. Example: false

withJourney   boolean  optional    

Sideload: inline workflow step history at journey[]. Default false. Example: false

withEvents   boolean  optional    

Sideload: inline scheduler events at events[]. Default false. Example: false

withEventAttendees   boolean  optional    

Sideload: only meaningful alongside withEvents=1. Replaces each event's attendees[] UUID array with PersonV2Resources. Default false. Example: false

withTickets   boolean  optional    

Sideload: inline ticket types at ticket_types[]. Default false. Example: false

withTranslatedValues   string  optional    

Translate data.* values via the application type's FormDefinition. Truthy non-locale values fall back to en. Example: `de

Every Application 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[]).`

Body Parameters

per_page   integer  optional    

Must be at least 1. Example: 16

application_type   string  optional    

Example: architecto

person_id   string  optional    

Must be a valid UUID. Example: a4855dc5-0acb-33c3-b921-f4291f719ca0

step   string  optional    

Example: architecto

updated_since   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-08-19T22:44:41+02:00

withOwner   boolean  optional    

Example: true

withMembersCollection   boolean  optional    

Example: true

withSubordinatedApplications   boolean  optional    

Example: true

withJourney   boolean  optional    

Example: true

withEvents   boolean  optional    

Example: false

withEventAttendees   boolean  optional    

Example: true

withTickets   boolean  optional    

Example: true

withTranslatedValues   string  optional    

Example: architecto

Create a new Application 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": "An Application 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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/applications

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Body Parameters

id   string  optional    
application_type   string     

ApplicationType shortname. Immutable post-create. Example: volunteer

person_id   string  optional    

Optional Person UUID for the owner. Example: 01a1e3a6-efbf-4413-9db1-f36e60489254

data   object     

Map of custom-data fields. Scalar values only.

Show a single Application 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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/applications/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   integer     

The ID of the application. Example: 16

project   integer     

Project id. Example: 16

application   string     

Application UUID. Example: architecto

Query Parameters

withOwner   boolean  optional    

Sideload owner as nested PersonV2Resource. Default false. Example: false

withMembersCollection   boolean  optional    

Sideload members as nested PersonV2Resources. Default false. Example: false

withJourney   boolean  optional    

Sideload workflow step history. Default false. Example: false

withEvents   boolean  optional    

Sideload scheduler events. Default false. Example: false

withEventAttendees   boolean  optional    

Replace event attendees[] UUIDs with PersonV2Resources. Default false. Example: false

withTickets   boolean  optional    

Sideload ticket types. Default false. Example: false

withSubordinatedApplications   boolean  optional    

Sideload subordinated applications (depth 1). Default false. Example: false

withTranslatedValues   string  optional    

Translate data.* via FormDefinition. Example: de

Update an Application (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 Application was modified by someone else; reload and retry.",
        "errors": {
            "current": {
                "updated_at": "2026-05-07T11:24:53+00:00"
            }
        }
    }
}
 

Example response (422, ApplicationTypeImmutable):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "application_type": [
                "The application_type cannot be changed once set."
            ]
        }
    }
}
 

Request      

PUT api/v2/projects/{project_id}/applications/{id}

PATCH api/v2/projects/{project_id}/applications/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   integer     

The ID of the application. Example: 16

project   integer     

Project id. Example: 16

application   string     

Application UUID. Example: architecto

Body Parameters

data   object     

Map of custom-data fields (scalar values only).

updated_at   string     

ISO 8601 timestamp of the last known version (optimistic-lock token). Example: 2026-05-07T11:24:53+00:00

person_id   string  optional    

Optional owner Person UUID. Pass null to clear. Example: architecto

application_type   string  optional    
application_type_id   string  optional    
step_id   string  optional    
workflow_step_id   string  optional    

Soft-delete an Application.

requires authentication

Requires explicit confirm: true. Missing/false confirmation returns 422 and the Application remains intact. Soft-deleted Applications 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."
            ]
        }
    }
}
 

Request      

DELETE api/v2/projects/{project_id}/applications/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   integer     

The ID of the application. Example: 16

project   integer     

Project id. Example: 16

application   string     

Application UUID. Example: architecto

Body Parameters

confirm   boolean     

Must be exactly true to confirm deletion. Example: true

Advance many Applications in one request.

requires authentication

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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/applications/transitions/bulk

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Body Parameters

ids   integer[]     

Array of Application ids to transition.

to_step_id   integer  optional    

Target WorkflowStep id (one-of with to_step_name). Example: 42

to_step_name   string  optional    

Target WorkflowStep shortname (one-of with to_step_id). Example: APPROVED

application_type   string  optional    

ApplicationType shortname (one-of with workflow_id). Example: vendor

workflow_id   integer  optional    

Workflow id (one-of with application_type; wins on collision). Example: 5

note   string  optional    

Optional audit note. Saved as a Comment on each transitioned Application. Example: Approved.

act_as_backend_creation   boolean  optional    

See single-endpoint docs. Default: false. Example: false

dry_run   boolean  optional    

If true, no DB writes and no triggers fire — the response reports per-id intended outcomes. Default: false. Example: false

force   boolean  optional    

If true, partial failures are graceful; if false, any failure aborts the batch with 400. Default: false.

Required permission: all_persons_and_applications_manage Example: false

Advance a single Application 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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/applications/{application_id}/transitions

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

application_id   integer     

The ID of the application. Example: 16

project   integer     

Project id. Example: 16

application   integer     

Application id. Example: 16

Body Parameters

to_step_id   integer  optional    

Target WorkflowStep id (one-of with to_step_name; wins on collision). Example: 42

to_step_name   string  optional    

Target WorkflowStep shortname, case-insensitive (one-of with to_step_id). Example: APPROVED

application_type   string  optional    

ApplicationType shortname (one-of with workflow_id). Example: vendor

workflow_id   integer  optional    

Workflow id (one-of with application_type; wins on collision). Example: 5

note   string  optional    

Optional audit note saved as a Comment on the Application. Example: Approved via n8n.

act_as_backend_creation   boolean  optional    

If true, triggers with skip_after_backend_creation=true do not fire. Default: false. CamelCase alias actAsBackendCreation accepted. Example: false

dry_run   boolean  optional    

If true, validate and report the intended outcome without writing to the DB and without firing triggers. Default: false.

Required permission: all_persons_and_applications_manage Example: false

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/ticket-types

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

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/phpbETLPN" 
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/phpbETLPN', '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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/ticket-types

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Body Parameters

name   string     

Required on POST, optional on PATCH. Display name (max 255 chars). Must not be greater than 255 characters. Example: Shuttle Berlin

shortname   string     

Required on POST, optional on PATCH. TicketCode prefix — 2-4 alphanumeric characters. Must match the regex /^[a-zA-Z0-9]{2,4}$/. Example: BFST

description   string  optional    

Optional descriptive text. Nullable. Example: Bus shuttle from Berlin Ostbahnhof to the festival site.

issue   integer  optional    

Optional total capacity. 0 means unlimited (capacity managed via options instead). Must be at least 0. Example: 100

available_in_backend   boolean  optional    

Optional. Whether the ticket type is visible to backend operators. Example: true

ticket_category_id   integer  optional    

Optional category foreign key. Nullable. Example: 7

additional_html   string  optional    

Optional extra HTML rendered on the PDF ticket. Nullable. Example: <p>Welcome!</p>

ticket_background   file  optional    

Optional PDF ticket background image (multipart file — on PATCH send as POST with _method=PATCH). Stored on the public disk; response returns ticket_background_url. Must be a file. Must be an image. Example: /tmp/phpbETLPN

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/ticket-types/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   integer     

The ID of the ticket type. Example: 16

project   integer     

Project id. Example: 16

ticketType   integer     

Ticket Type id. Example: 16

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/phpTqbynj" 
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/phpTqbynj', '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."
            ]
        }
    }
}
 

Request      

PUT api/v2/projects/{project_id}/ticket-types/{id}

PATCH api/v2/projects/{project_id}/ticket-types/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   integer     

The ID of the ticket type. Example: 16

project   integer     

Project id. Example: 16

ticketType   integer     

Ticket Type id. Example: 16

Body Parameters

name   string     

Required on POST, optional on PATCH. Display name (max 255 chars). Must not be greater than 255 characters. Example: Shuttle Berlin

shortname   string     

Required on POST, optional on PATCH. TicketCode prefix — 2-4 alphanumeric characters. Must match the regex /^[a-zA-Z0-9]{2,4}$/. Example: BFST

description   string  optional    

Optional descriptive text. Nullable. Example: Bus shuttle from Berlin Ostbahnhof to the festival site.

issue   integer  optional    

Optional total capacity. 0 means unlimited (capacity managed via options instead). Must be at least 0. Example: 100

available_in_backend   boolean  optional    

Optional. Whether the ticket type is visible to backend operators. Example: true

ticket_category_id   integer  optional    

Optional category foreign key. Nullable. Example: 7

additional_html   string  optional    

Optional extra HTML rendered on the PDF ticket. Nullable. Example: <p>Welcome!</p>

ticket_background   file  optional    

Optional PDF ticket background image (multipart file — on PATCH send as POST with _method=PATCH). Stored on the public disk; response returns ticket_background_url. Must be a file. Must be an image. Example: /tmp/phpTqbynj

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."
    }
}
 

Request      

DELETE api/v2/projects/{project_id}/ticket-types/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   integer     

The ID of the ticket type. Example: 16

project   integer     

Project id. Example: 16

ticketType   integer     

Ticket Type id. Example: 16

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.

Required permission: ticketcode_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes?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-codes"
);

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-codes';
$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": "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"
            },
            "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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/ticket-codes

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

Orders

Paginated list of Orders in a project.

requires authentication

Required permission: order_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/orders?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/orders"
);

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/orders';
$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": "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"
        }
    ],
    "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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/orders

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/offer-categories

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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.\"
}"
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."
};

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.',
        ],
    ]
);
$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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/offer-categories

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Body Parameters

name   string     

Required. Display name, shown as the shop group header (max 255 chars). Must not be greater than 255 characters. Example: Outbound

shortname   string     

Required. Short identifier, unique per project (max 255 chars). Must not be greater than 255 characters. Example: outbound

description   string  optional    

Optional explanation. Nullable. Example: Bus shuttles leaving the festival.

project_id   string  optional    
typeable_model   string  optional    
id   string  optional    

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/offer-categories/{category_id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

category_id   integer     

The ID of the category. Example: 16

project   integer     

Project id. Example: 16

category   integer     

OfferCategory id. Example: 16

Query Parameters

withOffers   boolean  optional    

Include offers[] with each offer's offerable inlined (legacy v1-shape). Default: false. Example: false

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,
    \"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,
    "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,
            '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."
            ]
        }
    }
}
 

Request      

PUT api/v2/projects/{project_id}/offer-categories/{category_id}

PATCH api/v2/projects/{project_id}/offer-categories/{category_id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

category_id   integer     

The ID of the category. Example: 16

project   integer     

Project id. Example: 16

category   integer     

OfferCategory id. Example: 16

Body Parameters

name   string     

Required on PUT, optional on PATCH. Display name (max 255 chars). Must not be greater than 255 characters. Example: Outbound

shortname   string     

Required on PUT, optional on PATCH. Short identifier (max 255 chars). Must not be greater than 255 characters. Example: outbound

description   string  optional    

Optional explanation. Nullable. Example: Bus shuttles leaving the festival.

order   integer  optional    

Optional sort position within the project. Example: 2

updated_at   string     

Required ISO 8601 last-known timestamp (optimistic-lock token). Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-06-23T09:00:00+00:00

project_id   string  optional    
typeable_model   string  optional    
id   string  optional    

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."
            ]
        }
    }
}
 

Request      

DELETE api/v2/projects/{project_id}/offer-categories/{category_id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

category_id   integer     

The ID of the category. Example: 16

project   integer     

Project id. Example: 16

category   integer     

OfferCategory id. Example: 16

Body Parameters

confirm   boolean     

Required guard against accidental deletion. Must be exactly truefalse or omission returns 422 and leaves the category intact. Must be accepted. Example: true

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/offerable-options

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

offerable_id   integer  optional    

Filter by the parent offerable's id. Example: 16

offerable_type   string  optional    

Filter by offerable class. Currently allow-listed to App\TicketType. Example: architecto

per_page   integer  optional    

Page size, clamped to 100. Example: 25

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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/offerable-options

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Body Parameters

offerable_id   integer     

Required. Parent TicketType id. Available types via GET /api/v2/projects/{project}/ticket-types. Example: 161

offerable_type   string     

Required. Always App\TicketType — the only allow-listed value. Example: App\TicketType

Must be one of:
  • App\TicketType
name   string     

Required. Display label (max 255 chars). Must not be greater than 255 characters. Example: Early bird seat

description   string  optional    

Optional help text. Nullable. Example: Discounted seat for early buyers.

issue   integer     

Required. Capacity. 0 means unlimited. Must be at least 0. Example: 50

overbook_allowance   integer  optional    

Optional. Coupon-only extra seats on top of issue (0 = off). Example: 16

active   boolean     

Required. Whether the option is purchasable. Example: true

valid_from   string  optional    

Optional availability window start (date). Nullable. Must be a valid date. Example: 2026-06-01

valid_to   string  optional    

Optional availability window end (date, after valid_from). Nullable. Must be a valid date. Must be a date after valid_from. Example: 2026-06-20

id   string  optional    
sort_order   string  optional    

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/offerable-options/{option_id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

option_id   integer     

The ID of the option. Example: 16

project   integer     

Project id. Example: 16

option   integer     

OfferableOption id. Example: 16

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."
            ]
        }
    }
}
 

Request      

PUT api/v2/projects/{project_id}/offerable-options/{option_id}

PATCH api/v2/projects/{project_id}/offerable-options/{option_id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

option_id   integer     

The ID of the option. Example: 16

project   integer     

Project id. Example: 16

option   integer     

OfferableOption id. Example: 16

Body Parameters

name   string     

Required on PUT, optional on PATCH. Display label (max 255 chars). Must not be greater than 255 characters. Example: Early bird seat

description   string  optional    

Optional help text. Nullable. Example: Discounted seat for early buyers.

issue   integer     

Required on PUT, optional on PATCH. Capacity. 0 means unlimited. Must be at least 0. Example: 75

overbook_allowance   integer  optional    

Optional. Coupon-only extra seats on top of issue (0 = off). Example: 16

active   boolean     

Required on PUT, optional on PATCH. Whether the option is purchasable. Example: true

valid_from   string  optional    

Optional availability window start (date). Nullable. Must be a valid date. Example: 2026-06-01

valid_to   string  optional    

Optional availability window end (date, after valid_from). Nullable. Must be a valid date. Must be a date after valid_from. Example: 2026-06-20

updated_at   string     

Required ISO 8601 last-known timestamp (optimistic-lock token). Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-06-23T09:00:00+00:00

offerable_id   string  optional    
offerable_type   string  optional    
id   string  optional    
sort_order   string  optional    

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."
            ]
        }
    }
}
 

Request      

DELETE api/v2/projects/{project_id}/offerable-options/{option_id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

option_id   integer     

The ID of the option. Example: 16

project   integer     

Project id. Example: 16

option   integer     

OfferableOption id. Example: 16

Body Parameters

confirm   boolean     

Required guard against accidental deletion. Must be exactly truefalse or omission returns 422 and leaves the option intact. Must be accepted. Example: true

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/offer-categories/{category_id}/offers

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

category_id   integer     

The ID of the category. Example: 16

project   integer     

Project id. Example: 16

category   integer     

OfferCategory id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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,
            "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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/offers

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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=Eius et animi quos velit et."\
    --form "price=0"\
    --form "tax=0"\
    --form "issue=60"\
    --form "hidden_link=club-hamburg"\
    --form "hide_show_all_link=1"\
    --form "email_product="\
    --form "max_order_amount=16"\
    --form "published_from=2026-08-19T22:44:41"\
    --form "published_to=2052-09-11"\
    --form "available_from=2026-08-19T22:44:41"\
    --form "available_to=2052-09-11"\
    --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]=1"\
    --form "ticket_type[ticket_category_id]=16"\
    --form "ticket_type[valid_from]=2026-08-19T22:44:41"\
    --form "ticket_type[valid_to]=2052-09-11"\
    --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/phpQNKm3Z" \
    --form "ticket_type[ticket_background]=@/tmp/phpoJ4mqo" 
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', 'Eius et animi quos velit et.');
body.append('price', '0');
body.append('tax', '0');
body.append('issue', '60');
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-08-19T22:44:41');
body.append('published_to', '2052-09-11');
body.append('available_from', '2026-08-19T22:44:41');
body.append('available_to', '2052-09-11');
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]', '1');
body.append('ticket_type[ticket_category_id]', '16');
body.append('ticket_type[valid_from]', '2026-08-19T22:44:41');
body.append('ticket_type[valid_to]', '2052-09-11');
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' => 'Eius et animi quos velit et.'
            ],
            [
                'name' => 'price',
                'contents' => '0'
            ],
            [
                'name' => 'tax',
                'contents' => '0'
            ],
            [
                'name' => 'issue',
                'contents' => '60'
            ],
            [
                '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-08-19T22:44:41'
            ],
            [
                'name' => 'published_to',
                'contents' => '2052-09-11'
            ],
            [
                'name' => 'available_from',
                'contents' => '2026-08-19T22:44:41'
            ],
            [
                'name' => 'available_to',
                'contents' => '2052-09-11'
            ],
            [
                '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' => '1'
            ],
            [
                'name' => 'ticket_type[ticket_category_id]',
                'contents' => '16'
            ],
            [
                'name' => 'ticket_type[valid_from]',
                'contents' => '2026-08-19T22:44:41'
            ],
            [
                'name' => 'ticket_type[valid_to]',
                'contents' => '2052-09-11'
            ],
            [
                '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/phpQNKm3Z', 'r')
            ],
            [
                'name' => 'ticket_type[ticket_background]',
                'contents' => fopen('/tmp/phpoJ4mqo', '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",
        "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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/offers

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Body Parameters

shortname   string     

Plain display string (legacy default name). Must not be greater than 255 characters. Example: Camping Club Hamburg

name   object  optional    

Optional multilanguage map ({de, en}). Plain strings are not accepted on v2 — send at least de. Omitted, it defaults to {de: shortname}.

description   string  optional    

Example: Eius et animi quos velit et.

description_translations   object  optional    
price   number     

Gross price, max 2 decimal places. 0 is an ordinary value. Must be at least 0. Must not be greater than 999999.99. Example: 0

tax   number     

Tax rate as decimal (0.0–1.0). E.g. 0.19 = 19%. Must be at least 0. Must not be greater than 1. Example: 0

tax_rules   object[]  optional    
layout   string  optional    

Example: architecto

key   string  optional    

Example: architecto

attributes   object  optional    

This field is required when tax_rules is present.

label   string  optional    

This field is required when tax_rules.*.attributes is present. Must not be greater than 50 characters. Example: n

percent   number  optional    

This field is required when tax_rules.*.attributes is present. Must be at least 0. Must not be greater than 1. Example: 1

rate   number  optional    

This field is required when tax_rules.*.attributes is present. Must be at least 0. Must not be greater than 1. Example: 1

issue   integer  optional    

Must be at least 0. Example: 60

hidden_link   string  optional    

Caller-supplied keyword. When set, the offer is hidden from the public shop list and reachable only via ?shop_link=. Case-insensitive, shareable across offers. Must not be greater than 255 characters. Example: club-hamburg

hide_show_all_link   boolean  optional    

When true, a shop opened via ?oid= drops its "show all tickets" switch and stays on this offer. Example: true

image   file  optional    

Offer logo image file (multipart). Stored on the public disk; response returns the absolute URL. Must be a file. Must be an image. Example: /tmp/phpQNKm3Z

email_product   boolean  optional    

Example: false

max_order_amount   integer  optional    

Must be at least 1. Example: 16

published_from   string  optional    

Must be a valid date. Example: 2026-08-19T22:44:41

published_to   string  optional    

Must be a valid date. Must be a date after published_from. Example: 2052-09-11

available_from   string  optional    

Must be a valid date. Example: 2026-08-19T22:44:41

available_to   string  optional    

Must be a valid date. Must be a date after available_from. Example: 2052-09-11

ticket_type_id   integer  optional    

Existing ticket type to attach to (same project). Mutually exclusive with ticket_type. This field is required when ticket_type is not present. Example: 208

ticket_type   object  optional    

This field is required when ticket_type_id is not present.

name   string  optional    

Ticket type display name (when creating one). This field is required when ticket_type is present. Must not be greater than 255 characters. Example: Camping Club Hamburg

shortname   string  optional    

TicketCode prefix — 2-4 alphanumeric characters. This field is required when ticket_type is present. Must match the regex /^[a-zA-Z0-9]{2,4}$/. Example: CCHH

description   string  optional    

Example: Eius et animi quos velit et.

issue   integer  optional    

Must be at least 0. Example: 60

available_in_backend   boolean  optional    

Example: true

ticket_category_id   integer  optional    

Example: 16

valid_from   string  optional    

Must be a valid date. Example: 2026-08-19T22:44:41

valid_to   string  optional    

Must be a valid date. Must be a date after ticket_type.valid_from. Example: 2052-09-11

additional_html   string  optional    

Extra HTML rendered on the PDF ticket. Example: <p>Welcome, Club Hamburg!</p>

ticket_background   file  optional    

PDF ticket background image file (multipart). Must be a file. Must be an image. Example: /tmp/phpoJ4mqo

offer_category_id   integer  optional    

Existing category id. Mutually exclusive with offer_category. This field is required when offer_category is not present. Example: 91

offer_category   object  optional    

This field is required when offer_category_id is not present.

name   string  optional    

Category display name (find-or-create). This field is required when offer_category is present. Must not be greater than 255 characters. Example: Camping

shortname   string  optional    

Category find-or-create key within the project. This field is required when offer_category is present. Must not be greater than 255 characters. Example: camping

description   string  optional    

Example: Eius et animi quos velit et.

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",
        "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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/offers/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   integer     

The ID of the offer. Example: 16

project   integer     

Project id. Example: 16

offer   integer     

Offer id. Example: 16

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.

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=Eius et animi quos velit et."\
    --form "hidden_link=club-hamburg-2027"\
    --form "hide_show_all_link=1"\
    --form "max_order_amount=16"\
    --form "published_from=2026-08-19T22:44:41"\
    --form "published_to=2052-09-11"\
    --form "available_from=2026-08-19T22:44:41"\
    --form "available_to=2052-09-11"\
    --form "offer_category_id=91"\
    --form "updated_at=2026-07-24T09:00:00+00:00"\
    --form "image=@/tmp/phpylAAto" 
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', 'Eius et animi quos velit et.');
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-08-19T22:44:41');
body.append('published_to', '2052-09-11');
body.append('available_from', '2026-08-19T22:44:41');
body.append('available_to', '2052-09-11');
body.append('offer_category_id', '91');
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' => 'Eius et animi quos velit et.'
            ],
            [
                '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-08-19T22:44:41'
            ],
            [
                'name' => 'published_to',
                'contents' => '2052-09-11'
            ],
            [
                'name' => 'available_from',
                'contents' => '2026-08-19T22:44:41'
            ],
            [
                'name' => 'available_to',
                'contents' => '2052-09-11'
            ],
            [
                'name' => 'offer_category_id',
                'contents' => '91'
            ],
            [
                'name' => 'updated_at',
                'contents' => '2026-07-24T09:00:00+00:00'
            ],
            [
                'name' => 'image',
                'contents' => fopen('/tmp/phpylAAto', '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."
            ]
        }
    }
}
 

Request      

PUT api/v2/projects/{project_id}/offers/{id}

PATCH api/v2/projects/{project_id}/offers/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: multipart/form-data

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   integer     

The ID of the offer. Example: 16

project   integer     

Project id. Example: 16

offer   integer     

Offer id. Example: 16

Body Parameters

shortname   string     

Required on PUT, optional on PATCH. Must not be greater than 255 characters. Example: Camping Club Hamburg

price   number     

Required on PUT, optional on PATCH. Max 2 decimal places. Must be at least 0. Must not be greater than 999999.99. Example: 0

tax   number     

Required on PUT, optional on PATCH. Decimal 0.0–1.0. Must be at least 0. Must not be greater than 1. Example: 0

issue   integer     

Required on PUT, optional on PATCH. Available quantity. Must be at least 0. Example: 0

email_product   boolean     

Required on PUT, optional on PATCH. Example: true

name   object  optional    

Multilanguage map ({de, en}). Plain strings are not accepted on v2. Null clears it.

description   string  optional    

Example: Eius et animi quos velit et.

description_translations   object  optional    
tax_rules   object  optional    
hidden_link   string  optional    

Hidden-shop keyword; null clears it. Must not be greater than 255 characters. Example: club-hamburg-2027

hide_show_all_link   boolean  optional    

Shop drops its "show all tickets" switch when opened via ?oid=. Example: true

max_order_amount   integer  optional    

Must be at least 1. Example: 16

published_from   string  optional    

Must be a valid date. Example: 2026-08-19T22:44:41

published_to   string  optional    

Must be a valid date. Must be a date after published_from. Example: 2052-09-11

available_from   string  optional    

Must be a valid date. Example: 2026-08-19T22:44:41

available_to   string  optional    

Must be a valid date. Must be a date after available_from. Example: 2052-09-11

offer_category_id   integer  optional    

Category of this project. Null clears the assignment. The id of an existing record in the types table. Example: 91

image   file  optional    

New logo image (multipart — send as POST with _method=PATCH). Replacing deletes the old file. Must be a file. Must be an image. Example: /tmp/phpylAAto

updated_at   string     

Required ISO 8601 last-known timestamp (optimistic-lock token). Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-07-24T09:00:00+00:00

project_id   string  optional    
id   string  optional    
offerable_id   string  optional    
offerable_type   string  optional    

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/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/offers/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/offers/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 (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "confirm": [
                "The confirm field must be accepted."
            ]
        }
    }
}
 

Request      

DELETE api/v2/projects/{project_id}/offers/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   integer     

The ID of the offer. Example: 16

project   integer     

Project id. Example: 16

offer   integer     

Offer id. Example: 16

Body Parameters

confirm   boolean     

Required guard against accidental deletion. Must be exactly truefalse or omission returns 422 and leaves the offer intact. Must be accepted. Example: true

Coupons

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\"
}"
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"
};

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',
        ],
    ]
);
$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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/coupons

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Body Parameters

type   string     

Required. Discount type — relative (percentage) or absolute (fixed amount). Example: relative

Must be one of:
  • absolute
  • relative
value   number     

Required. Discount value. relative: 0 < value <= 100. absolute: value > 0. Example: 100

max_units   integer  optional    

Optional. Cap the discount to at most N covered order-position units (the most expensive ones). 0 = unlimited (covers every covered unit). Defaults to 1. relative/100 + max_units=1 grants exactly one free unit of any price. Must be at least 0. Example: 1

is_singleuse   boolean  optional    

Optional. Whether the coupon may be redeemed only once. Defaults to true. Example: true

active   boolean  optional    

Optional. Whether the coupon is active. Defaults to true. Example: true

uses_reserved_contingent   boolean  optional    

Optional. Serve this coupon from the departure's reserved contingent (OfferableOption.overbook_allowance) instead of the public one. Defaults to false. When the reserve is exhausted the booking is refused rather than falling back to public seats. Options without a reserve are unaffected. Example: true

valid_from   string  optional    

Optional ISO 8601 start of the validity window. Nullable. Must be a valid date. Example: 2026-06-01T00:00:00+00:00

valid_until   string  optional    

Optional ISO 8601 end of the validity window (>= valid_from). Nullable. Must be a valid date. Must be a date after or equal to valid_from. Example: 2026-06-30T00:00:00+00:00

code_prefix   string  optional    

Optional prefix prepended to the generated code (max 40, [A-Za-z0-9-]). Nullable. Must match the regex /^[A-Za-z0-9-]*$/. Must not be greater than 40 characters. Example: BUS

offer_ids   integer[]  optional    

The id of an existing record in the offers table.

person_id   string  optional    

Optional person (uuid) to link the coupon to. Nullable. Available people via GET /api/v2/projects/{project}/people. Must be a valid UUID. The id of an existing record in the people table. Example: 9b1c8a7e-4f3d-4c2a-9e1b-2f5a6c7d8e9f

code   string  optional    
id   string  optional    
project_id   string  optional    

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/coupons/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   integer     

The ID of the coupon. Example: 16

project   integer     

Project id. Example: 16

coupon   integer     

Coupon id. Example: 16

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."
            ]
        }
    }
}
 

Request      

PATCH api/v2/projects/{project_id}/coupons/{id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

id   integer     

The ID of the coupon. Example: 16

project   integer     

Project id. Example: 16

coupon   integer     

Coupon id. Example: 16

Body Parameters

active   boolean     

Required. Whether the coupon is active. Set false to deactivate (works even for an already-redeemed coupon — a redeemed coupon cannot be deleted, only deactivated). Example: false

max_units   integer  optional    

Optional. Cap the discount to at most N covered order-position units (the most expensive ones). 0 = unlimited. Must be at least 0. Example: 1

uses_reserved_contingent   boolean  optional    

Optional. Serve this coupon from the departure's reserved contingent instead of the public one. Example: true

code   string  optional    
id   string  optional    
project_id   string  optional    

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: 02d09cdf81c546d0b62667612fa4c989)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
 

Request      

GET api/v2/projects/{project_id}/event-plans

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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: b3975481c0f04d10b6ef89ddfd53eaef)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
 

Request      

GET api/v2/projects/{project_id}/event-plans/{plan_id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

plan_id   integer     

The ID of the plan. Example: 16

project   integer     

Project id. Example: 16

plan   integer     

Scheduler plan id. Example: 16

Query Parameters

withEvents   boolean  optional    

Include the full nested events array (v1-shape). Default false. Example: false

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 / Application 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: 8e92aeb213334a439996c3454b591c34)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
 

Request      

GET api/v2/projects/{project_id}/event-plans/{plan_id}/events

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

plan_id   integer     

The ID of the plan. Example: 16

project   integer     

Project id. Example: 16

plan   integer     

Scheduler plan id (must belong to the project). Example: 16

Query Parameters

withAttendees   boolean  optional    

Upgrade attendees from UUIDs to nested Person resources. Default false. Example: false

withApplications   boolean  optional    

Upgrade applications from UUIDs to nested Application resources. Default false. Example: false

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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: c63907147910409ba5c6a9165a8da029)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
 

Request      

GET api/v2/projects/{project_id}/areas

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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: c9471bb78be54c3b8556865f95386fcb)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
 

Request      

GET api/v2/projects/{project_id}/areas/{area_id}/events

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

area_id   integer     

The ID of the area. Example: 16

project   integer     

Project id. Example: 16

area   integer     

Area id (must belong to the project). Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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: 46b4ea6f2c504ea3a22202468808d27b)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
 

Request      

GET api/v2/projects/{project_id}/event-types

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/supplies

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/supply-categories

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/supplies/{supply_id}/items

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

supply_id   integer     

The ID of the supply. Example: 16

project   integer     

Project id. Example: 16

supply   integer     

Supply (resource) id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/supplies/{supply_id}/items

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

supply_id   integer     

The ID of the supply. Example: 16

project   integer     

Project id. Example: 16

supply   integer     

Supply (resource) id. Example: 16

Body Parameters

name   string     

Required. Device name shown in the backend and on the label. Must not be greater than 255 characters. Example: Zebra ZD421 #3

description   string  optional    

Optional free-text description. Example: Label printer, 300 dpi

serial_number   string  optional    

Optional manufacturer serial. Unique per resource. Must not be greater than 255 characters. Example: ZD421-0099123

tag_number   string  optional    

Optional scannable QR/NFC code, unique instance-wide. Colons are stripped. Omit it to let the server generate one, or send generate_tag: false to create an item without a tag. Must not be greater than 255 characters. Example: A1B2C3D4

generate_tag   boolean  optional    

Defaults to true when no tag_number is sent: the server assigns a random unique tag even if the project setting "generate tag numbers" is off. Send false to deliberately create an item without a tag. Example: true

owner_type   string  optional    

Owner party type: person, application or team. Required together with owner_id. This field is required when owner_id is present. Example: team

Must be one of:
  • person
  • application
  • team
owner_id   string  optional    

Id of the owning party (Person UUID, Application id or Team id). Must belong to the same project. This field is required when owner_type is present. Example: 17

owner_person_tag_number   string  optional    

Scanned NFC tag or printed pass number of the owning person — the kiosk path for "this is mine". Cannot be combined with owner_type/owner_id. Example: 0010000101

available_from   string  optional    

Optional start of the availability window. Must be a valid date. Example: 2026-08-01T08:00:00+02:00

available_to   string  optional    

Optional end of the availability window. Must be a valid date. Must be a date after or equal to available_from. Example: 2026-08-10T20:00:00+02:00

image_url   string  optional    

Optional photo URL, also used as the label logo source. Must not be greater than 255 characters. Example: https://storage.example.com/supply/zd421.jpg

id   string  optional    
supply_id   string  optional    
project_id   string  optional    

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/supply-items/{supplyItem_id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

supplyItem_id   integer     

The ID of the supplyItem. Example: 16

project   integer     

Project id. Example: 16

supplyItem   integer     

Supply item id. Example: 16

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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/supply-tracking-events

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Body Parameters

event_type   string     

One of: issued, received, stocked, destocked, maintenance, disposed, lost, found, transferred. issued, received and transferred require a person. Example: issued

Must be one of:
  • issued
  • received
  • stocked
  • destocked
  • maintenance
  • disposed
  • lost
  • found
  • transferred
supply_item_ids   integer[]  optional    

The id of an existing record in the s_supply_items table.

supply_item_tag_numbers   string[]  optional    

The tag_number of an existing record in the s_supply_items table.

person_id   string  optional    

UUID of the counterpart person. Required for issued/received/transferred unless person_tag_number is sent. The id of an existing record in the people table. Example: 550e8400-e29b-41d4-a716-446655440000

person_tag_number   string  optional    

Scanned NFC tag or printed pass number of the counterpart person. Example: 0010000101

supply_order_id   integer  optional    

Optional supply order this booking belongs to. The id of an existing record in the s_supply_orders table. Example: 42

area_id   integer  optional    

Optional area where the event happened. The id of an existing record in the areas table. Example: 3

stock_location   string  optional    

Free-text storage location. Example: Warehouse A, Shelf 3

geolocation   string  optional    

GPS coordinates of the event. Example: 51.3397,12.3731

w3w   string  optional    

what3words address. Example: filled.count.soap

annotations   string  optional    

Free-text note, e.g. the damage found during maintenance. Example: Cracked housing, sent to repair

image_url   string  optional    

URL of a photo documenting the event. Example: https://storage.example.com/tracking/img001.jpg

tracked_at   string  optional    

When the event happened. Defaults to now — send it when replaying an offline queue. Must be a valid date. Example: 2026-07-15T10:30:00+02:00

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/teams

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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:

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."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/access-checks

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Body Parameters

code   string  optional    

Scanned value: NFC chip UID or the QR value of a printed pass. Colons are stripped. This field is required when person_id is not present. Must not be greater than 255 characters. Example: 04A1B2C3D4E5F6

person_id   string  optional    

Person to check instead of a scan, for a manual lookup. Mutually exclusive with code. This field is required when code is not present. The id of an existing record in the people table. Example: 550e8400-e29b-41d4-a716-446655440000

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/access-holders

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 100

page   integer  optional    

Page number (1-indexed). Example: 1

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/access-holders/version

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Project id. Example: 16

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: adfe73e9e49848f7ab6a74aacb993ff2)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
 

Request      

GET api/v2/projects

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

page   integer  optional    

Page number (1-indexed). Example: 1

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/workflows/{workflow_id}/export

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

workflow_id   integer     

The ID of the workflow. Example: 16

project   integer     

Project id. Example: 1

workflow   integer     

Workflow id (must belong to the project). Example: 5

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"
                }
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/workflows/import

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

project   integer     

Target project id. Example: 2

Body Parameters

payload   object     

The exported workflow payload (from the export endpoint).

workflow_shortname   string     

Workflow shortname. Example: onboarding

type_shortname   string     

Person/group type shortname. Example: artist

steps   object[]     

The workflow steps with their triggers.

target_type   string     

Shortname of the target type; must match the payload type. Example: artist

deactivate_unresolved   boolean  optional    

Commit despite blockers, importing unresolvable triggers switched off. Does not override a showstopper. Default: false.

Required permission: workflow_manage Example: false

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."
    }
}
 

Request      

GET api/v2/projects/{project_id}/workflows/{workflow_id}/triggers

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

workflow_id   integer     

The ID of the workflow. Example: 16

project   integer     

Project id. Example: 2

workflow   integer     

Workflow id (must belong to the project). Example: 42

Query Parameters

active   boolean  optional    

Filter by trigger state: 1 for on, 0 for off. Omit for all. Example: true

Fix and reactivate a workflow trigger.

requires authentication

Merge missing values into the trigger data and/or switch it back on — the final step of the transfer flow after an import deactivated a trigger it could not resolve. Scoped to the project: a trigger from another project is rejected.

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 "{
    \"active\": true,
    \"data\": null
}"
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 = {
    "active": true,
    "data": null
};

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' => [
            'active' => true,
            'data' => null,
        ],
    ]
);
$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
        }
    }
}
 

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."
    }
}
 

Request      

PATCH api/v2/projects/{project_id}/workflow-triggers/{workflowTrigger_id}

Headers

Authorization        

Example: Bearer {ACCESS_TOKEN}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

project_id   integer     

The ID of the project. Example: 564

workflowTrigger_id   integer     

The ID of the workflowTrigger. Example: 16

project   integer     

Project id. Example: 2

workflowTrigger   integer     

Workflow trigger id (must belong to the project). Example: 77

Body Parameters

active   boolean  optional    

Switch the trigger on/off. Example: true

data   object  optional    

Fields to merge into the trigger data (e.g. set notifiable_id).