MENU navbar-image

Introduction

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


Terminology: Group == Application

This API calls the resource a Group — the same entity the festiware backend shows as Gruppe. For backwards compatibility the wire format still uses the historic application spelling, and that spelling will not change.

Concept Wire identifier
Group GET /projects/{project}/applications, application_type_id
Group type GET /application-types, application_type
Sideload flags withApplications, withSubordinatedApplications
Permissions all_persons_and_applications_see / _manage / _administrate

Read every application* identifier below as "group".


Connecting your integration (e.g. n8n)

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

Prerequisites


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: 3aa100cff3f24384aabf026ce0816397)<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=&group=812&withGroups=&per_page=25" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"per_page\": 16,
    \"email\": \"zbailey@example.net\",
    \"updated_since\": \"2026-10-28T22:23:53+01:00\",
    \"person_type\": \"architecto\",
    \"step\": \"architecto\",
    \"workflow_id\": 16,
    \"withJourney\": true,
    \"group\": 22,
    \"withGroups\": true
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/people"
);

const params = {
    "email": "anna@example.com",
    "updated_since": "2026-04-30T00:00:00+00:00",
    "person_type": "volunteer",
    "step": "acc_buildup",
    "workflow_id": "209",
    "withJourney": "0",
    "group": "812",
    "withGroups": "0",
    "per_page": "25",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "per_page": 16,
    "email": "zbailey@example.net",
    "updated_since": "2026-10-28T22:23:53+01:00",
    "person_type": "architecto",
    "step": "architecto",
    "workflow_id": 16,
    "withJourney": true,
    "group": 22,
    "withGroups": true
};

fetch(url, {
    method: "GET",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'email' => 'anna@example.com',
            'updated_since' => '2026-04-30T00:00:00+00:00',
            'person_type' => 'volunteer',
            'step' => 'acc_buildup',
            'workflow_id' => '209',
            'withJourney' => '0',
            'group' => '812',
            'withGroups' => '0',
            'per_page' => '25',
        ],
        'json' => [
            'per_page' => 16,
            'email' => 'zbailey@example.net',
            'updated_since' => '2026-10-28T22:23:53+01:00',
            'person_type' => 'architecto',
            'step' => 'architecto',
            'workflow_id' => 16,
            'withJourney' => true,
            'group' => 22,
            'withGroups' => true,
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": [
        {
            "id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
            "project_id": 1,
            "data": {
                "firstname": "Anna",
                "email": "anna@example.com"
            },
            "calculated_data": {},
            "person_types": [
                "volunteer"
            ],
            "created_at": "2026-04-30T11:24:53+00:00",
            "updated_at": "2026-04-30T11:24:53+00:00",
            "related": {
                "teams": [
                    405,
                    426
                ],
                "team_roles": [
                    12
                ],
                "groups_owned": [],
                "groups_member": [
                    3310
                ],
                "events": [
                    881
                ],
                "tickets": [],
                "orders": [],
                "person_access_types": [
                    4
                ],
                "coupons": [],
                "nfc_tags": [],
                "contracts": [
                    3021
                ]
            }
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 25,
        "total": 1,
        "last_page": 1
    },
    "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": null
    }
}
 

Example response (200, With journey (?withJourney=1)):


{
    "data": [
        {
            "id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
            "project_id": 1,
            "data": {
                "firstname": "Anna"
            },
            "calculated_data": {},
            "person_types": [
                "volunteer"
            ],
            "created_at": "2026-04-30T11:24:53+00:00",
            "updated_at": "2026-04-30T11:24:53+00:00",
            "current_steps": [
                {
                    "workflow_id": 209,
                    "step": {
                        "id": 1904,
                        "shortname": "acc_buildup",
                        "name": "2a) Akkreditierung Aufbau"
                    },
                    "entered_at": "2026-05-01T09:00:00+00:00"
                }
            ],
            "journey": [
                {
                    "step": {
                        "id": 1904,
                        "shortname": "acc_buildup"
                    },
                    "registered_at": "2026-05-01T09:00:00+00:00",
                    "user_id": null
                }
            ]
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 25,
        "total": 1,
        "last_page": 1
    },
    "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (403, Filter on a hidden field):


{
    "error": {
        "code": "filter_not_allowed",
        "message": "Filter 'email' is not allowed: it searches data.email, which data qualification 'Personal Data' protects on a person form of this project, and your user does not hold it. Remove the email parameter and find the person another way (e.g. by id).",
        "errors": {
            "email": [
                "Filter 'email' is not allowed: it searches data.email, which data qualification 'Personal Data' protects on a person form of this project, and your user does not hold it. Remove the email parameter and find the person another way (e.g. by id)."
            ]
        }
    }
}
 

Example response (422, Invalid query parameter):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "per_page": [
                "The per page must not be greater than 100."
            ]
        }
    }
}
 

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 (also email_private / email_corporate). Variants that data qualification hides from you are not searched; if email itself is hidden on a person form of the project, the filter is refused with 403 filter_not_allowed. 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

group   integer  optional    

Optional. Only Persons who own OR are a member of this Group (id). Example: 812

withGroups   boolean  optional    

Optional. Sideload groups: one array of the Person's Groups, each {id, name, type: {id, shortname}, role} with role = owner or member. A Person who owns and is a member of the same Group appears twice, once per role. 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-10-28T22:23:53+01: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: true

group   integer  optional    

Must be at least 1. Example: 22

withGroups   boolean  optional    

Example: true

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

Groups

Groups — the resource the festiware backend shows as Gruppen. For backwards compatibility the wire format keeps the historic application spelling and will not change: route segment applications, payload keys application_type / application_type_id / subordinated_applications, sideload flags withApplications / withSubordinatedApplications, and the permissions all_persons_and_applications_*. Read each of them as "group".

List group types.

requires authentication

Returns every group type defined on the instance, paginated. Global reference data; not project-scoped.

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/application-types?per_page=25&page=1" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/application-types"
);

