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
| Type | When |
|---|---|
ticket.created | A ticket was created, by any door. |
ticket.updated | Subject, description, priority, assignee, tags or a custom field changed. |
ticket.status_changed | The status changed (also sent as ticket.updated). |
ticket.replied | A public reply was posted. Carries the reply. |
ticket.note_added | An internal note was added. Carries who and when — never the note. |
ticket.merged | The 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
{
"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
| Header | Value |
|---|---|
Solvey-Signature | t=<unix seconds>,v1=<hex> |
Solvey-Event | the event type |
Solvey-Delivery | the delivery id — the same on every retry; use it to deduplicate |
Solvey-Attempt | 1, 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.
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));
}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.