FlowieExchange
Webhook Cookbook

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.

Delivery guarantees
At-least-once. Your handler must be idempotent. Duplicates are rare but possible after a 2xx response times out on our side.

Event catalog

EventFires whenKey fields in data
document.receivedAn incoming Peppol document has been persisted.documentId, type, number, direction=incoming
document.sentAn outgoing document has been handed off to the recipient's access point.documentId, type, sentAt
document.deliveredThe recipient's access point confirmed final delivery.documentId, deliveredAt
document.failedDelivery permanently failed (recipient unreachable, schema rejection, …).documentId, errorCode, errorMessage
document.updatedA document's metadata was updated (e.g. tagged, archived, note added).documentId, changes (field diff)
lifecycle.updatedLifecycle status transitioned.documentId, previousStatus, currentStatus, compliance
company.smp_registeredA company's SMP record went live.companyId, peppolId
compliance.reportedA 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:

To verify:

  1. Split the header by , into t and v1.
  2. Reject if |now - t| > 5 minutes — that's a replay.
  3. Compute HMAC-SHA256(secret, t + "." + raw_body).
  4. Constant-time compare against v1.
Use the raw body
Verify before any JSON parsing or transcoding. Even a re-serialized JSON is no longer byte-identical — it will fail the HMAC check.
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:

AttemptDelay after failureCumulative
10m
230s30s
32m2m 30s
410m12m 30s
530m42m 30s
62h≈ 2h 42m
76h≈ 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.

Respond fast, process async
Ack within 5 seconds with 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:

  1. Dedupe table. Use X-Flowie-Event-Id as a unique key in a fast KV (Redis, DynamoDB). Ignore duplicates.
  2. Idempotent state transitions. Upsert by documentId — setting status = paid again 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

  1. Expose your dev server with ngrok http 3000 (or your preferred tunnel).
  2. Create a test-mode webhook pointing at https://<subdomain>.ngrok.io/hooks/peppol.
  3. Send a document in sandbox — you'll see document.received fire.
  4. In the dashboard, open any delivery and click Resend to replay the exact byte-identical request.

Troubleshooting

SymptomLikely causeFix
Webhook is paused after deployEndpoint returned 5xx 8 times in a rowFix the endpoint, then PATCH the webhook back to active and replay via Events API.
Signature mismatchYou're signing a re-serialized bodyVerify on the raw buffer, before JSON parse.
Events arrive out of orderRetries of an earlier delivery arrive after a later oneRead data.updatedAt — don't rely on receipt order. Store monotonic versions.
Duplicate processingYour handler isn't idempotentDedupe on X-Flowie-Event-Id.
Slow deliveriesYour endpoint takes > 5sEnqueue 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.