Webhooks
We POST to your URL when something happens here, signed so you can tell it came from us.
Or just Slack
If what you want is to know, rather than to build something, connect Slack instead: Settings, then Slack. You paste an incoming webhook URL from Slack — Apps → Incoming Webhooks → Add to Slack, where you pick the channel — and a message arrives in that channel when a form is answered, somebody comments, or a site goes live.
For a form it says which fields somebody filled in and never what they wrote; for a comment, what was said and the commenter's name, with a link to the thread. A channel is a wider audience than your inbox, and an email address in it is not ours to broadcast.
Adding one
Settings, then Webhooks. You give an https URL and choose what to be told about; we give you a signing secret, once. We keep it to sign with, not to show, so there is no way to see it again — only to replace the webhook.
What we send
A JSON body with an event field, and two headers:solta-event and solta-signature. The events are site.published, version.created, submission.received, site.deleted, comment.created and comment.resolved.
A comment, or a reply, arrives as comment.created. The author is a name, never an email address, and url opens the thread on the version it was left on:
{
"event": "comment.created",
"siteId": "6f1c…",
"slug": "quote-builder",
"title": "Quote builder",
"version": 3,
"versionState": "draft",
"comment": {
"id": "a41e…",
"threadId": "a41e…",
"parentId": null,
"author": "Ana",
"body": "The total should include VAT.",
"createdAt": "2026-10-12T09:41:07.000Z"
},
"url": "https://solta.now/s/quote-builder/v/3?panel=comments&thread=a41e…"
}parentId is the comment replied to, or null for a new thread, whose threadId is its own id. comment.resolved has the same site and version fields, and a comment with its id, threadId and resolvedAt.
Checking the signature
The header looks like t=1757155200,v1=9f86d0…. The digest is an HMAC-SHA256 of the timestamp, a full stop, and the exact body we sent — so check the raw bytes, before any JSON parsing reorders anything.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("=")),
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!(age < 300)) return false; // too old to be honest
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return parts.v1.length === expected.length &&
timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}The timestamp is inside what is signed rather than beside it. That is what stops somebody replaying a delivery they captured last week against your clock today.
When it fails
Anything that is not a 2xx is retried five times with a widening gap. A 4xx is retried too: from here a permanent refusal and a misconfigured route look identical, and giving up on the first 404 would lose events while you fixed a path.
We do not read your response body. Ten seconds is the timeout — answer first, work afterwards.

