Webhooks
Subscribe to Shortnd events with signed, verifiable HMAC-SHA256 deliveries.
Webhooks
Shortnd webhooks notify your service whenever something interesting happens on your account — a link is created, a click crosses a threshold, a domain finishes verifying. Each delivery is signed with HMAC-SHA256 so you can verify authenticity in your handler.
Subscribing
Create a webhook with POST /api/v1/webhooks (scope: webhooks:write):
curl -X POST https://api.shortnd.com/api/v1/webhooks \
-H "Authorization: Bearer $SHORTND_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/hooks/shortnd",
"events": ["url.created", "url.click_threshold", "domain.verified"],
"isActive": true
}'
The response includes a secret you'll use to verify deliveries. Store it the
same way you'd store an API key — it does not appear in subsequent reads of
the webhook record.
{
"success": true,
"data": {
"id": "wh_01HXYZ…",
"url": "https://your-app.example.com/hooks/shortnd",
"events": ["url.created", "url.click_threshold", "domain.verified"],
"secret": "whsec_T+QlkGRRr…",
"isActive": true
}
}
Delivery format
Every delivery has these headers:
| Header | Meaning |
|---|---|
X-Shortnd-Event | The event type, e.g. url.created |
X-Shortnd-Delivery-Id | UUID of this delivery attempt; safe to use as an idempotency key |
X-Shortnd-Timestamp | Unix seconds when we signed the payload |
X-Shortnd-Signature | sha256=<hex> HMAC of ${timestamp}.${rawBody} keyed by your webhook secret |
Content-Type | application/json; charset=utf-8 |
The body is the JSON event:
{
"id": "evt_01HXYZ…",
"type": "url.created",
"createdAt": "2026-05-07T10:23:11.482Z",
"data": {
"id": "url_01HXYZ…",
"shortCode": "abc123",
"targetUrl": "https://example.com/promo",
"organizationId": "org_01HXYZ…"
}
}
Verifying signatures
Reject any request whose signature doesn't validate. Always use a constant-time
compare; a == will leak information.
TypeScript / Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyShortndSignature(
rawBody: string,
signatureHeader: string,
timestampHeader: string,
secret: string,
toleranceSeconds = 300,
): boolean {
// 1. Reject if the timestamp is too old (replay protection).
const ts = parseInt(timestampHeader, 10);
if (!Number.isFinite(ts)) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > toleranceSeconds) return false;
// 2. Recompute and constant-time compare.
const expected = createHmac('sha256', secret)
.update(`${ts}.${rawBody}`, 'utf8')
.digest('hex');
const provided = signatureHeader.replace(/^sha256=/, '');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(provided, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
Python
import hashlib
import hmac
import time
def verify_shortnd_signature(
raw_body: bytes,
signature_header: str,
timestamp_header: str,
secret: str,
tolerance_seconds: int = 300,
) -> bool:
try:
ts = int(timestamp_header)
except (TypeError, ValueError):
return False
if abs(int(time.time()) - ts) > tolerance_seconds:
return False
signed_payload = f"{ts}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
provided = signature_header.removeprefix("sha256=")
return hmac.compare_digest(expected, provided)
Go
package shortnd
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func VerifyShortndSignature(rawBody []byte, signatureHeader, timestampHeader, secret string, toleranceSeconds int64) bool {
ts, err := strconv.ParseInt(timestampHeader, 10, 64)
if err != nil {
return false
}
if abs(time.Now().Unix()-ts) > toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(ts, 10) + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
provided := strings.TrimPrefix(signatureHeader, "sha256=")
return hmac.Equal([]byte(expected), []byte(provided))
}
func abs(x int64) int64 {
if x < 0 {
return -x
}
return x
}
Retries and ordering
If your endpoint returns a non-2xx status, we retry with exponential backoff:
| Attempt | Delay |
|---|---|
| 2 | 30 s |
| 3 | 2 min |
| 4 | 10 min |
| 5 | 1 h |
| 6 | 6 h |
| 7 (final) | 24 h |
After the final retry, the delivery is marked failed and surfaced in
GET /api/v1/webhooks/{id}/deliveries. Webhooks are at-least-once — your
handler must be idempotent. The X-Shortnd-Delivery-Id header is stable
across retries; use it as your dedupe key.
Events are not guaranteed to arrive in the order they were generated. If you
care about ordering, sort by the createdAt field on the event body.
Recommended handler shape
// /hooks/shortnd
export async function POST(req: Request) {
const rawBody = await req.text();
const sig = req.headers.get('x-shortnd-signature') ?? '';
const ts = req.headers.get('x-shortnd-timestamp') ?? '';
if (!verifyShortndSignature(rawBody, sig, ts, process.env.SHORTND_WEBHOOK_SECRET!)) {
return new Response('invalid signature', { status: 401 });
}
const event = JSON.parse(rawBody);
const deliveryId = req.headers.get('x-shortnd-delivery-id')!;
if (await alreadyProcessed(deliveryId)) return new Response('ok', { status: 200 });
await handleEvent(event);
await markProcessed(deliveryId);
return new Response('ok', { status: 200 });
}
Acknowledge fast (under ~5 s); do real work asynchronously. If your handler is slow we'll time out the request and retry — wasting your retry budget.
Plan availability
| Plan | Webhooks | Bundled deliveries / mo | Overage |
|---|---|---|---|
| Free / Sandbox | ❌ | — | — |
| Developer | ✅ | 100,000 | $1 / 100K |
| Scale | ✅ + replay | 1,000,000 | $0.75 / 100K |
| Enterprise | ✅ + replay + delivery SLA | Negotiated | Bundled |
See the B2B API pricing doc for the full tier matrix.