Webhooks

Webhooks

Subscribe to item lifecycle events. Each delivery is signed with HMAC-SHA256 using a secret you only see once at create time.

Events

  • item.created
  • item.updated
  • item.completed / item.uncompleted
  • item.archived / item.deleted

Register

Pro only. Free returns 402 pro_requiredon create. Listing existing webhooks works on any plan but free users won’t have any to list.

The url must be public HTTPS. Localhost, RFC1918 (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10), link-local, loopback, and any .local / .internal hostname are rejected to prevent SSRF.

POST/api/v1/webhooks

Body: { url, events[], description? }. Returns { webhook, secret } — store the secret immediately.

curl -X POST https://quik.md/api/v1/webhooks \
  -H "Authorization: Bearer qk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url":"https://hooks.example.com/quik",
    "events":["item.created","item.completed"]
  }'

List & delete

GET/api/v1/webhooks
DELETE/api/v1/webhooks/:id

Verify the signature

Each delivery includes X-Quik-Event and X-Quik-Signature: sha256=<hex>. Compare against the SHA-256 hash of the secret HMAC’d over the raw body.

import crypto from "node:crypto"

function verify(secret, rawBody, header) {
  const [, sig] = (header ?? "").split("=")
  // We sign with the SHA-256 hash of the secret as the HMAC key.
  const key = crypto.createHash("sha256").update(secret).digest("hex")
  const expected = crypto
    .createHmac("sha256", key)
    .update(rawBody)
    .digest("hex")
  return crypto.timingSafeEqual(
    Buffer.from(sig, "hex"),
    Buffer.from(expected, "hex"),
  )
}

Note: we sign with HMAC-SHA256 keyed on the SHA-256 hash of your secret. We never store the raw secret server-side.

Delivery contract

Best-effort, three attempts (immediate, +1s, +5s). If your endpoint returns 2xx within the timeout we stop. Otherwise the last status is recorded on the webhook row. We don’t guarantee ordering — use updated_at on the item to break ties.