Data ops
A webhook you fire by hand
Most webhooks tell you when something happened. This one goes the other way — pick rows, pick an endpoint, and the processor signs and POSTs them, with retries and a delivery log. Also the things the log will not tell you.
A webhook, in most tools, is a promise: when something happens, we will call you. SchemaStack's works the other way around. Nothing calls anyone until a person looks at a grid, selects rows, and decides that these should go to that endpoint, now.
That is a narrower thing than an event stream, and worth being clear about, because the word suggests otherwise. It is also exactly what the "push these three hundred contacts into the flow" moment needs.
Setting one up
A webhook belongs to a view and lives in its properties panel: a name, a URL, an optional signing secret, optional headers of your own, and a switch to disable it without deleting it. The secret is write-only from then on — the panel says HMAC signed, never what with. To rotate it, enter a new one.
The URL is checked when you save it. It has to be http or https, it has to have a host, and it may not point at a private or internal network: localhost, anything ending in .local or .internal, loopback, link-local, and the 10., 172.16–31. and 192.168. ranges are refused. That is the usual defence against a server being talked into fetching its own internals, and the check is on the address the name resolves to, not just on its spelling.
Sending
Select rows — ticked, or all rows matching the filter — open the action bar's menu, choose Send to webhook, and pick which one. Only enabled webhooks are offered. Like every other bulk action, the answer is a job, not a result: the grid says work is happening and carries on. Sends travel on their own queue, so a schema migration ahead of them does not hold them up, and vice versa.
The processor fetches the selected rows from your database at that moment — current values, every column in the view, ordered by primary key — and builds one JSON document:
{
"rows": [
{ "id": 1, "email": "[email protected]", "city": "Utrecht" },
{ "id": 3, "email": "[email protected]", "city": "Utrecht" }
],
"metadata": {
"viewId": "a1b2c3d4-…",
"tableName": "contacts",
"rowCount": 2,
"timestamp": "2026-09-04T12:00:00Z",
"jobId": "e5f6g7h8-…"
}
}It is POSTed once, as application/json, with User-Agent: SchemaStack-Webhook/1.0, an X-SchemaStack-Delivery-Id header carrying the job id, any headers you configured, and — if you set a secret — X-SchemaStack-Signature: sha256=<hex>: the HMAC-SHA256 of the exact bytes of the body. Verify it by recomputing over the raw body you received, before parsing anything:
const expected =
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const ok =
expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));The values are the database's values, serialised as JSON — the number under the currency widget, the key under the relationship label — for the same reason the widget is not the column type.
When your endpoint misbehaves
What happens next depends on the answer, and on whether there is one.
- 2xx is delivered. The job completes with the row count and the status.
- 5xx or 429, or no answer at all — connection refused, a timeout — is retried: again after one second, then after four. Three attempts in total, thirty seconds each. If the third fails, so does the job, with the last status and reply attached.
- Any other 4xx is not retried. A 400 or a 404 will not get better by being repeated, so the job fails at once.
Every job leaves one delivery record on the webhook: status code, duration, which attempt it was, the first four kilobytes of the reply, and the error if there was one. The properties panel shows the log under the webhook — green for 2xx, red for anything else, and ERR when nothing answered.
Limits
Ten thousand rows per send. A selection that matches more is refused before anything is sent, so you are never left with half a payload delivered. The fetch itself gets sixty seconds.
What it doesn't do (yet)
- It is not triggered by data. Nothing fires on insert or update. If you need "when a row changes", the Zapier integration polls for it; this webhook fires only when someone clicks.
- An empty selection "succeeds". If the selection matches nothing, no request is made — and the job reports success with HTTP 200 and zero rows. The delivery log shows a 200 that no endpoint ever sent.
- Most filter operators are misread on the way to the webhook. The fetch query understands equals, the four comparisons and like. It does not know not equals, in, not in, starts with or ends with, and reads each of them as equals. A "select all matching" send with a not equals filter therefore sends the rows that do equal the value — the opposite of what was on screen. Checked, not inferred: a filter of city ≠ Utrecht delivered exactly the Utrecht rows. Until this is fixed, send filtered selections only with equals, a comparison or like — or tick the rows.
- Composite primary keys send the whole table. With a ticked selection on a table whose key spans two columns, the fetch query has no single key column to filter on and applies no
WHEREclause at all: every row in the table goes to the endpoint, up to the ten-thousand cap. Checked: one ticked row out of five sent five. This is the most serious item on this list, and the reason the list exists. Do not point a webhook at a composite-key table until it is fixed. - One record per job, not per attempt. A send that failed twice and succeeded on the third try is one line saying attempt 3. The intermediate failures and their replies are not kept.
- Attempt number and reply body are recorded but not shown. The panel lists status, time, duration and error. The stored attempt and the first 4 KB of the response are only reachable through the API.
- Plain
http://is accepted. The documentation said HTTPS was required in production; nothing enforces it. On a plain URL the rows and the signature travel in clear. - No test send, no resend. To see a delivery you send real rows, and to retry a failed job you select and send again.
- The address is checked once. The private-network check runs when you save the URL, against what the name resolves to then. A name that does not resolve at that moment is allowed through.
The webhooks guide has the payload and header reference, and your database, wired to 7,000 apps covers the polling side of the same story.
Verified 4 Sep 2026: All green. WebhookDeliveryTest (11) was written for this post, against a real PostgreSQL in Testcontainers and a local HTTP server that records what arrives: the headers, the HMAC-SHA256 signature recomputed independently over the body, the payload shape, a retry on 5xx, on 429 and on a refused connection, no retry on 400, the three-attempt cap with the last reply attached, 4 KB truncation, the 10,000-row refusal, and the empty selection that sends nothing and reports 200. WebhookConfigServiceTest covers the URL rules, including each private range named above. BulkActionHandlerTest covers completion. The two 'wrong rows' limitations were each reproduced by a throwaway diagnostic before being written down..