Ticket-Endpunkt
Tickets
Support-Tickets erstellen, aktualisieren, exportieren und löschen. Inklusive Status-History, Admin-Zuweisungen, verknüpften CRM-Terminen, Dateianhängen und Bearbeitungs-Claims.
GET /Export/JSON/tickets
GET /Export/JSON/ticket/{id}
POST /Import/JSON/tickets
PUT /Import/JSON/tickets
PUT /Import/JSON/ticket_claims
DELETE /Delete/JSON/ticket/{id}Endpoints
| Methode | Endpoint | Scope | Beschreibung |
|---|---|---|---|
| GET | /Export/JSON/tickets | tickets:read | Alle Tickets abrufen (paginiert) |
| GET | /Export/JSON/ticket/{id} | tickets:read | Einzelnes Ticket mit Status-History und verknüpften Terminen |
| GET | /Export/JSON/ticket_attachments/{id} | tickets:read | Dateianhänge eines Tickets auflisten |
| GET | /Export/JSON/ticket_attachment/{id}?file=name | tickets:read | Einzelne Datei als Binary herunterladen |
| POST | /Import/JSON/tickets | tickets:write | Neues Ticket erstellen (INSERT) — optional mit Kommentar und Dateianhängen |
| PUT | /Import/JSON/tickets | tickets:write | Bestehendes Ticket aktualisieren (UPDATE) — optional mit Kommentar, öffentlicher Antwort und Dateianhängen |
| PUT | /Import/JSON/ticket_claims | tickets:write | Bearbeitungs-Claim setzen, verlängern, von einem anderen Administrator übernehmen oder beenden NEU v2.118.0 |
| DELETE | /Delete/JSON/ticket/{id} | tickets:delete | Ticket mit Cascade-Delete löschen |
Export: Query-Parameter
| Parameter | Beschreibung |
|---|---|
?status={id} | Nach Status-ID filtern |
?priority={id} | Nach Prioritäts-ID filtern |
?department={id} | Nach Abteilungs-ID filtern |
?customer_id={id} | Tickets eines bestimmten Kunden |
?admin_id={id} | Zugewiesene Tickets eines Admins |
?type={type} | Nach Ticket-Typ filtern |
?date_from=&date_to= | Zeitraum-Filter |
?last_modified=YYYY-MM-DD | Geändert seit Datum |
?language=de | Sprach-Filter |
?limit=50&offset=0 | Pagination (Default: 50, Max: 250) |
Export: Response-Felder
| Feld | Typ | Beschreibung |
|---|---|---|
ticket_id | integer | Ticket-ID |
ticket_link_id | string | 14-stellige alphanumerische Link-ID |
ticket_subject | string | Betreff des Tickets |
ticket_status | string | Aktueller Status |
ticket_priority | string | Priorität |
department_id | integer | Abteilungs-ID |
department_name | string | Abteilungsname |
status_history[] | array | Vollständige Konversation mit Kommentaren |
assigned_admins[] | array | Zugewiesene Administratoren |
followers[] | array | Follower des Tickets |
linked_appointments[] | array | NEU v2.11.0: Verknüpfte CRM-Termine (ID, Start, Ende, Titel, Ort, Status) |
claim | object|null | NEU v2.118.0: Aktiver Bearbeitungs-Claim mit admin_id, admin_name, label, claimed_at, expires_at und expires_in_seconds; null, wenn niemand das Ticket beansprucht |
edit_lock | object|null | NEU v2.118.0: Das Ticket ist gerade im Backend geöffnet: admin_id, admin_name, locked_at (gilt 300 Sekunden ab der letzten Aktivität der Maske); sonst null |
Import POST: Ticket erstellen
Pflichtfelder und optionale Felder beim Anlegen eines neuen Tickets.
Pflichtfelder
ticket_subject— Betreffticket_customers_email— Kunden-E-Mailticket_customers_name— Kundenname
Optionale Felder
ticket_type— Ticket-Typ (Standard: kunde)ticket_status_id— Status-ID (wird validiert)ticket_priority_id— Prioritäts-ID (wird validiert)ticket_department_id— Abteilungs-ID (wird validiert)ticket_customers_id— Kunden-IDticket_language_id— Sprach-IDticket_comments— Erster Kommentar als Text (immer intern)admin_id— Admin-ID für den Kommentar (0 = System)attachments[]— NEU v2.60.0: Dateianhänge (name+content_base64); setztticket_commentsvorausassigned_admins[]— Array mit Admin-IDs
Beispiel: Ticket erstellen
POST /Import/JSON/tickets
Authorization: Bearer {token}
Content-Type: application/json
{
"type": "tickets",
"data": [{
"ticket_subject": "API-Test-Ticket",
"ticket_customers_email": "kunde@example.com",
"ticket_customers_name": "Max Mustermann",
"ticket_priority_id": 2,
"ticket_department_id": 1,
"ticket_comments": "Erstellt via xoPort API",
"admin_id": 0,
"attachments": [
{ "name": "bestellung.pdf", "content_base64": "JVBERi0xLjQKJcfs…" }
],
"assigned_admins": [1, 3]
}]
}Import PUT: Ticket aktualisieren
Änderbare Felder und Kommentar-System.
ticket_internal_comment=1). Eine für den Kunden sichtbare Antwort erfordert seit v2.25.0 das ausdrückliche Flag is_public_reply: true und eine gültige admin_id > 0; der E-Mail-Versand zusätzlich notify_customer: true. Beide Flags werden strikt boolesch geprüft — "true" oder 1 zählen nicht.| Feld | Typ | Beschreibung |
|---|---|---|
ticket_id | integer | Pflicht. ID des zu aktualisierenden Tickets |
ticket_subject | string | Betreff ändern |
ticket_type | string | Typ ändern |
ticket_status_id | integer | Status ändern (wird validiert) |
ticket_priority_id | integer | Priorität ändern (wird validiert) |
ticket_department_id | integer | Abteilung ändern (wird validiert) |
ticket_date_hide_until | datetime | Ticket verstecken bis |
ticket_login_required | 0/1 | Login erforderlich |
ticket_allow_learning | 0/1 | KI-Lernen erlauben |
ticket_comments | string | Kommentartext — erzeugt einen neuen Verlaufseintrag |
admin_id | integer | Urheber des Kommentars (0 = System; bei öffentlicher Antwort zwingend > 0) |
is_public_reply | boolean | v2.25.0: true = für den Kunden sichtbare Antwort statt interner Notiz |
notify_customer | boolean | v2.25.0: true = E-Mail an den Kunden (nur zusammen mit is_public_reply) |
append_signature | boolean | v2.27.0: Ticket-Signatur des Admins an eine öffentliche Antwort anhängen (Standard an; Opt-out nur via false) |
attachments[] | array | NEU v2.60.0: Dateianhänge (name + content_base64); setzt ticket_comments voraus |
assigned_admins[] | array | Admin-Zuweisungen ersetzen |
Kommentar-Format
"ticket_comments": "Interner Vermerk via API",
"admin_id": 1ticket_comments ist der Kommentartext, admin_id sein Urheber (0 = System, >0 = Admin-ID, wird validiert).
Beispiel: Ticket updaten mit Kommentar
PUT /Import/JSON/tickets
Authorization: Bearer {token}
Content-Type: application/json
{
"type": "tickets",
"data": [{
"ticket_id": 42,
"ticket_status_id": 3,
"ticket_priority_id": 1,
"ticket_comments": "Status auf Erledigt gesetzt",
"admin_id": 1,
"assigned_admins": [1, 5]
}]
}Delete: Kaskadierende Löschung
Beim Löschen eines Tickets werden automatisch alle verknüpften Daten entfernt:
Cascade-Reihenfolge
ticket_status_history— Kommentare/Verlaufticket_to_admins— Admin-Zuweisungenticket_to_followers— Followerxocrm_appointments_relationships— Termin-Verknüpfungen (Termine bleiben erhalten)xocrm_followers— CRM-Follower- Dateien auf Disk —
upload/ticket/{link_id}/ ticket_ticket— Ticket selbst
Beispiel
DELETE /Delete/JSON/ticket/42
Authorization: Bearer {token}Response
{
"success": true,
"deleted_id": 42,
"details": {
"ticket_id": 42,
"ticket_subject": "Gelöschtes Ticket",
"ticket_link_id": "8i1c5O8g7w8b5c"
}
}Verknüpfte CRM-Termine v2.11.0
Im Export enthält jedes Ticket ein linked_appointments[] Array mit verknüpften CRM-Terminen:
"linked_appointments": [
{
"appointment_id": 15,
"startdate": "2026-04-14 10:00:00",
"enddate": "2026-04-14 11:00:00",
"allday": 0,
"done": 0,
"status_id": 1,
"title": "Rückruf Kunde",
"description": "Bezüglich offener Rückfrage",
"location": ""
}
]Termine werden über xocrm_appointments_relationships (target_type = ticket_id) verknüpft. Die Löschung eines Tickets entfernt nur die Verknüpfung, nicht den Termin selbst.
Korrigiert in v2.118.1: linked_appointments an POST/PUT /Import/JSON/tickets meldete zusätzlich die Warnung UNKNOWN_FIELD, obwohl die Termine angelegt wurden. Die Warnung entfällt; die Verarbeitung ist unverändert.
Attachment-Endpoints
Dateianhänge werden über die Ticket-ID addressiert:
Listing: GET /ticket_attachments/{id}
{
"success": true,
"ticket_id": 42,
"ticket_link_id": "8i1c5O8g7w8b5c",
"files": [
{
"name": "screenshot.png",
"size": 245760,
"mime_type": "image/png",
"last_modified": "2026-03-15 14:30:22"
}
]
}Download: GET /ticket_attachment/{id}?file=screenshot.png
Binary-Download mit korrektem Content-Type und Content-Disposition: attachment Header. Path-Traversal-Schutz via basename() + realpath()-Validierung.
Upload: attachments[] an POST/PUT NEU v2.60.0
Bis v2.59.0 ließen sich Anhänge nur lesen — geschrieben wurden sie ausschließlich über das Backend-Formular. Ein per API angelegtes Ticket blieb damit ohne Beleg (PDF, .eml, Screenshot). Seit v2.60.0 nimmt POST/PUT /Import/JSON/tickets das optionale Feld attachments[] entgegen:
"ticket_comments": "Beleg zur Reklamation",
"admin_id": 1,
"attachments": [
{
"name": "rechnung.pdf",
"content_base64": "JVBERi0xLjQKJcfs…"
}
]ticket_comments im selben Request entsteht kein Eintrag, an dem die Datei hängen könnte — sie wird dann mit einer Fehlermeldung abgelehnt statt still verworfen.| Feld | Typ | Beschreibung |
|---|---|---|
name | string | Pflicht. Dateiname inklusive Endung; die Endung muss in der Allowlist stehen (identische Regel wie beim Backend-Upload) |
content_base64 | string | Pflicht. Dateiinhalt Base64-kodiert; ungültiges Base64 wird abgelehnt und nicht gespeichert |
Die Ablage erfolgt über dieselbe Mechanik wie der Backend-Upload — gleiche Endungs-Allowlist, Namenskonvention <ticket>_<verlaufs-id>_<name> und Hash-Dedup gegen Doppelablage. Der tatsächlich vergebene Dateiname steht je Datensatz in der Antwort unter attachments. Die Obergrenze je Datei entspricht dem /media-Endpunkt (XOPORT_MEDIA_MAX_FILESIZE, Standard 10 MB).
Fehlerhafte Einträge (unerlaubte Endung, ungültiges Base64, Überschreitung der Größe) erzeugen einen Fehler je Datei; das Ticket-Update selbst bleibt erfolgreich. Bei is_public_reply: true zusammen mit notify_customer: true gehen die Dateien mit der Kundenmail hinaus — sie werden vor dem Versand geschrieben.
Bearbeitungs-Claim NEU v2.118.0
Ein Claim zeigt an, dass gerade jemand an einem Ticket arbeitet, zum Beispiel ein KI-Agent im Auftrag eines Mitarbeiters. Weitere Bearbeiter sehen ihn im Export und im Backend (Ticketliste und Ticketansicht) und beginnen nicht parallel. Der Claim ist ein Hinweis, keine Sperre: Kein Schreibzugriff prüft ihn, das Ticket bleibt für alle bearbeitbar.
PUT /Import/JSON/ticket_claims
{
"type": "ticket_claims",
"data": [
{ "ticket_id": 33999, "admin_id": 12, "state": "claimed", "ttl_minutes": 60, "label": "KI-Agent" }
]
}| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
ticket_id | integer | ja | ID eines bestehenden Tickets |
admin_id | integer | ja | Bestehender Administrator, für den gearbeitet wird. Ein Claim nennt immer eine Person. |
state | string | ja | claimed setzt oder verlängert den Claim, released beendet ihn. Es gibt keinen Vorgabewert: Der Endpunkt setzt einen Zustand und schaltet nie um. |
ttl_minutes | integer | nein | Laufzeit in Minuten, Vorgabe 60, begrenzt auf 5 bis 240. Ein Verlängern verkürzt einen laufenden Claim nie. |
label | string | nein | Kennzeichnung der Quelle, z. B. KI-Agent; höchstens 32 Zeichen, HTML wird entfernt. Das Backend zeigt „Name (Label)“. |
force | boolean | nein | Nur JSON-true/false. true übernimmt den Claim eines anderen Administrators oder gibt ihn frei. |
Ergebnis je Datensatz
records[].status nennt das Ergebnis, records[].claim den Stand nach dem Aufruf:
| status | Bedeutung |
|---|---|
claimed | Neuer Claim angelegt |
refreshed | Eigener Claim verlängert; claimed_at bleibt erhalten |
taken_over | Claim eines anderen Administrators mit force: true übernommen; previous_claim enthält den bisherigen |
released | Claim beendet; previous_claim enthält den bisherigen |
unchanged | state: released gesendet, aber kein Claim aktiv |
conflict | Ein anderer Administrator hält den Claim. Es wird nichts geschrieben, claim nennt den Halter und errors[] die Erklärung. Der Datensatz zählt als failed. |
Antwort (HTTP 200), wenn ein anderer Administrator anfragt, während Max Mustermann den Claim hält:
{
"success": false,
"outcome": "failed",
"api_version": "2.118.1",
"type": "ticket_claims",
"stats": { "total": 1, "claimed": 0, "refreshed": 0, "taken_over": 0, "released": 0, "unchanged": 0, "failed": 1 },
"errors": [
{ "record": 1, "error": "Ticket 33999 is claimed by Max Mustermann (admin_id 12) until 2026-09-17 14:05:10. Send force=true to take it over." }
],
"timestamp": "2026-09-17 13:20:02",
"records": [
{
"index": 1,
"ticket_id": 33999,
"status": "conflict",
"claim": {
"admin_id": 12,
"admin_name": "Max Mustermann",
"label": "KI-Agent",
"claimed_at": "2026-09-17 13:05:10",
"expires_at": "2026-09-17 14:05:10",
"expires_in_seconds": 2708
}
}
]
}Automatische Freigabe
- Öffentliche Antwort an den Kunden, per API (
PUT /Import/JSON/tickets) oder im Backend. Wer antwortet, spielt keine Rolle. - Wechsel auf einen Schließ-Status. Wer ein bereits geschlossenes Ticket erneut speichert, gibt keinen Claim frei.
- Ablauf der Laufzeit.
Eine interne Notiz gibt den Claim nicht frei. Setzen und Freigeben ändern weder ticket_date_last_modified noch den Verlauf und lösen keine Ereignisse aus; Sortierung und Delta-Export (?last_modified=) bleiben unberührt. Je Ticket gibt es höchstens einen Claim: Von zwei gleichzeitigen Anfragen erhält genau eine conflict.
claim eines anderen Administrators oder ein edit_lock, arbeitet gerade jemand an dem Ticket. Stimmen Sie sich ab, bevor Sie force: true senden.Ab Shop 4.9.73. Fehlt die Tabelle ticket_claims noch, antwortet der Endpunkt mit HTTP 503 TICKET_CLAIMS_UNAVAILABLE; der Shop legt sie beim nächsten Schema-Abgleich an (beim Öffnen einer xoCRM-Seite oder des HealthChecks).
Scopes
| Scope | Beschreibung |
|---|---|
tickets:read | Tickets und Attachments exportieren |
tickets:write | Tickets erstellen und aktualisieren, Kommentare und Dateianhänge schreiben, Bearbeitungs-Claims setzen und beenden |
tickets:delete | Tickets löschen (kaskadierend) |
Tickets via API verwalten
Erstellen Sie einen OAuth2 Client mit tickets:read, tickets:write und tickets:delete Scopes.