Slack and Discord to Tickets (and Back) with Webhooks
Turn chat messages into help desk tickets with signed inbound webhooks, and post ticket updates back to Slack or Discord — with HMAC verification, idempotency, retries with backoff, and SSRF-safe URLs.
To connect Slack and Discord to a help desk with webhooks, you need two directions. Inbound: a unique URL per integration that accepts a POST, verifies an HMAC signature, ignores duplicates by event id, and creates a ticket. Outbound: when a ticket changes, the database queues a message and POSTs it to the channel's webhook URL, retrying with backoff when delivery fails. The hard parts aren't the HTTP calls; they're signatures, idempotency, retries and refusing unsafe URLs. Here's how LetRelay builds each.
Why webhooks
People already ask for help in chat. Instead of asking them to switch tools, bring the chat to the desk: a message in #it-help (via a workflow or bot that forwards it) becomes a tracked request, and status changes post back to the channel. Webhooks are the simplest integration: plain HTTPS POSTs with JSON, no long-lived connection, supported by Slack, Discord and most automation tools.
Inbound: messages become tickets
A secret URL per endpoint
Each integration gets its own endpoint with an unguessable token in the path: /api/hooks/in/<token>. The token identifies the organization and endpoint; an unknown token returns 404 without revealing anything. Treat the URL like a password: anyone who has it can post to it — which is why the signature below matters.
Verify the signature
The sender computes an HMAC-SHA256 of the raw request body with a shared secret and sends it in a header. You recompute it and compare. GitHub's webhook documentation spells out the one rule people get wrong: never compare signatures with a plain ==; use a constant-time comparison such as Node's crypto.timingSafeEqual to avoid timing attacks.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyHmac(rawBody: string, secret: string, header: string | null) {
if (!header) return false;
const provided = header.startsWith("sha256=") ? header.slice(7) : header.trim();
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(provided, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Verify against the raw body, before parsing. Stripe's documentation notes the same requirement: any re-serialization changes the bytes and breaks the signature.
For replay protection, include a timestamp in what's signed and reject old ones. Stripe's libraries default to a 5-minute tolerance and warn against a tolerance of zero, which disables the check.
Limit the size
Reject bodies over a sensible limit (tens of kilobytes for chat messages) with HTTP 413 before doing any work. A webhook endpoint is a public door; don't let it accept megabytes.
Idempotency: the same event, once
Senders retry. Stripe, for example, retries failed deliveries for up to three days and warns that endpoints may receive the same event more than once. Without idempotency, a retry creates a duplicate ticket.
LetRelay's approach: when a payload has an event id, the handler first claims it by inserting a row with a unique constraint on (endpoint, event_id). If the insert fails with a unique violation (Postgres error 23505), another delivery already owns that event — return 200 with duplicate: true and the existing ticket reference. Only the delivery that won the claim creates the ticket. A check-then-insert without the unique constraint would let two concurrent retries both create tickets.
Respond fast, log everything
Return 2xx quickly — senders treat slow responses as failures and retry. Log each delivery (status, event id, resulting ticket, error) so an admin can see what arrived. Don't log every failed signature as a row, though: anyone who learns the URL could fill your log table. Count them on the endpoint instead.
Map the payload
Slack and Discord payloads differ, and automation tools produce anything. A small parser that looks for common fields (title, text, content, body, id, event_id) handles most senders; unknown shapes create a ticket with the raw text rather than failing.
Outbound: ticket updates post to chat
Queue from the database
When a ticket is created or changes status, a database trigger enqueues a delivery. LetRelay sends it with pg_net, Supabase's extension for asynchronous HTTP from Postgres, so the ticket update never waits on Slack. A pg_cron job every minute reconciles results and schedules retries.
Slack's incoming webhooks accept JSON with a text field or Block Kit blocks; Discord's accept content and embeds. Format per destination:
{ "text": "RLY-142 resolved by Sam: VPN access granted" }Retries with backoff, then stop
Deliveries fail: the channel was archived, the webhook was deleted, the service is down. LetRelay retries up to 5 attempts with exponential backoff plus a little random jitter (so many failed deliveries don't all retry in the same second), then marks the delivery failed and eventually disables a webhook that keeps failing, so a dead URL doesn't consume the queue forever. Slack returns specific errors worth surfacing to admins — channel_is_archived, invalid_token, invalid_payload — rather than a generic "failed".
One pg_net detail: it keeps responses only for a limited time (six hours by default). If your reconcile job doesn't run in that window — a free project paused for inactivity, say — a delivery can be stuck as "sent" with no response to check. LetRelay treats deliveries stuck that long as failed and retries them.
Refuse unsafe URLs
An outbound webhook is a feature that makes your servers send requests to user-supplied URLs — the definition of server-side request forgery (SSRF) risk. LetRelay's database checks every webhook URL:
https://only.- The host is extracted carefully (credentials and port stripped) and must not be
localhost, a private or link-local IP range, or an internal hostname. - The check is a table constraint, so no code path can store an unsafe URL.
In our security audit, the functions that send webhooks took the URL as a parameter — bypassing the table's check entirely and making them an open HTTP relay for anyone who could call them. The fix was to stop exposing those functions to API roles. Validate at the point of sending, not only at the point of storing.
A test button
Admins should be able to send a test message and see the result immediately. It's the fastest way to catch a wrong URL or an archived channel.
Security checklist
- Unguessable token per inbound endpoint; rotate on leak
- HMAC-SHA256 over the raw body, constant-time compare
- Timestamp in the signature; reject stale deliveries
- Body size limit (413)
- Idempotency via a unique constraint on event id
- Outbound URLs: https only, no private hosts, enforced in the database
- Send functions not callable from client roles
- Retries with backoff and a maximum; auto-disable dead webhooks
- Delivery log visible to admins
The routing side — which team an inbound ticket lands in when no human chose — is covered in support ticket routing. For the email side of notifications, see transactional email for free with Resend.
FAQ
How do I verify a webhook signature?
Compute an HMAC-SHA256 of the raw request body using the shared secret, and compare it to the signature header with a constant-time comparison. Reject requests without a valid signature.
How do I stop duplicate tickets from webhook retries?
Record each event id with a unique constraint before creating anything. If the insert conflicts, the event was already processed; return success with the existing ticket.
Can Postgres send webhooks directly?
Yes. On Supabase, the pg_net extension makes asynchronous HTTP requests from SQL, and pg_cron can run a worker that checks results and retries.
What is SSRF and why does it matter for webhooks?
Server-side request forgery is tricking your server into making requests to places it shouldn't, such as internal services. User-configured webhook URLs are a classic vector, so restrict them to public https hosts.
Sources
Building Relay — the internal request desk with AI triage and SLA tracking.
Run your internal requests on LetRelay
AI triage, SLA-tracked queues, and bottleneck analytics — the help desk your team actually likes. Free to start.
Keep reading
LLM Tool Calling for Internal Assistants: Design Choices
How to give an internal AI assistant tools — reading workspace data with the user's permissions, formatting results for the model, native function calling vs a routing step, limits on how many tools run, and answers that stay inside the data.
IT Asset Management for Small Companies: A Lightweight Approach
A practical IT asset management setup for small companies — what to track, one record per asset, lifecycle states from purchase to disposal, linking assets to people and requests, and the security reasons it matters.
Caching LLM Responses Safely: What to Cache and What Never To
Caching LLM answers saves tokens and latency — and can leak one user's data to another if done carelessly. A safe design: cache only answers that are identical for everyone, key on normalized question plus content version, expire, and lock the cache down.