> ## Documentation Index
> Fetch the complete documentation index at: https://docs.flouci.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Payment Webhooks

> Receive a signed server-to-server notification when a payment succeeds or fails, and verify that it really comes from Flouci.

When you pass a `webhook` URL to [Generate Payment](/api-reference/generate-transaction), Flouci calls it once the payment reaches a final state. Every call carries an `X-Flouci-Signature` header computed with your app's **private token**, so you can reject any request that does not come from Flouci before acting on it.

You choose the format per payment with the `webhook_version` field:

| `webhook_version` | Format | When to use it |
| - | - | - |
| `1` (default) | `GET <webhook>?payment_id=<id>&success=<True\|False>` | Existing integrations. Nothing to change, but add the signature check. |
| `2` | `POST` with a JSON event body | New integrations, or if you also handle [subscription webhooks](/api-reference/recurring-payments/webhooks): same format, one handler for both. |

<Warning>
  Verify the signature before you trust a webhook, then confirm the payment with [Verify Payment](/api-reference/verify-transaction) before you deliver goods. Without the check, anyone who knows your webhook URL can send you a fake `success=True`.
</Warning>

## Delivery

Both versions are delivered the same way:

| | |
| - | - |
| Signature header | `X-Flouci-Signature: t=<unix timestamp>,v1=<hex HMAC-SHA256>` |
| Key | Your app's private token: the second half of your `Authorization: Bearer <public>:<private>` credential |
| Retries | 5 retries with exponential backoff (1 min, 2 min, 4 min, …) while your endpoint does not answer 2xx |
| Duplicates | A payment is notified once your endpoint answered 2xx. A retry can still reach you twice, so make your handler idempotent on `payment_id` (version 1) or `event_id` (version 2). |
| Timeout | Answer within a few seconds and do your processing after acknowledging. |

The signature is always `HMAC-SHA256(private_token, "<t>.<signed content>")`. Only the signed content differs between the versions:

| Version | Signed content |
| - | - |
| `1` | The query string `payment_id=<payment_id>&success=<success>`, with both values exactly as you received them |
| `2` | The raw request body, byte for byte |

Compare the result to `v1` in constant time, and reject timestamps older than a few minutes to block replays.

## Version 1 — signed GET (default)

```http theme={null}
GET https://your-website.com/webhook?payment_id=AgCKuBm0S5uLPghBo571MQ&success=True
X-Flouci-Signature: t=1791014400,v1=5f0c3e…
```

<ResponseField name="payment_id" type="string" required>
  The payment concerned, as returned by Generate Payment.
</ResponseField>

<ResponseField name="success" type="string" required>
  `True` when the payment succeeded, `False` when it failed. The first letter is uppercase; use the value exactly as received when you compute the signature.
</ResponseField>

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  def verify_flouci_get_webhook(payment_id: str, success: str, signature_header: str, private_token: str, tolerance_seconds: int = 300) -> bool:
      parts = dict(item.split("=", 1) for item in signature_header.split(","))
      timestamp, received = parts["t"], parts["v1"]
      if abs(time.time() - int(timestamp)) > tolerance_seconds:
          return False
      signed = f"{timestamp}.payment_id={payment_id}&success={success}"
      expected = hmac.new(private_token.encode(), signed.encode(), hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, received)

  # Flask example
  # ok = verify_flouci_get_webhook(request.args["payment_id"], request.args["success"],
  #                                request.headers["X-Flouci-Signature"], PRIVATE_TOKEN)
  ```

  ```javascript Node.js theme={null}
  const crypto = require("crypto");

  function verifyFlouciGetWebhook(paymentId, success, signatureHeader, privateToken, toleranceSeconds = 300) {
    const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
    if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
    const expected = crypto
      .createHmac("sha256", privateToken)
      .update(`${parts.t}.payment_id=${paymentId}&success=${success}`)
      .digest("hex");
    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(parts.v1, "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  // Express example
  // const ok = verifyFlouciGetWebhook(req.query.payment_id, req.query.success, req.get("X-Flouci-Signature"), PRIVATE_TOKEN);
  ```
</CodeGroup>

## Version 2 — signed JSON events

Send `"webhook_version": 2` with [Generate Payment](/api-reference/generate-transaction) to receive a `POST` instead:

```http theme={null}
POST https://your-website.com/webhook
Content-Type: application/json
X-Flouci-Event: payment.succeeded
X-Flouci-Signature: t=1791014400,v1=9a41d2…

{"amount":40300,"created":"2026-10-04T10:00:00.120000+00:00","currency":"TND","developer_tracking_id":"order-1042","event":"payment.succeeded","event_id":"3b8e9c1d-5f2a-5c7e-8d4b-2a1f0e9c7b6a","payment_id":"AgCKuBm0S5uLPghBo571MQ","payment_method":"card"}
```

| Event | When |
| - | - |
| `payment.succeeded` | The customer paid. Confirm with [Verify Payment](/api-reference/verify-transaction), then deliver. |
| `payment.failed` | The payment failed or was refused. |

<ResponseField name="event" type="string" required>
  `payment.succeeded` or `payment.failed`. Also sent in the `X-Flouci-Event` header.
</ResponseField>

<ResponseField name="event_id" type="string" required>
  Unique per payment and event, and unchanged when a delivery is retried. Store it and ignore an event you have already processed.
</ResponseField>

<ResponseField name="created" type="timestamp" required>
  When the event was emitted.
</ResponseField>

<ResponseField name="payment_id" type="string" required>
  The payment concerned, as returned by Generate Payment.
</ResponseField>

<ResponseField name="developer_tracking_id" type="string">
  The `developer_tracking_id` you sent with Generate Payment.
</ResponseField>

<ResponseField name="amount" type="integer" required>
  Amount of the payment in millimes.
</ResponseField>

<ResponseField name="currency" type="string" required>
  Always `TND`. A payment requested in another currency is converted to TND when it is created.
</ResponseField>

<ResponseField name="payment_method" type="string" required>
  `card`, `flouci` (wallet) or `mpayment`. `NA` when no payment method was used, for example when the payment session expired.
</ResponseField>

The signature check is the same as for subscription events: `HMAC-SHA256` over `"<t>.<raw body>"`. See the Python and Node.js samples in [Subscription Webhooks](/api-reference/recurring-payments/webhooks#verifying-the-signature).

<Tip>
  Sign over the raw request body, not a re-serialized JSON object: any whitespace or key-order difference changes the digest. In Express, use `express.raw({ type: "application/json" })` for the webhook route.
</Tip>

## Moving from version 1 to version 2

<Steps>
  <Step title="Add the signature check to your GET handler">
    This protects your existing integration right away, with no change to your Generate Payment calls.
  </Step>

  <Step title="Deploy a POST handler on the same URL">
    Accept both methods during the migration: verify the signature with the matching rule, then de-duplicate on `payment_id` or `event_id`.
  </Step>

  <Step title="Send webhook_version 2">
    Add `"webhook_version": 2` to your Generate Payment calls. Payments created before the switch keep the version they were created with.
  </Step>
</Steps>

## Limitations

<Note>
  Only payment webhooks are signed. Payout (send money), partner and POS webhooks are not signed yet. Confirm their outcome with the matching status endpoint before acting on them.
</Note>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.