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

# Webhooks

> Receive and verify Payment, Refund, and Payout events on your server

Webhooks send Payment, Refund, and Payout lifecycle events to your HTTPS endpoint.
Use them to update orders and track refunds and payouts without continuous polling.
Keep [Payment retrieval](/payments-api/payments/retrieve-a-payment) available
for reconciliation and for checking the latest state after a browser return.

## Set up an endpoint

1. In the Merchant Portal, select your merchant and the intended mode, then
   open **Developers → Webhooks endpoints**.
2. Choose **Add endpoint**, enter a publicly reachable HTTPS URL, and select
   the event types your integration handles.
3. Store the returned signing secret in your server-side secrets manager.
   It is shown when the endpoint is created; do not put it in frontend code,
   logs, screenshots, or support messages.
4. Confirm that the endpoint is enabled. Create a Sandbox payment and verify
   that your server receives and acknowledges its subscribed events.

Use a direct HTTPS URL with a valid TLS certificate, for example
`https://shop.example.com/webhooks/flowlix`. URLs cannot contain credentials,
query parameters, or fragments, and must resolve to public addresses.
Redirects are not followed. Your endpoint authenticates Flowlix using the
signature below, not a browser session or an API key in the URL.

Sandbox and Live destinations, events, and signing secrets are separate.
Verify with the secret for the destination and mode receiving the request.

## Event types

| Event                        | When it occurs                                                             |
| ---------------------------- | -------------------------------------------------------------------------- |
| `payment.created`            | A Payment is created.                                                      |
| `payment.requires_action`    | A Payment requires customer action, or its next action changes materially. |
| `payment.succeeded`          | A Payment completes successfully.                                          |
| `payment.failed`             | A Payment reaches a terminal failed state.                                 |
| `payment.expired`            | A Payment expires before completion.                                       |
| `payment.partially_refunded` | Part, but not all, of the Payment amount has been refunded.                |
| `payment.refunded`           | The full Payment amount has been refunded.                                 |
| `refund.created`             | A Refund is created.                                                       |
| `refund.processing`          | A Refund starts processing.                                                |
| `refund.succeeded`           | A Refund completes successfully.                                           |
| `refund.failed`              | A Refund fails to complete.                                                |
| `payout.created`             | A Payout is created in `PENDING`.                                          |
| `payout.processing`          | A Payout enters `PROCESSING` after provider acknowledgement.               |
| `payout.succeeded`           | A Payout reaches `SUCCEEDED`.                                              |
| `payout.failed`              | A Payout reaches `FAILED`.                                                 |

A Payout can move directly from `PENDING` to `SUCCEEDED` or `FAILED`,
without a `payout.processing` event. Do not require every intermediate event
or assume delivery order. Use [Payout retrieval](/payments-api/payouts/retrieve-a-payout)
to reconcile its current state.

Refund-related Payment events describe changes to refund totals; they do not
add Payment statuses. A refunded Payment remains `SUCCEEDED`. Use
`amount_refunded`, `amount_refundable`, and the individual Refund statuses.
Safely acknowledge valid event types you do not handle, so compatible additions
do not break your endpoint.

## Event payload

Each delivery is an HTTPS `POST` with `Content-Type: application/json` and a
`Flowlix-Signature` header. The envelope contains an **immutable snapshot** of
the resource when the event occurred, not the latest state at delivery time.

| Field              | Meaning                                                                                     |
| ------------------ | ------------------------------------------------------------------------------------------- |
| `id`               | Opaque UUIDv7 Event ID. Use it for duplicate detection, not ordering.                       |
| `type`             | Lifecycle event name from the catalogue above.                                              |
| `mode`             | `SANDBOX` or `LIVE`.                                                                        |
| `api_version`      | Event representation version, currently `2026-09-01`; separate from the `/v1` API path.     |
| `created_at`       | Time the event occurred, in whole Unix seconds; not the delivery time.                      |
| `request`          | `{ "id": "req_..." }` for the API request that directly caused the event, otherwise `null`. |
| `data.object_type` | `payment`, `refund`, or `payout`.                                                           |
| `data.object`      | The corresponding Payment, Refund, or Payout snapshot.                                      |

