---
title: "Webhooks"
description: "Event catalog, HMAC signing, retry policy, and idempotency patterns for Flowie Exchange webhooks."
canonical: "https://docs.get-flowie.com/reference/webhooks"
source: "https://docs.get-flowie.com/reference/webhooks.html"
---

# Webhooks

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](<index.html#events>) 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

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:
[code] 
    {
      "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" }
      }
    }
[/code]

Request headers include:
[code] 
    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
[/code]

## Signing & verification

Every request carries `X-Flowie-Signature`. The header is comma-separated key/value pairs:

  * `t` — Unix timestamp at signing time
  * `v1` — HMAC-SHA256 of `t + "." + raw_body`, hex-encoded



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. 
[code] 
    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
[/code]
[code] 
    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");
    }
[/code]
[code] 
    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
    }
[/code]

## 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.

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](<index.html#events>). If your endpoint was down, fetch missed events:
[code] 
    curl "https://back.p2p-flowie.com/exchange/v1/events?type=document.received&limit=100" \
      -H "Authorization: Bearer $KEY"
[/code]

Process them, then acknowledge in bulk to clear the queue:
[code] 
    curl -X POST https://back.p2p-flowie.com/exchange/v1/events/ack \
      -H "Authorization: Bearer $KEY" \
      -d '{"eventIds": ["evt_01…", "evt_02…"]}'
[/code]

## 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

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.

Webhook secret Timestamp (t=…) Raw body Signature (v1=…) (optional)

Compute & verify Load example Clear

All computation happens in your browser via [SubtleCrypto](<https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/sign>). Your secret never leaves the page. 

## Payload fixtures

Need realistic JSON to seed your handler tests? [/fixtures](<../fixtures/index.html>) ships one downloadable `.json` per event type, with copy-to-clipboard and a tarball bundle.
