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_attributes appears on *.status_changed events and holds what changed.
  • source.kind says what caused it: ui, api, oauth, workflow or system. When one of your own keys made the change, source.api_key_prefix and the WoodSystems-Source-Key header name it, so you can drop echoes of your own writes.
  • livemode is false for events from the sandbox.

Headers

HeaderWhat it carries
WoodSystems-Signaturet=<unix seconds>,v1=<hex>, with a second v1 while a secret is being rotated.
WoodSystems-EventThe event type, such as job.created.
WoodSystems-Event-IdStable per event. Use it to drop repeats.
WoodSystems-Delivery-IdStable per delivery to your endpoint.
WoodSystems-Attempt1 for the first try.
WoodSystems-Replaytrue on retries.
WoodSystems-Environmentlive or sandbox.
WoodSystems-Source-KeyThe prefix of your key, when the change came from it.
User-AgentWoodSystems-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.

  • https only, 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.app or .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:

  1. Read the body as raw bytes, before any JSON parsing. Parsing and re-encoding changes the bytes and the signature will not match.
  2. Split WoodSystems-Signature on commas into t and one or more v1 values.
  3. Reject the delivery when t is more than 300 seconds from your clock.
  4. Compute HMAC-SHA256, keyed with the endpoint secret, over <t>.<raw body>, as lowercase hex.
  5. 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)

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.

Secretwhsec_test_4yR8u0Wm2h9kQ1zX6cV3bN7mL5pJ0sD2
Timestamp1791230400

Body:

{"api_version":"v1","id":"a0b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d","type":"ping"}

WoodSystems-Signature header:

t=1791230400,v1=8049a4144c3c86717be2b8f0f4c174a235e5db441bef434219be3ee0dacac431