<CodeGroup>
  ```json Payment event theme={null}
  {
    "id": "0198f6c2-8b42-7a21-9a30-52ee4f826d25",
    "type": "payment.failed",
    "mode": "SANDBOX",
    "api_version": "2026-09-01",
    "created_at": 1786903200,
    "request": { "id": "req_abc123def456" },
    "data": {
      "object_type": "payment",
      "object": {
        "id": "pay_q7Mk2Np8Vr4Xt6Yz9Ab3Cd5E",
        "amount": 4999,
        "currency": "EUR",
        "status": "FAILED",
        "integration_type": "DIRECT",
        "amount_refunded": 0,
        "amount_refundable": 0,
        "refunds": [],
        "status_transitions": {
          "requires_action_at": null,
          "processing_at": null,
          "succeeded_at": null,
          "failed_at": 1786903200,
          "expired_at": null
        },
        "failure_code": "issuer_declined",
        "failure_message": "The payment was declined by the issuer.",
        "created_at": 1786903190,
        "livemode": false
      }
    }
  }
  ```

  ```json Refund event theme={null}
  {
    "id": "0198f6c2-8b42-7a21-9a30-52ee4f826d26",
    "type": "refund.succeeded",
    "mode": "SANDBOX",
    "api_version": "2026-09-01",
    "created_at": 1786903200,
    "request": null,
    "data": {
      "object_type": "refund",
      "object": {
        "id": "ref_L9xQ4wE2rT8yU6iO3pA7sD1f",
        "payment_id": "pay_q7Mk2Np8Vr4Xt6Yz9Ab3Cd5E",
        "amount": 1500,
        "currency": "EUR",
        "reason": "Customer requested a refund",
        "refund_type": "PARTIAL",
        "status": "SUCCEEDED",
        "failure": null,
        "created_at": 1786903100,
        "updated_at": 1786903200,
        "completed_at": 1786903200,
        "livemode": false
      }
    }
  }
  ```

  ```json Payout event theme={null}
  {
    "id": "0198f6c2-8b42-7a21-9a30-52ee4f826d27",
    "type": "payout.succeeded",
    "mode": "SANDBOX",
    "api_version": "2026-09-01",
    "created_at": 1786903200,
    "request": null,
    "data": {
      "object_type": "payout",
      "object": {
        "id": "po_7Qr3Lm9Ns2Vx6Za8Bc4Df1Gh",
        "amount": 2500,
        "currency": "EUR",
        "merchant_reference": 1234567890,
        "status": "SUCCEEDED",
        "card_brand": "visa",
        "card_last4": "1111",
        "recipient": {
          "country": "DE",
          "first_name": "Jenny",
          "last_name": "Rosen"
        },
        "created_at": 1786903100,
        "updated_at": 1786903200
      }
    }
  }
  ```
</CodeGroup>

`request: null` is normal for an automatic or provider-caused event; do not
substitute the request that originally created the Payment or Payout. A Payout
snapshot has no separate `livemode` field; use the envelope's `mode`.
For a Refund, use
`data.object.payment_id` to retrieve its parent Payment.

## Verify the signature

The header has the form `t=<Unix seconds>,v1=<64 lowercase hexadecimal characters>`.
Its timestamp belongs to this delivery attempt, not to `event.created_at`.

1. Read the **raw request body** as bytes, before parsing or reserializing JSON.
2. Prefix those bytes with the header's timestamp and a period: `timestamp + "." + body`.
3. Compute HMAC-SHA256 using the signing secret's UTF-8 bytes, **including the `whsec_` prefix**.
   Do not base64-decode the secret or remove its prefix.
4. Compare the expected and received signature with a constant-time comparison.
5. Reject stale timestamps. The example below uses a recommended five-minute
   tolerance in either direction; keep your server clock synchronized.

```python theme={null}
import hashlib
import hmac
import re
import time


def verify_flowlix_signature(header: str, body: bytes, secret: str) -> bool:
    if not isinstance(header, str) or not isinstance(body, bytes):
        return False
    if not isinstance(secret, str) or not secret:
        return False
    match = re.fullmatch(r"t=(0|[1-9][0-9]{0,11}),\s*v1=([0-9a-f]{64})", header)
    if match is None:
        return False
    timestamp, signature = match.groups()
    if abs(time.time() - int(timestamp)) > 300:
        return False
    signed_bytes = timestamp.encode("ascii") + b"." + body
    expected = hmac.new(secret.encode("utf-8"), signed_bytes, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
```

<Warning>
  Verify before trusting or parsing the event for business processing. JSON
  formatting, whitespace, or encoding changes alter the signature. Never log
  the signing secret or entire request body to debug a mismatch.
</Warning>

## Acknowledge and process safely

Return a small, complete `2xx` response promptly, after durably accepting the
event. Do not perform slow business work before acknowledgement. A non-2xx
response, a redirect, a connection failure, or a timeout is a failed attempt.

Use a database uniqueness constraint on the Event `id`, scoped to your
merchant integration if your storage serves multiple merchants. In one
transaction, insert the event and a durable processing job. Commit before
returning `2xx`; if you cannot persist the event, return a non-2xx response.
A duplicate already durably accepted can receive `2xx` without a second job.

The worker processes accepted events and retries its own failures. Protect
external side effects, such as fulfilment, with their own idempotency key;
receiving an event once does not make a business action exactly-once.

```text theme={null}
Receive raw bytes → verify signature → parse and check mode/version/type
  Invalid signature: reject
  Valid but unhandled event type: acknowledge
  New handled event: durably store event + job → commit → return 2xx
  Already accepted event: return 2xx without repeating the business action
```

## Automatic retries

Failed deliveries use the following scheduled offsets from creation of the
delivery, including the initial attempt:

| Mode    | Scheduled attempts                                                                            |
| ------- | --------------------------------------------------------------------------------------------- |
| Sandbox | Immediately, 10 minutes, 2 hours.                                                             |
| Live    | Immediately, 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, 24 hours, 48 hours, 72 hours. |

Retries include positive jitter of up to 10% of the interval since the previous
slot. These are scheduling offsets, not exact arrival guarantees. Missed retry
slots can be skipped rather than replayed in a burst after downtime. Delivery
stops after acknowledgement or exhaustion of the available retry slots.

The same Event ID and snapshot can arrive more than once, with a fresh signature
timestamp for a new attempt. Events can arrive out of order; do not overwrite a
newer order state blindly with an older snapshot. Retrieve the current Payment
when you need the latest state, and use its `refunds` array to reconcile Refunds.

Keep a slower reconciliation loop for payments your system still considers
pending. Webhook delivery and your business processing are separate: a `2xx`
response confirms receipt, not successful fulfilment.
