Skip to main content

Webhooks

Webhooks send a signed HTTPS POST to your server when something happens in a project. Configure them in the console under Settings → Webhooks (project SETTINGS permission, EDIT level). They require the Outgoing webhooks right in your plan. The number of endpoints per organization is limited by the WEBHOOK_ENDPOINTS quota.

Events​

TypeSent when
link.createdA link is created (console, API, bulk import, distribution channel)
link.updatedA link is updated
link.deletedA link is deleted (data holds its last state)
feedback.createdA feedback item is published: on submission, or on approval when the board requires moderation. Items awaiting moderation are never sent
feedback.status_changedA feedback item's status changes (previousStatus, status)
feedback.vote_thresholdA feedback item reaches 10, 25, 50, 100, 250, 500 or 1,000 votes. Each threshold is sent once per item
pingYou click Test in the console. It is never retried

Request​

POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: SLNK-Webhooks/1
X-Slnk-Event: link.created
X-Slnk-Delivery: 5b0c3f1e-8c1d-4b8e-9d0a-2f6a1c7e9b42
X-Slnk-Signature: t=1761386400,v1=5f2b...e91c
{
"id": "0b9d8c2a-6c49-4c5e-9a57-2a3f0f6f1d10",
"type": "link.created",
"createdAt": "2026-10-25T10:00:00Z",
"projectId": "PROJECT_ID",
"data": {
"id": "LINK_ID",
"shortCode": "Ab3dE9x",
"shortUrl": "https://slnk.ma/Ab3dE9x",
"originalUrl": "https://example.com/offer",
"title": "Autumn offer",
"expiresAt": "2026-12-31T23:59:59Z",
"createdAt": "2026-10-25T10:00:00Z",
"updatedAt": "2026-10-25T10:00:00Z"
}
}

Fields without a value are omitted, for example title, externalId, parentLinkId or expiresAt.

A feedback event's data contains id, boardId, title, type, status, voteCount, moderationStatus, createdAt, plus previousStatus (feedback.status_changed) or threshold (feedback.vote_threshold). Payloads never contain visitor IP addresses, author names or e-mail addresses.

Respond, retries and disabling​

  • Answer with a 2xx status. Anything else is a failure: other status codes, redirects (3xx are never followed), and no answer within 3 s to connect or 10 s to respond (15 s in total).
  • Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h and 12 h, for 6 attempts in all.
  • The event id in the signed body is identical across retries and manual replays. Store it and ignore events you have already processed. X-Slnk-Delivery (also stable) identifies the delivery in the console log; it is not covered by the signature.
  • An endpoint whose deliveries have all failed for 3 days is disabled automatically. Organization admins get a notification. After you fix the receiver, re-enable the endpoint and replay the interrupted deliveries from the delivery log. The log is kept for 30 days.
  • Deliveries are not guaranteed to arrive in order. Use createdAt and the resource state, not arrival order.
  • Links removed by the automatic purge of expired links do not emit link.deleted.
  • SLNK only calls https URLs on port 443 or 8443 that resolve to public addresses. The address is checked again at every connection.
  • The delivery log keeps at most the first 1 KB of a text response (text, JSON, XML). Other bodies are not stored.
  • Test is limited to 5 requests per minute per endpoint.

Verify the signature​

X-Slnk-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256>. The HMAC is computed with your endpoint secret (whsec_…, shown once at creation or rotation) over the string t + "." + raw body. To verify:

  1. Use the raw request body. A re-serialized JSON object does not match.
  2. Compare in constant time.
  3. Reject timestamps more than 5 minutes from your clock.

Regenerating the secret replaces it immediately.

Node.js​

import crypto from 'node:crypto'
import express from 'express'

export function verifySlnkSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = (header ?? '').split(',').map((part) => part.trim().split('='))
const timestamp = parts.find(([key]) => key === 't')?.[1]
const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value)
if (!timestamp || !/^\d{1,12}$/.test(timestamp) || signatures.length === 0) return false
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex')
const expectedBytes = Buffer.from(expected)
return signatures.some((signature) => {
const bytes = Buffer.from(signature)
return bytes.length === expectedBytes.length && crypto.timingSafeEqual(bytes, expectedBytes)
})
}

const app = express()
app.post('/slnk/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySlnkSignature(req.body, req.get('X-Slnk-Signature'), process.env.SLNK_WEBHOOK_SECRET)) {
return res.sendStatus(400)
}
const event = JSON.parse(req.body.toString('utf8'))
// Deduplicate on event.id, then queue the work and answer quickly.
res.sendStatus(204)
})

Java​

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class SlnkWebhooks {
public static boolean verify(byte[] rawBody, String header, String secret, long toleranceSeconds)
throws Exception {
if (header == null) return false;
String timestamp = null;
java.util.List<String> signatures = new java.util.ArrayList<>();
for (String part : header.split(",")) {
String[] kv = part.trim().split("=", 2);
if (kv.length != 2) continue;
if (kv[0].equals("t")) timestamp = kv[1];
if (kv[0].equals("v1")) signatures.add(kv[1]);
}
if (timestamp == null || !timestamp.matches("\\d{1,12}") || signatures.isEmpty()) return false;
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - Long.parseLong(timestamp)) > toleranceSeconds) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.US_ASCII);
for (String signature : signatures)
if (MessageDigest.isEqual(expected, signature.getBytes(StandardCharsets.US_ASCII))) return true;
return false;
}
}

Python​

import hashlib
import hmac
import time


def verify_slnk_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [part.strip().split("=", 1) for part in (header or "").split(",") if "=" in part]
timestamp = next((value for key, value in parts if key == "t"), None)
signatures = [value for key, value in parts if key == "v1"]
if not timestamp or not timestamp.isascii() or not timestamp.isdigit() or len(timestamp) > 12 or not signatures:
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest().encode()
return any(hmac.compare_digest(expected, signature.encode()) for signature in signatures)

Test vector​

Secret whsec_test, timestamp 1760000000, body {"id":"evt","type":"link.created"}:

t=1760000000,v1=fdcfc3787a6dabc5735a7050693af165840df2f06a98b57e7b850ba527cc3bbd

The vector only checks the HMAC. Run it with a large tolerance, because this timestamp is in the past.