API

Tickets from your own systems

A ticket opened through the API is a real ticket: it creates a Discord channel, your team answers it as usual, and the answer goes straight back to the visitor. They never need a Discord account.

POST /v1/tickets
curl https://api.ticketanizer.com/v1/tickets \
  -H "X-API-Key: tk_dein_schluessel" \
  -H "Content-Type: application/json" \
  -d '{
        "panel_id": 3,
        "visitor_name": "Anna Berger",
        "first_message": "Meine Bestellung ist nicht angekommen.",
        "metadata": { "Bestellnummer": "2026-8891", "Tarif": "Pro" }
      }'
Response
{
  "ticket_request_id": 4102,
  "session_token": "3Qk1r…",
  "status": "pending"
}

The channel appears seconds later. Keep the session_token — it is the only thing that lets that one visitor read and answer their ticket.

Two keys, two jobs

Which one you send decides what the call is allowed to do.

X-API-Key

Your key from the dashboard. It covers the whole server: open tickets, read them, answer as the team.

Belongs on your server and nowhere else. In a browser, it lets any visitor read every ticket.

X-Session-Token

Comes back when you open a ticket and covers exactly that one ticket — not the server.

This one is safe to hand to the visitor whose ticket it is.

Permissions per key

You tick what the key may do when you create it. It can never do more.

  • ticket:createOpen tickets
  • ticket:readRead tickets, messages and panels
  • ticket:replyWrite into a ticket as the team
  • stats:readRead aggregate numbers

A missing permission returns 403, not 401. The key is fine — it simply lacks that tick.

Only send fields documented here. An unknown field name is not silently ignored but rejected with 422 — a typo should show up straight away, not as an empty ticket.

The endpoints

Six calls cover the whole flow. The full reference with every field is linked at the bottom.

POST/v1/ticketsOpen a ticket and receive a session token
GET/v1/ticketsList tickets, paged through a cursor
GET/v1/tickets/{id}One ticket including its conversation
POST/v1/tickets/{id}/messagesAnswer as the team — the route for e-mail, CRM or a helpdesk
GET/v1/panelsList panels to find the panel_id
GET/v1/statsNumbers for your own reporting

Limits

Counted per key, not per IP — so all of your customers can share one backend. Every response tells you how much you have left.

On every response
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 47
Retry-After: 47

Retries

When a request times out, you cannot tell whether the ticket was created. Send a key you choose yourself and the retry returns the same answer instead of a second ticket.

When opening a ticket
Idempotency-Key: 7f3a1c9e-4b21-4f0a-9d55-1e8c2b7a6d40

We remember the key for 24 hours. Reusing one key for a genuinely different request returns 422 — better an error than the wrong ticket number.

Webhooks: when something should arrive at your end

The other direction: instead of you asking, Ticketanizer calls you. Every event goes out as a signed POST to a URL of your choice — for an e-mail bridge, a CRM, or simply a notification in your own system.

POST your URL
{
  "event": "message.created",
  "sent_at": "2026-08-01T12:34:56Z",
  "guild_id": "1470847667327471738",
  "ticket": {
    "id": 42, "number": 17,
    "panel_id": 3, "panel_name": "Support",
    "status": "open", "priority": 2,
    "opener_id": "234...", "metadata": null
  },
  "message": {
    "id": 8891,
    "discord_message_id": "13...",
    "origin": "discord",
    "is_staff": true,
    "is_ai": false,
    "author_id": "234...",
    "author_bot": false,
    "author_name": "Lisa",
    "content": "Ich schaue mir das an.",
    "created_at": "2026-08-01T12:34:56Z"
  }
}
Headers on every delivery
X-Ticketanizer-Event: message.created
X-Ticketanizer-Delivery: 90183
X-Ticketanizer-Timestamp: 1785600896
X-Ticketanizer-Signature: t=1785600896,v1=9f2c…

X-Ticketanizer-Delivery is unique per delivery — remember it and you can spot a repeat and avoid processing it twice.

sent_at and created_at in webhooks are UTC. The read API still returns times in server time without an offset — keep that in mind when you merge webhook pushes with polling.

Verifying the signature

Compute HMAC-SHA256 over <timestamp>.<rawBody> with your secret and compare the result against one of the v1 values. Two things that are easy to miss: the header can carry several v1 values (during a secret rotation), and the comparison should run in constant time.

Node.js
const crypto = require("crypto");

function verify(rawBody, header, secret) {
  const parts = header.split(",").map(p => p.split("="));
  const ts = parts.find(([k]) => k === "t")?.[1];
  // v1 kann MEHRFACH vorkommen — waehrend einer Secret-Rotation
  const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const expected = crypto.createHmac("sha256", secret)
    .update(ts + "." + rawBody)
    .digest("hex");

  return sigs.some(s => s.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

We sign the raw body — exactly the bytes that arrive. Verifying after parsing and re-serialising the JSON produces a different signature and nothing but failures.

The events

You tick what you want when you create the endpoint. With no selection you get every event — with one exception, see below.

createdTicket opened
claimed · unclaimedTicket claimed or released again
closed · reopenedTicket closed or reopened
escalatedTicket handed over to another panel
priority · member_added · member_removedPriority changed, someone added to or removed from the ticket
feedbackRating given after closing
message.createdEvery message in the ticket, full text. Only when explicitly ticked — conversation content should not flow unasked to an endpoint someone set up for status changes.
deleted · transcript · snoozedTicket deleted, transcript stored, ticket snoozed. If your system mirrors tickets, tick deleted as well — otherwise a deleted ticket stays open on your side forever.

What to know about delivery

  • RetriesAnswer with a 2xx status. Anything else counts as a failure and we try again — after 1, 5, 15 and 60 minutes, then no more.
  • Loop guardEvery message carries an origin (discord, web, inbox, relay). If you answer in response to an event, filter out your own — otherwise your reply triggers the next event.
  • DuplicatesAnswer too slowly and the same delivery arrives again. That is deliberate: twice beats not at all. The delivery ID is what protects you from processing it twice.
  • Dead endpointsAfter 20 deliveries in a row that fail for good, we switch the endpoint off and write the reason into the dashboard. A brief outage will not do it — counting starts only once all retries are exhausted.
  • Rotating the secretWhen you rotate, the old secret stays valid for 7 days and we sign with both. That lets you switch over calmly without losing a delivery.

Deliveries are visible in the dashboard — including the payload sent and your server's response. From there you can also replay a delivery by hand, as long as it is still in the log.

The full reference

Every endpoint, field and error code — generated from the running service and testable right in the browser.