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
| Type | Sent when |
|---|---|
link.created | A link is created (console, API, bulk import, distribution channel) |
link.updated | A link is updated |
link.deleted | A link is deleted (data holds its last state) |
feedback.created | A feedback item is published: on submission, or on approval when the board requires moderation. Items awaiting moderation are never sent |
feedback.status_changed | A feedback item's status changes (previousStatus, status) |
feedback.vote_threshold | A feedback item reaches 10, 25, 50, 100, 250, 500 or 1,000 votes. Each threshold is sent once per item |
ping | You 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
2xxstatus. Anything else is a failure: other status codes, redirects (3xxare 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
idin 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
createdAtand the resource state, not arrival order. - Links removed by the automatic purge of expired links do not emit
link.deleted. - SLNK only calls
httpsURLs 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:
- Use the raw request body. A re-serialized JSON object does not match.
- Compare in constant time.
- 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.