Skip to content

BlogGuides

Discord Ticket Bot Webhooks: Send Ticket Events to Your Own System

One signed webhook per panel, on ticket opened, claimed, closed and transferred. What the payload carries, how to verify it, and when a free log channel is better.

Dani, Founder, AI Ticket Bot

18 min read

There are two completely different reasons somebody goes looking for ticket bot webhooks, and only one of them is served by a webhook.

The first is "I want to see tickets happening somewhere other than the ticket channel". The second is "I want a ticket event to reach a system that is not Discord". They sound like the same request and they are not, because the first one has a free answer built into the bot and the second one is what webhooks exist for. Getting this wrong is the most common webhook mistake we see, and it is expensive in the literal sense: outgoing webhooks are a paid feature and log channels are not.

So the sentence to keep in your head for the rest of this guide is short. A log channel tells your staff. A webhook tells your software.

What is a Discord ticket bot webhook?

A webhook is an outgoing HTTP POST. When something happens to a ticket, the bot sends a small JSON document to a URL you control, and your server does whatever you want with it. Nothing is polled, nothing is scheduled, and you never call us.

That is the whole idea, and everything else is the fine print about who is allowed to send it, what is inside it, and how you know it was really us.

  1. Ticket event happens
  2. Bot builds the JSON
  3. Body is signed
  4. POST to your URL
  5. Your server responds 2xx

The distinction that matters is the direction. Our public API is something you call, with a key, when you want to read something. A webhook is something we call, without warning, when a ticket changes. If your integration needs to react within a second of a ticket closing, you want the webhook. If it needs to answer "how many tickets were open last Tuesday", you want the dashboard or the API instead.

Do you need a webhook, or just a log channel?

Answer this before you upgrade anything, because the free option is genuinely better at the job most people describe.

Log channel

  • Free on every plan
  • Posts a Discord embed into a channel you pick
  • Read by humans
  • Carries the close reason, the category and a jump button
  • One channel per event, so you can split them
  • Nothing to build, nothing to host

Outgoing webhook

  • Paid feature, from Premium up
  • Sends JSON to a URL you own
  • Read by software
  • Carries IDs, a transcript link and who acted
  • One URL per panel for all its events
  • You build and host the receiver

Log channels post a structured embed with a jump button on open, claim and transfer, and a "view transcript" button on close. They are configured per event, so ticket opens can land in one channel and closes in another. If your goal is a staff feed, an audit trail your moderators can scroll, or a quiet channel where a manager can watch the queue, that is the tool, it costs nothing, and this guide is not for you.

Webhooks earn their place when the destination is not Discord. A CRM record, a row in your own database, a ping to an on-call rota, a counter on an internal dashboard, a message to a system your players or customers use. That is work a Discord embed cannot do.

Which ticket events fire a webhook?

Four, and you choose which of the four are on for each panel.

EventWhen it firesWhat the payload adds
openedA member opens a ticket on this panelNothing beyond the ticket object
claimedA staff member claims the ticketclaimer_user_id
closedThe ticket closes by any routecloser_user_id, transcript_url
transferredThe ticket moves to another category on the same panelfrom_category_id, to_category_id

opened and closed are on by default and claimed and transferred are off, which is a sensible starting point for most integrations: the two that bracket a ticket's life are usually the two you want.

The closed event is worth reading carefully, because "by any route" is doing real work in that sentence. Staff closing manually, a member closing their own ticket, the AI closing after the member confirmed, the inactivity timer closing a stale ticket, a bulk close from the dashboard, an admin deleting the channel by hand: all of them go through the same close path, so all of them fire the same webhook. Your receiver does not need to care which one happened, and it should not assume a human was involved.

How do you set up a webhook on a ticket panel?

Two places, one result. In Discord it lives inside the panel editor, and on the web dashboard it is a tab on the panel.

