Shortnd Docs

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:

HeaderMeaning
X-Shortnd-EventThe event type, e.g. url.created
X-Shortnd-Delivery-IdUUID of this delivery attempt; safe to use as an idempotency key
X-Shortnd-TimestampUnix seconds when we signed the payload
X-Shortnd-Signaturesha256=<hex> HMAC of ${timestamp}.${rawBody} keyed by your webhook secret
Content-Typeapplication/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:

AttemptDelay
230 s
32 min
410 min
51 h
66 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.

// /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

PlanWebhooksBundled deliveries / moOverage
Free / Sandbox
Developer100,000$1 / 100K
Scale✅ + replay1,000,000$0.75 / 100K
Enterprise✅ + replay + delivery SLANegotiatedBundled

See the B2B API pricing doc for the full tier matrix.