Webhooks
Webhooks are how your stack learns that something happened on Peppol. Every time a document arrives, a delivery fails, or a lifecycle status changes, Flowie makes an HTTPS POST to each endpoint you've configured — with exponential retries, HMAC signatures, and a durable twin in the Events API for replay.
Event catalog
| Event | Fires when | Key fields in data |
|---|---|---|
document.received | An incoming Peppol document has been persisted. | documentId, type, number, direction=incoming |
document.sent | An outgoing document has been handed off to the recipient's access point. | documentId, type, sentAt |
document.delivered | The recipient's access point confirmed final delivery. | documentId, deliveredAt |
document.failed | Delivery permanently failed (recipient unreachable, schema rejection, …). | documentId, errorCode, errorMessage |
document.updated | A document's metadata was updated (e.g. tagged, archived, note added). | documentId, changes (field diff) |
lifecycle.updated | Lifecycle status transitioned. | documentId, previousStatus, currentStatus, compliance |
company.smp_registered | A company's SMP record went live. | companyId, peppolId |
compliance.reported | A lifecycle change was reported to PPF (FR) or SDI (IT). Belgium has no regulator-side report. | documentId, reportedTo, status |
* | Subscribes to every event. | Use sparingly — prefer explicit lists. |
Payload shape
Every delivery is a JSON POST with this envelope:
{
"id": "evt_01HY3AB9C2DE3FG",
"type": "document.received",
"livemode": true,
"createdAt": "2026-04-25T10:05:08Z",
"apiVersion":"2026-04-01",
"data": {
"documentId": "doc_01HY7AB9C2DE3FG",
"type": "invoice",
"direction": "incoming",
"number": "INV-2026-0417",
"sender": { "peppolId": "0208:0123456789", "name": "ACME BVBA" },
"receiver": { "peppolId": "0208:9876543210", "name": "Globex SRL" }
}
}
Request headers include:
POST /hooks/peppol HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Flowie-Webhooks/3.0
X-Flowie-Signature: t=1714046708,v1=3d9e8b7…
X-Flowie-Event: document.received
X-Flowie-Event-Id: evt_01HY3AB9C2DE3FG
X-Flowie-Delivery: dlv_01HY3AB9C2DE3FG
X-Flowie-Attempt: 1
Signing & verification
Every request carries X-Flowie-Signature. The header is comma-separated key/value pairs:
t— Unix timestamp at signing timev1— HMAC-SHA256 oft + "." + raw_body, hex-encoded
To verify:
- Split the header by
,intotandv1. - Reject if
|now - t| > 5 minutes— that's a replay. - Compute
HMAC-SHA256(secret, t + "." + raw_body). - Constant-time compare against
v1.
import hmac, hashlib, time
from fastapi import Request, HTTPException
SECRET = b"whsec_..." # the secret you created with the webhook
async def verify(req: Request):
raw = await req.body()
header = req.headers.get("X-Flowie-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = parts.get("t"), parts.get("v1")
if not t or not sig: raise HTTPException(400, "Missing signature")
if abs(time.time() - int(t)) > 300: raise HTTPException(400, "Stale")
expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
raise HTTPException(401, "Invalid signature")
return raw
import crypto from "node:crypto";
const SECRET = process.env.FLOWIE_WEBHOOK_SECRET;
export function verify(req, rawBody) {
const header = req.headers["x-flowie-signature"] || "";
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const { t, v1 } = parts;
if (!t || !v1) throw new Error("Missing signature");
if (Math.abs(Date.now()/1000 - Number(t)) > 300) throw new Error("Stale");
const mac = crypto.createHmac("sha256", SECRET)
.update(`${t}.`).update(rawBody).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(v1));
if (!ok) throw new Error("Invalid signature");
}
func verify(r *http.Request, secret []byte) error {
raw, _ := io.ReadAll(r.Body); r.Body = io.NopCloser(bytes.NewReader(raw))
parts := map[string]string{}
for _, p := range strings.Split(r.Header.Get("X-Flowie-Signature"), ",") {
if kv := strings.SplitN(p, "=", 2); len(kv) == 2 { parts[kv[0]] = kv[1] }
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 { return errors.New("stale") }
h := hmac.New(sha256.New, secret)
h.Write([]byte(parts["t"] + ".")); h.Write(raw)
if !hmac.Equal([]byte(hex.EncodeToString(h.Sum(nil))), []byte(parts["v1"])) {
return errors.New("invalid signature")
}
return nil
}
Retries & backoff
Flowie retries any non-2xx response (and any timeout > 20s) on this schedule:
| Attempt | Delay after failure | Cumulative |
|---|---|---|
| 1 | — | 0m |
| 2 | 30s | 30s |
| 3 | 2m | 2m 30s |
| 4 | 10m | 12m 30s |
| 5 | 30m | 42m 30s |
| 6 | 2h | ≈ 2h 42m |
| 7 | 6h | ≈ 8h 42m |
| 8 (last) | 12h | ≈ 20h 42m |
After 8 failures, the webhook is auto-paused. You'll receive an email and the status field on the webhook flips to paused. Manually re-activate it with a PATCH once the endpoint is healthy.
200, then hand the payload to a queue. Long synchronous processing in your handler multiplies tail-latency and increases the odds of a retry storm.
Idempotency on your side
Because retries can overlap with a successful delivery you missed, your handler must treat every event as "at-least-once". Two patterns work well:
- Dedupe table. Use
X-Flowie-Event-Idas a unique key in a fast KV (Redis, DynamoDB). Ignore duplicates. - Idempotent state transitions. Upsert by
documentId— settingstatus = paidagain is a no-op.
Replay & the Events API
Every webhook attempt has a matching event in the Events API. If your endpoint was down, fetch missed events:
curl "https://back.p2p-flowie.com/exchange/v1/events?type=document.received&limit=100" \
-H "Authorization: Bearer $KEY"
Process them, then acknowledge in bulk to clear the queue:
curl -X POST https://back.p2p-flowie.com/exchange/v1/events/ack \
-H "Authorization: Bearer $KEY" \
-d '{"eventIds": ["evt_01…", "evt_02…"]}'
Testing locally
- Expose your dev server with
ngrok http 3000(or your preferred tunnel). - Create a test-mode webhook pointing at
https://<subdomain>.ngrok.io/hooks/peppol. - Send a document in sandbox — you'll see
document.receivedfire. - In the dashboard, open any delivery and click Resend to replay the exact byte-identical request.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Webhook is paused after deploy | Endpoint returned 5xx 8 times in a row | Fix the endpoint, then PATCH the webhook back to active and replay via Events API. |
| Signature mismatch | You're signing a re-serialized body | Verify on the raw buffer, before JSON parse. |
| Events arrive out of order | Retries of an earlier delivery arrive after a later one | Read data.updatedAt — don't rely on receipt order. Store monotonic versions. |
| Duplicate processing | Your handler isn't idempotent | Dedupe on X-Flowie-Event-Id. |
| Slow deliveries | Your endpoint takes > 5s | Enqueue fast, process async. |
Interactive signature verifier
Paste a webhook secret, the timestamp from X-Flowie-Signature, and the raw body. We compute the HMAC in your browser (nothing is sent to a server) and compare against the signature you provide.
All computation happens in your browser via SubtleCrypto. Your secret never leaves the page.
Payload fixtures
Need realistic JSON to seed your handler tests? /fixtures ships one downloadable .json per event type, with copy-to-clipboard and a tarball bundle.
