Solvey.

Webhooks

Signed, retried, logged.

Subscribe a URL to event types and Solvey POSTs a signed JSON body for each one. Failures retry on a schedule; every attempt is in a log you can read and resend from — in Admin center → Webhooks or through the API.

Events

TypeWhen
ticket.createdA ticket was created, by any door.
ticket.updatedSubject, description, priority, assignee, tags or a custom field changed.
ticket.status_changedThe status changed (also sent as ticket.updated).
ticket.repliedA public reply was posted. Carries the reply.
ticket.note_addedAn internal note was added. Carries who and when — never the note.
ticket.mergedThe ticket was marked a duplicate of another.

The ticket in a payload is its fields — id, key, subject, description, status, priority, tags, requester, assignee, custom fields, SLA — never the thread; fetch GET /tickets/{id} for that. A public reply carries its body. An internal note event carries who and when only: notes never leave the organization, by webhook or otherwise.

Payload

application/json
{
  "id": "01a0…",                         // the delivery id — also in the Solvey-Delivery header
  "type": "ticket.replied",
  "createdAt": "2026-09-05T11:28:03.702Z",
  "data": {
    "ticket": { "id": "…", "key": "SLV-1043", "subject": "…", "status": "open", "priority": "high", "…": "…" },
    "comment": { "id": "…", "kind": "public", "body": "Fixed tomorrow.", "author": { "…": "…" }, "createdAt": "…" }
  }
}

Headers

HeaderValue
Solvey-Signaturet=<unix seconds>,v1=<hex>
Solvey-Eventthe event type
Solvey-Deliverythe delivery id — the same on every retry; use it to deduplicate
Solvey-Attempt1, 2, …

Verifying a delivery

v1is HMAC-SHA256, keyed with the subscription's secret (shown once when you subscribe), over the string t + "." + raw body. Verify before you parse. Refuse timestamps more than five minutes old. Compare in constant time.

Node
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, signatureHeader, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return expected.length === parts.v1.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Python
import hmac, hashlib, time

def verify(secret: str, signature_header: str, raw_body: bytes, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    t = int(parts["t"])
    if 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["v1"])

Answer with any 2xx within 10 seconds. Anything else — a non-2xx, a timeout, a connection error — is a failed attempt.

Retries and redelivery

8 attempts, exponential backoff from one minute: 1, 2, 4, 8, 16, 32, 64 minutes between them, about 2 hours in all. After the last failure the delivery is failed and stays in the log. Redeliver — the button, or POST /webhooks/deliveries/{deliveryId}/redeliver — queues the same payload again, with the attempt count continuing. Deliveries to a paused subscription are marked failed without a request.

Secrets

Each subscription has its own secret, shown once when it is created and stored encrypted. If a secret leaks, delete the subscription and create it again; the new one gets a new secret. Rotation in place is on the list.