const params = {
    "per_page": "25",
    "page": "1",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/application-types';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'per_page' => '25',
            'page' => '1',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (500):

Show headers
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
 

{
    "message": "An error occurred and was reported automatically.<br>(Event ID: 00ced9cdf5a240ac9b9dcdc6ebbdbd71)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
 

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 Groups in a project.

requires authentication

Required permission: all_persons_and_applications_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/applications?application_type=volunteer&person_id=01a1e3a6-efbf-4413-9db1-f36e60489254&step=applied&updated_since=2026-05-07T00%3A00%3A00%2B00%3A00&per_page=25&withOwner=&withMembersCollection=&withSubordinatedApplications=&withJourney=&withEvents=&withEventAttendees=&withTickets=&withTranslatedValues=de%0A%0AEvery+Group+also+carries+an+always-on+%60related%60+block+%28no+flag+needed%29%3A+ID-only%0Aarrays+of+linked+resources+%E2%80%94+events%2C+teams%2C+ticket_types%2C+subordinated_applications%2C%0Acontracts+%E2%80%94+for+follow-up+requests.+%60owner%60+and+%60members%60+are+omitted+from+%60related%60%0Aas+they+are+already+top-level+%28person_id%2C+members%5B%5D%29." \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"per_page\": 16,
    \"application_type\": \"architecto\",
    \"person_id\": \"a4855dc5-0acb-33c3-b921-f4291f719ca0\",
    \"step\": \"architecto\",
    \"updated_since\": \"2026-10-28T22:23:53+01:00\",
    \"withOwner\": false,
    \"withMembersCollection\": true,
    \"withSubordinatedApplications\": true,
    \"withJourney\": true,
    \"withEvents\": false,
    \"withEventAttendees\": false,
    \"withTickets\": false,
    \"withTranslatedValues\": \"architecto\"
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/applications"
);

const params = {
    "application_type": "volunteer",
    "person_id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
    "step": "applied",
    "updated_since": "2026-05-07T00:00:00+00:00",
    "per_page": "25",
    "withOwner": "0",
    "withMembersCollection": "0",
    "withSubordinatedApplications": "0",
    "withJourney": "0",
    "withEvents": "0",
    "withEventAttendees": "0",
    "withTickets": "0",
    "withTranslatedValues": "de

Every Group also carries an always-on `related` block (no flag needed): ID-only
arrays of linked resources — events, teams, ticket_types, subordinated_applications,
contracts — for follow-up requests. `owner` and `members` are omitted from `related`
as they are already top-level (person_id, members[]).",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "per_page": 16,
    "application_type": "architecto",
    "person_id": "a4855dc5-0acb-33c3-b921-f4291f719ca0",
    "step": "architecto",
    "updated_since": "2026-10-28T22:23:53+01:00",
    "withOwner": false,
    "withMembersCollection": true,
    "withSubordinatedApplications": true,
    "withJourney": true,
    "withEvents": false,
    "withEventAttendees": false,
    "withTickets": false,
    "withTranslatedValues": "architecto"
};

fetch(url, {
    method: "GET",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'application_type' => 'volunteer',
            'person_id' => '01a1e3a6-efbf-4413-9db1-f36e60489254',
            'step' => 'applied',
            'updated_since' => '2026-05-07T00:00:00+00:00',
            'per_page' => '25',
            'withOwner' => '0',
            'withMembersCollection' => '0',
            'withSubordinatedApplications' => '0',
            'withJourney' => '0',
            'withEvents' => '0',
            'withEventAttendees' => '0',
            'withTickets' => '0',
            'withTranslatedValues' => 'de

Every Group also carries an always-on `related` block (no flag needed): ID-only
arrays of linked resources — events, teams, ticket_types, subordinated_applications,
contracts — for follow-up requests. `owner` and `members` are omitted from `related`
as they are already top-level (person_id, members[]).',
        ],
        'json' => [
            'per_page' => 16,
            'application_type' => 'architecto',
            'person_id' => 'a4855dc5-0acb-33c3-b921-f4291f719ca0',
            'step' => 'architecto',
            'updated_since' => '2026-10-28T22:23:53+01:00',
            'withOwner' => false,
            'withMembersCollection' => true,
            'withSubordinatedApplications' => true,
            'withJourney' => true,
            'withEvents' => false,
            'withEventAttendees' => false,
            'withTickets' => false,
            'withTranslatedValues' => 'architecto',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": [
        {
            "id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
            "project_id": 1,
            "application_type_id": 3,
            "application_type": {
                "id": 3,
                "shortname": "volunteer"
            },
            "person_id": null,
            "members": [],
            "current_step": {
                "id": 42,
                "shortname": "APPROVED"
            },
            "data": {
                "shirt_size": "M"
            },
            "created_at": "2026-05-07T11:24:53+00:00",
            "updated_at": "2026-05-07T11:24:53+00:00",
            "anonymized_at": null,
            "deactivated_at": null,
            "deleted_at": null,
            "related": {
                "events": [
                    881
                ],
                "teams": [
                    405
                ],
                "ticket_types": [],
                "subordinated_applications": [],
                "contracts": []
            }
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 25,
        "total": 1,
        "last_page": 1
    },
    "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (422, Invalid query parameter):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "per_page": [
                "The per page must not be greater than 100."
            ]
        }
    }
}
 

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 Groups 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 groups (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 group type's FormDefinition. Truthy non-locale values fall back to en. Example: `de

Every Group also carries an always-on related block (no flag needed): ID-only arrays of linked resources — events, teams, ticket_types, subordinated_applications, contracts — for follow-up requests. owner and members are omitted from related as they are already top-level (person_id, members[]).`

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-10-28T22:23:53+01:00

withOwner   boolean  optional    

Example: false

withMembersCollection   boolean  optional    

Example: true

withSubordinatedApplications   boolean  optional    

Example: true

withJourney   boolean  optional    

Example: true

withEvents   boolean  optional    

Example: false

withEventAttendees   boolean  optional    

Example: false

withTickets   boolean  optional    

Example: false

withTranslatedValues   string  optional    

Example: architecto

Create a new Group in a project.

requires authentication

Required permission: all_persons_and_applications_administrate

Example request:
curl --request POST \
    "https://{instance}.festiwa.re/api/v2/projects/564/applications" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"application_type\": \"volunteer\",
    \"person_id\": \"01a1e3a6-efbf-4413-9db1-f36e60489254\",
    \"data\": {
        \"shirt_size\": \"M\",
        \"dietary_pref\": \"vegan\"
    }
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/applications"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "application_type": "volunteer",
    "person_id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
    "data": {
        "shirt_size": "M",
        "dietary_pref": "vegan"
    }
};

fetch(url, {
    method: "POST",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications';
$response = $client->post(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'json' => [
            'application_type' => 'volunteer',
            'person_id' => '01a1e3a6-efbf-4413-9db1-f36e60489254',
            'data' => [
                'shirt_size' => 'M',
                'dietary_pref' => 'vegan',
            ],
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (201, Created):


{
    "data": {
        "id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
        "project_id": 1,
        "application_type_id": 3,
        "application_type": {
            "id": 3,
            "shortname": "volunteer"
        },
        "person_id": null,
        "members": [],
        "current_step": {
            "id": 40,
            "shortname": "PENDING"
        },
        "data": {
            "shirt_size": "M",
            "dietary_pref": "vegan"
        },
        "calculated_data": {},
        "created_at": "2026-05-07T11:24:53+00:00",
        "updated_at": "2026-05-07T11:24:53+00:00",
        "anonymized_at": null,
        "deactivated_at": null,
        "deleted_at": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (409, DuplicateId):


{
    "error": {
        "code": "duplicate",
        "message": "A Group with this id already exists."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "application_type": [
                "The application_type field is required."
            ],
            "data": [
                "The data field must be an array."
            ]
        }
    }
}
 

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 Group by UUID.

requires authentication

Same sideload flags (withOwner, withMembersCollection, withJourney, withEvents, withEventAttendees, withTickets, withSubordinatedApplications, withTranslatedValues) are supported here as on the list endpoint.

Required permission: all_persons_and_applications_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/applications/16?withOwner=&withMembersCollection=&withJourney=&withEvents=&withEventAttendees=&withTickets=&withSubordinatedApplications=&withTranslatedValues=de" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16"
);

const params = {
    "withOwner": "0",
    "withMembersCollection": "0",
    "withJourney": "0",
    "withEvents": "0",
    "withEventAttendees": "0",
    "withTickets": "0",
    "withSubordinatedApplications": "0",
    "withTranslatedValues": "de",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'withOwner' => '0',
            'withMembersCollection' => '0',
            'withJourney' => '0',
            'withEvents' => '0',
            'withEventAttendees' => '0',
            'withTickets' => '0',
            'withSubordinatedApplications' => '0',
            'withTranslatedValues' => 'de',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
        "project_id": 1,
        "application_type_id": 3,
        "application_type": {
            "id": 3,
            "shortname": "volunteer"
        },
        "person_id": null,
        "members": [],
        "current_step": {
            "id": 42,
            "shortname": "APPROVED"
        },
        "data": {
            "shirt_size": "M"
        },
        "calculated_data": {},
        "created_at": "2026-05-07T11:24:53+00:00",
        "updated_at": "2026-05-07T11:24:53+00:00",
        "anonymized_at": null,
        "deactivated_at": null,
        "deleted_at": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

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     

Group 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 groups (depth 1). Default false. Example: false

withTranslatedValues   string  optional    

Translate data.* via FormDefinition. Example: de

Update a Group (PUT = full replace, PATCH = sparse merge).

requires authentication

Both PUT and PATCH route here; replaceMissing is derived from the HTTP method. applicationType (immutable) and step_id / workflow_step_id (transitions belong to a separate endpoint) are rejected if present in the body. members[] writes return 422 — manage members in the festiware backend or via v1.

Required permission: all_persons_and_applications_manage

Example request:
curl --request PUT \
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"data\": [],
    \"updated_at\": \"2026-05-07T11:24:53+00:00\",
    \"person_id\": \"architecto\"
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "data": [],
    "updated_at": "2026-05-07T11:24:53+00:00",
    "person_id": "architecto"
};

fetch(url, {
    method: "PUT",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16';
$response = $client->put(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'json' => [
            'data' => [],
            'updated_at' => '2026-05-07T11:24:53+00:00',
            'person_id' => 'architecto',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
        "project_id": 1,
        "application_type_id": 3,
        "application_type": {
            "id": 3,
            "shortname": "volunteer"
        },
        "person_id": null,
        "members": [],
        "current_step": {
            "id": 42,
            "shortname": "APPROVED"
        },
        "data": {
            "shirt_size": "L"
        },
        "calculated_data": {},
        "created_at": "2026-05-07T11:24:53+00:00",
        "updated_at": "2026-05-07T12:00:00+00:00",
        "anonymized_at": null,
        "deactivated_at": null,
        "deleted_at": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Example response (409, OptimisticLockFailed):


{
    "error": {
        "code": "optimistic_lock_failed",
        "message": "The Group was modified by someone else; reload and retry.",
        "errors": {
            "current": {
                "updated_at": "2026-05-07T11:24:53+00:00"
            }
        }
    }
}
 

Example response (422, GroupTypeImmutable):


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

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     

Group 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 a Group.

requires authentication

Requires explicit confirm: true. Missing/false confirmation returns 422 and the Group remains intact. Soft-deleted Groups return 404 from all subsequent v2 endpoints.

Required permission: all_persons_and_applications_administrate

Example request:
curl --request DELETE \
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"confirm\": true
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "confirm": true
};

fetch(url, {
    method: "DELETE",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16';
$response = $client->delete(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'json' => [
            'confirm' => true,
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (204, SoftDeleted):

Empty response
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Example response (422, ValidationFailed):


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

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     

Group UUID. Example: architecto

Body Parameters

confirm   boolean     

Must be exactly true to confirm deletion. Example: true

Advance many Groups 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 Group 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 Group. 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 Group to a new workflow step.

requires authentication

Example request:
curl --request POST \
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16/transitions" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"to_step_id\": 42,
    \"to_step_name\": \"APPROVED\",
    \"application_type\": \"vendor\",
    \"workflow_id\": 5,
    \"note\": \"Approved via n8n.\",
    \"act_as_backend_creation\": false,
    \"dry_run\": false
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16/transitions"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "to_step_id": 42,
    "to_step_name": "APPROVED",
    "application_type": "vendor",
    "workflow_id": 5,
    "note": "Approved via n8n.",
    "act_as_backend_creation": false,
    "dry_run": false
};

fetch(url, {
    method: "POST",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16/transitions';
$response = $client->post(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'json' => [
            'to_step_id' => 42,
            'to_step_name' => 'APPROVED',
            'application_type' => 'vendor',
            'workflow_id' => 5,
            'note' => 'Approved via n8n.',
            'act_as_backend_creation' => false,
            'dry_run' => false,
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, Transitioned):


{
    "data": {
        "application_id": 4711,
        "previous_workflow_step": {
            "id": 41,
            "shortname": "PENDING",
            "name": "Pending"
        },
        "current_workflow_step": {
            "id": 42,
            "shortname": "APPROVED",
            "name": "Approved",
            "entered_at": "2026-05-25T10:00:00+00:00"
        },
        "triggers_fired": 3,
        "note_saved": true
    }
}
 

Example response (200, DryRun):


{
    "data": {
        "application_id": 4711,
        "previous_workflow_step": {
            "id": 41,
            "shortname": "PENDING",
            "name": "Pending"
        },
        "current_workflow_step": {
            "id": 42,
            "shortname": "APPROVED",
            "name": "Approved",
            "entered_at": "2026-05-25T10:00:00+00:00"
        },
        "triggers_fired": 0,
        "note_saved": false,
        "dry_run": true
    }
}
 

Example response (400, AlreadyInStep):


{
    "error": {
        "code": "already_in_step",
        "message": "The target is already in workflow step APPROVED."
    }
}
 

Example response (400, StepNotInWorkflow):


{
    "error": {
        "code": "step_not_in_workflow",
        "message": "The target step does not belong to the identified workflow."
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You do not have permission to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

Example response (404, WorkflowNotFound):


{
    "error": {
        "code": "workflow_not_found",
        "message": "Workflow could not be resolved from the supplied disambiguation."
    }
}
 

Example response (422, InvalidPayload):


{
    "error": {
        "code": "invalid_payload",
        "message": "One of `to_step_id` or `to_step_name` is required.",
        "errors": {
            "to_step": [
                "One of `to_step_id` or `to_step_name` is required."
            ]
        }
    }
}
 

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     

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

Form Attachments

Upload and clear the file/image fields of a Group or a Person.

Every other data.* field is written through the Group/Person update endpoints, but an upload field cannot be: its value is a stored path, and a JSON body carries no file. These endpoints take the file itself (multipart/form-data, one file per request) and write the resulting path into data.{field} for you.

The payload names neither a model type nor a type id — the URL says which record it is, and the record's own type(s) decide which form definition declares the field.

Download the file stored in a Group's form field.

requires authentication

Streams the file with its stored Content-Type and Content-Disposition: attachment. No signed or public URL is handed out: the file comes through this authenticated request.

404 when the field is unknown, is not a file/image field, is empty, or its file is gone, and when field-level access (data qualification) hides the field from you — the same fields the backend hides.

Required permission: all_persons_and_applications_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


[binary file content]
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NoFile):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Request      

GET api/v2/projects/{project_id}/applications/{application_id}/files/{field}

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

field   string     

Name of the file/image field as declared in the group type's form definition. Example: passport_scan

project   integer     

Project id. Example: 1

application   string     

Group id. Example: 812

Download the file stored in a Person's form field.

requires authentication

Same behaviour as the Group download. The field counts as declared when any of the Person's types declares it.

Required permission: all_persons_and_applications_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


[binary file content]
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NoFile):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Request      

GET api/v2/projects/{project_id}/people/{person_id}/files/{field}

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

field   string     

Name of the file/image field as declared in one of the person's types. Example: photo

project   integer     

Project id. Example: 1

person   string     

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

Upload a file into a Group's form field.

requires authentication

multipart/form-data, one file per request, part name file. Replaces whatever the field held before; the previously stored file is deleted once the new path is committed, unless another record still references it. Size and type are validated against the same limits the frontend forms use — image fields accept image formats only, file fields the instance's upload allowlist. HEIC/HEIF uploads are converted to JPEG before validation, as everywhere else.

Required permission: all_persons_and_applications_manage

Example request:
curl --request POST \
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: multipart/form-data" \
    --header "Accept: application/json" \
    --form "file=@/tmp/phpedU7gw" 
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "multipart/form-data",
    "Accept": "application/json",
};

const body = new FormData();
body.append('file', document.querySelector('input[name="file"]').files[0]);

fetch(url, {
    method: "POST",
    headers,
    body,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan';
$response = $client->post(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'multipart/form-data',
            'Accept' => 'application/json',
        ],
        'multipart' => [
            [
                'name' => 'file',
                'contents' => fopen('/tmp/phpedU7gw', 'r')
            ],
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
        "project_id": 1,
        "application_type_id": 3,
        "data": {
            "passport_scan": "form-attachments/01a1e3a6-efbf-4413-9db1-f36e60489254/8f14e45fceea167a5a36dedd4bea2543.jpg"
        },
        "created_at": "2026-05-07T11:24:53+00:00",
        "updated_at": "2026-05-07T12:00:00+00:00"
    }
}
 

Example response (401, Unauthorized):


{
    "message": "Unauthenticated."
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, UnknownFieldOrGroup):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

Example response (422, NotAnUploadField):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "field": [
                "The field 'shirt_size' does not take a file upload."
            ]
        }
    }
}
 

Example response (422, ProtectedByDataQualification):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "field": [
                "Field 'passport_scan': you may not upload or clear its file — your user holds data qualification 'Personal Data' read-only. Do not retry; this needs a user with write access to it."
            ]
        }
    }
}
 

Example response (422, FileRejected):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "file": [
                "The file is too large. A maximum of 20 MB is allowed."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/applications/{application_id}/files/{field}

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

application_id   integer     

The ID of the application. Example: 16

field   string     

Name of the file/image field as declared in the group type's form definition. Example: passport_scan

project   integer     

Project id. Example: 1

application   string     

Group UUID. Example: 01a1e3a6-efbf-4413-9db1-f36e60489254

Body Parameters

file   file     

The file to store. Example: /tmp/phpedU7gw

Clear a Group's file/image field.

requires authentication

Removes the field from data and deletes the stored file (kept if another record still references it). Clearing a field that is already empty is a no-op and still returns 200, so retries are safe.

Required permission: all_persons_and_applications_manage

Example request:
curl --request DELETE \
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "DELETE",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16/files/passport_scan';
$response = $client->delete(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
        "project_id": 1,
        "application_type_id": 3,
        "data": {},
        "created_at": "2026-05-07T11:24:53+00:00",
        "updated_at": "2026-05-07T12:00:00+00:00"
    }
}
 

Example response (401, Unauthorized):


{
    "message": "Unauthenticated."
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, UnknownFieldOrGroup):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

Example response (422, NotAnUploadField):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "field": [
                "The field 'shirt_size' does not take a file upload."
            ]
        }
    }
}
 

Example response (422, ProtectedByDataQualification):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "field": [
                "Field 'passport_scan': you may not upload or clear its file — your user holds data qualification 'Personal Data' read-only. Do not retry; this needs a user with write access to it."
            ]
        }
    }
}
 

Request      

DELETE api/v2/projects/{project_id}/applications/{application_id}/files/{field}

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

field   string     

Name of the file/image field. Example: passport_scan

project   integer     

Project id. Example: 1

application   string     

Group UUID. Example: 01a1e3a6-efbf-4413-9db1-f36e60489254

Upload a file into a Person's form field.

requires authentication

Same contract as the Group endpoint. A Person carries several person types, each with its own form definition; the field is accepted when any of them declares it.

Required permission: all_persons_and_applications_manage

Example request:
curl --request POST \
    "https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: multipart/form-data" \
    --header "Accept: application/json" \
    --form "file=@/tmp/phppbdDWp" 
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "multipart/form-data",
    "Accept": "application/json",
};

const body = new FormData();
body.append('file', document.querySelector('input[name="file"]').files[0]);

fetch(url, {
    method: "POST",
    headers,
    body,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo';
$response = $client->post(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'multipart/form-data',
            'Accept' => 'application/json',
        ],
        'multipart' => [
            [
                'name' => 'file',
                'contents' => fopen('/tmp/phppbdDWp', 'r')
            ],
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
        "project_id": 1,
        "data": {
            "photo": "form-attachments/01a1e3a6-efbf-4413-9db1-f36e60489254/8f14e45fceea167a5a36dedd4bea2543.jpg"
        },
        "person_types": [
            "volunteer"
        ],
        "related": {
            "teams": [],
            "groups_owned": [],
            "groups_member": [],
            "events": [],
            "tickets": [],
            "orders": []
        },
        "created_at": "2026-04-30T11:24:53+00:00",
        "updated_at": "2026-04-30T12:00:00+00:00"
    }
}
 

Example response (401, Unauthorized):


{
    "message": "Unauthenticated."
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, UnknownFieldOrPerson):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

Example response (422, NotAnUploadField):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "field": [
                "The field 'firstname' does not take a file upload."
            ]
        }
    }
}
 

Example response (422, ProtectedByDataQualification):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "field": [
                "Field 'passport_scan': you may not upload or clear its file — your user holds data qualification 'Personal Data' read-only. Do not retry; this needs a user with write access to it."
            ]
        }
    }
}
 

Example response (422, FileRejected):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "file": [
                "This file type is not allowed."
            ]
        }
    }
}
 

Request      

POST api/v2/projects/{project_id}/people/{person_id}/files/{field}

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

person_id   string     

The ID of the person. Example: architecto

field   string     

Name of the file/image field as declared in one of the person type's form definitions. Example: photo

project   integer     

Project id. Example: 1

person   string     

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

Body Parameters

file   file     

The file to store. Example: /tmp/phppbdDWp

Clear a Person's file/image field.

requires authentication

Required permission: all_persons_and_applications_manage

Example request:
curl --request DELETE \
    "https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "DELETE",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/files/photo';
$response = $client->delete(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
        "project_id": 1,
        "data": {},
        "person_types": [
            "volunteer"
        ],
        "related": {
            "teams": [],
            "groups_owned": [],
            "groups_member": [],
            "events": [],
            "tickets": [],
            "orders": []
        },
        "created_at": "2026-04-30T11:24:53+00:00",
        "updated_at": "2026-04-30T12:00:00+00:00"
    }
}
 

Example response (401, Unauthorized):


{
    "message": "Unauthenticated."
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, UnknownFieldOrPerson):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

Example response (422, NotAnUploadField):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "field": [
                "The field 'firstname' does not take a file upload."
            ]
        }
    }
}
 

Example response (422, ProtectedByDataQualification):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "field": [
                "Field 'passport_scan': you may not upload or clear its file — your user holds data qualification 'Personal Data' read-only. Do not retry; this needs a user with write access to it."
            ]
        }
    }
}
 

Request      

DELETE api/v2/projects/{project_id}/people/{person_id}/files/{field}

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

field   string     

Name of the file/image field. Example: photo

project   integer     

Project id. Example: 1

person   string     

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

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/phpzo3m7B" 
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/ticket-types"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "multipart/form-data",
    "Accept": "application/json",
};

const body = new FormData();
body.append('name', 'Shuttle Berlin');
body.append('shortname', 'BFST');
body.append('description', 'Bus shuttle from Berlin Ostbahnhof to the festival site.');
body.append('issue', '100');
body.append('available_in_backend', '1');
body.append('ticket_category_id', '7');
body.append('additional_html', '<p>Welcome!</p>');
body.append('ticket_background', document.querySelector('input[name="ticket_background"]').files[0]);

fetch(url, {
    method: "POST",
    headers,
    body,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/ticket-types';
$response = $client->post(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'multipart/form-data',
            'Accept' => 'application/json',
        ],
        'multipart' => [
            [
                'name' => 'name',
                'contents' => 'Shuttle Berlin'
            ],
            [
                'name' => 'shortname',
                'contents' => 'BFST'
            ],
            [
                'name' => 'description',
                'contents' => 'Bus shuttle from Berlin Ostbahnhof to the festival site.'
            ],
            [
                'name' => 'issue',
                'contents' => '100'
            ],
            [
                'name' => 'available_in_backend',
                'contents' => '1'
            ],
            [
                'name' => 'ticket_category_id',
                'contents' => '7'
            ],
            [
                'name' => 'additional_html',
                'contents' => '<p>Welcome!</p>'
            ],
            [
                'name' => 'ticket_background',
                'contents' => fopen('/tmp/phpzo3m7B', 'r')
            ],
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (201, Created):


{
    "id": 161,
    "name": "Shuttle Berlin",
    "shortname": "BFST",
    "description": null,
    "issue": 100,
    "available_in_backend": true,
    "ticket_category_id": null,
    "created_at": "2026-05-07T11:24:53+00:00",
    "updated_at": "2026-05-07T11:24:53+00:00"
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "name": [
                "The name field is required."
            ],
            "shortname": [
                "The shortname must be 2-4 alphanumeric characters."
            ]
        }
    }
}
 

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 "Contextual information": HTML shown in the check-in app when a ticket of this type is scanned, not printed on the PDF ticket. Twig placeholders: ticketType, owner, order, orderPosition, ticketCode. Cleaned on save: script, event handlers, unsafe links and external resources are removed; inline styles, <style> blocks (scoped to this template) and classes (prefixed fw-u-) stay. Twig runs in a sandbox: {{ }}, if, for, set and simple filters; placeholders work in text and as a style value, not in other attributes. A template the sandbox cannot render is refused with 422. 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/phpzo3m7B

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/phpCeMk0A" 
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/ticket-types/16"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "multipart/form-data",
    "Accept": "application/json",
};

const body = new FormData();
body.append('name', 'Shuttle Berlin');
body.append('shortname', 'BFST');
body.append('description', 'Bus shuttle from Berlin Ostbahnhof to the festival site.');
body.append('issue', '100');
body.append('available_in_backend', '1');
body.append('ticket_category_id', '7');
body.append('additional_html', '<p>Welcome!</p>');
body.append('ticket_background', document.querySelector('input[name="ticket_background"]').files[0]);

fetch(url, {
    method: "PUT",
    headers,
    body,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/ticket-types/16';
$response = $client->put(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'multipart/form-data',
            'Accept' => 'application/json',
        ],
        'multipart' => [
            [
                'name' => 'name',
                'contents' => 'Shuttle Berlin'
            ],
            [
                'name' => 'shortname',
                'contents' => 'BFST'
            ],
            [
                'name' => 'description',
                'contents' => 'Bus shuttle from Berlin Ostbahnhof to the festival site.'
            ],
            [
                'name' => 'issue',
                'contents' => '100'
            ],
            [
                'name' => 'available_in_backend',
                'contents' => '1'
            ],
            [
                'name' => 'ticket_category_id',
                'contents' => '7'
            ],
            [
                'name' => 'additional_html',
                'contents' => '<p>Welcome!</p>'
            ],
            [
                'name' => 'ticket_background',
                'contents' => fopen('/tmp/phpCeMk0A', 'r')
            ],
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "id": 161,
    "name": "Shuttle Berlin (updated)",
    "shortname": "BFST",
    "description": "Updated description.",
    "issue": 150,
    "available_in_backend": true,
    "ticket_category_id": 7,
    "created_at": "2026-05-07T11:24:53+00:00",
    "updated_at": "2026-05-07T12:00:00+00:00"
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Ticket Type not found."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "shortname": [
                "The shortname must be 2-4 alphanumeric characters."
            ]
        }
    }
}
 

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 "Contextual information": HTML shown in the check-in app when a ticket of this type is scanned, not printed on the PDF ticket. Twig placeholders: ticketType, owner, order, orderPosition, ticketCode. Cleaned on save: script, event handlers, unsafe links and external resources are removed; inline styles, <style> blocks (scoped to this template) and classes (prefixed fw-u-) stay. Twig runs in a sandbox: {{ }}, if, for, set and simple filters; placeholders work in text and as a style value, not in other attributes. A template the sandbox cannot render is refused with 422. 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/phpCeMk0A

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.

status is the code's current status, the same value v1 returns as current_ticket_code_status.ticket_status: pending (not yet activated), valid, redeemed (scanned at the gate) or revoked (annulled). It is null for the rare code without any status row; such a code never opens a gate. status_changed_at is when the current status was set.

All filters combine (AND).

Required permission: ticketcode_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes?per_page=25&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00&ticket_type=VIP&status=redeemed" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"per_page\": 16,
    \"updated_since\": \"2026-10-28T22:23:53+01:00\",
    \"ticket_type\": \"n\",
    \"status\": \"revoked\"
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes"
);

const params = {
    "per_page": "25",
    "updated_since": "2026-06-01T00:00:00+00:00",
    "ticket_type": "VIP",
    "status": "redeemed",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "per_page": 16,
    "updated_since": "2026-10-28T22:23:53+01:00",
    "ticket_type": "n",
    "status": "revoked"
};

fetch(url, {
    method: "GET",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'per_page' => '25',
            'updated_since' => '2026-06-01T00:00:00+00:00',
            'ticket_type' => 'VIP',
            'status' => 'redeemed',
        ],
        'json' => [
            'per_page' => 16,
            'updated_since' => '2026-10-28T22:23:53+01:00',
            'ticket_type' => 'n',
            'status' => 'revoked',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": [
        {
            "id": "9b1c4d2e-0000-4000-8000-000000000001",
            "code": "ABC123",
            "ticket_type": {
                "id": 42,
                "shortname": "VIP"
            },
            "owner": {
                "id": "7a2f4d2e-0000-4000-8000-000000000002",
                "firstname": "Anna",
                "lastname": "Meyer",
                "email": "anna@example.com"
            },
            "status": "redeemed",
            "status_changed_at": "2026-06-02T18:30:00+00:00",
            "created_at": "2026-06-01T10:00:00+00:00",
            "updated_at": "2026-06-01T10:00:00+00:00"
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 25,
        "total": 1,
        "last_page": 1
    },
    "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "status": [
                "The selected status is invalid."
            ]
        }
    }
}
 

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

updated_since   string  optional    

ISO 8601 timestamp with offset. Returns codes whose row changed OR whose status changed (e.g. redeemed) at or after this moment — a status change does not touch the code's own updated_at. Example: 2026-06-01T00:00:00+00:00

ticket_type   string  optional    

Ticket type id or shortname. Example: VIP

status   string  optional    

Current status: pending, valid, redeemed or revoked. Example: redeemed

Body Parameters

per_page   integer  optional    

Must be at least 1. Example: 16

updated_since   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-28T22:23:53+01:00

ticket_type   string  optional    

Must not be greater than 255 characters. Example: n

status   string  optional    

Example: revoked

Must be one of:
  • pending
  • valid
  • redeemed
  • revoked

Show one Ticket Code, including its current status.

requires authentication

Same shape as an item of the list.

Required permission: ticketcode_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes/architecto" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes/architecto"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/ticket-codes/architecto';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": "9b1c4d2e-0000-4000-8000-000000000001",
        "code": "ABC123",
        "ticket_type": {
            "id": 42,
            "shortname": "VIP"
        },
        "owner": null,
        "status": "valid",
        "status_changed_at": "2026-06-01T10:00:00+00:00",
        "created_at": "2026-06-01T10:00:00+00:00",
        "updated_at": "2026-06-01T10:00:00+00:00"
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Request      

GET api/v2/projects/{project_id}/ticket-codes/{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 ticket code. Example: architecto

project   integer     

Project id. Example: 16

ticket_code   string     

Ticket code UUID (not the printed code). Example: 9b1c4d2e-0000-4000-8000-000000000001

Orders

Paginated list of Orders in a project.

requires authentication

Each order carries currency, total_price, total_discount and total_donation — major units, rounded to 2 decimals, the same values as the backend's order detail. current_status is the status shortname (OPEN, PAID, DELIVERED, CANCELLED, CONFLICTING, RETURNREQUESTED, RETURNED, SWAPPED). All filters combine (AND).

Required permission: order_see; withTicketCodes additionally ticketcode_see.

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/orders?per_page=25&status=PAID%2CDELIVERED&created_from=2026-06-01T00%3A00%3A00%2B02%3A00&created_until=2026-07-01T00%3A00%3A00%2B02%3A00&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00&withPositions=&withTicketCodes=" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"per_page\": 16,
    \"status\": \"architecto\",
    \"statuses\": [
        \"RETURNED\"
    ],
    \"created_from\": \"2026-10-28T22:23:53+01:00\",
    \"created_until\": \"2026-10-28T22:23:53+01:00\",
    \"updated_since\": \"2026-10-28T22:23:53+01:00\",
    \"withPositions\": false,
    \"withTicketCodes\": false
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/orders"
);

const params = {
    "per_page": "25",
    "status": "PAID,DELIVERED",
    "created_from": "2026-06-01T00:00:00+02:00",
    "created_until": "2026-07-01T00:00:00+02:00",
    "updated_since": "2026-06-01T00:00:00+00:00",
    "withPositions": "0",
    "withTicketCodes": "0",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "per_page": 16,
    "status": "architecto",
    "statuses": [
        "RETURNED"
    ],
    "created_from": "2026-10-28T22:23:53+01:00",
    "created_until": "2026-10-28T22:23:53+01:00",
    "updated_since": "2026-10-28T22:23:53+01:00",
    "withPositions": false,
    "withTicketCodes": false
};

fetch(url, {
    method: "GET",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/orders';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'per_page' => '25',
            'status' => 'PAID,DELIVERED',
            'created_from' => '2026-06-01T00:00:00+02:00',
            'created_until' => '2026-07-01T00:00:00+02:00',
            'updated_since' => '2026-06-01T00:00:00+00:00',
            'withPositions' => '0',
            'withTicketCodes' => '0',
        ],
        'json' => [
            'per_page' => 16,
            'status' => 'architecto',
            'statuses' => [
                'RETURNED',
            ],
            'created_from' => '2026-10-28T22:23:53+01:00',
            'created_until' => '2026-10-28T22:23:53+01:00',
            'updated_since' => '2026-10-28T22:23:53+01:00',
            'withPositions' => false,
            'withTicketCodes' => false,
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": [
        {
            "id": "3f8a4d2e-0000-4000-8000-000000000001",
            "order_number": "2026-000123",
            "current_status": "PAID",
            "firstname": "Anna",
            "lastname": "Meyer",
            "email": "anna@example.com",
            "created_at": "2026-06-01T10:00:00+00:00",
            "updated_at": "2026-06-01T10:00:00+00:00",
            "currency": "EUR",
            "total_price": 89,
            "total_discount": 10,
            "total_donation": 5
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 25,
        "total": 1,
        "last_page": 1
    },
    "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": null
    }
}
 

Example response (200, With positions (?withPositions=1)):


{
    "data": [
        {
            "id": "3f8a4d2e-0000-4000-8000-000000000001",
            "order_number": "2026-000123",
            "current_status": "PAID",
            "firstname": "Anna",
            "lastname": "Meyer",
            "email": "anna@example.com",
            "created_at": "2026-06-01T10:00:00+00:00",
            "updated_at": "2026-06-01T10:00:00+00:00",
            "currency": "EUR",
            "total_price": 89,
            "total_discount": 10,
            "total_donation": 5,
            "positions": [
                {
                    "id": 501,
                    "offer": {
                        "id": 300,
                        "shortname": "weekend"
                    },
                    "coupon": null,
                    "amount": 2,
                    "price": 47,
                    "total_price": 94,
                    "tax": 19,
                    "currency": "EUR",
                    "is_donation": false
                }
            ]
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 25,
        "total": 1,
        "last_page": 1
    },
    "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "statuses.0": [
                "The selected statuses.0 is invalid."
            ]
        }
    }
}
 

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

status   string  optional    

Current status, one or several comma-separated, case-insensitive. Example: PAID,DELIVERED

created_from   string  optional    

ISO 8601 timestamp with offset; orders created at or after it. Example: 2026-06-01T00:00:00+02:00

created_until   string  optional    

ISO 8601 timestamp with offset; orders created before it (exclusive). Example: 2026-07-01T00:00:00+02:00

updated_since   string  optional    

ISO 8601 timestamp with offset; orders updated at or after it. Example: 2026-06-01T00:00:00+00:00

withPositions   boolean  optional    

Sideload positions on each order. Default false. Example: false

withTicketCodes   boolean  optional    

Sideload ticket_codes (id, code, ticket_type) on each order. Requires ticketcode_see. Default false. Example: false

Body Parameters

per_page   integer  optional    

Must be at least 1. Example: 16

status   string  optional    

Example: architecto

statuses   string[]  optional    
Must be one of:
  • OPEN
  • PAID
  • DELIVERED
  • CANCELLED
  • CONFLICTING
  • RETURNREQUESTED
  • RETURNED
  • SWAPPED
created_from   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-28T22:23:53+01:00

created_until   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-28T22:23:53+01:00

updated_since   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-28T22:23:53+01:00

withPositions   boolean  optional    

Example: false

withTicketCodes   boolean  optional    

Example: false

Show one Order with positions, payments and its invoice reference.

requires authentication

Adds billing data, payment_type, amount_received / amount_open, positions, payments (the order's transactions that are not ignored — the same set amount_received is summed from) and invoice (id, invoice_number, or null).

Required permission: order_see; withTicketCodes additionally ticketcode_see.

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/orders/architecto?withTicketCodes=" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/orders/architecto"
);

const params = {
    "withTicketCodes": "0",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/orders/architecto';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'withTicketCodes' => '0',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": "3f8a4d2e-0000-4000-8000-000000000001",
        "order_number": "2026-000123",
        "current_status": "PAID",
        "firstname": "Anna",
        "lastname": "Meyer",
        "email": "anna@example.com",
        "created_at": "2026-06-01T10:00:00+00:00",
        "updated_at": "2026-06-01T10:00:00+00:00",
        "currency": "EUR",
        "total_price": 89,
        "total_discount": 10,
        "total_donation": 5,
        "positions": [
            {
                "id": 501,
                "offer": {
                    "id": 300,
                    "shortname": "weekend"
                },
                "coupon": null,
                "amount": 2,
                "price": 47,
                "total_price": 94,
                "tax": 19,
                "currency": "EUR",
                "is_donation": false
            },
            {
                "id": 502,
                "offer": null,
                "coupon": {
                    "id": 12,
                    "code": "BUS-7F3K9A"
                },
                "amount": 1,
                "price": -10,
                "total_price": -10,
                "tax": 0,
                "currency": "EUR",
                "is_donation": false
            },
            {
                "id": 503,
                "offer": null,
                "coupon": null,
                "amount": 1,
                "price": 5,
                "total_price": 5,
                "tax": 0,
                "currency": "EUR",
                "is_donation": true
            }
        ],
        "person_id": "7a2f4d2e-0000-4000-8000-000000000002",
        "company": null,
        "address": "Hauptstr. 1",
        "address2": null,
        "zip": "04109",
        "city": "Leipzig",
        "country": "DE",
        "vat_id": null,
        "payment_type": "mollie",
        "amount_received": 89,
        "amount_open": 0,
        "payments": [
            {
                "id": 7001,
                "transaction_number": "tr_abc123",
                "transaction_type": "payment",
                "payment_method": "creditcard",
                "status": "paid",
                "balance": 89,
                "currency": "EUR",
                "registered_at": "2026-06-01T10:01:00+00:00",
                "created_at": "2026-06-01T10:01:00+00:00"
            }
        ],
        "invoice": {
            "id": 880,
            "invoice_number": "RE-2026-0042"
        }
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Request      

GET api/v2/projects/{project_id}/orders/{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 order. Example: architecto

project   integer     

Project id. Example: 16

order   string     

Order UUID. Example: 3f8a4d2e-0000-4000-8000-000000000001

Query Parameters

withTicketCodes   boolean  optional    

Sideload ticket_codes. Requires ticketcode_see. Default false. Example: false

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.\",
    \"name_translations\": {
        \"de\": \"Hinfahrt\",
        \"en\": \"Outbound\"
    },
    \"description_translations\": {
        \"de\": \"Busse ab dem Festival.\",
        \"en\": \"Bus shuttles leaving the festival.\"
    }
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/offer-categories"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "name": "Outbound",
    "shortname": "outbound",
    "description": "Bus shuttles leaving the festival.",
    "name_translations": {
        "de": "Hinfahrt",
        "en": "Outbound"
    },
    "description_translations": {
        "de": "Busse ab dem Festival.",
        "en": "Bus shuttles leaving the festival."
    }
};

fetch(url, {
    method: "POST",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offer-categories';
$response = $client->post(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'json' => [
            'name' => 'Outbound',
            'shortname' => 'outbound',
            'description' => 'Bus shuttles leaving the festival.',
            'name_translations' => [
                'de' => 'Hinfahrt',
                'en' => 'Outbound',
            ],
            'description_translations' => [
                'de' => 'Busse ab dem Festival.',
                'en' => 'Bus shuttles leaving the festival.',
            ],
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (201, Created):


{
    "id": 42,
    "project_id": 5,
    "name": "Outbound",
    "shortname": "outbound",
    "description": null,
    "order": 0,
    "created_at": "2026-06-23T09:00:00+00:00",
    "updated_at": "2026-06-23T09:00:00+00:00"
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "name": [
                "The name field is required."
            ],
            "shortname": [
                "The shortname field is required."
            ]
        }
    }
}
 

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  optional    

Required unless name_translations is sent. Display name (max 255 chars); sent alone, it also becomes the German entry of name_translations. This field is required when name_translations is not present. 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. Sent alone, it also becomes the German entry of description_translations. Example: Bus shuttles leaving the festival.

name_translations   object  optional    

Multilanguage name map ({de, en}, de required): what the backend editor and the shop group header show. Its de value is copied into name; wins over name when both are sent.

description_translations   object  optional    

Multilanguage description map ({de, en}, de required): what the backend editor and the shop show. Its de value is copied into description; wins over description when both are sent.

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,
    \"name_translations\": {
        \"de\": \"Hinfahrt\",
        \"en\": \"Outbound\"
    },
    \"description_translations\": {
        \"de\": \"Busse ab dem Festival.\",
        \"en\": \"Bus shuttles leaving the festival.\"
    },
    \"updated_at\": \"2026-06-23T09:00:00+00:00\"
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "name": "Outbound",
    "shortname": "outbound",
    "description": "Bus shuttles leaving the festival.",
    "order": 2,
    "name_translations": {
        "de": "Hinfahrt",
        "en": "Outbound"
    },
    "description_translations": {
        "de": "Busse ab dem Festival.",
        "en": "Bus shuttles leaving the festival."
    },
    "updated_at": "2026-06-23T09:00:00+00:00"
};

fetch(url, {
    method: "PUT",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offer-categories/16';
$response = $client->put(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'json' => [
            'name' => 'Outbound',
            'shortname' => 'outbound',
            'description' => 'Bus shuttles leaving the festival.',
            'order' => 2,
            'name_translations' => [
                'de' => 'Hinfahrt',
                'en' => 'Outbound',
            ],
            'description_translations' => [
                'de' => 'Busse ab dem Festival.',
                'en' => 'Bus shuttles leaving the festival.',
            ],
            'updated_at' => '2026-06-23T09:00:00+00:00',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "id": 42,
    "project_id": 5,
    "name": "Outbound (updated)",
    "shortname": "outbound",
    "description": "Bus shuttles leaving the festival",
    "order": 2,
    "created_at": "2026-06-23T09:00:00+00:00",
    "updated_at": "2026-06-23T12:00:00+00:00"
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

Example response (409, OptimisticLockFailed):


{
    "error": {
        "code": "optimistic_lock_failed",
        "message": "The resource was modified after your last read. Re-fetch and retry.",
        "errors": {
            "current": {
                "updated_at": "2026-06-23T12:00:00+00:00"
            }
        }
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "updated_at": [
                "The updated_at field is required."
            ]
        }
    }
}
 

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  optional    

Required on PUT unless name_translations is sent, optional on PATCH. Display name (max 255 chars); sent alone, it also replaces the German entry of name_translations, English stays. This field is required when name_translations is not present. 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. Sent alone, it also replaces the German entry of description_translations (English stays); null clears both. Example: Bus shuttles leaving the festival.

order   integer  optional    

Optional sort position within the project. Example: 2

name_translations   object  optional    

Multilanguage name map ({de, en}, de required), replaced as a whole: what the backend editor and the shop group header show. Its de value is copied into name. Cannot be null.

description_translations   object  optional    

Multilanguage description map ({de, en}, de required), replaced as a whole. Its de value is copied into description; null clears both.

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 true — false 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 true — false 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,
            "required_ticket_types": [],
            "required_person_types": [],
            "no_dynamic_showing": false,
            "available_from": null,
            "available_to": null,
            "published_from": null,
            "published_to": null,
            "sort_order": 1,
            "updated_at": "2026-07-24T09:00:00+00:00",
            "offerable": {
                "id": 208,
                "name": "Camping Club Hamburg",
                "shortname": "CCHH",
                "description": null,
                "capacity": 0,
                "valid_from": null,
                "valid_to": null,
                "available_in_backend": true,
                "category_id": null,
                "ticket_background_url": null,
                "additional_html": null,
                "created_at": "2026-07-24T09:00:00+00:00",
                "updated_at": "2026-07-24T09:00:00+00:00"
            },
            "offer_category": {
                "id": 91,
                "project_id": 5,
                "name": "Camping",
                "shortname": "camping",
                "description": null,
                "order": 0,
                "created_at": "2026-07-24T09:00:00+00:00",
                "updated_at": "2026-07-24T09:00:00+00:00"
            }
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 25,
        "total": 1,
        "last_page": 1
    },
    "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

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=Includes breakfast"\
    --form "description_translations[de]=Inkl. Frühstück"\
    --form "description_translations[en]=Includes breakfast"\
    --form "price=0"\
    --form "tax=0"\
    --form "issue=27"\
    --form "hidden_link=club-hamburg"\
    --form "hide_show_all_link=1"\
    --form "email_product="\
    --form "max_order_amount=16"\
    --form "published_from=2026-10-28T22:23:53"\
    --form "published_to=2052-11-20"\
    --form "available_from=2026-10-28T22:23:53"\
    --form "available_to=2052-11-20"\
    --form "required_ticket_types[]=16"\
    --form "required_person_types[]=16"\
    --form "no_dynamic_showing=1"\
    --form "ticket_type_id=208"\
    --form "ticket_type[name]=Camping Club Hamburg"\
    --form "ticket_type[shortname]=CCHH"\
    --form "ticket_type[description]=Eius et animi quos velit et."\
    --form "ticket_type[issue]=60"\
    --form "ticket_type[available_in_backend]="\
    --form "ticket_type[ticket_category_id]=16"\
    --form "ticket_type[valid_from]=2022-11-22"\
    --form "ticket_type[valid_to]=2052-11-20"\
    --form "ticket_type[additional_html]=<p>Welcome, Club Hamburg!</p>"\
    --form "offer_category_id=91"\
    --form "offer_category[name]=Camping"\
    --form "offer_category[shortname]=camping"\
    --form "offer_category[description]=Eius et animi quos velit et."\
    --form "tax_rules[][layout]=architecto"\
    --form "tax_rules[][key]=architecto"\
    --form "tax_rules[][attributes][label]=n"\
    --form "tax_rules[][attributes][percent]=1"\
    --form "tax_rules[][attributes][rate]=1"\
    --form "image=@/tmp/phpYaW5Yk" \
    --form "ticket_type[ticket_background]=@/tmp/phpIU9fZs" 
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/offers"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "multipart/form-data",
    "Accept": "application/json",
};

const body = new FormData();
body.append('shortname', 'Camping Club Hamburg');
body.append('name[de]', 'Camping Club Hamburg');
body.append('name[en]', 'Camping Club Hamburg');
body.append('description', 'Includes breakfast');
body.append('description_translations[de]', 'Inkl. Frühstück');
body.append('description_translations[en]', 'Includes breakfast');
body.append('price', '0');
body.append('tax', '0');
body.append('issue', '27');
body.append('hidden_link', 'club-hamburg');
body.append('hide_show_all_link', '1');
body.append('email_product', '');
body.append('max_order_amount', '16');
body.append('published_from', '2026-10-28T22:23:53');
body.append('published_to', '2052-11-20');
body.append('available_from', '2026-10-28T22:23:53');
body.append('available_to', '2052-11-20');
body.append('required_ticket_types[]', '16');
body.append('required_person_types[]', '16');
body.append('no_dynamic_showing', '1');
body.append('ticket_type_id', '208');
body.append('ticket_type[name]', 'Camping Club Hamburg');
body.append('ticket_type[shortname]', 'CCHH');
body.append('ticket_type[description]', 'Eius et animi quos velit et.');
body.append('ticket_type[issue]', '60');
body.append('ticket_type[available_in_backend]', '');
body.append('ticket_type[ticket_category_id]', '16');
body.append('ticket_type[valid_from]', '2022-11-22');
body.append('ticket_type[valid_to]', '2052-11-20');
body.append('ticket_type[additional_html]', '<p>Welcome, Club Hamburg!</p>');
body.append('offer_category_id', '91');
body.append('offer_category[name]', 'Camping');
body.append('offer_category[shortname]', 'camping');
body.append('offer_category[description]', 'Eius et animi quos velit et.');
body.append('tax_rules[][layout]', 'architecto');
body.append('tax_rules[][key]', 'architecto');
body.append('tax_rules[][attributes][label]', 'n');
body.append('tax_rules[][attributes][percent]', '1');
body.append('tax_rules[][attributes][rate]', '1');
body.append('image', document.querySelector('input[name="image"]').files[0]);
body.append('ticket_type[ticket_background]', document.querySelector('input[name="ticket_type[ticket_background]"]').files[0]);

fetch(url, {
    method: "POST",
    headers,
    body,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offers';
$response = $client->post(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'multipart/form-data',
            'Accept' => 'application/json',
        ],
        'multipart' => [
            [
                'name' => 'shortname',
                'contents' => 'Camping Club Hamburg'
            ],
            [
                'name' => 'name[de]',
                'contents' => 'Camping Club Hamburg'
            ],
            [
                'name' => 'name[en]',
                'contents' => 'Camping Club Hamburg'
            ],
            [
                'name' => 'description',
                'contents' => 'Includes breakfast'
            ],
            [
                'name' => 'description_translations[de]',
                'contents' => 'Inkl. Frühstück'
            ],
            [
                'name' => 'description_translations[en]',
                'contents' => 'Includes breakfast'
            ],
            [
                'name' => 'price',
                'contents' => '0'
            ],
            [
                'name' => 'tax',
                'contents' => '0'
            ],
            [
                'name' => 'issue',
                'contents' => '27'
            ],
            [
                'name' => 'hidden_link',
                'contents' => 'club-hamburg'
            ],
            [
                'name' => 'hide_show_all_link',
                'contents' => '1'
            ],
            [
                'name' => 'email_product',
                'contents' => ''
            ],
            [
                'name' => 'max_order_amount',
                'contents' => '16'
            ],
            [
                'name' => 'published_from',
                'contents' => '2026-10-28T22:23:53'
            ],
            [
                'name' => 'published_to',
                'contents' => '2052-11-20'
            ],
            [
                'name' => 'available_from',
                'contents' => '2026-10-28T22:23:53'
            ],
            [
                'name' => 'available_to',
                'contents' => '2052-11-20'
            ],
            [
                'name' => 'required_ticket_types[]',
                'contents' => '16'
            ],
            [
                'name' => 'required_person_types[]',
                'contents' => '16'
            ],
            [
                'name' => 'no_dynamic_showing',
                'contents' => '1'
            ],
            [
                'name' => 'ticket_type_id',
                'contents' => '208'
            ],
            [
                'name' => 'ticket_type[name]',
                'contents' => 'Camping Club Hamburg'
            ],
            [
                'name' => 'ticket_type[shortname]',
                'contents' => 'CCHH'
            ],
            [
                'name' => 'ticket_type[description]',
                'contents' => 'Eius et animi quos velit et.'
            ],
            [
                'name' => 'ticket_type[issue]',
                'contents' => '60'
            ],
            [
                'name' => 'ticket_type[available_in_backend]',
                'contents' => ''
            ],
            [
                'name' => 'ticket_type[ticket_category_id]',
                'contents' => '16'
            ],
            [
                'name' => 'ticket_type[valid_from]',
                'contents' => '2022-11-22'
            ],
            [
                'name' => 'ticket_type[valid_to]',
                'contents' => '2052-11-20'
            ],
            [
                'name' => 'ticket_type[additional_html]',
                'contents' => '<p>Welcome, Club Hamburg!</p>'
            ],
            [
                'name' => 'offer_category_id',
                'contents' => '91'
            ],
            [
                'name' => 'offer_category[name]',
                'contents' => 'Camping'
            ],
            [
                'name' => 'offer_category[shortname]',
                'contents' => 'camping'
            ],
            [
                'name' => 'offer_category[description]',
                'contents' => 'Eius et animi quos velit et.'
            ],
            [
                'name' => 'tax_rules[][layout]',
                'contents' => 'architecto'
            ],
            [
                'name' => 'tax_rules[][key]',
                'contents' => 'architecto'
            ],
            [
                'name' => 'tax_rules[][attributes][label]',
                'contents' => 'n'
            ],
            [
                'name' => 'tax_rules[][attributes][percent]',
                'contents' => '1'
            ],
            [
                'name' => 'tax_rules[][attributes][rate]',
                'contents' => '1'
            ],
            [
                'name' => 'image',
                'contents' => fopen('/tmp/phpYaW5Yk', 'r')
            ],
            [
                'name' => 'ticket_type[ticket_background]',
                'contents' => fopen('/tmp/phpIU9fZs', 'r')
            ],
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (201, Created):


{
    "data": {
        "id": 311,
        "shortname": "Camping Club Hamburg",
        "price": 0,
        "tax": 0,
        "hidden_link": "club-hamburg",
        "required_ticket_types": [
            208
        ],
        "required_person_types": [],
        "no_dynamic_showing": true,
        "image_url": "https://instance.example/storage/api-uploads/offers/1753364000_logo.png",
        "offerable": {
            "id": 208,
            "shortname": "CCHH",
            "ticket_background_url": "https://instance.example/storage/api-uploads/ticket-backgrounds/1753364000_bg.jpg",
            "additional_html": "<p>Welcome!</p>"
        },
        "offer_category": {
            "id": 91,
            "name": "Camping",
            "shortname": "camping"
        }
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "ticket_type.shortname": [
                "The ticket type.shortname field format is invalid."
            ]
        }
    }
}
 

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    

Plain product description (legacy column, used by mails and PDFs). Sent alone, it also becomes the German entry of description_translations. Ignored when description_translations is sent. Example: Includes breakfast

description_translations   object  optional    

Multilanguage description map ({de, en}): what the backend editor and the shop show. Its de value (or the first non-empty one) is copied into description. Multipart: description_translations[de]=…&description_translations[en]=….

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: 27

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/phpYaW5Yk

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-10-28T22:23:53

published_to   string  optional    

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

available_from   string  optional    

Must be a valid date. Example: 2026-10-28T22:23:53

available_to   string  optional    

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

required_ticket_types   integer[]  optional    

The id of an existing record in the ticket_types table.

required_person_types   integer[]  optional    

The id of an existing record in the types table.

no_dynamic_showing   boolean  optional    

Only meaningful together with required_ticket_types: when true the offer stays hidden until the person already owns one of the required tickets; a matching ticket in the cart no longer reveals it. Example: true

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: false

ticket_category_id   integer  optional    

Example: 16

valid_from   string  optional    

Must be a valid date. Must be a date after or equal to 2000-01-01. Must be a date before 2100-01-01. Example: 2022-11-22

valid_to   string  optional    

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

additional_html   string  optional    

"Contextual information": HTML shown in the check-in app when a ticket of this type is scanned, not printed on the PDF ticket. Twig placeholders: ticketType, owner, order, orderPosition, ticketCode. Cleaned on save: script, event handlers, unsafe links and external resources are removed; inline styles, <style> blocks (scoped to this template) and classes (prefixed fw-u-) stay. Twig runs in a sandbox: {{ }}, if, for, set and simple filters; placeholders work in text and as a style value, not in other attributes. A template the sandbox cannot render is refused with 422. 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/phpIU9fZs

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",
        "required_ticket_types": [
            208
        ],
        "required_person_types": [],
        "no_dynamic_showing": true,
        "image_url": null,
        "offerable": {
            "id": 208,
            "shortname": "CCHH"
        },
        "offer_category": {
            "id": 91,
            "shortname": "camping"
        }
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

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. Shop visibility gates (required_ticket_types, required_person_types, no_dynamic_showing) are accepted here and on store.

Example request:
curl --request PUT \
    "https://{instance}.festiwa.re/api/v2/projects/564/offers/16" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: multipart/form-data" \
    --header "Accept: application/json" \
    --form "shortname=Camping Club Hamburg"\
    --form "price=0"\
    --form "tax=0"\
    --form "issue=0"\
    --form "email_product=1"\
    --form "name[de]=Camping Club Hamburg"\
    --form "description=Includes breakfast and dinner"\
    --form "description_translations[de]=Inkl. Frühstück und Abendessen"\
    --form "description_translations[en]=Includes breakfast and dinner"\
    --form "hidden_link=club-hamburg-2027"\
    --form "hide_show_all_link=1"\
    --form "max_order_amount=16"\
    --form "published_from=2026-10-28T22:23:53"\
    --form "published_to=2052-11-20"\
    --form "available_from=2026-10-28T22:23:53"\
    --form "available_to=2052-11-20"\
    --form "offer_category_id=91"\
    --form "required_ticket_types[]=16"\
    --form "required_person_types[]=16"\
    --form "no_dynamic_showing=1"\
    --form "updated_at=2026-07-24T09:00:00+00:00"\
    --form "image=@/tmp/phpsv3DqL" 
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/offers/16"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "multipart/form-data",
    "Accept": "application/json",
};

const body = new FormData();
body.append('shortname', 'Camping Club Hamburg');
body.append('price', '0');
body.append('tax', '0');
body.append('issue', '0');
body.append('email_product', '1');
body.append('name[de]', 'Camping Club Hamburg');
body.append('description', 'Includes breakfast and dinner');
body.append('description_translations[de]', 'Inkl. Frühstück und Abendessen');
body.append('description_translations[en]', 'Includes breakfast and dinner');
body.append('hidden_link', 'club-hamburg-2027');
body.append('hide_show_all_link', '1');
body.append('max_order_amount', '16');
body.append('published_from', '2026-10-28T22:23:53');
body.append('published_to', '2052-11-20');
body.append('available_from', '2026-10-28T22:23:53');
body.append('available_to', '2052-11-20');
body.append('offer_category_id', '91');
body.append('required_ticket_types[]', '16');
body.append('required_person_types[]', '16');
body.append('no_dynamic_showing', '1');
body.append('updated_at', '2026-07-24T09:00:00+00:00');
body.append('image', document.querySelector('input[name="image"]').files[0]);

fetch(url, {
    method: "PUT",
    headers,
    body,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offers/16';
$response = $client->put(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'multipart/form-data',
            'Accept' => 'application/json',
        ],
        'multipart' => [
            [
                'name' => 'shortname',
                'contents' => 'Camping Club Hamburg'
            ],
            [
                'name' => 'price',
                'contents' => '0'
            ],
            [
                'name' => 'tax',
                'contents' => '0'
            ],
            [
                'name' => 'issue',
                'contents' => '0'
            ],
            [
                'name' => 'email_product',
                'contents' => '1'
            ],
            [
                'name' => 'name[de]',
                'contents' => 'Camping Club Hamburg'
            ],
            [
                'name' => 'description',
                'contents' => 'Includes breakfast and dinner'
            ],
            [
                'name' => 'description_translations[de]',
                'contents' => 'Inkl. Frühstück und Abendessen'
            ],
            [
                'name' => 'description_translations[en]',
                'contents' => 'Includes breakfast and dinner'
            ],
            [
                'name' => 'hidden_link',
                'contents' => 'club-hamburg-2027'
            ],
            [
                'name' => 'hide_show_all_link',
                'contents' => '1'
            ],
            [
                'name' => 'max_order_amount',
                'contents' => '16'
            ],
            [
                'name' => 'published_from',
                'contents' => '2026-10-28T22:23:53'
            ],
            [
                'name' => 'published_to',
                'contents' => '2052-11-20'
            ],
            [
                'name' => 'available_from',
                'contents' => '2026-10-28T22:23:53'
            ],
            [
                'name' => 'available_to',
                'contents' => '2052-11-20'
            ],
            [
                'name' => 'offer_category_id',
                'contents' => '91'
            ],
            [
                'name' => 'required_ticket_types[]',
                'contents' => '16'
            ],
            [
                'name' => 'required_person_types[]',
                'contents' => '16'
            ],
            [
                'name' => 'no_dynamic_showing',
                'contents' => '1'
            ],
            [
                'name' => 'updated_at',
                'contents' => '2026-07-24T09:00:00+00:00'
            ],
            [
                'name' => 'image',
                'contents' => fopen('/tmp/phpsv3DqL', 'r')
            ],
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": 311,
        "shortname": "Camping Club Hamburg",
        "hidden_link": "club-hamburg-2027",
        "updated_at": "2026-07-24T12:00:00+00:00"
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

Example response (409, OptimisticLockFailed):


{
    "error": {
        "code": "optimistic_lock_failed",
        "message": "The resource was modified after your last read. Re-fetch and retry.",
        "errors": {
            "current": {
                "updated_at": "2026-07-24T12:00:00+00:00"
            }
        }
    }
}
 

Example response (422, TicketTypeFieldsRejected):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "ticket_type": [
                "Ticket type fields cannot be changed through the offer. Please use the ticket-types endpoint."
            ]
        }
    }
}
 

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    

Plain product description (legacy column, used by mails and PDFs). Sent without description_translations, it also replaces the German entry of description_translations; English stays. Example: Includes breakfast and dinner

description_translations   object  optional    

Multilanguage description map ({de, en}), replaced as a whole: what the backend editor and the shop show. Its de value (or the first non-empty one) is copied into description. Multipart: description_translations[de]=….

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-10-28T22:23:53

published_to   string  optional    

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

available_from   string  optional    

Must be a valid date. Example: 2026-10-28T22:23:53

available_to   string  optional    

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

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/phpsv3DqL

required_ticket_types   integer[]  optional    

The id of an existing record in the ticket_types table.

required_person_types   integer[]  optional    

The id of an existing record in the types table.

no_dynamic_showing   boolean  optional    

Only meaningful together with required_ticket_types: when true the offer stays hidden until the person already owns one of the required tickets; a matching ticket in the cart no longer reveals it. Example: true

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/3" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"confirm\": true
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/offers/3"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "confirm": true
};

fetch(url, {
    method: "DELETE",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/offers/3';
$response = $client->delete(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'json' => [
            'confirm' => true,
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (204, Deleted):

Empty response
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

Example response (422, ValidationFailed):


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

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

project   integer     

Project id. Example: 16

offer   integer     

Offer id. Example: 16

Body Parameters

confirm   boolean     

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

Coupons

Paginated list of coupons in a project, including their redemption status.

requires authentication

Every item has the same shape as the show endpoint: order_amount (orders placed on the coupon), redeemed and is_valid. Use it for reconciliation and export; walk the pages with updated_since for incremental syncs.

Required permission: coupon_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/coupons?per_page=25&active=1&redeemed=&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"per_page\": 16,
    \"active\": false,
    \"redeemed\": false,
    \"updated_since\": \"2026-10-28T22:23:53+01:00\"
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/coupons"
);

const params = {
    "per_page": "25",
    "active": "1",
    "redeemed": "0",
    "updated_since": "2026-06-01T00:00:00+00:00",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "per_page": 16,
    "active": false,
    "redeemed": false,
    "updated_since": "2026-10-28T22:23:53+01:00"
};

fetch(url, {
    method: "GET",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/coupons';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'per_page' => '25',
            'active' => '1',
            'redeemed' => '0',
            'updated_since' => '2026-06-01T00:00:00+00:00',
        ],
        'json' => [
            'per_page' => 16,
            'active' => false,
            'redeemed' => false,
            'updated_since' => '2026-10-28T22:23:53+01:00',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": [
        {
            "id": 12,
            "project_id": 5,
            "code": "BUS-7F3K9A",
            "type": "relative",
            "value": 100,
            "max_units": 1,
            "active": true,
            "is_singleuse": true,
            "uses_reserved_contingent": false,
            "valid_from": null,
            "valid_until": null,
            "person_id": null,
            "offer_ids": [
                300,
                301
            ],
            "created_at": "2026-06-23T09:00:00+00:00",
            "updated_at": "2026-06-23T09:00:00+00:00",
            "order_amount": 0,
            "redeemed": false,
            "is_valid": true
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 25,
        "total": 1,
        "last_page": 1
    },
    "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "redeemed": [
                "The redeemed field must be true or false."
            ]
        }
    }
}
 

Request      

GET 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

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

active   boolean  optional    

Only active (true) or only inactive (false) coupons. Example: true

redeemed   boolean  optional    

Only coupons with at least one order (true) or with none (false) — the same rule as redeemed in the response. Example: false

updated_since   string  optional    

ISO 8601 timestamp with offset; only coupons updated at or after it. Example: 2026-06-01T00:00:00+00:00

Body Parameters

per_page   integer  optional    

Must be at least 1. Example: 16

active   boolean  optional    

Example: false

redeemed   boolean  optional    

Example: false

updated_since   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-28T22:23:53+01:00

Create a coupon in a project.

requires authentication

Generates a unique code server-side and returns the coupon with its linked offers. Restrict the coupon to specific products via offer_ids; omit them to apply to all.

Example request:
curl --request POST \
    "https://{instance}.festiwa.re/api/v2/projects/564/coupons" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"type\": \"relative\",
    \"value\": 100,
    \"max_units\": 1,
    \"is_singleuse\": true,
    \"active\": true,
    \"uses_reserved_contingent\": true,
    \"valid_from\": \"2026-06-01T00:00:00+00:00\",
    \"valid_until\": \"2026-06-30T00:00:00+00:00\",
    \"code_prefix\": \"BUS\",
    \"offer_ids\": [
        16
    ],
    \"person_id\": \"9b1c8a7e-4f3d-4c2a-9e1b-2f5a6c7d8e9f\",
    \"code\": \"BURTON-MUC\"
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/coupons"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "type": "relative",
    "value": 100,
    "max_units": 1,
    "is_singleuse": true,
    "active": true,
    "uses_reserved_contingent": true,
    "valid_from": "2026-06-01T00:00:00+00:00",
    "valid_until": "2026-06-30T00:00:00+00:00",
    "code_prefix": "BUS",
    "offer_ids": [
        16
    ],
    "person_id": "9b1c8a7e-4f3d-4c2a-9e1b-2f5a6c7d8e9f",
    "code": "BURTON-MUC"
};

fetch(url, {
    method: "POST",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/coupons';
$response = $client->post(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'json' => [
            'type' => 'relative',
            'value' => 100.0,
            'max_units' => 1,
            'is_singleuse' => true,
            'active' => true,
            'uses_reserved_contingent' => true,
            'valid_from' => '2026-06-01T00:00:00+00:00',
            'valid_until' => '2026-06-30T00:00:00+00:00',
            'code_prefix' => 'BUS',
            'offer_ids' => [
                16,
            ],
            'person_id' => '9b1c8a7e-4f3d-4c2a-9e1b-2f5a6c7d8e9f',
            'code' => 'BURTON-MUC',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (201, Created):


{
    "id": 12,
    "project_id": 5,
    "code": "BUS-7F3K9A",
    "type": "relative",
    "value": 100,
    "active": true,
    "is_singleuse": true,
    "valid_from": null,
    "valid_until": null,
    "person_id": null,
    "offer_ids": [
        300,
        301
    ],
    "created_at": "2026-06-23T09:00:00+00:00",
    "updated_at": "2026-06-23T09:00:00+00:00"
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "type": [
                "The type field is required."
            ],
            "value": [
                "The value field is required."
            ]
        }
    }
}
 

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. 0 mints an unlock coupon: no discount, but it reveals coupon_required products. 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    

Optional. Set the coupon code verbatim (letters, digits and hyphens, max 64). Must be unused, compared case-insensitively. Omit it to have one generated; cannot be combined with code_prefix. Must match the regex /^[A-Za-z0-9-]+$/. Must not be greater than 64 characters. Example: BURTON-MUC

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: f093e7dd023f49bc8e86225f789eaab0)<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: 5971f7f2797f4a09b70b5904a47405f1)<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 / Group resources via the opt-in ?withAttendees=1 / ?withApplications=1 flags (mirrors ApplicationsController's sideload pattern).

403 if the caller cannot view the plan (canUserView). 404 if the plan does not belong to the URL's project.

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/event-plans/16/events?withAttendees=&withApplications=&per_page=25&page=1" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/event-plans/16/events"
);

const params = {
    "withAttendees": "0",
    "withApplications": "0",
    "per_page": "25",
    "page": "1",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/event-plans/16/events';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'withAttendees' => '0',
            'withApplications' => '0',
            'per_page' => '25',
            'page' => '1',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (500):

Show headers
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
 

{
    "message": "An error occurred and was reported automatically.<br>(Event ID: e4a6fca54f2041c48927671b77ada771)<br>We usually resolve errors within a few hours.<br>Please try again later!"
}
 

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 the applications array from UUIDs to nested Group 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: 3390d145dae94eeaa645fe0ac987e865)<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: 166d2b9a16144d5a9ff03a75ee659246)<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: 0cd1a332b1cf402c827f93ad3c6c611c)<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, Group 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: e2dfc879cc34450a9ed77b97e671c4c3)<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

Comments

Paginated comments of a Group, oldest first.

requires authentication

Same shape and rules as the Person comments.

Required permission: all_persons_and_applications_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/applications/16/comments?per_page=25&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"per_page\": 16,
    \"updated_since\": \"2026-10-28T22:23:53+01:00\"
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/applications/16/comments"
);

const params = {
    "per_page": "25",
    "updated_since": "2026-06-01T00:00:00+00:00",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "per_page": 16,
    "updated_since": "2026-10-28T22:23:53+01:00"
};

fetch(url, {
    method: "GET",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/applications/16/comments';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'per_page' => '25',
            'updated_since' => '2026-06-01T00:00:00+00:00',
        ],
        'json' => [
            'per_page' => 16,
            'updated_since' => '2026-10-28T22:23:53+01:00',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": [
        {
            "id": 8813,
            "text": "Stage plan received.",
            "author": {
                "id": 3,
                "name": "Backoffice"
            },
            "source": "user",
            "created_at": "2026-06-23T09:00:00+00:00",
            "updated_at": "2026-06-23T09:00:00+00:00",
            "edited_at": null
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 25,
        "total": 1,
        "last_page": 1
    },
    "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "updated_since": [
                "The updated since field must match the format Y-m-d\\TH:i:sP."
            ]
        }
    }
}
 

Request      

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

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     

Group id. Example: 16

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

updated_since   string  optional    

ISO 8601 timestamp with offset; only comments updated at or after it. Example: 2026-06-01T00:00:00+00:00

Body Parameters

per_page   integer  optional    

Must be at least 1. Example: 16

updated_since   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-28T22:23:53+01:00

Paginated comments of a Person, oldest first.

requires authentication

author is the backend user who wrote the comment ({id, name}), or null for system comments. source is user or system accordingly. A note saved with a workflow transition carries the acting user and reads user; it cannot be told apart from a comment typed in the backend. Soft-deleted comments are not listed.

Required permission: all_persons_and_applications_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/comments?per_page=25&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"per_page\": 16,
    \"updated_since\": \"2026-10-28T22:23:53+01:00\"
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/comments"
);

const params = {
    "per_page": "25",
    "updated_since": "2026-06-01T00:00:00+00:00",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "per_page": 16,
    "updated_since": "2026-10-28T22:23:53+01:00"
};

fetch(url, {
    method: "GET",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/people/architecto/comments';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'per_page' => '25',
            'updated_since' => '2026-06-01T00:00:00+00:00',
        ],
        'json' => [
            'per_page' => 16,
            'updated_since' => '2026-10-28T22:23:53+01:00',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": [
        {
            "id": 8812,
            "text": "Approved via n8n.",
            "author": {
                "id": 14,
                "name": "n8n Integration"
            },
            "source": "user",
            "created_at": "2026-06-23T09:00:00+00:00",
            "updated_at": "2026-06-23T09:00:00+00:00",
            "edited_at": null
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 25,
        "total": 1,
        "last_page": 1
    },
    "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "updated_since": [
                "The updated since field must match the format Y-m-d\\TH:i:sP."
            ]
        }
    }
}
 

Request      

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

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

Query Parameters

per_page   integer  optional    

Page size, clamped to 100. Example: 25

updated_since   string  optional    

ISO 8601 timestamp with offset; only comments updated at or after it. Example: 2026-06-01T00:00:00+00:00

Body Parameters

per_page   integer  optional    

Must be at least 1. Example: 16

updated_since   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-28T22:23:53+01:00

Contracts

Paginated list of contracts in a project.

requires authentication

subject ({type: person|application, id}) is what the contract is about; the ids in related.contracts of people and groups are these subjects' contracts, so ?person= and ?application= return exactly those. status is the contract state (draft, approved, signed, set_without_signature, canceled, …). Content, signatures and signing IPs are not part of the API. All filters combine (AND).

Required permission: contract_manage

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/contracts?per_page=25&status=signed%2Cset_without_signature&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00&person=01a1e3a6-efbf-4413-9db1-f36e60489254&application=812&partner=01a1e3a6-efbf-4413-9db1-f36e60489254" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"per_page\": 16,
    \"status\": \"architecto\",
    \"statuses\": [
        \"draft\"
    ],
    \"updated_since\": \"2026-10-28T22:23:53+01:00\",
    \"person\": \"a4855dc5-0acb-33c3-b921-f4291f719ca0\",
    \"application\": 66,
    \"partner\": \"977e5426-8d13-3824-86aa-b092f8ae52c5\"
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/contracts"
);

const params = {
    "per_page": "25",
    "status": "signed,set_without_signature",
    "updated_since": "2026-06-01T00:00:00+00:00",
    "person": "01a1e3a6-efbf-4413-9db1-f36e60489254",
    "application": "812",
    "partner": "01a1e3a6-efbf-4413-9db1-f36e60489254",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "per_page": 16,
    "status": "architecto",
    "statuses": [
        "draft"
    ],
    "updated_since": "2026-10-28T22:23:53+01:00",
    "person": "a4855dc5-0acb-33c3-b921-f4291f719ca0",
    "application": 66,
    "partner": "977e5426-8d13-3824-86aa-b092f8ae52c5"
};

fetch(url, {
    method: "GET",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/contracts';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'per_page' => '25',
            'status' => 'signed,set_without_signature',
            'updated_since' => '2026-06-01T00:00:00+00:00',
            'person' => '01a1e3a6-efbf-4413-9db1-f36e60489254',
            'application' => '812',
            'partner' => '01a1e3a6-efbf-4413-9db1-f36e60489254',
        ],
        'json' => [
            'per_page' => 16,
            'status' => 'architecto',
            'statuses' => [
                'draft',
            ],
            'updated_since' => '2026-10-28T22:23:53+01:00',
            'person' => 'a4855dc5-0acb-33c3-b921-f4291f719ca0',
            'application' => 66,
            'partner' => '977e5426-8d13-3824-86aa-b092f8ae52c5',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": [
        {
            "id": 326,
            "contract_number": "V-2026-0042",
            "status": "signed",
            "contract_type": {
                "id": 4,
                "shortname": "ARTIST",
                "name": "Artist contract"
            },
            "subject": {
                "type": "application",
                "id": "812"
            },
            "partner_id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
            "owner_id": "7a2f4d2e-0000-4000-8000-000000000002",
            "pdf_available": true,
            "sent_at": "2026-05-02T10:00:00+02:00",
            "approved_at": "2026-05-01T09:00:00+02:00",
            "partner_feedback_at": null,
            "owner_signed_at": "2026-05-03T12:00:00+02:00",
            "partner_signed_at": "2026-05-02T18:00:00+02:00",
            "owner_set_at": "2026-05-03T12:00:00+02:00",
            "owner_declined_at": null,
            "partner_declined_at": null,
            "owner_revoked_at": null,
            "partner_revoked_at": null,
            "canceled_at": null,
            "paid_at": null,
            "owner_fulfilled_at": null,
            "partner_fulfilled_at": null,
            "fulfilled_at": null,
            "created_at": "2026-05-01T09:00:00+02:00",
            "updated_at": "2026-05-03T12:00:00+02:00"
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 25,
        "total": 1,
        "last_page": 1
    },
    "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "statuses.0": [
                "The selected statuses.0 is invalid."
            ]
        }
    }
}
 

Request      

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

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

status   string  optional    

One or several comma-separated states. Example: signed,set_without_signature

updated_since   string  optional    

ISO 8601 timestamp with offset; contracts updated at or after it. Example: 2026-06-01T00:00:00+00:00

person   string  optional    

Contracts about this Person (UUID) — the ids in the person's related.contracts. Example: 01a1e3a6-efbf-4413-9db1-f36e60489254

application   integer  optional    

Contracts about this Group — the ids in the group's related.contracts. Example: 812

partner   string  optional    

Contracts whose partner is this Person (UUID). Example: 01a1e3a6-efbf-4413-9db1-f36e60489254

Body Parameters

per_page   integer  optional    

Must be at least 1. Example: 16

status   string  optional    

Example: architecto

statuses   string[]  optional    
Must be one of:
  • draft
  • approved
  • declined_by_owner
  • declined_by_partner
  • feedback_by_partner
  • signed_by_owner
  • signed_by_partner
  • signed
  • set_without_signature
  • revoked_by_owner
  • revoked_by_partner
  • paid
  • fulfilled_by_owner
  • fulfilled_by_partner
  • fulfilled
  • canceled
updated_since   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-28T22:23:53+01:00

person   string  optional    

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

application   integer  optional    

Must be at least 1. Example: 66

partner   string  optional    

Must be a valid UUID. Example: 977e5426-8d13-3824-86aa-b092f8ae52c5

Show one contract, including its state and the timestamps of every step.

requires authentication

Required permission: contract_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/contracts/16" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/contracts/16"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/contracts/16';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": 326,
        "contract_number": "V-2026-0042",
        "status": "signed",
        "contract_type": {
            "id": 4,
            "shortname": "ARTIST",
            "name": "Artist contract"
        },
        "subject": {
            "type": "application",
            "id": "812"
        },
        "partner_id": "01a1e3a6-efbf-4413-9db1-f36e60489254",
        "owner_id": "7a2f4d2e-0000-4000-8000-000000000002",
        "pdf_available": true,
        "sent_at": "2026-05-02T10:00:00+02:00",
        "approved_at": "2026-05-01T09:00:00+02:00",
        "partner_feedback_at": null,
        "owner_signed_at": "2026-05-03T12:00:00+02:00",
        "partner_signed_at": "2026-05-02T18:00:00+02:00",
        "owner_set_at": "2026-05-03T12:00:00+02:00",
        "owner_declined_at": null,
        "partner_declined_at": null,
        "owner_revoked_at": null,
        "partner_revoked_at": null,
        "canceled_at": null,
        "paid_at": null,
        "owner_fulfilled_at": null,
        "partner_fulfilled_at": null,
        "fulfilled_at": null,
        "created_at": "2026-05-01T09:00:00+02:00",
        "updated_at": "2026-05-03T12:00:00+02:00"
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Request      

GET api/v2/projects/{project_id}/contracts/{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 contract. Example: 16

project   integer     

Project id. Example: 16

contract   integer     

Contract id. Example: 326

Download the stored PDF of a finalised contract.

requires authentication

Only contracts the owner has finalised (signed or set) carry a stored PDF — the legally relevant document, never re-rendered. 404 for drafts and other contracts without one (pdf_available: false). Nothing is rendered or saved by this call.

Required permission: contract_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/contracts/16/pdf" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/contracts/16/pdf"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/contracts/16/pdf';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


[binary PDF]
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Request      

GET api/v2/projects/{project_id}/contracts/{contract_id}/pdf

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

contract_id   integer     

The ID of the contract. Example: 16

project   integer     

Project id. Example: 16

contract   integer     

Contract id. Example: 326

Form Definitions

Validate a form definition.

requires authentication

Runs the deterministic rule set (docs/form-definition-validator-rules.md, ids FD-*) against the stored form definition and returns every finding:

Each finding names its rule, the field and a JSON path into the definition. Findings on a stored definition describe the current state; data-impact findings appear when a change is checked (dry run of an update).

Required permission: formdefinition_see, plus access to the form's person or group type. Rate limited per user (api-v2-config-read).

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16/validation" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16/validation"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16/validation';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "form_definition_id": 42,
        "valid": false,
        "requires_confirm_data_loss": false,
        "errors": [
            {
                "rule": "FD-L03",
                "severity": "reject",
                "field": null,
                "path": "data.settings.app.triggers.shirt_size.conditions.0.field",
                "message": "A condition of the visibility rule of \"shirt_size\" refers to \"merch\", which is not a field of this form."
            }
        ],
        "warnings": [
            {
                "rule": "FD-C04",
                "severity": "warn",
                "field": "shirt_size",
                "path": "data.fields.shirt_size.label",
                "message": "The label has no text for the active language(s): en."
            }
        ],
        "data_impact": []
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Example response (429, TooManyRequests):


{
    "error": {
        "code": "too_many_requests",
        "message": "Too many requests."
    }
}
 

Request      

GET api/v2/projects/{project_id}/form-definitions/{formDefinition_id}/validation

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

formDefinition_id   integer     

The ID of the formDefinition. Example: 16

project   integer     

Project id. Example: 16

formDefinition   integer     

Form definition id. Example: 42

List the form definitions of a project.

requires authentication

Only forms of the person and group types the user may see (as in the backend). data is left out; add withData=1 to get it.

Required permission: formdefinition_see. Rate limited per user (api-v2-config-read).

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/form-definitions?model=person&withData=&per_page=25" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"model\": \"group\",
    \"withData\": false
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/form-definitions"
);

const params = {
    "model": "person",
    "withData": "0",
    "per_page": "25",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "model": "group",
    "withData": false
};

fetch(url, {
    method: "GET",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/form-definitions';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'model' => 'person',
            'withData' => '0',
            'per_page' => '25',
        ],
        'json' => [
            'model' => 'group',
            'withData' => false,
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": [
        {
            "id": 42,
            "project_id": 5,
            "name": "Crew",
            "description": null,
            "model": "person",
            "type_shortname": "CrewPerson",
            "related_form_id": null,
            "url_shortname": "crew-2026",
            "tab_label": {
                "de": "Crew",
                "en": "Crew"
            },
            "published": true,
            "published_from": null,
            "published_to": null,
            "created_at": "2026-01-10T09:00:00+00:00",
            "updated_at": "2026-10-08T09:00:00+00:00"
        }
    ],
    "meta": {
        "current_page": 1,
        "last_page": 1,
        "per_page": 25,
        "total": 1
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (429, TooManyRequests):


{
    "error": {
        "code": "too_many_requests",
        "message": "Too many requests."
    }
}
 

Request      

GET api/v2/projects/{project_id}/form-definitions

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

model   string  optional    

Only person forms (person) or group forms (group). Example: person

withData   boolean  optional    

Include data (fields + settings) per form. Example: false

per_page   integer  optional    

Page size (1–100, default 25). Example: 25

Body Parameters

model   string  optional    

Example: group

Must be one of:
  • person
  • group
withData   boolean  optional    

Example: false

Show a form definition.

requires authentication

Everything the write endpoint takes, in the stored format: data (fields + settings), texts per language, publication window — plus the read-only functions and calculation (null, or the state of a running recalculation of the calculated fields).

Required permission: formdefinition_see, plus access to the form's person or group type.

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": 42,
        "project_id": 5,
        "name": "Crew",
        "description": null,
        "model": "person",
        "type_shortname": "CrewPerson",
        "related_form_id": null,
        "url_shortname": "crew-2026",
        "tab_label": {
            "de": "Crew",
            "en": "Crew"
        },
        "published": true,
        "published_from": null,
        "published_to": null,
        "created_at": "2026-01-10T09:00:00+00:00",
        "updated_at": "2026-10-08T09:00:00+00:00",
        "html_intro": {
            "de": "<p>Hallo</p>"
        },
        "success_response": null,
        "unpublished_content": null,
        "functions": null,
        "calculation": null,
        "data": {
            "fields": {
                "firstname": {
                    "id": "868f3b29-26d0-4cc9-b377-82751e94b758",
                    "name": "firstname",
                    "type": "text",
                    "label": {
                        "de": "Vorname",
                        "en": "First name"
                    },
                    "views": [
                        "index",
                        "detail",
                        "edit",
                        "app"
                    ],
                    "tab": "name_and_contact"
                }
            },
            "settings": {
                "app": {
                    "triggers": []
                },
                "tabOrder": [
                    "name_and_contact"
                ]
            }
        }
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Request      

GET api/v2/projects/{project_id}/form-definitions/{formDefinition_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

formDefinition_id   integer     

The ID of the formDefinition. Example: 16

project   integer     

Project id. Example: 16

formDefinition   integer     

Form definition id. Example: 42

Update a form definition.

requires authentication

PUT and PATCH both route here; PUT requires name, tab_label and data, PATCH keeps what it omits. data is replaced as a whole.

Every write runs the form definition validator (docs/form-definition-validator-rules.md):

When the request changes the fields and the calculated fields of this form are being recalculated, it answers 409 calculation_in_progress with the progress and a Retry-After header (rule FD-X01) — a save would abort and restart the calculation. A dry run still works.

Every save is written to the form's change log (who, when, before/after), like an edit in the backend. Renamed fields move their values. Calculated fields recalculate in the background only when the request changes the fields; editing attributes alone (name, texts, publication) does not recalculate.

model and to_type (the form's type) cannot be changed.

Required permission: formdefinition_manage, plus access to the form's person or group type. Rate limited per user (api-v2-config-write).

Example request:
curl --request PUT \
    "https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"name\": \"Crew registration\",
    \"description\": \"Main crew form\",
    \"tab_label\": {
        \"de\": \"Crew\",
        \"en\": \"Crew\"
    },
    \"url_shortname\": \"crew-2026\",
    \"published\": true,
    \"published_from\": \"2026-03-01T00:00:00+01:00\",
    \"published_to\": \"2026-06-30T23:59:00+02:00\",
    \"html_intro\": {
        \"de\": \"<p>Willkommen!<\\/p>\",
        \"en\": \"<p>Welcome!<\\/p>\"
    },
    \"success_response\": {
        \"de\": \"<p>Danke!<\\/p>\",
        \"en\": \"<p>Thanks!<\\/p>\"
    },
    \"unpublished_content\": {
        \"de\": \"<p>Geschlossen.<\\/p>\",
        \"en\": \"<p>Closed.<\\/p>\"
    },
    \"data\": {
        \"fields\": {
            \"firstname\": {
                \"id\": \"868f3b29-26d0-4cc9-b377-82751e94b758\",
                \"name\": \"firstname\",
                \"type\": \"text\",
                \"label\": {
                    \"de\": \"Vorname\",
                    \"en\": \"First name\"
                },
                \"views\": [
                    \"index\",
                    \"detail\",
                    \"edit\",
                    \"app\"
                ],
                \"tab\": \"name_and_contact\",
                \"searchable\": true
            }
        },
        \"settings\": {
            \"app\": {
                \"triggers\": []
            },
            \"tabOrder\": [
                \"name_and_contact\"
            ]
        }
    },
    \"updated_at\": \"2026-10-08T09:00:00+00:00\",
    \"dry_run\": true,
    \"confirm_data_loss\": false
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "name": "Crew registration",
    "description": "Main crew form",
    "tab_label": {
        "de": "Crew",
        "en": "Crew"
    },
    "url_shortname": "crew-2026",
    "published": true,
    "published_from": "2026-03-01T00:00:00+01:00",
    "published_to": "2026-06-30T23:59:00+02:00",
    "html_intro": {
        "de": "<p>Willkommen!<\/p>",
        "en": "<p>Welcome!<\/p>"
    },
    "success_response": {
        "de": "<p>Danke!<\/p>",
        "en": "<p>Thanks!<\/p>"
    },
    "unpublished_content": {
        "de": "<p>Geschlossen.<\/p>",
        "en": "<p>Closed.<\/p>"
    },
    "data": {
        "fields": {
            "firstname": {
                "id": "868f3b29-26d0-4cc9-b377-82751e94b758",
                "name": "firstname",
                "type": "text",
                "label": {
                    "de": "Vorname",
                    "en": "First name"
                },
                "views": [
                    "index",
                    "detail",
                    "edit",
                    "app"
                ],
                "tab": "name_and_contact",
                "searchable": true
            }
        },
        "settings": {
            "app": {
                "triggers": []
            },
            "tabOrder": [
                "name_and_contact"
            ]
        }
    },
    "updated_at": "2026-10-08T09:00:00+00:00",
    "dry_run": true,
    "confirm_data_loss": false
};

fetch(url, {
    method: "PUT",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/form-definitions/16';
$response = $client->put(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'json' => [
            'name' => 'Crew registration',
            'description' => 'Main crew form',
            'tab_label' => [
                'de' => 'Crew',
                'en' => 'Crew',
            ],
            'url_shortname' => 'crew-2026',
            'published' => true,
            'published_from' => '2026-03-01T00:00:00+01:00',
            'published_to' => '2026-06-30T23:59:00+02:00',
            'html_intro' => [
                'de' => '<p>Willkommen!</p>',
                'en' => '<p>Welcome!</p>',
            ],
            'success_response' => [
                'de' => '<p>Danke!</p>',
                'en' => '<p>Thanks!</p>',
            ],
            'unpublished_content' => [
                'de' => '<p>Geschlossen.</p>',
                'en' => '<p>Closed.</p>',
            ],
            'data' => [
                'fields' => [
                    'firstname' => [
                        'id' => '868f3b29-26d0-4cc9-b377-82751e94b758',
                        'name' => 'firstname',
                        'type' => 'text',
                        'label' => [
                            'de' => 'Vorname',
                            'en' => 'First name',
                        ],
                        'views' => [
                            'index',
                            'detail',
                            'edit',
                            'app',
                        ],
                        'tab' => 'name_and_contact',
                        'searchable' => true,
                    ],
                ],
                'settings' => [
                    'app' => [
                        'triggers' => [],
                    ],
                    'tabOrder' => [
                        'name_and_contact',
                    ],
                ],
            ],
            'updated_at' => '2026-10-08T09:00:00+00:00',
            'dry_run' => true,
            'confirm_data_loss' => false,
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, Saved):


{
    "data": {
        "id": 42,
        "project_id": 5,
        "name": "Crew",
        "model": "person",
        "type_shortname": "CrewPerson",
        "updated_at": "2026-10-08T09:05:00+00:00",
        "calculation": null,
        "data": {
            "fields": {},
            "settings": {}
        }
    },
    "validation": {
        "valid": true,
        "requires_confirm_data_loss": false,
        "errors": [],
        "warnings": [],
        "data_impact": []
    }
}
 

Example response (200, DryRun):


{
    "data": {
        "dry_run": true,
        "would_save": false,
        "validation": {
            "valid": true,
            "requires_confirm_data_loss": true,
            "errors": [],
            "warnings": [],
            "data_impact": [
                {
                    "rule": "FD-D01",
                    "severity": "confirm",
                    "field": "diet",
                    "path": "data.fields.diet",
                    "message": "The field holds values that would become unreachable.",
                    "impact": {
                        "affected_count": 12,
                        "sample_ids": [
                            101,
                            102,
                            103,
                            104,
                            105
                        ],
                        "change": "removed",
                        "recommended_action": "Hide the field instead of removing it."
                    }
                }
            ]
        }
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Example response (409, OptimisticLockFailed):


{
    "error": {
        "code": "optimistic_lock_failed",
        "message": "The resource was modified after your last read. Re-fetch and retry.",
        "errors": {
            "current": {
                "updated_at": "2026-10-08T09:05:00+00:00"
            }
        }
    }
}
 

Example response (409, CalculationInProgress):


{
    "error": {
        "code": "calculation_in_progress",
        "message": "The calculated fields of this form are being recalculated (running, 400 of 1390). Retry after the recalculation has finished.",
        "errors": {
            "calculation": {
                "state": "running",
                "since": "2026-10-08T09:01:00+00:00",
                "done": 400,
                "total": 1390
            }
        }
    }
}
 

Example response (422, Invalid):


{
    "error": {
        "code": "form_definition_invalid",
        "message": "The form definition was not saved: 1 error(s).",
        "errors": {
            "validation": {
                "valid": false,
                "requires_confirm_data_loss": false,
                "errors": [
                    {
                        "rule": "FD-L03",
                        "severity": "reject",
                        "path": "data.settings.app.triggers.shirt_size.conditions.0.field",
                        "message": "A condition of the visibility rule of \"shirt_size\" refers to \"merch\", which is not a field of this form."
                    }
                ],
                "warnings": [],
                "data_impact": []
            }
        }
    }
}
 

Example response (422, ConfirmDataLoss):


{
    "error": {
        "code": "confirm_data_loss_required",
        "message": "The change affects values that records already hold.",
        "errors": {
            "validation": {
                "valid": true,
                "requires_confirm_data_loss": true,
                "errors": [],
                "warnings": [],
                "data_impact": []
            }
        }
    }
}
 

Example response (429, TooManyRequests):


{
    "error": {
        "code": "too_many_requests",
        "message": "Too many requests."
    }
}
 

Request      

PUT api/v2/projects/{project_id}/form-definitions/{formDefinition_id}

PATCH api/v2/projects/{project_id}/form-definitions/{formDefinition_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

formDefinition_id   integer     

The ID of the formDefinition. Example: 16

project   integer     

Project id. Example: 16

formDefinition   integer     

Form definition id. Example: 42

Body Parameters

name   string     

Required on PUT. Display name (max 255). Must not be greater than 255 characters. Example: Crew registration

description   string  optional    

Internal description. Example: Main crew form

tab_label   object     

Required on PUT. Tab label per language; at least one language (rule FD-C19).

url_shortname   string  optional    

Slug of the public form URL: max 24 characters, letters, digits, single -/_, unique (rule FD-C18). Example: crew-2026

published   boolean  optional    

Whether the public form is reachable. Example: true

published_from   string  optional    

Start of the publication window. Must be a valid date. Example: 2026-03-01T00:00:00+01:00

published_to   string  optional    

End of the publication window. Must be a valid date. Example: 2026-06-30T23:59:00+02:00

html_intro   object  optional    

Intro HTML of the public form per language.

success_response   object  optional    

HTML shown after a submission, per language.

unpublished_content   object  optional    

HTML shown outside the publication window, per language.

data   object     

CAUTION: replaces the whole form definition (fields + settings). Read it with GET first, change it, send it back complete. Every rule of docs/form-definition-validator-rules.md runs on it; use dry_run to see the report first. Fields are matched by their id — keep the ids, or a rename becomes remove + add.

updated_at   string     

Optimistic lock: the updated_at you read. A newer stored version answers 409 optimistic_lock_failed. Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-08T09:00:00+00:00

dry_run   boolean  optional    

Validate only: returns the validation report and whether the change would be saved, writes nothing. Example: true

confirm_data_loss   boolean  optional    

Required when the report has data_impact findings (a field or option that records already use is removed, renamed or retyped). Check data_impact first. Example: false

Invoices

Paginated list of invoices, cancellations and credit notes of people, groups and orders.

requires authentication

invoiceable names the Person, Group or Order the document belongs to ({type, id}), so a failed person deletion (409 person_has_invoices) can be traced to its documents with ?person=. status is the document state: DRAFT, OPEN, PARTIALLY_PAID, PAID, VOIDED, CREDITED, DONE, PAIDOUT. Money in major units, rounded to the currency. Order invoices that do not exist yet are not created here. All filters combine (AND).

Required permission: invoice_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/invoices?per_page=25&status=OPEN%2CPARTIALLY_PAID&document_type=invoice&created_from=2026-06-01T00%3A00%3A00%2B02%3A00&created_until=2026-07-01T00%3A00%3A00%2B02%3A00&updated_since=2026-06-01T00%3A00%3A00%2B00%3A00&person=01a1e3a6-efbf-4413-9db1-f36e60489254&application=812&order=3f8a4d2e-0000-4000-8000-000000000001" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"per_page\": 16,
    \"status\": \"architecto\",
    \"statuses\": [
        \"PAID\"
    ],
    \"document_type\": \"cancellation\",
    \"created_from\": \"2026-10-28T22:23:53+01:00\",
    \"created_until\": \"2026-10-28T22:23:53+01:00\",
    \"updated_since\": \"2026-10-28T22:23:53+01:00\",
    \"person\": \"a4855dc5-0acb-33c3-b921-f4291f719ca0\",
    \"application\": 66,
    \"order\": \"977e5426-8d13-3824-86aa-b092f8ae52c5\"
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/invoices"
);

const params = {
    "per_page": "25",
    "status": "OPEN,PARTIALLY_PAID",
    "document_type": "invoice",
    "created_from": "2026-06-01T00:00:00+02:00",
    "created_until": "2026-07-01T00:00:00+02:00",
    "updated_since": "2026-06-01T00:00:00+00:00",
    "person": "01a1e3a6-efbf-4413-9db1-f36e60489254",
    "application": "812",
    "order": "3f8a4d2e-0000-4000-8000-000000000001",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "per_page": 16,
    "status": "architecto",
    "statuses": [
        "PAID"
    ],
    "document_type": "cancellation",
    "created_from": "2026-10-28T22:23:53+01:00",
    "created_until": "2026-10-28T22:23:53+01:00",
    "updated_since": "2026-10-28T22:23:53+01:00",
    "person": "a4855dc5-0acb-33c3-b921-f4291f719ca0",
    "application": 66,
    "order": "977e5426-8d13-3824-86aa-b092f8ae52c5"
};

fetch(url, {
    method: "GET",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/invoices';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'per_page' => '25',
            'status' => 'OPEN,PARTIALLY_PAID',
            'document_type' => 'invoice',
            'created_from' => '2026-06-01T00:00:00+02:00',
            'created_until' => '2026-07-01T00:00:00+02:00',
            'updated_since' => '2026-06-01T00:00:00+00:00',
            'person' => '01a1e3a6-efbf-4413-9db1-f36e60489254',
            'application' => '812',
            'order' => '3f8a4d2e-0000-4000-8000-000000000001',
        ],
        'json' => [
            'per_page' => 16,
            'status' => 'architecto',
            'statuses' => [
                'PAID',
            ],
            'document_type' => 'cancellation',
            'created_from' => '2026-10-28T22:23:53+01:00',
            'created_until' => '2026-10-28T22:23:53+01:00',
            'updated_since' => '2026-10-28T22:23:53+01:00',
            'person' => 'a4855dc5-0acb-33c3-b921-f4291f719ca0',
            'application' => 66,
            'order' => '977e5426-8d13-3824-86aa-b092f8ae52c5',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": [
        {
            "id": 880,
            "invoice_number": "RE-2026-0042",
            "document_type": "invoice",
            "status": "OPEN",
            "invoiceable": {
                "type": "application",
                "id": "812"
            },
            "invoice_type": {
                "id": 3,
                "name": "Exhibitor fee"
            },
            "original_invoice_id": null,
            "currency": "EUR",
            "total_amount": 238,
            "total_net": 200,
            "total_tax": 38,
            "paid_amount": 0,
            "open_amount": 238,
            "delivered_date": "2026-07-12T00:00:00+02:00",
            "created_at": "2026-06-02T10:00:00+02:00",
            "updated_at": "2026-06-02T10:00:00+02:00"
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 25,
        "total": 1,
        "last_page": 1
    },
    "links": {
        "first": null,
        "last": null,
        "prev": null,
        "next": null
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (422, ValidationFailed):


{
    "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": {
            "statuses.0": [
                "The selected statuses.0 is invalid."
            ]
        }
    }
}
 

Request      

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

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

status   string  optional    

One or several comma-separated states, case-insensitive. Example: OPEN,PARTIALLY_PAID

document_type   string  optional    

invoice, cancellation or credit_note. Example: invoice

created_from   string  optional    

ISO 8601 timestamp with offset; documents created at or after it. Example: 2026-06-01T00:00:00+02:00

created_until   string  optional    

ISO 8601 timestamp with offset; documents created before it (exclusive). Example: 2026-07-01T00:00:00+02:00

updated_since   string  optional    

ISO 8601 timestamp with offset; documents updated at or after it. Example: 2026-06-01T00:00:00+00:00

person   string  optional    

Only documents of this Person (UUID). Example: 01a1e3a6-efbf-4413-9db1-f36e60489254

application   integer  optional    

Only documents of this Group. Example: 812

order   string  optional    

Only documents of this Order (UUID). Example: 3f8a4d2e-0000-4000-8000-000000000001

Body Parameters

per_page   integer  optional    

Must be at least 1. Example: 16

status   string  optional    

Example: architecto

statuses   string[]  optional    
Must be one of:
  • DRAFT
  • OPEN
  • PARTIALLY_PAID
  • PAID
  • VOIDED
  • CREDITED
  • DONE
  • PAIDOUT
document_type   string  optional    

Example: cancellation

Must be one of:
  • invoice
  • cancellation
  • credit_note
created_from   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-28T22:23:53+01:00

created_until   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-28T22:23:53+01:00

updated_since   string  optional    

Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-28T22:23:53+01:00

person   string  optional    

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

application   integer  optional    

Must be at least 1. Example: 66

order   string  optional    

Must be a valid UUID. Example: 977e5426-8d13-3824-86aa-b092f8ae52c5

Show one invoice with billing address, payment terms and positions.

requires authentication

Required permission: invoice_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/invoices/16" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/invoices/16"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/invoices/16';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": 880,
        "invoice_number": "RE-2026-0042",
        "document_type": "invoice",
        "status": "OPEN",
        "invoiceable": {
            "type": "application",
            "id": "812"
        },
        "invoice_type": {
            "id": 3,
            "name": "Exhibitor fee"
        },
        "original_invoice_id": null,
        "currency": "EUR",
        "total_amount": 238,
        "total_net": 200,
        "total_tax": 38,
        "paid_amount": 0,
        "open_amount": 238,
        "delivered_date": "2026-07-12T00:00:00+02:00",
        "created_at": "2026-06-02T10:00:00+02:00",
        "updated_at": "2026-06-02T10:00:00+02:00",
        "billing_address": "Example GmbH, Hauptstr. 1, 04109 Leipzig, DE",
        "payment_terms": "14 days net",
        "positions": [
            {
                "shortname": "Stand fee",
                "price": 200,
                "amount": 1,
                "unit": "",
                "totalPrice": 238,
                "totalTax": 38,
                "totalNet": 200,
                "currency": "EUR",
                "tax": 19,
                "service_type": "default",
                "reverse_charge_applies": false
            }
        ]
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Request      

GET api/v2/projects/{project_id}/invoices/{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 invoice. Example: 16

project   integer     

Project id. Example: 16

invoice   integer     

Invoice id. Example: 880

Download the invoice as PDF.

requires authentication

Rendered on request from the stored invoice, the same document the backend's "download invoice" actions produce (with the e-invoice XML embedded when the project's export setting says so). Nothing is created or saved. 404 when the invoice has nothing to render it for — a person/group invoice without recipient person, or an order invoice whose order is gone.

Required permission: invoice_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/invoices/16/pdf" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/invoices/16/pdf"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/invoices/16/pdf';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


[binary PDF]
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You are not authorized to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "Not found."
    }
}
 

Request      

GET api/v2/projects/{project_id}/invoices/{invoice_id}/pdf

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

invoice_id   integer     

The ID of the invoice. Example: 16

project   integer     

Project id. Example: 16

invoice   integer     

Invoice id. Example: 880

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

Update a workflow trigger.

requires authentication

Fix and reactivate a trigger after an import (merge missing values into data, set active), or edit its content: texts, conditions, name, delay. Scoped to the project; only triggers of a workflow step.

Every request runs the trigger validator (docs/workflow-trigger-validator-rules.md, WT-*): Twig syntax with line numbers, unknown placeholders, an allow-list of Twig constructs, the condition tree, references to deleted records, the delay range. What you send must be clean, including errors it already had; errors the stored trigger already had in parts you do not send (preexisting: true) do not block. Switching a trigger on validates all of it. Any error → 422 workflow_trigger_invalid; every finding names the rule, the path, what is wrong and a hint how to fix it. {"active": false} alone always saves (emergency stop). dry_run=true returns the report without saving. Unknown placeholders that are filled at run time can be accepted with force=true (softens WT-06 only) — every forced save is logged. Every save is written to the trigger's change log.

Required permission: workflow_manage

Example request:
curl --request PATCH \
    "https://{instance}.festiwa.re/api/v2/projects/564/workflow-triggers/16" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"name\": \"Welcome mail\",
    \"active\": true,
    \"data\": {
        \"subject\": {
            \"de\": \"Willkommen {{ firstname }}\",
            \"en\": \"Welcome {{ firstname }}\"
        }
    },
    \"conditions\": {
        \"and\": [
            {
                \"field\": \"data.shirt_size\",
                \"operator\": \"==\",
                \"value\": \"m\"
            }
        ]
    },
    \"delay_minutes\": 30,
    \"updated_at\": \"2026-10-08T09:00:00+00:00\",
    \"dry_run\": true,
    \"force\": false
}"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/workflow-triggers/16"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "name": "Welcome mail",
    "active": true,
    "data": {
        "subject": {
            "de": "Willkommen {{ firstname }}",
            "en": "Welcome {{ firstname }}"
        }
    },
    "conditions": {
        "and": [
            {
                "field": "data.shirt_size",
                "operator": "==",
                "value": "m"
            }
        ]
    },
    "delay_minutes": 30,
    "updated_at": "2026-10-08T09:00:00+00:00",
    "dry_run": true,
    "force": false
};

fetch(url, {
    method: "PATCH",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/workflow-triggers/16';
$response = $client->patch(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'json' => [
            'name' => 'Welcome mail',
            'active' => true,
            'data' => [
                'subject' => [
                    'de' => 'Willkommen {{ firstname }}',
                    'en' => 'Welcome {{ firstname }}',
                ],
            ],
            'conditions' => [
                'and' => [
                    [
                        'field' => 'data.shirt_size',
                        'operator' => '==',
                        'value' => 'm',
                    ],
                ],
            ],
            'delay_minutes' => 30,
            'updated_at' => '2026-10-08T09:00:00+00:00',
            'dry_run' => true,
            'force' => false,
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, Updated):


{
    "data": {
        "id": 77,
        "name": "Notify crew",
        "action_type": "notify_user",
        "triggers_on": [
            "enter"
        ],
        "active": true,
        "observed_id": 42,
        "data": {
            "notifiable_id": 123
        },
        "conditions": null,
        "delay_minutes": null,
        "updated_at": "2026-10-08T09:05:00+00:00"
    },
    "validation": {
        "valid": true,
        "requires_confirm_data_loss": false,
        "errors": [],
        "warnings": [],
        "data_impact": []
    }
}
 

Example response (200, DryRun):


{
    "data": {
        "dry_run": true,
        "would_save": false,
        "validation": {
            "valid": false,
            "requires_confirm_data_loss": false,
            "errors": [
                {
                    "rule": "WT-05",
                    "severity": "reject",
                    "field": "content",
                    "path": "data.content.de",
                    "message": "WT-05 at data.content.de: Twig syntax error in line 3: Unclosed \"if\".",
                    "hint": "Fix the Twig in line 3: every {% if %} / {% for %} needs its {% endif %} / {% endfor %}, every \"{{\" its \"}}\", every \"{%\" its \"%}\", every quote its closing quote."
                }
            ],
            "warnings": [],
            "data_impact": []
        }
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You do not have permission to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

Example response (409, OptimisticLockFailed):


{
    "error": {
        "code": "optimistic_lock_failed",
        "message": "The resource was modified after your last read. Re-fetch and retry.",
        "errors": {
            "current": {
                "updated_at": "2026-10-08T09:05:00+00:00"
            }
        }
    }
}
 

Example response (422, Invalid):


{
    "error": {
        "code": "workflow_trigger_invalid",
        "message": "The trigger was not saved: 1 blocking error(s). Every entry in error.errors.validation.errors names the rule, the path, what is wrong and a \"hint\" how to fix it; entries with \"preexisting\": true do not block. Deactivating the trigger ({\"active\": false}) is always possible.",
        "errors": {
            "validation": {
                "valid": false,
                "requires_confirm_data_loss": false,
                "errors": [
                    {
                        "rule": "WT-07",
                        "severity": "reject",
                        "field": "content",
                        "path": "data.content.de",
                        "message": "WT-07 at data.content.de, line 1: The function `config_get()` is not allowed in templates: templates may only transform and format values, not read server state or run code.",
                        "hint": "Allowed functions: …"
                    }
                ],
                "warnings": [],
                "data_impact": []
            }
        }
    }
}
 

Example response (429, TooManyRequests):


{
    "error": {
        "code": "too_many_requests",
        "message": "Too many requests."
    }
}
 

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

name   string  optional    

Display name. Must not be greater than 255 characters. Example: Welcome mail

active   boolean  optional    

Switch the trigger on/off. Switching on validates the whole trigger, also errors it already had (WT-13). {"active": false} alone (optionally with updated_at, dry_run, force) always saves — the emergency stop, also for a trigger with errors. Example: true

data   object  optional    

Keys to merge into the trigger data (shallow merge; keys of the trigger type only): subject and content (string or {"de": …, "en": …}), reply_to, notifiable_id, template_id, … Texts are Twig: syntax, placeholders and an allow-list are checked. A key you send must be fully valid, also errors it already had.

conditions   object  optional    

Replaces the run conditions: {"and": [...]} / {"or": [...]} groups with {"field", "operator", "value"} leaves; null removes them.

delay_minutes   string  optional    

Delay before the action runs: empty/0 or 1–1440. Example: 30

updated_at   string  optional    

Optimistic lock: the updated_at you read. When sent, a newer stored version answers 409 optimistic_lock_failed. Recommended for content edits. Must be a valid date in the format Y-m-d\TH:i:sP. Example: 2026-10-08T09:00:00+00:00

dry_run   boolean  optional    

Validate only: returns the report and would_save, writes nothing. Example: true

force   boolean  optional    

Accept unknown placeholders (rule WT-06) that are filled at run time. Softens nothing else. Every forced save is logged. Example: false

List the workflows of a project.

requires authentication

Only workflows of the person and group types the user may see (as in the backend).

Required permission: workflow_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/workflows?per_page=25" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/workflows"
);

const params = {
    "per_page": "25",
};
Object.keys(params)
    .forEach(key => url.searchParams.append(key, params[key]));

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/workflows';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'per_page' => '25',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": [
        {
            "id": 42,
            "project_id": 2,
            "name": "Crew",
            "shortname": "crew",
            "description": null,
            "type": {
                "id": 7,
                "shortname": "CrewPerson",
                "model": "person"
            },
            "created_at": "2026-01-10T09:00:00+00:00",
            "updated_at": "2026-10-08T09:00:00+00:00"
        }
    ],
    "meta": {
        "current_page": 1,
        "last_page": 1,
        "per_page": 25,
        "total": 1
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You do not have permission to perform this action."
    }
}
 

Request      

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

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

Query Parameters

per_page   integer  optional    

Page size (1–100, default 25). Example: 25

Show a workflow with its steps.

requires authentication

Steps in their order, each with the ids of its triggers.

Required permission: workflow_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/workflows/16" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/workflows/16"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/workflows/16';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": 42,
        "project_id": 2,
        "name": "Crew",
        "shortname": "crew",
        "description": null,
        "type": {
            "id": 7,
            "shortname": "CrewPerson",
            "model": "person"
        },
        "created_at": "2026-01-10T09:00:00+00:00",
        "updated_at": "2026-10-08T09:00:00+00:00",
        "steps": [
            {
                "id": 5,
                "name": "Applied",
                "shortname": "applied",
                "description": null,
                "is_start": true,
                "is_endpoint": false,
                "inactive": false,
                "previous_id": null,
                "trigger_ids": [
                    77,
                    78
                ]
            }
        ]
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You do not have permission to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

Request      

GET api/v2/projects/{project_id}/workflows/{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 workflow. Example: 16

project   integer     

Project id. Example: 2

workflow   integer     

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

Show a workflow trigger.

requires authentication

The full trigger: texts (data.subject, data.content), recipients and other settings in data, run conditions, delay_minutes, active, and updated_at for the optimistic lock.

Required permission: workflow_see

Example request:
curl --request GET \
    --get "https://{instance}.festiwa.re/api/v2/projects/564/workflow-triggers/16" \
    --header "Authorization: Bearer {ACCESS_TOKEN}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json"
const url = new URL(
    "https://{instance}.festiwa.re/api/v2/projects/564/workflow-triggers/16"
);

const headers = {
    "Authorization": "Bearer {ACCESS_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};


fetch(url, {
    method: "GET",
    headers,
}).then(response => response.json());
$client = new \GuzzleHttp\Client();
$url = 'https://{instance}.festiwa.re/api/v2/projects/564/workflow-triggers/16';
$response = $client->get(
    $url,
    [
        'headers' => [
            'Authorization' => 'Bearer {ACCESS_TOKEN}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]
);
$body = $response->getBody();
print_r(json_decode((string) $body));

Example response (200, OK):


{
    "data": {
        "id": 77,
        "name": "Welcome mail",
        "action_type": "notify_self",
        "triggers_on": [
            "enter"
        ],
        "active": true,
        "observed_id": 42,
        "data": {
            "subject": {
                "de": "Willkommen {{ firstname }}"
            },
            "content": {
                "de": "<p>Hallo {{ firstname }}</p>"
            }
        },
        "conditions": null,
        "delay_minutes": null,
        "updated_at": "2026-10-08T09:00:00+00:00"
    }
}
 

Example response (401, Unauthorized):


{
    "error": {
        "code": "unauthorized",
        "message": "Authentication required."
    }
}
 

Example response (403, Forbidden):


{
    "error": {
        "code": "permission_denied",
        "message": "You do not have permission to perform this action."
    }
}
 

Example response (404, NotFound):


{
    "error": {
        "code": "not_found",
        "message": "The requested resource was not found."
    }
}
 

Request      

GET 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