DocsReference
Webhook reference
The request, headers, JSON fields, signature check and retry rules of Sortr's ticket webhooks, for developers.
This page is for developers writing their own receiver. Sortr sends each ticket event as an HTTPS POST with a JSON body, signed with the webhook's secret. Setting a webhook up in the dashboard is covered in Webhooks.
#The request
One request per event per webhook. Header names are case-insensitive.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Sortr-Webhooks/1.0 (+https://sortr.xyz) |
Sortr-Event | The event type, such as ticket.opened. ping for a test. |
Sortr-Event-Id | The event's id, the same as id in the body. |
Sortr-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. See Verify the signature. |
POST /your/path HTTP/1.1
Content-Type: application/json
User-Agent: Sortr-Webhooks/1.0 (+https://sortr.xyz)
Sortr-Event: ticket.opened
Sortr-Event-Id: evt_3f9a1c07d2e84b6c5a0e7d21
Sortr-Signature: t=1790517731,v1=5257a869e7ecebeda32a…
The body is compact JSON on one line. The examples below are spread out to be readable.
#Fields in every event
| Field | Type | Meaning |
|---|---|---|
id | string | The event's id: evt_ and 24 hex characters. Unchanged on retries. |
type | string | ticket.opened, ticket.closed, ticket.reopened, ticket.deleted, or ping for a test. |
created_at | string | When the event happened, ISO 8601 in UTC, for example 2026-09-27T14:02:11.412Z. |
server | object | id: the Discord server id. name: the server's name. |
panel | object | id: Sortr's panel id. name: the panel's name. The panel is the one whose settings the ticket opened with. If that panel has since been deleted, these are the id and name it had. |
Ticket events add a ticket object and fields of their own.
A ticket Sortr took over from Ticket Tool sends events from the take-over on, to the webhooks of the panel it runs on (none when it runs on Sortr's defaults). There is no ticket.opened for it. Its opened_at is when Ticket Tool made the channel, and its number is the one in its channel name where Sortr had not used it.
#The ticket object
| Field | Type | Meaning |
|---|---|---|
id | number | Sortr's id for the ticket, the same in every event about it. Use it to match a close to its open. |
number | string | The number members see, padded with zeros to the panel's digits, such as "0042". Counts up across the whole server. |
channel_id | string | The Discord id of the ticket channel. |
owner | user | The member the ticket is for. |
opened_at | string | When it opened, ISO 8601 in UTC. |
closed_at | string or null | When it closed. null while it is open. |
#The user object
| Field | Type | Meaning |
|---|---|---|
id | string | The Discord user id. Always present. |
username | string or null | Their Discord username. |
display_name | string or null | The name shown for them in the server. |
username and display_name are null when Sortr could not look the member up, for example after they left the server. The owner of a ticket.deleted event with deleted_in_discord: true always has them as null.
#ticket.opened
| Field | Type | Meaning |
|---|---|---|
opened_by | user | Who opened it: the member, or a staff member who opened it for them with /new. |
answers | array | The answers to the panel's questions, in the form's order, each { "question", "answer" }. An empty array when no form was filled in: the panel's form is off, or the ticket was opened with /new. Answers are in full, up to 4,000 characters each. An optional question left blank is sent with an empty string as its answer. question is the question's label, or Question 1, Question 2 and so on when it has none.A question that takes files adds files; see Answers with files. |
#Answers with files
An answer to a question that takes files (Answer set to Files on the panel's Questions screen) is { "question", "answer", "files" }. Text answers never have files.
| Field | Type | Meaning |
|---|---|---|
answer | string | The files' names, joined with ", ". An empty string when nothing was attached. |
files | array | One object per file the member attached, in the order Discord listed them, at most the question's Most files (10 at most). An empty array when nothing was attached. |
files[].name | string | The file's name as Sortr keeps it: every character other than ASCII letters, digits, spaces, ., -, _, ( and ) becomes _, leading full stops become one _, and the name stops at 100 characters (file when nothing is left). The same names as in answer. |
files[].size | number | The size in bytes, as Discord reported it. |
files[].content_type | string or null | The media type Discord reported, such as image/png. It comes from the member's app, so treat it as a hint. null when Discord gave none. |
There is no link to the file. Discord's own links expire, so Sortr posts a copy of the files in the ticket channel instead, and stores only their names. Every attached file is listed here, including one Sortr did not copy into the ticket, for example because it was too big or of a kind the question does not take.
{
"id": "evt_3f9a1c07d2e84b6c5a0e7d21",
"type": "ticket.opened",
"created_at": "2026-09-27T14:02:11.412Z",
"server": { "id": "100000000000000000", "name": "Aurora SMP" },
"panel": { "id": "100000000000000000-mfk2q8x1a7c", "name": "Billing" },
"ticket": {
"id": 1842,
"number": "0042",
"channel_id": "100000000000000003",
"owner": { "id": "100000000000000001", "username": "ren.oakley", "display_name": "Ren" },
"opened_at": "2026-09-27T14:02:11.000Z",
"closed_at": null
},
"opened_by": { "id": "100000000000000001", "username": "ren.oakley", "display_name": "Ren" },
"answers": [
{ "question": "What do you need help with?", "answer": "My order never arrived." },
{ "question": "Order number", "answer": "#10442" },
{
"question": "Screenshots",
"answer": "receipt.png",
"files": [{ "name": "receipt.png", "size": 188416, "content_type": "image/png" }]
}
]
}
#ticket.closed
| Field | Type | Meaning |
|---|---|---|
closed_by | user or null | Who closed it, staff or the member. null when Sortr closed it for inactivity. |
automatic | boolean | true when the automatic close timer closed it. |
reason | string or null | The reason given. An automatic close says how long the ticket was quiet, such as "No activity for 2 days". |
transcript_url | string or null | The transcript saved at the close: its page on sortr.xyz or, when the panel keeps no copy there, the Discord message the transcript file was posted with. null when no transcript was saved at the close. |
transcript_url is always null for a panel that saves transcripts when the ticket is deleted (Save it set to When the ticket is deleted under Transcripts/Logging), since nothing is saved at the close. It is also null when the panel keeps no transcripts, when it only sends them to the ticket owner by direct message, or when the save at the close made no copy. For a ticket taken over from Ticket Tool whose transcript was kept off a channel others can read, it is the sortr.xyz page, even when the panel keeps no copy there (Transcripts).
A sortr.xyz transcript link opens only for the member who opened the ticket and for the server's owner and Administrators, after they sign in with Discord. In a server that requires 2FA for moderator actions, an Administrator also needs 2FA on their Discord account. Anyone else sees the same "No such transcript" page as for a link that does not exist. The link stops working once that copy is deleted: when the panel's Keep it for runs out, when newer saves push it out of the copies the ticket keeps (its first and its 3 newest), when an admin deletes it, or 30 days after Sortr is removed from the server.
{
"id": "evt_a41c7e02b95d3f80c6e1d7b2",
"type": "ticket.closed",
"created_at": "2026-09-27T14:17:48.530Z",
"server": { "id": "100000000000000000", "name": "Aurora SMP" },
"panel": { "id": "100000000000000000-mfk2q8x1a7c", "name": "Billing" },
"ticket": {
"id": 1842,
"number": "0042",
"channel_id": "100000000000000003",
"owner": { "id": "100000000000000001", "username": "ren.oakley", "display_name": "Ren" },
"opened_at": "2026-09-27T14:02:11.000Z",
"closed_at": "2026-09-27T14:17:48.000Z"
},
"closed_by": { "id": "100000000000000002", "username": "mira.k", "display_name": "Mira" },
"automatic": false,
"reason": "Refund issued",
"transcript_url": "https://sortr.xyz/t/9c2e41d0-7b3a-4f6e-9d8c-2a1b3c4d5e6f"
}
#ticket.reopened
| Field | Type | Meaning |
|---|---|---|
reopened_by | user | Who reopened it. ticket.closed_at is back to null. |
{
"id": "evt_0d6b93e5f2a7c14e8b90a3d6",
"type": "ticket.reopened",
"created_at": "2026-09-27T15:40:02.118Z",
"server": { "id": "100000000000000000", "name": "Aurora SMP" },
"panel": { "id": "100000000000000000-mfk2q8x1a7c", "name": "Billing" },
"ticket": {
"id": 1842,
"number": "0042",
"channel_id": "100000000000000003",
"owner": { "id": "100000000000000001", "username": "ren.oakley", "display_name": "Ren" },
"opened_at": "2026-09-27T14:02:11.000Z",
"closed_at": null
},
"reopened_by": { "id": "100000000000000002", "username": "mira.k", "display_name": "Mira" }
}
#ticket.deleted
| Field | Type | Meaning |
|---|---|---|
deleted_by | user or null | Who deleted it through Sortr. null when the automatic delete timer did it, or when the channel was deleted in Discord. |
automatic | boolean | true when the automatic delete timer deleted it. |
deleted_in_discord | boolean | true when someone deleted the channel in Discord rather than through Sortr. |
transcript_url | string or null | The transcript this delete saved, the same way ticket.closed gives it: its page on sortr.xyz or, when the panel keeps no copy there, the Discord message the transcript file was posted with (the sortr.xyz page for a taken-over ticket whose transcript was kept off a channel others can read). null when the delete saved none. |
A delete through Sortr is sent before the channel goes. When the delete saves a transcript first, the event is sent after that save and after the last notice in the channel. The channel itself is removed a few seconds later: 8 seconds after a delete by hand, 10 after the automatic delete. With deleted_in_discord: true, the channel was already gone when the event was made.
A delete saves a transcript first when the panel saves transcripts when the ticket is deleted (Save it set to When the ticket is deleted), or when the ticket has no copy since its last close, such as a ticket deleted while still open. Otherwise the ticket already has a copy, saved at the close (the one in ticket.closed) or by hand since, and transcript_url here is null. It is also null when the panel keeps no transcripts, when the save made no copy or only sent one by direct message, and always with deleted_in_discord: true, since a channel deleted in Discord cannot be saved. ticket.closed_at is null for a ticket deleted while it was still open.
{
"id": "evt_e87f20a9c3b64d15f0a2c9e4",
"type": "ticket.deleted",
"created_at": "2026-10-04T14:20:31.907Z",
"server": { "id": "100000000000000000", "name": "Aurora SMP" },
"panel": { "id": "100000000000000000-mfk2q8x1a7c", "name": "Billing" },
"ticket": {
"id": 1842,
"number": "0042",
"channel_id": "100000000000000003",
"owner": { "id": "100000000000000001", "username": "ren.oakley", "display_name": "Ren" },
"opened_at": "2026-09-27T14:02:11.000Z",
"closed_at": "2026-09-27T16:05:19.000Z"
},
"deleted_by": null,
"automatic": true,
"deleted_in_discord": false,
"transcript_url": null
}
#ping (the test event)
Send test in the dashboard posts a ping to the address and secret in the editor, saved or not. It has the fields every event has, plus a message, and no ticket. It is sent once, never retried, and not listed under Recent deliveries. Answer it with any 2xx.
{
"id": "evt_7b2e90c4a1d35f68e0c9b417",
"type": "ping",
"created_at": "2026-09-27T14:00:00.000Z",
"server": { "id": "100000000000000000", "name": "Aurora SMP" },
"panel": { "id": "100000000000000000-mfk2q8x1a7c", "name": "Billing" },
"message": "A test from Sortr. If you can read this, Sortr can reach this address."
}
#Verify the signature
Sortr-Signature looks like t=1790517600,v1=820da578….
tis the time of this delivery attempt in whole seconds since 1970 (UTC). A retry carries a newtand a new signature.v1is the HMAC-SHA256, in lowercase hex, oft, a full stop and the raw request body:"<t>.<body>". The key is the webhook's whole signing secret,whsec_prefix included.
The dashboard makes the secret when you press New secret: whsec_ followed by 32 random letters, digits, - and _. Sortr only signs with a secret of that shape, 32 to 64 such characters after whsec_, at least 16 of them different. One that falls short is never used, and saving the panel asks for a new one.
- Read the raw body. Take the bytes exactly as they arrived, before any JSON parsing. Re-serialised JSON will not match.
- Compute the signature. HMAC-SHA256 of
t,.and the body, keyed with the secret. - Compare. Compare it with
v1using a constant-time comparison. - Check the time. We recommend rejecting a
tmore than 5 minutes from your clock, so an old request replayed later fails.
#Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const SECRET = process.env.SORTR_WEBHOOK_SECRET; // the whole secret, whsec_ included
const TOLERANCE_SECONDS = 5 * 60;
// rawBody is a Buffer: the request body exactly as it arrived
function fromSortr(rawBody, header, secret) {
const parts = {};
for (const piece of (header ?? "").split(",")) {
const [key, value] = piece.trim().split("=");
if (key && value) parts[key] = value;
}
if (!/^\d+$/.test(parts.t ?? "") || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
const given = Buffer.from(parts.v1);
return given.length === expected.length && timingSafeEqual(given, Buffer.from(expected));
}
const app = express();
app.post("/sortr", express.raw({ type: "application/json" }), (req, res) => {
if (!fromSortr(req.body, req.get("Sortr-Signature"), SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
// Answer first, then do the slow work.
res.sendStatus(204);
handle(event); // your own code
});
#Python
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["SORTR_WEBHOOK_SECRET"] # the whole secret, whsec_ included
TOLERANCE_SECONDS = 5 * 60
def from_sortr(raw_body, header, secret):
parts = {}
for piece in (header or "").split(","):
key, _, value = piece.strip().partition("=")
if key and value:
parts[key] = value
t, v1 = parts.get("t", ""), parts.get("v1")
if not t.isdigit() or not v1:
return False
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
signed = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
app = Flask(__name__)
@app.post("/sortr")
def sortr():
raw = request.get_data() # the body exactly as it arrived
if not from_sortr(raw, request.headers.get("Sortr-Signature"), SECRET):
abort(400)
event = request.get_json()
# queue the event for your own processing here, then answer
return "", 204
#Check your code against this
With the secret whsec_Zx4kQ9mT2vLr8NpB6yHc3WdF7gJs1aEu (an example, not a real one), t=1790517600 and this exact body:
{"id":"evt_7b2e90c4a1d35f68e0c9b417","type":"ping","created_at":"2026-09-27T14:00:00.000Z","server":{"id":"100000000000000000","name":"Aurora SMP"},"panel":{"id":"100000000000000000-mfk2q8x1a7c","name":"Billing"},"message":"A test from Sortr. If you can read this, Sortr can reach this address."}
the signature is:
Sortr-Signature: t=1790517600,v1=820da5780524d86db543367d9679e48749f5e16c4ec375aecb068dee75b27406
Leave out the time check while testing with this, since t is in the past.
#Responding
- Answer with any 2xx status within 10 seconds. The body of your answer is ignored.
- Anything else counts as a failure and is retried: another status, no answer in time, a refused connection or an invalid TLS certificate.
- Redirects are not followed. A 3xx is a failure, and the dashboard asks the admin to paste the final address.
- Answer before doing slow work. Store or queue the event, respond, then process it. A 2xx that takes longer than 5 seconds still counts as delivered, but your webhook's next events then wait 30 seconds before Sortr sends them.
#Retries
A failed delivery is tried again after a wait that doubles each time, measured from the failed attempt:
| Attempt | When |
|---|---|
| 1 | As the event happens, usually within a few seconds. If older events for the same webhook are still waiting, straight after them. When many events are waiting, each round of sending takes at most 3 per webhook and 5 per server, and servers take turns. |
| 2 | 30 seconds after the first failure |
| 3 to 8 | 1, 2, 4, 8, 16 and 32 minutes after the previous failure |
| 9 onwards | Every hour |
Sortr looks for due retries every 15 seconds, so an attempt can come a little later than listed. When an attempt fails and the event is more than a day old, Sortr gives up. An address that can never work as it is, such as one that resolves to a private network, is given up on at once without retries.
#How delivery works
- At least once. The same event can arrive more than once, for example when your code handled it but its answer did not reach Sortr within 10 seconds. Use
id(orSortr-Event-Id) to spot repeats. Every webhook on the panel receives the same id for the same event. - Order. Each webhook receives its events one at a time, in the order they happened. While one is being retried, every later event for that webhook waits behind it, including events that happen during the wait, so a
ticket.closednever arrives before theticket.openedit follows. An event Sortr gives up on is skipped, and the next one goes out. Different webhooks are independent of each other. - Settings at send time. Waiting events go to the webhook's current address, signed with its current secret. If the webhook is switched off, removed, or no longer wants that event, its waiting events are not sent, and the dashboard lists them as Not sent.
- Backlog. At most 500 events wait for one webhook. While 500 are waiting, each newer event is not sent: the dashboard lists it as Failed with "Not sent: 500 earlier events are still waiting for this address." New events are queued as usual again once fewer than 500 are waiting. A server has a cap of its own, 2,000 events waiting across all its webhooks, with "Not sent: this server already has 2,000 events waiting to be sent."
- Slow answers. A 2xx that takes longer than 5 seconds is a delivery, not a failure. That webhook's next events wait 30 seconds, still in order, and the wait does not count as a failed try.
#Address rules
https://only, with a valid certificate, on port 443 or a port above 1023.- Public addresses only. The name is looked up on every attempt, and if any address it resolves to is private, loopback, link-local or otherwise reserved, or is one of the addresses of Sortr's own server (those
sortr.xyzresolves to), the request is not made: "That address is on a private network. Sortr only sends to public addresses." - No user name or password in the address, at most 500 characters, and not on a Discord host (
discord.com,discordapp.com,discordapp.net,discord.gg,discord.mediaor any subdomain of them), whatever the path. Such an address is refused on save and on Send test, and gets no events: "Discord can't take Sortr's events. To post them in a channel, use the Event log under Transcripts/Logging." - Two switched-on webhooks on one panel can't share an address. A panel has at most 5 webhooks.