Skip to content

Send events to another system with webhooks ​

The real problem ​

Northwind's payroll vendor needs to know the moment an employee joins. Polling the ERP every few minutes wastes calls and is always late. Calling the vendor from inside a page means a slow vendor slows the page, and a vendor outage loses the update.

A webhook sends the event to the vendor on its own: the platform queues it, signs it, retries when the vendor is down, and keeps a log you can read and replay.

The idea in one minute ​

  • A webhook says: when event X happens, send it to address Y. * means every event. It starts off.
  • Each matching event becomes a delivery: one signed POST with a JSON body { id, type, occurredAt, data }. The id is the same on every retry, so the receiver can ignore duplicates.
  • A delivery that fails is retried after 30 seconds, then 1 minute, 2, 4 and so on (up to 6 hours, with a little random spread). A Retry-After from the receiver is respected. After the last attempt it is parked as failed for good so a person can replay it.
  • A client error (HTTP 400, 401, 403, 404, ...) is not retried, because retrying rarely fixes it. Fix the receiver, then replay. HTTP 408 and 429 are retried.
  • The signing secret is created with the webhook and shown once. Only public https addresses are allowed.

Designer path (Studio) ​

  1. Open Integrations > Webhooks > Add webhook. Name it, enter the event (for example hcm.employee.created) and the receiver's https address.
  2. Copy the signing secret from the dialog. It is not shown again. Lost it? Use New secret.
  3. Send test sends a webhook.test event straight away and tells you whether the receiver accepted it.
  4. Switch on. From now on each matching event is delivered.
  5. Delivery log shows every delivery: queued time, status, attempts, the last answer and the next retry. A failed-for-good delivery has Replay.
  6. The strip at the top and Administration > Monitoring show the last 24 hours. Failed-for-good deliveries count as "needs attention".

Developer path ​

The receiver checks the signature. Each request carries three headers: webhook-id, webhook-timestamp (seconds) and webhook-signature (v1,<base64>). The signature is HMAC-SHA256(secret, id + "." + timestamp + "." + body), using the secret string as issued.

python
import base64, hashlib, hmac, time

def verify(secret: str, headers: dict, body: str) -> bool:
    if abs(time.time() - int(headers["webhook-timestamp"])) > 300:
        return False                      # too old: refuse replays
    signed = f'{headers["webhook-id"]}.{headers["webhook-timestamp"]}.{body}'.encode()
    expected = "v1," + base64.b64encode(hmac.new(secret.encode(), signed, hashlib.sha256).digest()).decode()
    return hmac.compare_digest(expected, headers["webhook-signature"])

Create and manage webhooks over the API (X-Tenant-Id and your session as for any call):

bash
curl -X POST "$BASE/api/v1/integration/webhooks" -H "Content-Type: application/json" \
  -d '{"code":"payroll","name":"Payroll vendor","eventType":"hcm.employee.created","targetUrl":"https://payroll.example.com/hooks/spark"}'
curl -X POST "$BASE/api/v1/integration/webhooks/payroll/active" -H "Content-Type: application/json" -d '{"active":true}'
curl -X POST "$BASE/api/v1/integration/webhooks/payroll/test"
curl "$BASE/api/v1/integration/webhooks/deliveries?webhook=payroll&status=dead"
curl -X POST "$BASE/api/v1/integration/webhooks/deliveries/42/replay"

Events come from the same bus that starts flows. To emit one from your own code, publish it (POST /api/v1/integration/flows/events/publish with {"eventType":"hcm.employee.created","payload":{...}}). Any switched-on webhook for that event, and any flow triggered by it, then runs.

How to verify ​

  • Send test returns Test delivered (HTTP 200) when the receiver accepts a signed request.
  • Publish a real event and open the Delivery log: the delivery should move from waiting to delivered within about 15 seconds.
  • Point a webhook at a receiver that answers 500 with max attempts 2 and watch it go from retrying to failed for good; then Replay it.
  • Each action is in Administration > Logging as webhook.* entries.

What is verified: signing, retry, dead-lettering and replay were exercised against a real database and a real public receiver, and the signature was checked by an independent implementation in tests. Receiving is covered in the section below.

Start a flow from another system (receiving) ​

Any flow can be started by a web request: POST /api/v1/integration/flows/webhooks/{code} with the header X-Tenant-Id and a JSON object body (up to 256 KB). Under Integrations > Webhooks > Receiving each flow shows its address and whether it is protected.

  • Open means anyone who knows the address can start the flow. Choose Protect with a secret to generate a signing secret (shown once).
  • The sender signs the request exactly as this platform signs its own webhooks: headers webhook-id, webhook-timestamp and webhook-signature (v1, plus the base64 HMAC-SHA256 of id.timestamp.body). A request older than five minutes or with a changed body is refused with 401.
  • A signed message is remembered by its webhook-id, so a sender that retries never starts the flow twice.
  • The older way, ?webhookSecret= in the address, still works but puts the secret in URLs and logs; move senders to signing.
  • A tenant is limited to 300 such calls a minute (429 beyond that).
bash
BODY='{"employee":"Asha"}'; TS=$(date +%s); ID=msg_1
SIG="v1,$(printf '%s' "$ID.$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64)"
curl -X POST "$BASE/api/v1/integration/flows/webhooks/my-flow" -H "X-Tenant-Id: $TENANT" -H "Content-Type: application/json" \n  -H "webhook-id: $ID" -H "webhook-timestamp: $TS" -H "webhook-signature: $SIG" -d "$BODY"

Common mistakes ​

  • Expecting exactly-once. Delivery is at-least-once. Use the event id to ignore repeats.
  • Checking the signature against a parsed body. Compute it over the raw body text exactly as received.
  • Using an internal address. localhost, private ranges and non-https addresses are refused when you add the webhook and again when it sends.
  • Forgetting to switch it on. A new webhook is off. Events that happen while it is off are not queued.
  • A receiver that is slow. The request times out after 10 seconds and is retried later; answer quickly and do the work afterwards.

Next ​