/panel edit
Pick the panel, open Webhooks, then Add or Edit
  1. Open the panel's Webhooks tab

    On the dashboard, Panels, then your panel, then Webhooks. In Discord it is a button inside /panel edit

  2. Paste the receiver URL

    It must be http or https and it must resolve to a public address. Private ranges, loopback and cloud metadata hostnames are refused at save time

  3. Pick an authentication style

    None, an X-API-Key header, an Authorization: Bearer token, or Discord webhook. The secret you type is encrypted before it is stored and is never shown again

  4. Choose your events

    Tick any of opened, claimed, closed and transferred. At least one is required

  5. Reveal the signing key and give it to your receiver

    It is a 64 character hex string, separate from the auth secret above, and it is what proves the body came from us

  6. Send a test delivery

    The Test button fires a synthetic opened event and shows you the exact HTTP status your server returned

The Test button is more useful than it looks, because it hands you the real status code rather than a green tick. A 404 means your route is wrong. A 401 or 403 means your auth check is rejecting us. A 500 usually means your handler crashed on the test payload, which brings us to a trap worth knowing before you press it.

What is actually in the payload?

Here is a real closed body, formatted for reading. On the wire it arrives with no spaces at all, and that detail matters for the next section.

{
  "event": "closed",
  "ticket": {
    "id": 4821,
    "number": 137,
    "opener_user_id": "216783729190535168",
    "panel_id": 12,
    "category_id": 34,
    "opened_at": "2026-08-13T09:14:22"
  },
  "closer_user_id": "184926315047837696",
  "transcript_url": "https://aiticketbot.com/transcripts/8f2c...ac91"
}

Three things in there are worth calling out, because each one has bitten somebody.

The ticket number and the ticket id are different fields. number is the human one your staff say out loud, and it counts up per server. id is our internal row id. Log the number, key on the id.

transcript_url can be null. A server can turn transcripts off entirely, in which case a close stores nothing and this field arrives empty. It is also null if the transcript could not be built. Treat it as optional, always. If you want the background on what a transcript actually contains, we wrote that up separately in the transcripts guide.

There is no guild_id. If several servers point their panels at one endpoint, the payload alone will not tell you which server an event came from. Map panel_id to a server on your side when you set the panel up, or give each server its own URL path. This is the single most common surprise on a multi tenant receiver.

Why do the Discord IDs arrive as strings?

Because JavaScript would silently corrupt them otherwise, and a silent corruption is worse than an error.

Discord IDs, called snowflakes, are about nineteen digits. JavaScript's largest exactly representable integer is a little over nine quadrillion, which is sixteen digits. Parse a snowflake as a JSON number in Node or a browser and you get a number that is close to the right one and is not the right one, with no warning anywhere. So the bot converts every integer above that limit to a string before serialising.

The rule is mechanical, which means you can predict the shape of any payload without a table:

Which fields are strings and which are numbers

Above JavaScript's safe integer limit
String. Every Discord user, channel and message id
At or below it
Number. Ticket id, ticket number, panel id, category id
Booleans
Always booleans, never stringified

So in the example above, opener_user_id and closer_user_id are strings while id, number, panel_id and category_id are numbers. Write your schema that way and do not run parseInt over the user IDs to "tidy them up", because that is the exact bug this design is avoiding.

How do you verify the X-Webhook-Signature header?

Every delivery except the Discord webhook style carries a header like this:

X-Webhook-Signature: sha256=4c1e0f...9ab3

That is HMAC-SHA256 of the request body, keyed with your panel's signing key. Verifying it takes four lines, and there is exactly one way to get it wrong.

  1. Read the raw body first

    Capture the bytes before any JSON middleware parses them. Express needs express.json({ verify }) or express.raw, FastAPI needs await request.body()

  2. Recompute the digest

    HMAC-SHA256 over those exact bytes, using the signing key from the panel, hex encoded

  3. Prefix it

    Put sha256= in front, because that is the format in the header

  4. Compare in constant time

    timingSafeEqual in Node, hmac.compare_digest in Python. A plain === leaks timing information about how much of the digest you matched

