# Webhooks

SimPay Flow uses two independent kinds of notifications:

| Kind | What it covers | Default URL and key |
|  --- | --- | --- |
| **Flow webhooks** | Status changes of submerchants, their payment channels and withdrawals | URL and key set in the Flow service in the SimPay Partner Panel |
| **Payment webhooks (IPN)** | Submerchant transactions, refunds, BLIK codes, subscriptions and BLIK aliases - [standard IPN notifications](/notifications/payment) | Parent service IPN URL and key |


You can override both URLs for each submerchant - see [Custom URLs for a submerchant](#custom-urls-for-a-submerchant).

## Flow webhooks

### Notification structure

Flow webhooks are sent with the `POST` method as JSON, with the `User-Agent: SimPay-IPN/2.0` header.


```json
{
  "type": "flow_submerchant_withdraw:status_changed",
  "webhook_id": "0199a6c2-5f3e-7d21-8b4a-2c3d4e5f6a7b",
  "attempt": 0,
  "flow_service": {
    "id": "flow_aaaabbbb",
    "name": "My platform"
  },
  "date": "2026-10-01T12:00:00+02:00",
  "data": {
    "id": "0199a5f0-2b8c-7c1e-9e3a-1f2b3c4d5e6f",
    "short_id": "1f2b3c4d",
    "amount": 150.5,
    "created_at": "2026-10-01T10:00:00+02:00",
    "updated_at": "2026-10-01T12:00:00+02:00",
    "wallet": {
      "id": "0199a5f0-1111-7c1e-9e3a-1f2b3c4d5e6f",
      "currency": "PLN"
    },
    "status": {
      "previous": "accepted",
      "current": "done"
    },
    "submerchant": {
      "id": "flsm_aaaabbbb",
      "custom_id": "user-123"
    }
  },
  "signature": "f54b85c687f2f4dd6f554350561bf9eb970bc51f0c5bbd1bf4b5172ad07f9f4e"
}
```

| Field | Description |
|  --- | --- |
| `type` | Event type (see below) |
| `webhook_id` | Unique notification ID (UUID). Each retry attempt has a new `webhook_id`. |
| `attempt` | Delivery attempt number, starting from `0` |
| `flow_service.id` | Flow service ID |
| `flow_service.name` | Flow service name |
| `date` | Notification send date (ISO 8601) |
| `data` | Event data - depends on `type` |
| `signature` | SHA-256 signature - see [Signature verification](#signature-verification) |


### Response

A notification is considered delivered when your server responds with:

- code `200` with the body `OK`, or
- code `202` or `204`.


Any other response, connection error or timeout (5 s to connect, 15 s for the response) means a failed attempt.

### Retries

Failed notifications are retried - up to **30 attempts**:

| Attempts | Interval between attempts |
|  --- | --- |
| 1-10 | approx. 1 minute |
| 11-15 | approx. 5 minutes |
| 16-20 | approx. 30 minutes |
| 21-30 | approx. 1 hour |


Only the current state is sent
If the object's state changes again before a retry (e.g. a withdrawal goes from `accepted` to `done`), the outdated notification is no longer sent - you'll only receive the notification about the latest change. Therefore always treat `current` as the current state and don't assume you'll receive every intermediate change.

You'll find the history of sent webhooks (including your server's responses) in the SimPay Partner Panel, in the Flow service settings. You can also send a test notification from there.

## Flow events

### `flow_submerchant:status_changed`

The submerchant status has changed - e.g. after onboarding data is submitted, after SimPay approval or after the account is blocked.


```json
"data": {
  "submerchant": {
    "id": "flsm_aaaabbbb",
    "custom_id": "user-123"
  },
  "previous": {
    "status": "client_verify",
    "aml_level": 1,
    "aml_status": "sent",
    "fully_active": false
  },
  "current": {
    "status": "client_active",
    "aml_level": 2,
    "aml_status": "accepted",
    "fully_active": true
  }
}
```

| Field | Description |
|  --- | --- |
| `status` | Submerchant account status at SimPay (e.g. `client_new`, `client_verify`, `client_active`, `client_block`) |
| `aml_level` | AML verification level (`2` - full verification) |
| `aml_status` | AML verification status (e.g. `sent`, `accepted`, `declined`) |
| `fully_active` | `true` when the submerchant is fully active and can accept payments |


Tip
To decide whether the submerchant can accept payments, use the `current.fully_active` field. The other fields are informational.

### `flow_submerchant_payment_channel:status_changed`

The status of a submerchant payment channel has changed - e.g. a channel sent for verification was approved or rejected, the BLIK OneClick status changed, or the active BLIK Recurring Payments models changed.


```json
"data": {
  "submerchant": {
    "id": "flsm_aaaabbbb",
    "custom_id": "user-123"
  },
  "channel": {
    "slug": "blik-level0",
    "name": "BLIK Level 0"
  },
  "previous": {
    "status": "active",
    "blik_one_click_status": "REGISTERED_WAITING_ACTIVATION",
    "blik_recurrent_a": null,
    "blik_recurrent_m": null,
    "blik_recurrent_o": null
  },
  "current": {
    "status": "active",
    "blik_one_click_status": "ACTIVE",
    "blik_recurrent_a": null,
    "blik_recurrent_m": null,
    "blik_recurrent_o": null
  }
}
```

| Field | Description |
|  --- | --- |
| `channel.slug` | Payment channel ID (same as `id` in [`GET .../payment-channels`](/apis/flow/flowgetsubmerchantpaymentchannels)) |
| `status` | Channel status: `new`, `in_progress`, `active`, `rejected` |
| `blik_one_click_status` | BLIK OneClick status (only `blik-level0`): `PENDING_REGISTRATION`, `REGISTERED_WAITING_ACTIVATION`, `ACTIVE`, `REJECTED` or `null` |
| `blik_recurrent_a` / `_m` / `_o` | Whether the BLIK Recurring Payments model AUTO / MANUAL / OPEN is active (only `blik-recurrent`) or `null` |


### `flow_submerchant_withdraw:status_changed`

The status of a submerchant withdrawal has changed. Example `data` - see [Notification structure](#notification-structure).

| Field | Description |
|  --- | --- |
| `id` / `short_id` | Withdrawal ID (UUID) and withdrawal short ID |
| `amount` | Withdrawal amount |
| `wallet.id` / `wallet.currency` | Wallet the withdrawal is made from and its currency |
| `status.previous` / `status.current` | Previous and current withdrawal status: `new`, `accepted`, `waiting`, `done`, `rejected` |


### `flow:test`

Test notification sent from the Partner Panel. `data` contains only a random `nonce` field:


```json
"data": {
  "nonce": "01K6FZ8X3Q9V2N7M4T5R1W0YAB"
}
```

## Signature verification

Every Flow webhook contains the `signature` field - a **SHA-256** hash (lowercase hex). The rule is the same as for [payment notifications](/notifications/payment/signature):

1. take **all values** from the notification (without the `signature` field) in the order they appear in the JSON, flattening nested objects,
2. join them with the `|` separator,
3. append the Flow webhook key at the end,
4. compute SHA-256 and compare it with `signature` using a timing-safe comparison function.



```text
type|webhook_id|attempt|flow_service.id|flow_service.name|date|data_value1|...|data_valueN|FLOW_WEBHOOK_KEY
```

Booleans, null and numbers
Unlike payment notifications, Flow webhooks contain `true`/`false`, `null` and numeric values. Before joining, convert them to text the way PHP does:

- `true` → `1`,
- `false` → empty string,
- `null` → empty string,
- numbers → without redundant zeros (`0`, `2`, `150.5`, `100` for `100.0`).


In JavaScript `String(true)` gives `"true"`, and in Python `str(True)` gives `"True"` - without the conversion the signature won't match.

**Key:** if the submerchant has a [custom Flow webhook URL](#custom-urls-for-a-submerchant), notifications about it are signed with its key. Otherwise - with the webhook key set in the Flow service.

details
summary
See the signature calculation for the example above
Key: `FLOW_WEBHOOK_KEY_64`


```text
flow_submerchant_withdraw:status_changed|0199a6c2-5f3e-7d21-8b4a-2c3d4e5f6a7b|0|flow_aaaabbbb|My platform|2026-10-01T12:00:00+02:00|0199a5f0-2b8c-7c1e-9e3a-1f2b3c4d5e6f|1f2b3c4d|150.5|2026-10-01T10:00:00+02:00|2026-10-01T12:00:00+02:00|0199a5f0-1111-7c1e-9e3a-1f2b3c4d5e6f|PLN|accepted|done|flsm_aaaabbbb|user-123|FLOW_WEBHOOK_KEY_64
```

SHA-256: `f54b85c687f2f4dd6f554350561bf9eb970bc51f0c5bbd1bf4b5172ad07f9f4e`

PHP

```php
<?php

function simpayFlowSignatureValid(array $payload, string $key): bool
{
    $signature = $payload['signature'] ?? '';
    unset($payload['signature']);

    $values = [];
    array_walk_recursive($payload, function ($value) use (&$values) {
        $values[] = $value;
    });
    $values[] = $key;

    // implode() converts true to "1", and false and null to an empty string
    return hash_equals(hash('sha256', implode('|', $values)), $signature);
}

$payload = json_decode(file_get_contents('php://input'), true);

if (!is_array($payload) || !simpayFlowSignatureValid($payload, 'FLOW_WEBHOOK_KEY')) {
    http_response_code(403);
    exit('INVALID_SIGNATURE');
}

// handle the $payload['type'] event

echo 'OK';
```

Node

```js
const crypto = require('crypto');

function toPhpString(value) {
    if (value === true) return '1';
    if (value === false || value === null) return '';
    return String(value);
}

function flatten(value, result = []) {
    if (value !== null && typeof value === 'object') {
        Object.values(value).forEach((item) => flatten(item, result));
    } else {
        result.push(toPhpString(value));
    }
    return result;
}

function simpayFlowSignatureValid(payload, key) {
    const { signature, ...rest } = payload;
    if (typeof signature !== 'string' || !/^[0-9a-f]{64}$/.test(signature)) {
        return false;
    }

    const values = flatten(rest);
    values.push(key);

    const expected = crypto.createHash('sha256').update(values.join('|')).digest('hex');

    return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'));
}

// Express.js
app.post('/webhooks/simpay/flow', express.json(), (req, res) => {
    if (!simpayFlowSignatureValid(req.body, process.env.SIMPAY_FLOW_WEBHOOK_KEY)) {
        return res.status(403).send('INVALID_SIGNATURE');
    }

    // handle the req.body.type event

    res.send('OK');
});
```

Python

```python
import hashlib
import hmac


def to_php_string(value) -> str:
    if value is True:
        return '1'
    if value is False or value is None:
        return ''
    if isinstance(value, float) and value.is_integer():
        return str(int(value))
    return str(value)


def flatten(value, result):
    if isinstance(value, dict):
        for item in value.values():
            flatten(item, result)
    elif isinstance(value, list):
        for item in value:
            flatten(item, result)
    else:
        result.append(to_php_string(value))
    return result


def simpay_flow_signature_valid(payload: dict, key: str) -> bool:
    data = {k: v for k, v in payload.items() if k != 'signature'}
    values = flatten(data, [])
    values.append(key)

    expected = hashlib.sha256('|'.join(values).encode()).hexdigest()

    return hmac.compare_digest(expected, payload.get('signature', ''))
```

## Payment webhooks (IPN) in SimPay Flow

Notifications about submerchant transactions, refunds, BLIK codes, subscriptions and BLIK aliases are [standard IPN notifications](/notifications/payment) - with the same structure and the same [signature calculation](/notifications/payment/signature). In SimPay Flow they differ in a few details.

### URL and key

| Situation | URL | Signature key |
|  --- | --- | --- |
| The submerchant **doesn't have** a custom IPN URL | Parent service IPN URL | Parent service IPN key |
| The submerchant **has** a custom IPN URL | Submerchant's custom URL | Submerchant's IPN key (returned when the URL was set) |


This way, in the basic setup you handle notifications for all submerchants with a single endpoint and a single key - the same one you use for the parent service.

### `service_id`

In **all** payment notifications related to submerchants (`transaction:status_changed`, `transaction_refund:status_changed`, `transaction_blik_level0:code_status_changed`, `subscription:status_changed`, `blik:alias_status_changed`) the `service_id` field contains the ID of the **parent service** - the one you pass in the `/payment/{serviceId}/...` path. The internal submerchant service never appears in notifications.

### The `data.flow` object

Every payment notification related to a submerchant (all events except `ipn:test`) contains the `flow` object in `data`. For regular Online Payments services the object is not present.

| Field | Description |
|  --- | --- |
| `flow.submerchant_id` | Submerchant ID (`flsm_...`) |
| `flow.submerchant_custom_id` | Submerchant `customId` |
| `flow.flow_service_id` | Flow service ID |


Since `service_id` points to the parent service, it's the `flow` object that lets you assign a transaction, refund, subscription or alias to the right submerchant in your system.


```json
"flow": {
  "submerchant_id": "flsm_aaaabbbb",
  "submerchant_custom_id": "user-123",
  "flow_service_id": "flow_aaaabbbb"
}
```

### Changes in `transaction:status_changed`

Additionally, the `transaction:status_changed` event contains the **`amount.commission_flow`** field - your Flow commission on the transaction (`string`, `%.2f` format). It's present only when the commission is greater than zero.


```json
{
  "type": "transaction:status_changed",
  "notification_id": "0199a6d0-1234-7abc-9def-0123456789ab",
  "date": "2026-10-01T12:05:00+02:00",
  "data": {
    "id": "0199a6cf-aaaa-7bbb-8ccc-0123456789ab",
    "payer_transaction_id": "Q68KLAKN",
    "service_id": "252b85c9",
    "status": "transaction_paid",
    "amount": {
      "final_currency": "PLN",
      "final_value": "49.99",
      "original_currency": "PLN",
      "original_value": "49.99",
      "commission_system": "0.60",
      "commission_partner": "49.14",
      "commission_currency": "PLN",
      "commission_flow": "0.25"
    },
    "control": "order-1001",
    "payment": {
      "channel": "blik",
      "type": "blik"
    },
    "customer": {
      "country_code": "PL"
    },
    "paid_at": "2026-10-01T12:04:58+02:00",
    "created_at": "2026-10-01T12:03:10+02:00",
    "flow": {
      "submerchant_id": "flsm_aaaabbbb",
      "submerchant_custom_id": "user-123",
      "flow_service_id": "flow_aaaabbbb"
    }
  },
  "signature": "..."
}
```

The signature covers the new fields
The `commission_flow` and `flow` fields are included in the signature calculation like any other field of the `data` object. If your validator dynamically flattens the whole payload (as in the [examples](/notifications/payment/signature)), you don't need to change anything.

## Custom URLs for a submerchant

By default, all notifications go to the URLs shared by the whole service. You can set separate URLs for a selected submerchant - e.g. when each of your Customers has its own application instance.

You get the current settings with [`GET .../submerchants/{submerchantId}/webhooks`](/apis/flow/flowgetsubmerchantwebhooks) and change them with [`PATCH .../submerchants/{submerchantId}/webhooks`](/apis/flow/flowupdatesubmerchantwebhooks):


```bash
curl -X PATCH "https://api.simpay.pl/flow/{flowServiceId}/submerchants/user-123/webhooks" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "flow": { "customUrl": "https://customer-123.example.com/webhooks/flow" },
    "payment": { "customUrl": "https://customer-123.example.com/webhooks/ipn" }
  }'
```


```json
{
  "success": true,
  "data": {
    "paymentNewIpnKey": "aVeryLongAndSecureKeyFor32Chars0",
    "flowNewIpnKey": "..."
  }
}
```

Save the keys from the response
When a custom URL is set for the first time, SimPay generates a **separate key** for the submerchant to verify notification signatures. The key is returned **only once** - in the response to this request. On subsequent URL changes the key doesn't change, and the response field is `null`.

To go back to the default URL, send `{"flow": {"useDefault": true}}` (or `payment`). The submerchant's custom URL and key will be removed, and notifications will go back to the service URL and key.

### New key

If the key leaked or you lost it, generate a new one with [`PATCH .../webhooks/key`](/apis/flow/flowupdatesubmerchantwebhookkey) with `{"type": "flow"}` or `{"type": "payment"}`. This works only for submerchants with a custom webhook URL of the given type. After a new key is generated, the previous one is no longer used.