Webhooks
Receive signed HTTP notifications for 23 ticket events and connect TicketCord to Zapier, Make, Slack, or your own service
Webhooks send an HTTP POST to a URL you choose every time something happens in your tickets: a ticket is created or closed, a message arrives, a priority changes, an SLA is breached, an approval is decided, and so on. Each request is signed so your receiver can prove it came from TicketCord. Use them to push tickets into a CRM, alert a Slack channel, or drive automation platforms such as Zapier, Make, or n8n. They are for Enterprise servers that need TicketCord data outside Discord and the dashboard.
Webhooks are an Enterprise feature. Each server can have up to 20 webhooks, and each webhook delivers at most 60 events per minute. Only the bot owner can create, edit, test, or delete webhooks and view delivery logs; dashboard staff see the list with the URL hidden. Upgrade your plan to use webhooks.
Creating a webhook
Open the Integrations tab
Go to Dashboard → Bots, pick your bot, choose the server, and open Integrations under the Platform group. Scroll to the Webhooks section; the header shows "Using X of 20 webhooks".
Click Create Webhook
Enter a Name and the URL of your receiver, then tick the events you want. Events are grouped as Ticket Lifecycle, Messages, Priority & SLA, Sentiment & Approvals, and Notes & Access Control. Pick at least one.
Save the secret
On Webhook Created Successfully, copy the secret and store it somewhere safe. It is shown once and cannot be retrieved later. You need it to verify signatures.
Send a test
Use Test Fire on the webhook's row. A test payload is queued for delivery under the first event type you subscribed to, and the result appears in View Logs within a minute.
For no-code platforms (Zapier, Make, n8n), paste the catch-hook URL the platform gives you as the URL, subscribe to the events you need, and map the fields from the payloads below. Signature verification is optional there.
Settings
| Field | Allowed values | Default |
|---|---|---|
| Name | 1 to 50 characters: letters, numbers, spaces, hyphens, underscores | required |
| URL | https:// only, public hostname, no embedded credentials | required |
| Events | any of the 23 events below, at least one | none |
| Active | on or off (toggle on the row) | on |
| Retry policy (advanced, not in the form) | up to 5 retries, backoff multiplier 1 to 5, initial delay 100 ms to 10 s | 3 retries, multiplier 2, 1 s |
URL rules are enforced on save and again at delivery time: http://, localhost, private and internal IP addresses, and hostnames that resolve to them are rejected with messages such as "Webhook URL must use HTTPS protocol" or "Webhook hostname resolves to an internal/private IP address".
Events
TicketCord sends 23 event types.
| Group | Events |
|---|---|
| Ticket lifecycle | ticket.created, ticket.claimed, ticket.unclaimed, ticket.closed, ticket.reopened, ticket.deleted, ticket.renamed |
| Messages | message.created, message.edited, message.deleted |
| Priority and SLA | priority.changed, sla.warning, sla.breach |
| Sentiment and approvals | sentiment.negative, approval.requested, approval.approved, approval.denied |
| Notes and access | ticket.note.added, ticket.note.removed, ticket.user.added, ticket.user.removed, ticket.role.added, ticket.role.removed |
sla.* events need SLA Management, approval.* events need Approval Workflows, and sentiment.negative needs Sentiment Detection to be enabled on the server; otherwise they never fire.
Request format
Every delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | TicketCord-Webhooks/1.0 |
X-TicketCord-Event | The event type, for example ticket.created |
X-TicketCord-Timestamp | Unix time in seconds when the request was signed |
X-TicketCord-Signature | Hex HMAC-SHA256 signature (see below) |
The body always has the same envelope:
{
"event": "ticket.created",
"timestamp": 1702483200,
"data": { },
"attemptNumber": 1
}| Field | Type | Meaning |
|---|---|---|
event | string | Event type, identical to the header |
timestamp | number | Unix time in seconds, identical to the header |
data | object | Event payload; shape depends on the event |
attemptNumber | number | 1 for the first try, 2 and up for retries |
Redirects are not followed, so the URL must answer directly. Any 2xx status counts as success; the response body is ignored.
Payloads
Fields marked optional are omitted when empty. IDs are strings; times are ISO 8601.
ticket.created, ticket.claimed, ticket.closed, ticket.reopened, ticket.deleted
ticketId, ticketNumber, channelId, creatorId, and optionally creatorName, claimerId, claimerName, category, priority, subject, createdAt, closedAt, reopenedAt, deletedAt, closeReason, reopenReason, staffMemberId, staffName.
{
"event": "ticket.closed",
"timestamp": 1702486800,
"data": {
"ticketId": "507f1f77bcf86cd799439011",
"ticketNumber": 42,
"channelId": "111222333444555666",
"creatorId": "123456789012345678",
"creatorName": "John Doe",
"closedAt": "2024-12-13T11:00:00.000Z",
"closeReason": "Issue resolved",
"staffMemberId": "999888777666555444",
"staffName": "Support Agent"
},
"attemptNumber": 1
}ticket.unclaimed
ticketId, ticketNumber, channelId, previousClaimer, unclaimedBy, unclaimedAt, optionally previousClaimerName, unclaimedByName.
ticket.renamed
ticketId, ticketNumber, channelId, oldName, newName, renamedBy, renamedAt, optionally renamedByName.
message.created, message.edited, message.deleted
messageId, ticketId, channelId, authorId, content, embeds (count), attachments (count), isStaff, isBot, createdAt, optionally authorName, editedAt, deletedAt, oldContent, deletedBy, mentions (array of user IDs), roleMentions (array of role IDs).
{
"event": "message.created",
"timestamp": 1702484500,
"data": {
"messageId": "222333444555666777",
"ticketId": "507f1f77bcf86cd799439011",
"channelId": "111222333444555666",
"authorId": "123456789012345678",
"authorName": "John Doe",
"content": "Hello, I need help with my subscription.",
"embeds": 0,
"attachments": 1,
"isStaff": false,
"isBot": false,
"createdAt": "2024-12-13T10:15:00.000Z"
},
"attemptNumber": 1
}priority.changed
ticketId, channelId, oldPriority, newPriority, changedBy, changedByName, timestamp, optionally reason.
sla.warning, sla.breach
ticketId, channelId, priority, eventType (warning or breach), slaType (first_response or resolution), targetMinutes, elapsedMinutes, percentageUsed, timestamp, optionally remainingMinutes, breachTime, warningPercentage, assignedStaff, assignedStaffName, escalationLevel, idempotencyKey.
sentiment.negative
ticketId, channelId, messageId, customerId, sentiment (angry, frustrated, or upset), confidence (0 to 1), timestamp, optionally customerName, messageContent (first 200 characters), detectedTone, assignedStaff.
approval.requested, approval.approved, approval.denied
approvalId, ticketId, channelId, approvalType (refund, ban, policy_exception, escalation, custom), eventType (requested, approved, denied), requestedBy, requestedAt, optionally requestedByName, approverId, approverName, reason, decision, decisionReason, decidedAt, customerId, amount, metadata.
ticket.note.added, ticket.note.removed
noteId, ticketId, ticketNumber, channelId, authorId, optionally authorName, content (added only), createdAt, removedBy, removedByName, removedAt.
ticket.user.added, ticket.user.removed, ticket.role.added, ticket.role.removed
ticketId, ticketNumber, channelId, eventType (user_added, user_removed, role_added, role_removed), targetType (user or role), targetId, performedBy, performedAt, optionally targetName, performedByName.
Test Fire payload
Sent under your first subscribed event type: test: true, message: "This is a test webhook delivery from TicketCord", timestamp (ISO), botId, guildId, webhookId.
Verifying signatures
Every request is signed with your webhook secret, a 64-character hex string shown once at creation.
- Read
X-TicketCord-Timestamp(Unix seconds) andX-TicketCord-Signature. - Take the raw request body exactly as received, before any JSON parsing.
- Build the string
v0:<timestamp>:<raw body>. - Compute HMAC-SHA256 of that string with your secret and encode it as lowercase hex.
- Compare it to the header with a constant-time comparison. There is no
v0=prefix on the header value; it is the bare hex digest. - Reject requests whose timestamp is more than 5 minutes old to block replays.
Node receiver with verification
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.TICKETCORD_WEBHOOK_SECRET;
// Keep the raw body: the signature covers the exact bytes TicketCord sent.
app.use('/webhooks/ticketcord', express.raw({ type: 'application/json' }));
function verify(rawBody, timestamp, signature) {
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;
const expected = crypto
.createHmac('sha256', SECRET)
.update(`v0:${timestamp}:${rawBody.toString('utf8')}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/ticketcord', (req, res) => {
const signature = req.get('X-TicketCord-Signature') || '';
const timestamp = req.get('X-TicketCord-Timestamp') || '';
if (!verify(req.body, timestamp, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { event, data, attemptNumber } = JSON.parse(req.body.toString('utf8'));
// Acknowledge first, then do the real work so you stay under the 30 s timeout.
res.status(200).json({ received: true });
setImmediate(() => {
if (attemptNumber > 1) {
// A retry: make your handling idempotent (for example key on data.ticketId + event + timestamp).
}
switch (event) {
case 'ticket.created':
console.log(`New ticket #${data.ticketNumber} from ${data.creatorName}`);
break;
case 'sla.breach':
console.log(`SLA ${data.slaType} breached on ${data.ticketId}`);
break;
default:
console.log(`Received ${event}`);
}
});
});
app.listen(3000);Python receiver with verification
import os, hmac, hashlib, time
from flask import Flask, request, jsonify, abort
app = Flask(__name__)
SECRET = os.environ['TICKETCORD_WEBHOOK_SECRET'].encode()
def verify(raw: bytes, timestamp: str, signature: str) -> bool:
try:
age = abs(int(time.time()) - int(timestamp))
except ValueError:
return False
if age > 300:
return False
expected = hmac.new(SECRET, f"v0:{timestamp}:".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post('/webhooks/ticketcord')
def receive():
if not verify(request.get_data(), request.headers.get('X-TicketCord-Timestamp', ''),
request.headers.get('X-TicketCord-Signature', '')):
abort(401)
payload = request.get_json()
print(payload['event'], payload['data'].get('ticketId'))
return jsonify(received=True), 200Respond with 200 as soon as the signature checks out and process the event afterwards. A receiver that takes longer than 30 seconds is treated as failed and retried, which means duplicate work for you.
Delivery, retries, and rate limits
- Timeout: 30 seconds per request.
- Success: any
2xxresponse. - Retries: a failed delivery (non-
2xx, timeout, or connection error) is retried up to 3 times by default with exponential backoff of 1 s, 2 s, then 4 s (capped at 5 minutes for custom policies).attemptNumberin the body tells you which try you are seeing. - 429 responses: not counted as a failure. Delivery pauses for the seconds given in your
Retry-Afterheader (60 if absent, at most 300) and is queued again. - Rate limit: 60 deliveries per minute per webhook. Events beyond that are held and delivered later, not dropped.
- Ordering: events are delivered as they are queued, but retries and rate limiting can reorder them. Use the timestamps in
datarather than arrival order. - Counters: each webhook row shows Deliveries and Last Fired, and a health pill of Healthy, Failing, or Idle.
Delivery logs
View Logs on a webhook row opens the delivery history for the last 30 days, with columns Timestamp, Event Type, Attempt, Status, Duration, and Error. Filter by event, by Success or Failed, and by time window (Last Hour, Last 24 Hours, Last 7 Days, Last 30 Days), or search the error text. Retries are labelled Retry. Logs are read-only; there is no button to resend a specific delivery, so use Test Fire or trigger the event again.
Limits and gotchas
- The secret is shown only once. There is no way to reveal or rotate it later; to get a new secret, delete the webhook and create it again.
- 20 webhooks per server. At the limit, Create Webhook returns "Webhook limit reached".
https://only, no redirects, and no private or internal addresses. Tunnels such as ngrok work for testing because they expose a public HTTPS hostname.- The Deliveries and Last Fired counters and the delivery logs are visible to the bot owner only.
- Editing the event list takes effect for new events immediately.
- Turning Active off stops deliveries without losing the configuration or the secret.
- Deleting a webhook deletes its delivery history too: "This action cannot be undone and all webhook delivery history will be lost."
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| "Webhook URL must use HTTPS protocol" | The URL starts with http:// | Use an HTTPS endpoint |
| "Internal/private IP addresses are not allowed" or "...resolves to an internal/private IP address" | The host is on a private network or loopback | Expose the receiver on a public hostname, or use a tunnel |
| "Webhook name can only contain letters, numbers, spaces, hyphens, and underscores" | Disallowed characters in Name | Rename it |
| "Webhook limit reached" | 20 webhooks already exist | Delete an unused one |
Log shows HTTP 401 or 403 from your server | Your receiver rejected the signature or requires auth headers TicketCord does not send | Check the verification code against the six steps above; make sure you hash the raw body |
| Log shows "Request timeout (30s)" | Your receiver did not answer within 30 seconds | Return 200 immediately and process afterwards |
| Log shows "DNS lookup failed" or "Connection refused" | The hostname or port is wrong, or the server is down | Fix the URL and test with Test Fire |
| Log shows "SSL certificate error" | The certificate is self-signed, expired, or for another hostname | Use a valid certificate |
| Events arrive twice | A retry after a slow or failed response | Respond faster and dedupe on event plus data.ticketId plus timestamp |
| Events arrive late or in bursts | The 60 per minute rate limit or a 429 pause | Raise your endpoint's own limit or spread work across two webhooks with different event sets |
No sla.*, approval.*, or sentiment.negative events | The feature that produces them is not enabled on the server | Enable SLA Management, Approvals, or Sentiment Detection |
| Signature check fails only for some events | The body was parsed and re-serialised before hashing | Hash the raw bytes exactly as received |
| The URL column reads "Visible to the bot owner only" | You are dashboard staff | Ask the bot owner |
Common questions
Where do I set up webhooks? In the server configuration: Dashboard → Bots → your bot → Servers → the server → Integrations, in the Webhooks section. They are per server, not per bot or account.
I lost my webhook secret. Can I see it again? No. It is shown once at creation. Delete the webhook and create a new one to get a new secret.
Do I have to verify signatures? For your own receiver, yes; otherwise anyone who learns the URL can send you fake events. For Zapier, Make, or n8n catch hooks it is optional.
How is the signature computed?
HMAC-SHA256 over the string v0:<timestamp>:<raw body> using your secret, sent as lowercase hex in X-TicketCord-Signature. The timestamp is Unix seconds from X-TicketCord-Timestamp.
How many times will a failed delivery be retried?
Three times by default, after 1, 2, and 4 seconds. A 429 with Retry-After pauses delivery instead of using a retry. After the last failed attempt the delivery is logged as failed and not sent again.
Can I get a Discord message instead of an HTTP request? Not through this feature. Webhooks post JSON to a URL. To announce tickets in a Discord channel, point a Zapier, Make, or n8n scenario at a Discord channel webhook.
Can I send webhooks to more than one server? Each server has its own webhooks and its own limit of 20. Set them up on each server separately.
Which plan do I need? Enterprise. On lower plans the section shows an upgrade notice and no webhooks can be created.
Join our Discord server for support
Was this page helpful?
Last updated on