The reason the raw body matters is not pedantry. On our side the payload is serialised exactly once, with no spaces between keys, and the signature is computed over those precise bytes, which are then the bytes we POST. The code carries a comment saying why:

Serialize once so the HMAC is computed over the EXACT bytes the receiver parses. matters for whitespace, key ordering, unicode escaping.

The bot's own note on why the body is serialised once

If your framework parses the JSON into an object and you then re-serialise it to check the hash, your library's spacing, key order or unicode escaping may differ from ours by a single character, and a single character changes the whole digest. It will fail, it will look like our signature is broken, and it is not.

  • Hash the raw bytes exactly as received

    The digest matches, first time, every time

  • Hash JSON.stringify(req.body) after parsing

    Your serialiser reorders keys or adds spaces and the digest never matches

  • Compare with timingSafeEqual or compare_digest

    Constant time comparison, no information leaked about near misses

  • Compare with === or ==

    Fails fast on the first wrong character, which is measurable

  • Reject the request when the signature is missing

    An unsigned POST to your endpoint is somebody else's

  • Trust the payload because the URL is secret

    URLs leak through logs, proxies and browser history

Here is the whole check in Node:

const crypto = require('crypto');

const sent = req.get('X-Webhook-Signature') || '';
const expected =
  'sha256=' +
  crypto.createHmac('sha256', process.env.TICKET_SIGNING_KEY)
        .update(req.rawBody)          // raw bytes, not req.body
        .digest('hex');

const ok =
  sent.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));

And in Python:

import hmac, hashlib

raw = await request.body()
expected = "sha256=" + hmac.new(
    SIGNING_KEY.encode(), raw, hashlib.sha256,
).hexdigest()
ok = hmac.compare_digest(expected, request.headers.get("X-Webhook-Signature", ""))
Signing key, auth secret, and why there are two

They do different jobs and they are stored separately.

The auth secret is what you set as X-API-Key or a bearer token. It authenticates us to your endpoint, the same way any API client authenticates. It is optional.

The signing key proves the body was not altered in transit and that the sender holds the key. It is generated automatically when you save a webhook and it is always there.

Splitting them means a leaked API key alone does not let anybody forge a delivery that passes your signature check. You can reveal the signing key from the panel whenever you need it, and rotate it with one button. Rotation is instant and destructive by design: every deployed receiver fails verification until it has the new value, so update your server first and rotate second.

One more thing your receiver should do, because we cannot do it for you. A signature proves a body is genuine, not that it is new. If somebody captures a valid delivery they can replay it. Store an idempotency key made of the event name and the ticket id, and ignore a combination you have already processed.

What happens when your receiver is down?

It is retried, then it is dropped, and eventually the whole webhook is switched off.

Delivery behaviour, exactly

Attempts per event
3
Backoff between attempts
1 second, then 2 seconds
Timeout per attempt
10 seconds
Success
Any 2xx status. Anything else counts as a failure
After all 3 fail
The event is discarded. There is no queue and no replay
After 10 consecutive failures
The webhook is disabled automatically
On auto disable
An audit entry is written and the server owner receives a direct message

That last pair is the part people do not expect and are grateful for later. A webhook that quietly stops working is a data loss you find out about weeks afterwards. Auto disabling makes it loud, and the direct message names the panel and the failure count so the owner knows where to look. The dashboard also shows the current consecutive failure count and the last status code we received, so you can see a receiver degrading before it trips.

Be honest with yourself about the "no replay" line, though, because it shapes what you should build. If your system must never miss a closed ticket, do not treat the webhook as your only source of truth. Use it as the fast path and reconcile against the dashboard or the API on a schedule.

Why is there only one webhook per panel?

