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

# Subscription Webhooks

> Signed events delivered to your webhook URL at every state change of a subscription — activation, renewals, failures, cancellation.

Every state change of a subscription is delivered to the `webhook` URL you gave at creation as an HTTP `POST` with a JSON body and an HMAC signature. Webhooks are the way to learn about renewals: they happen while your customer is not on your site, so there is no redirect to catch.

<Warning>
  Always verify the signature before acting on an event, and de-duplicate on `event_id`: a delivery is retried until your endpoint answers with a 2xx, so the same event can reach you more than once.
</Warning>

## Delivery

| | |
| - | - |
| Method | `POST`, `Content-Type: application/json` |
| Headers | `X-Flouci-Event` — the event name<br />`X-Flouci-Signature` — `t=<unix timestamp>,v1=<hex HMAC-SHA256>` |
| Retries | 5 attempts with exponential backoff (1 min, 2 min, 4 min, …) while your endpoint does not answer 2xx |
| Timeout | Answer within a few seconds; do your processing after acknowledging |

<Note>
  The one-off payment webhook is unchanged: a payment created with [Generate Payment](/api-reference/generate-transaction) still receives the legacy `GET <webhook>?payment_id=<id>&success=<true|false>` call. Only subscription events use the signed POST described here.
</Note>

## Payload

```json theme={null}
{
  "event": "subscription.charge.succeeded",
  "event_id": "9f1c2b7e-3d4a-4e8f-9a0b-6c5d4e3f2a1b",
  "created": "2026-10-30T09:00:05+01:00",
  "subscription_id": "s7Qm3kLpTq2e9hZx0bYw1A",
  "payment_id": "Xk29PqRtS8mL4vBn7cDw2Q",
  "status": "active",
  "developer_tracking_id": "sub-2026-000418",
  "amount": 15000,
  "currency": "TND",
  "period_start": "2026-10-30",
  "period_end": "2026-11-30",
  "attempt": 1,
  "payment_method": "flouci"
}
```

<ResponseField name="event" type="string" required>
  One of the [event names](#events) below.
</ResponseField>

<ResponseField name="event_id" type="string" required>
  Unique per event. Store it and ignore a body you have already processed.
</ResponseField>

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

<ResponseField name="subscription_id" type="string" required>
  The subscription concerned.
</ResponseField>

<ResponseField name="payment_id" type="string | null">
  The charge concerned, when the event is about a charge. `null` for lifecycle-only events such as `subscription.updated`.
</ResponseField>

<ResponseField name="status" type="string" required>
  The subscription's status **after** the event.
</ResponseField>

<ResponseField name="developer_tracking_id" type="string" required>
  Your reference for the subscription.
</ResponseField>

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

<ResponseField name="currency" type="string" required>
  Always `TND`.
</ResponseField>

<ResponseField name="period_start" type="date | null">
  First day of the period the charge pays for.
</ResponseField>

<ResponseField name="period_end" type="date | null">
  Last day (exclusive) of that period.
</ResponseField>

<ResponseField name="attempt" type="integer | null">
  `0` for the first attempt of a period, `1`–`3` for retries.
</ResponseField>

<ResponseField name="payment_method" type="string" required>
  `flouci` (wallet) or `card`.
</ResponseField>

## Events

| Event | When | Suggested reaction |
| - | - | - |
| `subscription.activated` | The first charge was paid; the subscription is `active`. | Provision access. |
| `subscription.incomplete_expired` | The first charge was not paid in time or failed. | Offer the customer a new subscription. |
| `subscription.charge.succeeded` | A charge (first payment, renewal or retry) was paid. `period_start` / `period_end` say what it covers. | Extend access to `period_end`. |
| `subscription.charge.failed` | A renewal or retry failed. `status` tells you whether the subscription is `past_due`, `unpaid` or `canceled`. | Warn the customer; restrict access per your policy. |
| `subscription.past_due` | The first failure of a period: retries are scheduled. | Optional — `charge.failed` carries the same information. |
| `subscription.reactivated` | A retry succeeded on a `past_due` subscription. | Restore access. |
| `subscription.unpaid` | Retries exhausted; the subscription is kept as `unpaid`. | Revoke access. |
| `subscription.canceled` | Terminal cancel — by you, the customer, or Flouci (`dunning_exhausted`, `customer_wallet_closed`). | Revoke access at the end of the paid period. |
| `subscription.completed` | The last of `max_cycles` payments was collected; the subscription is `canceled` with `cancellation_reason: completed`. | Deliver the fully paid product. |
| `subscription.updated` | A non-status change, such as `cancel_at_period_end` being set. | Refresh your copy with [Get Subscription](/api-reference/recurring-payments/get-subscription). |

A single charge can produce two events (for example `subscription.charge.succeeded` then `subscription.reactivated`). Each carries its own `event_id`.

## Verifying the signature

The signature is `HMAC-SHA256` over the string `"<t>.<raw body>"` — the timestamp from the header, a dot, and the **exact bytes** of the request body — keyed with your app's **private token** (the second half of your `Authorization: Bearer <public>:<private>` credential). Compare it to `v1` in constant time and reject timestamps older than a few minutes.

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

  def verify_flouci_signature(raw_body: bytes, 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
      expected = hmac.new(
          private_token.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(expected, received)

  # Flask example
  # raw = request.get_data()
  # if not verify_flouci_signature(raw, request.headers["X-Flouci-Signature"], PRIVATE_TOKEN):
  #     abort(400)
  ```

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

  function verifyFlouciSignature(rawBody, 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}.`)
      .update(rawBody) // Buffer of the exact request body
      .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: use express.raw({ type: "application/json" }) so req.body is the untouched Buffer
  // if (!verifyFlouciSignature(req.body, req.get("X-Flouci-Signature"), PRIVATE_TOKEN)) return res.sendStatus(400);
  ```
</CodeGroup>

<Tip>
  Sign over the raw request body, not a re-serialized JSON object — any whitespace or key-order difference changes the digest.
</Tip>

## Handling events safely

<Steps>
  <Step title="Acknowledge fast">
    Verify the signature, store the event, answer `200`, then process. Slow handlers are retried and produce duplicates.
  </Step>

  <Step title="De-duplicate on event_id">
    Retries and the occasional double delivery mean you must treat `event_id` as the idempotency key.
  </Step>

  <Step title="Trust the status, confirm the money">
    `status` is authoritative for access. For accounting, confirm a charge with [Verify Payment](/api-reference/verify-transaction) using its `payment_id` — the same rule as for one-off payments.
  </Step>
</Steps>


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