Webhooks
When something happens in Foudra, it can tell another system by sending it a message: mission control learns that a boat's service is finished, a shop learns that stock has changed. Admins only. To ask Foudra things, or to change them, use the API; webhooks are for being told.
Creating one
| Field | Meaning |
|---|---|
| Name | What is on the other end |
| URL | Where to send. It must be https and reachable from the internet. An address inside a private network is refused unless whoever runs Foudra has allowed that host |
| Events | Which events to send |
The signing secret is shown once, on the page that follows. Copy it then: the receiver needs it to check that a message came from Foudra.
Send test sends a ping event. Switch off stops sending; events that happen while a webhook is off are not saved up for it. Remove deletes it and keeps its delivery history.
Events
| Event | When | data |
|---|---|---|
work_order.completed |
A work order's full quantity is done | work_order (id, number, external_ref, quantity, quantity_done, due_at, notes) and part (id, part_number, name, is_stocked) |
stock.changed |
Any stock movement. A transfer is two | movement (id, type, quantity_delta, reference_type, reference_id, occurred_at), part, location, on_hand_here, on_hand_total |
ping |
Send test | {"hello": "world"} |
A work order's external_ref is how the other system knows what the job was about. Raise a service with the reference "Hull 207" and the completion event says so.
What is sent
A POST with a JSON body:
{
"id": 412,
"type": "work_order.completed",
"occurred_at": "2026-10-01T14:11:18Z",
"text": "WO-00031 completed: 1 × Class 1 service (Hull 207).",
"data": { "work_order": { "number": "WO-00031", "external_ref": "Hull 207" }, "part": { } }
}
text is one sentence for a person. Quantities are strings, as in the API.
| Header | Meaning |
|---|---|
X-Foudra-Event |
The event type |
X-Foudra-Event-Id |
The event's id, the same as id in the body |
X-Foudra-Delivery |
This delivery's id |
X-Foudra-Signature |
t=<unix time>,v1=<signature> |
What the receiver must do
- Answer quickly with any
2xxstatus. Do the work afterwards. No answer within 10 seconds counts as a failure. Redirects are not followed. - Check the signature. Compute HMAC-SHA256, keyed with the signing secret, over the text
<t>.<body>: thetvalue from the header, a full stop, then the body exactly as received. Compare the result, in hex, withv1. Refuse the message if they differ, or iftis more than a few minutes old. - Expect repeats. A message can arrive twice. Remember the event ids you have handled and ignore one you have seen.
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
signed = parts["t"].encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"]) and abs(time.time() - int(parts["t"])) < 300
When it fails
A failed delivery is tried again after 1 minute, then 5, 15, an hour, 3, 6 and 12 hours, and given up on after the eighth attempt, about a day later. Recent deliveries shows each one, how many tries it has had and the last answer; Send now tries one again at once.
After eight failures in a row with no success between them, the webhook is switched off and a task appears on the dashboard saying why. Fix the receiver, switch the webhook back on, and use Send now on anything that was given up on.
Events are sent in roughly the order they happen, but a retried one can arrive after a later one. Use occurred_at if order matters.