Receiving webhooks
How WoodSystems sends events to your endpoint, how to verify the signature, and how retries and switch-offs work.
A webhook is an HTTPS POST that WoodSystems sends to your endpoint when something changes in the account: a job moves status, an estimate is signed, a payment arrives. The full list is on the events page.
Set up an endpoint
An admin adds endpoints in Settings, Integrations, or your code adds them with POST /v1/webhooks and a key that holds the webhooks:manage scope. Each endpoint has a URL, the events it wants (or all events), and a signing secret that starts whsec_. The secret is shown when the endpoint is created; store it to verify deliveries. An account can have up to 10 endpoints.
Send test in the app, or POST /v1/webhooks/{endpoint_id}/test, sends a ping event to that one endpoint.
What a delivery looks like
The body is a small envelope. data.object is a short summary of the record (id, reference, name, status, job and when it changed), never money. Fetch data.url with your API key for the rest; that read is an ordinary API call.
{
"api_version": "v1",
"created_at": "2026-10-06T15:12:00Z",
"data": {
"object": {
"id": "3f9a1c77-5e3b-4a02-9d8e-2c7b6a1f0e4d",
"name": "Whitfield kitchen remodel",
"reference": "J1042",
"stage": "job",
"status": { "id": "7a1e2f30-9c4d-4b5e-8f60-1a2b3c4d5e6f", "name": "Production" },
"updated_at": "2026-10-06T15:12:00Z"
},
"previous_attributes": {
"status": { "id": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Backlog" }
},
"url": "https://api.woodsystems.com/v1/jobs/3f9a1c77-5e3b-4a02-9d8e-2c7b6a1f0e4d"
},
"id": "a0b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d",
"livemode": true,
"object": "event",
"source": { "kind": "ui", "user_id": "e1d2c3b4-a596-4877-8695-a4b3c2d1e0f9" },
"tenant_id": "5c8d2e1f-3a4b-4c6d-8e9f-0a1b2c3d4e5f",
"type": "job.status_changed"
}
previous_attributesappears on*.status_changedevents and holds what changed.source.kindsays what caused it:ui,api,oauth,workfloworsystem. When one of your own keys made the change,source.api_key_prefixand theWoodSystems-Source-Keyheader name it, so you can drop echoes of your own writes.livemodeisfalsefor events from the sandbox.
Headers
| Header | What it carries |
|---|---|
WoodSystems-Signature | t=<unix seconds>,v1=<hex>, with a second v1 while a secret is being rotated. |
WoodSystems-Event | The event type, such as job.created. |
WoodSystems-Event-Id | Stable per event. Use it to drop repeats. |
WoodSystems-Delivery-Id | Stable per delivery to your endpoint. |
WoodSystems-Attempt | 1 for the first try. |
WoodSystems-Replay | true on retries. |
WoodSystems-Environment | live or sandbox. |
WoodSystems-Source-Key | The prefix of your key, when the change came from it. |
User-Agent | WoodSystems-Webhooks/1.0 (+https://developers.woodsystems.com/webhooks) |
Answer quickly
Answer any 2xx within 10 seconds. Do the real work after you have answered, for example by putting the event on a queue. Anything else, including a timeout or a redirect, counts as a failure. Redirects are not followed.
Events can arrive more than once and in any order. Treat WoodSystems-Event-Id as an idempotency key, and use created_at or a fresh read of data.url when order matters.
Retries
A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and 24 hours, then given up: seven attempts over about 40 hours. A 429 answer is retried after its Retry-After, up to an hour. A delivery that was given up can be sent again from the app or with POST /v1/webhook-deliveries/{delivery_id}/retry.
When an endpoint is switched off
An endpoint is switched off, and the account's admins are told, when:
- it fails 20 times in a row over at least a day, or
- it answers
410 Gone, which switches it off at once.
Its waiting deliveries are given up. Switching it back on in the app, or with PATCH /v1/webhooks/{endpoint_id}, resets the count.
Endpoint URL rules
The URL is checked when the endpoint is saved and again at every send, against the addresses its host resolves to at that moment.
httpsonly, on port 443 or 8443.- The host must resolve to public addresses. Private, loopback, link-local, multicast and reserved addresses are refused, as are shared-address-space (
100.64.0.0/10) and cloud metadata addresses. localhost, and hosts ending.internal,.local,.localhost,.run.appor.woodsystems.com, are refused. Put your own domain in front of a service hosted on one of those.- No user name or password in the URL.
Rotating the secret
Rotate a secret in the app or with POST /v1/webhooks/{endpoint_id}/rotate-secret. For 24 hours every delivery is signed with both the new and the old secret, so the header carries two v1 values and a receiver holding either one verifies. Update your receiver within that day.
Sandbox
Sandbox endpoints are set up in the sandbox and receive only sandbox events, with "livemode": false and WoodSystems-Environment: sandbox. The same URL rules apply, so https is still required: to receive on your own machine, use a tunnel such as ngrok or cloudflared.
Usage
The first delivery attempt of each event to each endpoint counts as one API call. Automatic retries are free; a manual retry or a test event counts one more. See limits and pricing.
Verify the signature
Verify every delivery before you trust it:
- Read the body as raw bytes, before any JSON parsing. Parsing and re-encoding changes the bytes and the signature will not match.
- Split
WoodSystems-Signatureon commas intotand one or morev1values. - Reject the delivery when
tis more than 300 seconds from your clock. - Compute HMAC-SHA256, keyed with the endpoint secret, over
<t>.<raw body>, as lowercase hex. - Compare it in constant time with each
v1. Accept when any one matches.
import hashlib
import hmac
import time
from typing import Optional
def verify_signature(secret: str, header: str, raw_body: bytes, tolerance: int = 300, now: Optional[int] = None) -> bool:
"""True when the WoodSystems-Signature header matches the raw request body.
secret: the endpoint's signing secret (whsec_...), from Settings, Integrations.
header: the WoodSystems-Signature header, t=<unix>,v1=<hex>[,v1=<hex>].
raw_body: the request body exactly as received, before any JSON parsing.
"""
timestamp = None
signatures = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1" and value:
signatures.append(value)
if timestamp is None or not signatures:
return False
current = int(time.time()) if now is None else now
if abs(current - timestamp) > tolerance:
return False
message = str(timestamp).encode("utf-8") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
# In a Flask view, for example:
#
# if not verify_signature(os.environ["WOODSYSTEMS_WEBHOOK_SECRET"],
# request.headers.get("WoodSystems-Signature", ""),
# request.get_data()):
# abort(400) const crypto = require('node:crypto');
// True when the WoodSystems-Signature header matches the raw request body.
// secret: the endpoint's signing secret (whsec_...), from Settings, Integrations.
// header: the WoodSystems-Signature header, t=<unix>,v1=<hex>[,v1=<hex>].
// rawBody: the request body exactly as received (a Buffer or string), before JSON parsing.
function verifySignature(secret, header, rawBody, toleranceSeconds = 300, now = Math.floor(Date.now() / 1000)) {
let timestamp = null;
const signatures = [];
for (const part of String(header || '').split(',')) {
const index = part.indexOf('=');
if (index < 0) continue;
const key = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === 'v1' && value) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(now - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
module.exports = { verifySignature };
// With Express, read the body raw on this route so the bytes are unchanged:
//
// app.post('/woodsystems', express.raw({ type: 'application/json' }), (req, res) => {
// const ok = verifySignature(process.env.WOODSYSTEMS_WEBHOOK_SECRET,
// req.get('WoodSystems-Signature'), req.body);
// if (!ok) return res.sendStatus(400);
// res.sendStatus(200);
// }); <?php
// True when the WoodSystems-Signature header matches the raw request body.
// $secret: the endpoint's signing secret (whsec_...), from Settings, Integrations.
// $header: the WoodSystems-Signature header, t=<unix>,v1=<hex>[,v1=<hex>].
// $rawBody: the request body exactly as received, before json_decode.
function woodsystems_verify_signature(string $secret, string $header, string $rawBody, int $tolerance = 300, ?int $now = null): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
continue;
}
[$key, $value] = $pair;
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && $value !== '') {
$signatures[] = $value;
}
}
if ($timestamp === null || count($signatures) === 0) {
return false;
}
$now = $now ?? time();
if (abs($now - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
// For example:
//
// $rawBody = file_get_contents('php://input');
// $header = $_SERVER['HTTP_WOODSYSTEMS_SIGNATURE'] ?? '';
// if (!woodsystems_verify_signature(getenv('WOODSYSTEMS_WEBHOOK_SECRET'), $header, $rawBody)) {
// http_response_code(400);
// exit;
// } package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
// VerifySignature reports whether the WoodSystems-Signature header matches the raw request body.
//
// secret: the endpoint's signing secret (whsec_...), from Settings, Integrations.
// header: the WoodSystems-Signature header, t=<unix>,v1=<hex>[,v1=<hex>].
// rawBody: the request body exactly as received, before JSON decoding.
//
// Pass 5*time.Minute as tolerance and time.Now() as now.
func VerifySignature(secret, header string, rawBody []byte, tolerance time.Duration, now time.Time) bool {
timestamp := int64(-1)
var signatures []string
for _, part := range strings.Split(header, ",") {
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch key {
case "t":
if t, err := strconv.ParseInt(value, 10, 64); err == nil && t >= 0 {
timestamp = t
}
case "v1":
if value != "" {
signatures = append(signatures, value)
}
}
}
if timestamp < 0 || len(signatures) == 0 {
return false
}
age := now.Sub(time.Unix(timestamp, 0))
if age < 0 {
age = -age
}
if age > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, signature := range signatures {
given, err := hex.DecodeString(signature)
if err == nil && hmac.Equal(given, expected) {
return true
}
}
return false
} Check your code with this example
Each snippet above is tested against this delivery. With the secret below, your code should accept the header and body when your clock reads the timestamp, and refuse them if one byte of the body changes or the timestamp is more than 300 seconds old.
| Secret | whsec_test_4yR8u0Wm2h9kQ1zX6cV3bN7mL5pJ0sD2 |
|---|---|
| Timestamp | 1791230400 |
Body:
{"api_version":"v1","id":"a0b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d","type":"ping"} WoodSystems-Signature header:
t=1791230400,v1=8049a4144c3c86717be2b8f0f4c174a235e5db441bef434219be3ee0dacac431