Sortd API

Receive webhooks

Sortd delivers events to your URL with a Stripe-style signature, retries on failure, and keeps a replayable delivery log.

Needs a key with webhooks.manage.

1. Subscribe

Register the URL to receive events at and the event types you want. The response includes the subscription id (wh_…) and a signing secret (whsec_…) — shown once; store it to verify deliveries.

# curl
curl -X POST "https://api.sortd.com/v2/webhooks/subscriptions" \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "target_url": "https://your-app.example.com/hooks/sortd",
    "events": ["task.created", "task.completed"]
  }'
# -> { "data": { "id": "wh_…", "target_url": "…", "events": ["…"], "secret": "whsec_…" }, … }
// C# (HttpClient)
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", "sk_live_…");
var content = new StringContent("""
{ "target_url": "https://your-app.example.com/hooks/sortd", "events": ["task.created"] }
""", Encoding.UTF8, "application/json");
var res = await client.PostAsync("https://api.sortd.com/v2/webhooks/subscriptions", content);

2. Verify the signature on every delivery

Each delivery carries an x-sortd-signature header of the form t=<unix>,v1=<hex>. The signed payload is <t>.<raw request body>, HMAC-SHA256 with your subscription secret. Recompute it over the raw body, compare in constant time, and reject if the timestamp is outside your tolerance (defends against replay).

// JavaScript (Express) — verify before trusting the event
import crypto from "crypto";

function verifySortdSignature(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // stale
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Mount with the RAW body (not parsed JSON) so the bytes match what was signed:
app.post("/hooks/sortd", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifySortdSignature(req.body.toString("utf8"), req.get("x-sortd-signature"), "whsec_…");
  if (!ok) return res.status(400).end();
  const event = JSON.parse(req.body.toString("utf8"));
  // … handle event … then ack quickly:
  res.status(200).end();
});
# Python (Flask)
import hmac, hashlib, time
from flask import request, abort

def verify_sortd_signature(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", 0))
    if not t or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

@app.post("/hooks/sortd")
def hook():
    if not verify_sortd_signature(request.get_data(), request.headers.get("x-sortd-signature", ""), "whsec_…"):
        abort(400)
    # … handle request.get_json() … then return 200 quickly
    return "", 200

Return a 2xx quickly to acknowledge. Non-2xx (or a timeout) is retried with backoff.

3. Test, inspect, replay, rotate

See every endpoint in the reference.