Because that is what the storage actually is, and we would rather say so than advertise a number we cannot honour.

The bot stores webhook configuration as one row per panel, keyed on the panel itself. There is physically no second row. An early version of the plan catalogue advertised three per panel on Pro, and the multi row model behind it was never built, so in April the cap was lowered to match the code rather than left as a promise. The migration that did it says so plainly:

Lower the Pro plan's max outgoing webhooks per panel from 3 to 1 so the promised cap matches the actual data model.

The migration that lowered the advertised cap

We are telling you that because a limit you can trust is worth more than a bigger number you cannot. In practice the panel is the multiplier anyway:

Webhooks by plan, read from the live plan catalogue on 13 August 2026

Free
None. Outgoing webhooks are off
Premium
One per panel, across up to 5 panels
Pro
One per panel, across up to 15 panels
Enterprise
One per panel, with the panel count set per agreement

If you need two systems fed from the same panel, the usual answer is one receiver of yours that fans out, which is also where retries, filtering and per system authentication belong. If you need genuinely different destinations for genuinely different ticket types, split them across panels and give each panel its own URL.

The plan values above are read from the live catalogue rather than typed from memory, and the current ones are always on the pricing page.

What the webhook does not send

This is the section to read before you design anything, because assuming a field exists is how an integration ships broken.

Sends

  • Ticket id and the per server ticket number
  • Who opened it, who claimed it, who closed it
  • The panel and category the ticket belongs to
  • The transcript link at close, when transcripts are on
  • Which of the four events occurred

Does not send

  • The close reason. That goes to the member, the log channel and the audit trail, not the payload
  • The ticket's messages or any conversation content
  • The server id, so map the panel id yourself
  • Category and panel names, only their ids
  • Anything about the AI: no escalation event, no resolution flag

The close reason one deserves a sentence of its own, because it catches people who assume the webhook mirrors the log channel embed. It does not. The log embed carries the reason your staff typed, and the payload carries closer_user_id and transcript_url only. If your system needs the reason, read it from the ticket in the dashboard, or open the transcript.

Two more boundaries worth stating plainly. Tickets created by a connected third party bot never fire a webhook, because they have no panel of ours to hang one on. And if you downgrade to a plan without the feature, the configuration is hidden rather than deleted, so it is still there when you upgrade again and you will not have to rebuild it.

What can you actually build with this?

The useful ones are boring, which is a compliment.

Integrations servers actually run

  • A row in your own database for every ticket, so support volume lives next to your other business data
  • An on call ping when a ticket opens outside your working hours
  • A CRM note against the customer, keyed on the Discord user id you already store
  • A counter on an internal status board, incremented on open and decremented on close
  • An archive job that pulls the transcript link at close and files it against an order or an account
  • A weekly export that joins ticket counts to whatever else you measure

Notice what is not on that list: anything that needs the conversation. The payload is deliberately metadata, and it stays that way. Ticket content lives in the transcript, behind its own link, under your plan's retention window, which is where it belongs.

Where a webhook is the wrong choice

We would rather you did not pay for this if it is not going to help you, so here is the honest list.

You want ticket activity in a Discord channel. Use log channels. Free, better formatted, and they carry the close reason the webhook does not.

You want to mirror the conversation. There is no message event. This is not a chat relay.

You have nowhere to host a receiver. A webhook needs a public HTTPS endpoint that is up when a ticket closes. If that does not exist yet, the dashboard and its analytics already answer most of the questions people build a receiver for.

You need guaranteed delivery. Three attempts and no queue is fine for a dashboard counter and not fine for billing.

You only wanted the numbers. Ticket counts, response times and staff activity are already on the dashboard, and no integration is needed to see them.

