Send an outbound webhook
A per-workflow side channel: an HMAC-signed POST to your own endpoint for every accepted submission, alongside the workflow's normal output.
What is sent
One POST request per accepted submission, delivered in the same pass as the workflow's own output, with the same lease and retry as other alerts.
{
"event": "submission.accepted",
"deliveryId": "…",
"submissionId": "…",
"formId": "…",
"workflow": "…",
"receivedAt": "2026-09-25T00:00:00.000Z",
"record": { "<field>": "<value>" },
"files": [ { "field": "…", "name": "…", "bytes": 0 } ]
}
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webtzm-Delivery | This delivery's id |
X-Webtzm-Timestamp | Unix seconds, the moment the request was signed |
X-Webtzm-Signature | sha256=<hex HMAC-SHA256 over "timestamp.body"> |
User-Agent | Webtzm-Webhook/1 |
Verify the signature
Recompute the signature from the raw request body and the timestamp header, using the secret shown when it was generated. Compare in constant time; do not use === on the two strings.
import { createHmac, timingSafeEqual } from "node:crypto";
function isValidWebhook(secret, timestampHeader, rawBody, signatureHeader) {
const expected = createHmac("sha256", secret)
.update(`${timestampHeader}.${rawBody}`)
.digest("hex");
const expectedBuffer = Buffer.from(`sha256=${expected}`);
const providedBuffer = Buffer.from(signatureHeader || "");
return expectedBuffer.length === providedBuffer.length
&& timingSafeEqual(expectedBuffer, providedBuffer);
}
Retries
A ten-second timeout applies to every attempt. Any 2xx response is success. Anything else, including a timeout, retries on the same schedule as the workflow's other delivery alerts.