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

FieldAllowed valuesDefault
Name1 to 50 characters: letters, numbers, spaces, hyphens, underscoresrequired
URLhttps:// only, public hostname, no embedded credentialsrequired
Eventsany of the 23 events below, at least onenone
Activeon 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 s3 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.

GroupEvents
Ticket lifecycleticket.created, ticket.claimed, ticket.unclaimed, ticket.closed, ticket.reopened, ticket.deleted, ticket.renamed
Messagesmessage.created, message.edited, message.deleted
Priority and SLApriority.changed, sla.warning, sla.breach
Sentiment and approvalssentiment.negative, approval.requested, approval.approved, approval.denied
Notes and accessticket.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:

HeaderValue
Content-Typeapplication/json
User-AgentTicketCord-Webhooks/1.0
X-TicketCord-EventThe event type, for example ticket.created
X-TicketCord-TimestampUnix time in seconds when the request was signed
X-TicketCord-SignatureHex HMAC-SHA256 signature (see below)

The body always has the same envelope:

{
  "event": "ticket.created",
  "timestamp": 1702483200,
  "data": { },
  "attemptNumber": 1
}
FieldTypeMeaning
eventstringEvent type, identical to the header
timestampnumberUnix time in seconds, identical to the header
dataobjectEvent payload; shape depends on the event
attemptNumbernumber1 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.

  1. Read X-TicketCord-Timestamp (Unix seconds) and X-TicketCord-Signature.
  2. Take the raw request body exactly as received, before any JSON parsing.
  3. Build the string v0:<timestamp>:<raw body>.
  4. Compute HMAC-SHA256 of that string with your secret and encode it as lowercase hex.
  5. 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.
  6. Reject requests whose timestamp is more than 5 minutes old to block replays.

Node receiver with verification

server.js
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

server.py
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), 200

Respond 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 2xx response.
  • 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). attemptNumber in 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-After header (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 data rather 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

SymptomCauseFix
"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 loopbackExpose the receiver on a public hostname, or use a tunnel
"Webhook name can only contain letters, numbers, spaces, hyphens, and underscores"Disallowed characters in NameRename it
"Webhook limit reached"20 webhooks already existDelete an unused one
Log shows HTTP 401 or 403 from your serverYour receiver rejected the signature or requires auth headers TicketCord does not sendCheck 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 secondsReturn 200 immediately and process afterwards
Log shows "DNS lookup failed" or "Connection refused"The hostname or port is wrong, or the server is downFix the URL and test with Test Fire
Log shows "SSL certificate error"The certificate is self-signed, expired, or for another hostnameUse a valid certificate
Events arrive twiceA retry after a slow or failed responseRespond faster and dedupe on event plus data.ticketId plus timestamp
Events arrive late or in burstsThe 60 per minute rate limit or a 429 pauseRaise your endpoint's own limit or spread work across two webhooks with different event sets
No sla.*, approval.*, or sentiment.negative eventsThe feature that produces them is not enabled on the serverEnable SLA Management, Approvals, or Sentiment Detection
Signature check fails only for some eventsThe body was parsed and re-serialised before hashingHash the raw bytes exactly as received
The URL column reads "Visible to the bot owner only"You are dashboard staffAsk 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.

Last updated on

On this page