Webhooks
Skald supports two kinds of webhooks:
- Outbound webhooks — Skald POSTs events to your URL. HMAC-signed, automatically retried, with a delivery log. Requires Enterprise tier.
- Inbound webhooks — your system POSTs a chat message into a specific room via a unique token URL. Slack-style. Available in all tiers.
This page is the integrator reference. For the admin UI flow, see Admin → Webhooks in the dashboard.
Outbound webhooks
Creating a webhook endpoint
In the admin dashboard go to Webhooks → New endpoint and provide:
| Field | Notes |
|---|---|
| Name | Human-readable label. Shown in the delivery log. |
| URL | HTTPS endpoint to POST events to. |
| Secret | Random string used to sign requests (HMAC-SHA256). Store this in the receiver. |
| Events | One or more of the event types below. |
| Enabled | When unchecked, the endpoint is skipped without being deleted. |
You can also use the REST API:
POST /api/admin/webhooks
Authorization: Bearer <admin-jwt>
Content-Type: application/json
{
"name": "Audit pipeline",
"url": "https://audit.example.com/skald-events",
"secret": "a-long-random-string",
"events": ["MESSAGE_CREATED", "ROOM_CREATED", "FILE_UPLOADED"]
}Payload envelope
Every outbound delivery looks the same. The data object varies by event type.
{
"event": "MESSAGE_CREATED",
"timestamp": "2026-05-26T14:23:11.482Z",
"webhookId": 7,
"data": {
"roomId": "general",
"messageId": 8842,
"senderId": "alice",
"timestamp": "2026-05-26T14:23:11.395Z"
}
}Top-level fields:
event— one of the event type names.timestamp— ISO-8601 instant the delivery was queued (not necessarily when the underlying action happened — seedata.timestampordata.occurredAtfor the source event time).webhookId— your endpoint's numeric ID. Useful when one receiver handles multiple Skald installations or endpoints.data— event-specific object documented per type below.
The body is sent verbatim as the request body — no form-encoding, no envelope wrapping. Content-Type is application/json.
Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Skald/1.0 |
X-Webhook-Signature-256 | sha256=<hex> — see Signature verification |
Signature verification
Skald signs every delivery with HMAC-SHA256 using your endpoint's secret. Verify before trusting the payload:
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
if not header.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(
secret.encode("utf-8"), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header)const crypto = require("crypto");
function verify(rawBody, header, secret) {
if (!header?.startsWith("sha256=")) return false;
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}Important: Hash the raw request body bytes, not a re-serialized JSON object. JSON serialization isn't stable across libraries.
Use constant-time comparison (
hmac.compare_digest/crypto.timingSafeEqual) to avoid timing side-channel attacks.
Retry behavior
A delivery is successful when your endpoint returns a 2xx response within 30 seconds. Anything else (non-2xx, timeout, DNS failure, connection error) counts as a failure and is retried.
Retries use exponential back-off with a 1-hour ceiling:
| Attempt | Delay since previous |
|---|---|
| 1 | (initial dispatch) |
| 2 | 30 s |
| 3 | 60 s |
| 4 | 120 s |
| 5 | 240 s |
| (cap) | 3600 s |
After 5 total attempts the delivery is marked EXHAUSTED and Skald stops trying. The full attempt history (status code, response body up to 2 KB, duration, last error) is kept in the delivery log for 30 days, then garbage-collected by a daily job.
A separate scheduled job retries pending and failed deliveries once per minute; you do not need to retry them manually.
Delivery statuses
PENDING (queued, never attempted) → SUCCESS (2xx received) | FAILED (will be retried) → EXHAUSTED (5 attempts, giving up).
Idempotency
Skald does not currently send an Idempotency-Key header. If your receiver is sensitive to duplicates (e.g. you write to a ledger), deduplicate on (webhookId, event, data.messageId) or another payload-specific composite key. Duplicates can occur if your endpoint returns 2xx but the response body never makes it back to Skald.
Testing an endpoint
The admin UI has a Send test button. Programmatically:
POST /api/admin/webhooks/{id}/test
Authorization: Bearer <admin-jwt>This queues a synthetic delivery for the endpoint so you can verify connectivity and signature handling before flipping it on.
Event types
| Event | When it fires | data fields |
|---|---|---|
MESSAGE_CREATED | A chat message is sent to a room | roomId, messageId, senderId, timestamp |
USER_JOINED_ROOM | A user joins a room (presence or live call) | roomId, username |
USER_LEFT_ROOM | A user leaves a room | roomId, username |
ROOM_CREATED | A new room is created (or a DM is promoted to a room) | roomId, createdBy, plus convertedFromDm: true when applicable |
ROOM_DELETED | A room is deleted | roomId, deletedBy |
FILE_UPLOADED | A file is successfully uploaded | fileName, fileSize, fileType, uploadedBy |
FILE_INFECTION_DETECTED | ClamAV flagged an upload (Enterprise) | fileName, fileSize, signature, … |
ADMIN_USER_DISABLED | An admin disables a user account | username, adminActor |
ADMIN_USER_DELETED | An admin deletes a user account | username, adminActor |
ADMIN_ROOM_DELETED | An admin deletes a room | roomId, adminActor |
MESSAGE_AUTO_MODERATION_FLAGGED | AI moderation flagged a message for review (Enterprise) | flaggedMessageId, messageId, roomId, senderId, category, score, band, status, occurredAt, optional moderationQueueUrl |
MESSAGE_AUTO_MODERATION_QUARANTINED | AI moderation quarantined a message (auto-hidden pending review) | same fields as the flagged event |
Example: MESSAGE_CREATED
{
"event": "MESSAGE_CREATED",
"timestamp": "2026-05-26T14:23:11.482Z",
"webhookId": 7,
"data": {
"roomId": "general",
"messageId": 8842,
"senderId": "alice",
"timestamp": "2026-05-26T14:23:11.395Z"
}
}Skald does not include message content in the webhook payload — the body could be sensitive or end-to-end encrypted. Fetch the full message via the REST API (GET /api/rooms/{roomId}/messages/{messageId} with an admin or bot token) if you need it.
Example: MESSAGE_AUTO_MODERATION_QUARANTINED
{
"event": "MESSAGE_AUTO_MODERATION_QUARANTINED",
"timestamp": "2026-05-26T14:23:14.012Z",
"webhookId": 7,
"data": {
"flaggedMessageId": 412,
"messageId": 8842,
"roomId": "general",
"senderId": "alice",
"category": "harassment",
"score": 0.94,
"band": "HIGH",
"status": "QUARANTINED",
"occurredAt": "2026-05-26T14:23:13.998Z",
"moderationQueueUrl": "https://chat.example.com/admin/moderation-queue?flag=412"
}
}moderationQueueUrl is only present when SKALD_PUBLIC_BASE_URL is set on the server.
Example: FILE_INFECTION_DETECTED
{
"event": "FILE_INFECTION_DETECTED",
"timestamp": "2026-05-26T14:25:01.001Z",
"webhookId": 7,
"data": {
"fileName": "invoice.zip",
"fileSize": 18342,
"signature": "Win.Trojan.Doina-1",
"uploadedBy": "alice",
"scannedAt": "2026-05-26T14:25:00.987Z"
}
}Inbound webhooks
Inbound webhooks let an external system post a chat message into a specific Skald room via an HTTP POST — analogous to Slack's "incoming webhook" feature.
Creating an inbound webhook
Open the room, go to Settings → Inbound webhooks → New, and choose a display name (and optional avatar URL). Skald returns a one-time URL that includes the token. Store it immediately — for security, listing endpoints later returns only the last 8 characters of each token.
Posting a message
POST /api/public/webhooks/ingest/{token}
Content-Type: application/json
{
"content": "Build #4242 **failed** on `main`.\nSee [details](https://ci.example.com/4242).",
"contentFormat": "markdown"
}content— required, up to 16 KB.contentFormat—markdown(default) orhtml.
Response
On success (200 OK):
{
"messageId": 8843,
"timestamp": "2026-05-26T14:30:01.482Z"
}The message appears in the room with the display name and avatar configured on the webhook, identifying the sender as the integration rather than a real user.
Status codes
| Code | Meaning |
|---|---|
200 | Message posted |
400 | Missing / oversize content or invalid contentFormat |
404 | Token unknown or webhook disabled |
422 | Content blocked by DLP scanning |
429 | Rate limit exceeded (per-token) |
Authentication
The token in the URL is the only credential. Treat it like a password — rotate it by deleting the webhook and creating a new one if it leaks.
Rate limits
Per-token rate limits are configurable in Admin → Rate limits under the webhook_ingest policy. The default protects the API from a runaway script.
Notes
- Inbound webhooks are DLP-scanned like any user message. If a DLP rule blocks the content, you get a
422. - They count as a real chat message: they fire
MESSAGE_CREATEDoutbound webhooks (if you have any configured), they appear in audit logs and are indexed for search, and they are subject to the room's data retention policy.
See also
- Licensing — outbound webhooks require an Enterprise license; inbound webhooks work in every tier.
- Compliance / data retention — retention policies affect messages posted via inbound webhooks.
- Auto-moderation events — context on the moderation event payloads.