Skip to content

Webhooks

Skald supports two kinds of webhooks:

  1. Outbound webhooks — Skald POSTs events to your URL. HMAC-signed, automatically retried, with a delivery log. Requires Enterprise tier.
  2. 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:

FieldNotes
NameHuman-readable label. Shown in the delivery log.
URLHTTPS endpoint to POST events to.
SecretRandom string used to sign requests (HMAC-SHA256). Store this in the receiver.
EventsOne or more of the event types below.
EnabledWhen unchecked, the endpoint is skipped without being deleted.

You can also use the REST API:

http
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.

json
{
  "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 — see data.timestamp or data.occurredAt for 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

HeaderValue
Content-Typeapplication/json
User-AgentSkald/1.0
X-Webhook-Signature-256sha256=<hex> — see Signature verification

Signature verification

Skald signs every delivery with HMAC-SHA256 using your endpoint's secret. Verify before trusting the payload:

python
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)
javascript
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:

AttemptDelay since previous
1(initial dispatch)
230 s
360 s
4120 s
5240 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:

http
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

EventWhen it firesdata fields
MESSAGE_CREATEDA chat message is sent to a roomroomId, messageId, senderId, timestamp
USER_JOINED_ROOMA user joins a room (presence or live call)roomId, username
USER_LEFT_ROOMA user leaves a roomroomId, username
ROOM_CREATEDA new room is created (or a DM is promoted to a room)roomId, createdBy, plus convertedFromDm: true when applicable
ROOM_DELETEDA room is deletedroomId, deletedBy
FILE_UPLOADEDA file is successfully uploadedfileName, fileSize, fileType, uploadedBy
FILE_INFECTION_DETECTEDClamAV flagged an upload (Enterprise)fileName, fileSize, signature, …
ADMIN_USER_DISABLEDAn admin disables a user accountusername, adminActor
ADMIN_USER_DELETEDAn admin deletes a user accountusername, adminActor
ADMIN_ROOM_DELETEDAn admin deletes a roomroomId, adminActor
MESSAGE_AUTO_MODERATION_FLAGGEDAI moderation flagged a message for review (Enterprise)flaggedMessageId, messageId, roomId, senderId, category, score, band, status, occurredAt, optional moderationQueueUrl
MESSAGE_AUTO_MODERATION_QUARANTINEDAI moderation quarantined a message (auto-hidden pending review)same fields as the flagged event

Example: MESSAGE_CREATED

json
{
  "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

json
{
  "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

json
{
  "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

http
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.
  • contentFormatmarkdown (default) or html.

Response

On success (200 OK):

json
{
  "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

CodeMeaning
200Message posted
400Missing / oversize content or invalid contentFormat
404Token unknown or webhook disabled
422Content blocked by DLP scanning
429Rate 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_CREATED outbound 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

Skald user documentation