Does a webhook cost AI tokens?
No. Webhooks are HTTP calls from the bot and have nothing to do with the AI or its token budget
Can I filter by category?
Not on our side. Every enabled event on the panel is sent, and your receiver decides what to ignore using category_id
Is the receiver URL visible to staff?
It is visible to anyone who can edit the panel, so treat it as a shared secret at best and rely on the signature rather than on the URL being unknown
Can I use a serverless function?
Yes, and it is a good fit. Keep the cold start inside the ten second timeout and return 2xx before doing slow work

The short version

Webhooks are the answer to one specific question: how does a ticket event reach software that is not Discord. If that is your question, you get one signed endpoint per panel, four events to choose from, and a payload of metadata you can trust the shape of. Verify against the raw body, expect Discord IDs as strings, handle the null transcript, and reconcile somewhere else if you cannot afford to miss one.

If your question was really "how do my staff see what is happening", close this tab and go and set up a log channel. It is free, and it is better at that job.

185,000+
Tickets handled over the bot's lifetime
Roughly half
Resolved by the AI with no human involved
3,400+
Discord servers running the bot

Where these numbers come from

Sample
Every ticket handled across both generations of the bot, all servers
Window
Lifetime to date, refreshed continuously
Definition
Resolved means the AI closed the ticket without escalating to a human
Check it
The live endpoint at /api/stats/global returns the current figures
Webhook
An HTTP POST the bot sends to your URL when something happens, with no request from you
Receiver
The endpoint on your server that accepts those POSTs and does something with them
Signing key
The per panel secret used to compute the signature. Separate from any API key you set
HMAC-SHA256
A keyed hash proving both that the body is unaltered and that the sender holds the key
Raw body
The exact bytes of the request before your framework parses them into an object
Idempotency key
A value identifying one event, so a repeated delivery can be recognised and ignored

Frequently asked questions

It is an outgoing HTTP POST from the bot to a URL you control, sent the moment a ticket opens, is claimed, is closed or is transferred. The body is JSON describing what happened, and it is signed so your server can prove the request came from us. It is how a ticket event reaches software that is not Discord, such as a CRM, a status board, a spreadsheet or your own database.

One, on every plan that includes the feature. The limit is the data model rather than a pricing tier: the bot stores one webhook row per panel, keyed on the panel. You scale by adding panels, not by adding webhooks to a panel, so a Premium server has up to five receiving endpoints and a Pro server up to fifteen. The free plan has no outgoing webhooks at all.

Four: opened, claimed, closed and transferred. You pick which of the four you want per panel, and opened plus closed are on by default. There is no event for a new message, no event for an AI escalation and no event for an unclaim. If you need those, the webhook is not the right tool.

Every delivery carries an X-Webhook-Signature header in the form sha256 followed by a hex digest. Recompute HMAC-SHA256 over the raw request body using your panel's signing key, then compare the two in constant time. The critical part is raw: sign the exact bytes you received, before your web framework parses them into an object, because re-serialising JSON can change whitespace and key order and the digest will not match.

Yes, and the bot detects the Discord-shaped URL and switches to a Discord-compatible body, which is a single line of text such as Ticket 42 closed. You do not get the JSON payload and there is no signature header, because Discord ignores custom headers. If what you actually want is ticket activity posted into a Discord channel, use log channels instead. They are free on every plan and they post a proper embed with a jump button and the close reason.

The bot tries three times, backing off one second then two seconds, with a ten second timeout on each attempt. If all three fail the delivery is dropped. There is no queue and no replay, so a receiver that is down misses those events permanently. After ten consecutive failed deliveries the webhook is switched off automatically, an entry is written to your audit log, and the server owner gets a direct message explaining why.

No. Webhooks are configured on a panel, and a ticket created by another bot through the connect feature has no panel of ours, so there is nothing to look up and nothing fires. Webhooks cover tickets opened through your own panels only.

It’s not just an AI, it’s your AI.

See it on your own server.

Add the bot free, teach it a few of your most common answers, and watch it clear the repeat tickets on its own.

Free plan, no card. Your first panel starts 14 days of Premium.