Ticket-Endpunkt

v2.118.1 · Full CRUD · Tickets, Attachments & Claims

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

MethodeEndpointScopeBeschreibung
GET/Export/JSON/ticketstickets:readAlle Tickets abrufen (paginiert)
GET/Export/JSON/ticket/{id}tickets:readEinzelnes Ticket mit Status-History und verknüpften Terminen
GET/Export/JSON/ticket_attachments/{id}tickets:readDateianhänge eines Tickets auflisten
GET/Export/JSON/ticket_attachment/{id}?file=nametickets:readEinzelne Datei als Binary herunterladen
POST/Import/JSON/ticketstickets:writeNeues Ticket erstellen (INSERT) — optional mit Kommentar und Dateianhängen
PUT/Import/JSON/ticketstickets:writeBestehendes Ticket aktualisieren (UPDATE) — optional mit Kommentar, öffentlicher Antwort und Dateianhängen
PUT/Import/JSON/ticket_claimstickets:writeBearbeitungs-Claim setzen, verlängern, von einem anderen Administrator übernehmen oder beenden NEU v2.118.0
DELETE/Delete/JSON/ticket/{id}tickets:deleteTicket mit Cascade-Delete löschen

Export: Query-Parameter

ParameterBeschreibung
?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-DDGeändert seit Datum
?language=deSprach-Filter
?limit=50&offset=0Pagination (Default: 50, Max: 250)

Export: Response-Felder

FeldTypBeschreibung
ticket_idintegerTicket-ID
ticket_link_idstring14-stellige alphanumerische Link-ID
ticket_subjectstringBetreff des Tickets
ticket_statusstringAktueller Status
ticket_prioritystringPriorität
department_idintegerAbteilungs-ID
department_namestringAbteilungsname
status_history[]arrayVollständige Konversation mit Kommentaren
assigned_admins[]arrayZugewiesene Administratoren
followers[]arrayFollower des Tickets
linked_appointments[]arrayNEU v2.11.0: Verknüpfte CRM-Termine (ID, Start, Ende, Titel, Ort, Status)
claimobject|nullNEU 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_lockobject|nullNEU 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 — Betreff
  • ticket_customers_email — Kunden-E-Mail
  • ticket_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-ID
  • ticket_language_id — Sprach-ID
  • ticket_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); setzt ticket_comments voraus
  • assigned_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.

Wichtig: Kommentare sind standardmäßig intern (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.
FeldTypBeschreibung
ticket_idintegerPflicht. ID des zu aktualisierenden Tickets
ticket_subjectstringBetreff ändern
ticket_typestringTyp ändern
ticket_status_idintegerStatus ändern (wird validiert)
ticket_priority_idintegerPriorität ändern (wird validiert)
ticket_department_idintegerAbteilung ändern (wird validiert)
ticket_date_hide_untildatetimeTicket verstecken bis
ticket_login_required0/1Login erforderlich
ticket_allow_learning0/1KI-Lernen erlauben
ticket_commentsstringKommentartext — erzeugt einen neuen Verlaufseintrag
admin_idintegerUrheber des Kommentars (0 = System; bei öffentlicher Antwort zwingend > 0)
is_public_replybooleanv2.25.0: true = für den Kunden sichtbare Antwort statt interner Notiz
notify_customerbooleanv2.25.0: true = E-Mail an den Kunden (nur zusammen mit is_public_reply)
append_signaturebooleanv2.27.0: Ticket-Signatur des Admins an eine öffentliche Antwort anhängen (Standard an; Opt-out nur via false)
attachments[]arrayNEU v2.60.0: Dateianhänge (name + content_base64); setzt ticket_comments voraus
assigned_admins[]arrayAdmin-Zuweisungen ersetzen

Kommentar-Format

"ticket_comments": "Interner Vermerk via API",
"admin_id": 1

ticket_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

  1. ticket_status_history — Kommentare/Verlauf
  2. ticket_to_admins — Admin-Zuweisungen
  3. ticket_to_followers — Follower
  4. xocrm_appointments_relationships — Termin-Verknüpfungen (Termine bleiben erhalten)
  5. xocrm_followers — CRM-Follower
  6. Dateien auf Disk — upload/ticket/{link_id}/
  7. 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…"
  }
]
Anhänge hängen an einem Verlaufseintrag, nicht am Ticket. Ohne 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.
FeldTypBeschreibung
namestringPflicht. Dateiname inklusive Endung; die Endung muss in der Allowlist stehen (identische Regel wie beim Backend-Upload)
content_base64stringPflicht. 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" }
  ]
}
FeldTypPflichtBeschreibung
ticket_idintegerjaID eines bestehenden Tickets
admin_idintegerjaBestehender Administrator, für den gearbeitet wird. Ein Claim nennt immer eine Person.
statestringjaclaimed setzt oder verlängert den Claim, released beendet ihn. Es gibt keinen Vorgabewert: Der Endpunkt setzt einen Zustand und schaltet nie um.
ttl_minutesintegerneinLaufzeit in Minuten, Vorgabe 60, begrenzt auf 5 bis 240. Ein Verlängern verkürzt einen laufenden Claim nie.
labelstringneinKennzeichnung der Quelle, z. B. KI-Agent; höchstens 32 Zeichen, HTML wird entfernt. Das Backend zeigt „Name (Label)“.
forcebooleanneinNur 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:

statusBedeutung
claimedNeuer Claim angelegt
refreshedEigener Claim verlängert; claimed_at bleibt erhalten
taken_overClaim eines anderen Administrators mit force: true übernommen; previous_claim enthält den bisherigen
releasedClaim beendet; previous_claim enthält den bisherigen
unchangedstate: released gesendet, aber kein Claim aktiv
conflictEin 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.

Vor der Bearbeitung prüfen: Nennt der Export einen 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

ScopeBeschreibung
tickets:readTickets und Attachments exportieren
tickets:writeTickets erstellen und aktualisieren, Kommentare und Dateianhänge schreiben, Bearbeitungs-Claims setzen und beenden
tickets:deleteTickets löschen (kaskadierend)

Tickets via API verwalten

Erstellen Sie einen OAuth2 Client mit tickets:read, tickets:write und tickets:delete Scopes.

OAuth2 Client erstellen