# Webhooki

W SimPay Flow działają dwa niezależne rodzaje powiadomień:

| Rodzaj | Czego dotyczy | Domyślny adres i klucz |
|  --- | --- | --- |
| **Webhooki Flow** | Zmiany statusów submerchantów, ich kanałów płatności i wypłat | Adres i klucz ustawione w usłudze Flow w Panelu Partnera SimPay |
| **Webhooki płatności (IPN)** | Transakcje, zwroty, kody BLIK, subskrypcje i aliasy BLIK submerchantów - [standardowe powiadomienia IPN](/notifications/payment) | Adres i klucz IPN usługi nadrzędnej |


Dla każdego submerchanta możesz nadpisać oba adresy - zobacz [Własne adresy dla submerchanta](#w%C5%82asne-adresy-dla-submerchanta).

## Webhooki Flow

### Struktura powiadomienia

Webhooki Flow są wysyłane metodą `POST` jako JSON, z nagłówkiem `User-Agent: SimPay-IPN/2.0`.


```json
{
  "type": "flow_submerchant_withdraw:status_changed",
  "webhook_id": "0199a6c2-5f3e-7d21-8b4a-2c3d4e5f6a7b",
  "attempt": 0,
  "flow_service": {
    "id": "flow_aaaabbbb",
    "name": "Moja platforma"
  },
  "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": "eb4f27ad689b4613fd7cbeff1aa7b130b5cbe00e6fe3bc93772fb72fa037e472"
}
```

| Pole | Opis |
|  --- | --- |
| `type` | Typ zdarzenia (patrz niżej) |
| `webhook_id` | Unikalne ID powiadomienia (UUID). Każda kolejna próba wysyłki ma nowe `webhook_id`. |
| `attempt` | Numer próby wysyłki, liczony od `0` |
| `flow_service.id` | ID usługi Flow |
| `flow_service.name` | Nazwa usługi Flow |
| `date` | Data wysłania powiadomienia (ISO 8601) |
| `data` | Dane zdarzenia - zależne od `type` |
| `signature` | Sygnatura SHA-256 - zobacz [Weryfikacja sygnatury](#weryfikacja-sygnatury) |


### Odpowiedź

Powiadomienie uznajemy za dostarczone, gdy Twój serwer odpowie:

- kodem `200` z treścią `OK`, lub
- kodem `202` albo `204`.


Każda inna odpowiedź, błąd połączenia lub przekroczenie czasu (5 s na połączenie, 15 s na odpowiedź) oznacza nieudaną próbę.

### Ponowienia

Nieudane powiadomienia są ponawiane - maksymalnie **30 prób**:

| Próby | Odstęp między próbami |
|  --- | --- |
| 1-10 | ok. 1 minuty |
| 11-15 | ok. 5 minut |
| 16-20 | ok. 30 minut |
| 21-30 | ok. 1 godziny |


Wysyłamy tylko aktualny stan
Jeśli przed ponowieniem stan obiektu zmieni się ponownie (np. wypłata przejdzie z `accepted` na `done`), nieaktualne powiadomienie nie zostanie już wysłane - otrzymasz tylko powiadomienie o najnowszej zmianie. Dlatego zawsze traktuj `current` jako aktualny stan, a nie zakładaj, że otrzymasz każdą pośrednią zmianę.

Historię wysłanych webhooków (wraz z odpowiedziami Twojego serwera) znajdziesz w Panelu Partnera SimPay, w ustawieniach usługi Flow. Stamtąd możesz też wysłać powiadomienie testowe.

## Zdarzenia Flow

### `flow_submerchant:status_changed`

Zmienił się status submerchanta - np. po przekazaniu danych w onboardingu, po akceptacji przez SimPay lub po zablokowaniu konta.


```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
  }
}
```

| Pole | Opis |
|  --- | --- |
| `status` | Status konta submerchanta w SimPay (np. `client_new`, `client_verify`, `client_active`, `client_block`) |
| `aml_level` | Poziom weryfikacji AML (`2` - pełna weryfikacja) |
| `aml_status` | Status weryfikacji AML (np. `sent`, `accepted`, `declined`) |
| `fully_active` | `true`, gdy submerchant jest w pełni aktywny i może przyjmować płatności |


Wskazówka
Do decyzji, czy submerchant może przyjmować płatności, używaj pola `current.fully_active`. Pozostałe pola mają charakter informacyjny.

### `flow_submerchant_payment_channel:status_changed`

Zmienił się status kanału płatności submerchanta - np. kanał wysłany do weryfikacji został zaakceptowany lub odrzucony, zmienił się status BLIK OneClick lub aktywne modele BLIK Płatności Powtarzalnych.


```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
  }
}
```

| Pole | Opis |
|  --- | --- |
| `channel.slug` | ID kanału płatności (to samo co `id` w [`GET .../payment-channels`](/apis/flow/flowgetsubmerchantpaymentchannels)) |
| `status` | Status kanału: `new`, `in_progress`, `active`, `rejected` |
| `blik_one_click_status` | Status BLIK OneClick (tylko `blik-level0`): `PENDING_REGISTRATION`, `REGISTERED_WAITING_ACTIVATION`, `ACTIVE`, `REJECTED` lub `null` |
| `blik_recurrent_a` / `_m` / `_o` | Czy model BLIK Płatności Powtarzalnych AUTO / MANUAL / OPEN jest aktywny (tylko `blik-recurrent`) lub `null` |


### `flow_submerchant_withdraw:status_changed`

Zmienił się status wypłaty submerchanta. Przykład `data` - patrz [Struktura powiadomienia](#struktura-powiadomienia).

| Pole | Opis |
|  --- | --- |
| `id` / `short_id` | ID wypłaty (UUID) i krótkie ID wypłaty |
| `amount` | Kwota wypłaty |
| `wallet.id` / `wallet.currency` | Portfel, z którego realizowana jest wypłata, i jego waluta |
| `status.previous` / `status.current` | Poprzedni i aktualny status wypłaty: `new`, `accepted`, `waiting`, `done`, `rejected` |


### `flow:test`

Powiadomienie testowe wysyłane z Panelu Partnera. `data` zawiera tylko losowe pole `nonce`:


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

## Weryfikacja sygnatury

Każdy webhook Flow zawiera pole `signature` - hash **SHA-256** (lowercase hex). Zasada jest taka sama jak w [powiadomieniach płatności](/notifications/payment/signature):

1. weź **wszystkie wartości** z powiadomienia (bez pola `signature`) w kolejności, w jakiej występują w JSON, spłaszczając zagnieżdżone obiekty,
2. połącz je separatorem `|`,
3. dołącz na końcu klucz webhooków Flow,
4. policz SHA-256 i porównaj z `signature` bezpieczną funkcją porównującą.



```text
type|webhook_id|attempt|flow_service.id|flow_service.name|date|data_value1|...|data_valueN|KLUCZ_WEBHOOKÓW_FLOW
```

Wartości logiczne, null i liczby
W odróżnieniu od powiadomień płatności webhooki Flow zawierają wartości `true`/`false`, `null` i liczby. Przed połączeniem zamień je na tekst tak jak PHP:

- `true` → `1`,
- `false` → pusty ciąg,
- `null` → pusty ciąg,
- liczby → zapis bez zbędnych zer (`0`, `2`, `150.5`, `100` dla `100.0`).


W JavaScript `String(true)` daje `"true"`, a w Pythonie `str(True)` daje `"True"` - bez konwersji sygnatura się nie zgodzi.

**Klucz:** jeśli submerchant ma [własny adres webhooków Flow](#w%C5%82asne-adresy-dla-submerchanta), powiadomienia o nim są podpisywane jego kluczem. W pozostałych przypadkach - kluczem webhooków ustawionym w usłudze Flow.

details
summary
Zobacz wyliczenie sygnatury dla przykładu powyżej
Klucz: `FLOW_WEBHOOK_KEY_64`


```text
flow_submerchant_withdraw:status_changed|0199a6c2-5f3e-7d21-8b4a-2c3d4e5f6a7b|0|flow_aaaabbbb|Moja platforma|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: `eb4f27ad689b4613fd7cbeff1aa7b130b5cbe00e6fe3bc93772fb72fa037e472`

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() zamienia true na "1", a false i null na pusty ciąg
    return hash_equals(hash('sha256', implode('|', $values)), $signature);
}

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

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

// obsługa zdarzenia $payload['type']

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');
    }

    // obsługa zdarzenia req.body.type

    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', ''))
```

## Webhooki płatności (IPN) w SimPay Flow

Powiadomienia o transakcjach, zwrotach, kodach BLIK, subskrypcjach i aliasach BLIK submerchantów to [standardowe powiadomienia IPN](/notifications/payment) - z tą samą strukturą i tym samym sposobem [wyliczania sygnatury](/notifications/payment/signature). W SimPay Flow różnią się kilkoma szczegółami.

### Adres i klucz

| Sytuacja | Adres | Klucz do sygnatury |
|  --- | --- | --- |
| Submerchant **nie ma** własnego adresu IPN | Adres IPN usługi nadrzędnej | Klucz IPN usługi nadrzędnej |
| Submerchant **ma** własny adres IPN | Własny adres submerchanta | Klucz IPN submerchanta (zwrócony przy ustawianiu adresu) |


Dzięki temu w podstawowej konfiguracji obsługujesz powiadomienia wszystkich submerchantów jednym endpointem i jednym kluczem - tym samym, którego używasz dla usługi nadrzędnej.

### `service_id`

We **wszystkich** powiadomieniach płatności dotyczących submerchantów (`transaction:status_changed`, `transaction_refund:status_changed`, `transaction_blik_level0:code_status_changed`, `subscription:status_changed`, `blik:alias_status_changed`) pole `service_id` zawiera ID **usługi nadrzędnej** - tej, którą podajesz w ścieżce `/payment/{serviceId}/...`. Wewnętrzna usługa submerchanta nigdy nie pojawia się w powiadomieniach.

### Obiekt `data.flow`

Każde powiadomienie płatności dotyczące submerchanta (wszystkie zdarzenia poza `ipn:test`) zawiera w `data` obiekt `flow`. Dla zwykłych usług Płatności Online obiekt nie występuje.

| Pole | Opis |
|  --- | --- |
| `flow.submerchant_id` | ID submerchanta (`flsm_...`) |
| `flow.submerchant_custom_id` | `customId` submerchanta |
| `flow.flow_service_id` | ID usługi Flow |


Ponieważ `service_id` wskazuje usługę nadrzędną, to właśnie obiekt `flow` pozwala przypisać transakcję, zwrot, subskrypcję czy alias do właściwego submerchanta w Twoim systemie.


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

### Zmiany w `transaction:status_changed`

Dodatkowo w zdarzeniu `transaction:status_changed` pojawia się pole **`amount.commission_flow`** - Twoja prowizja Flow od transakcji (`string`, format `%.2f`). Występuje tylko, gdy prowizja jest większa od zera.


```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": "..."
}
```

Sygnatura obejmuje nowe pola
Pola `commission_flow` i `flow` biorą udział w wyliczaniu sygnatury jak każde inne pole obiektu `data`. Jeśli Twój walidator dynamicznie spłaszcza cały payload (tak jak w [przykładach](/notifications/payment/signature)), nie musisz niczego zmieniać.

## Własne adresy dla submerchanta

Domyślnie wszystkie powiadomienia trafiają na adresy wspólne dla całej usługi. Dla wybranego submerchanta możesz ustawić osobne adresy - np. gdy każdy z Twoich Klientów ma własną instancję aplikacji.

Aktualne ustawienia pobierzesz przez [`GET .../submerchants/{submerchantId}/webhooks`](/apis/flow/flowgetsubmerchantwebhooks), a zmienisz je przez [`PATCH .../submerchants/{submerchantId}/webhooks`](/apis/flow/flowupdatesubmerchantwebhooks):


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


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

Zapisz klucze z odpowiedzi
Przy pierwszym ustawieniu własnego adresu SimPay generuje dla submerchanta **osobny klucz** do weryfikacji sygnatury powiadomień. Klucz jest zwracany **tylko raz** - w odpowiedzi na to żądanie. Przy kolejnych zmianach adresu klucz się nie zmienia, a pole w odpowiedzi ma wartość `null`.

Aby wrócić do adresu domyślnego, wyślij `{"flow": {"useDefault": true}}` (lub `payment`). Własny adres i klucz submerchanta zostaną usunięte, a powiadomienia wrócą na adres i klucz usługi.

### Nowy klucz

Jeśli klucz wyciekł lub go zgubiłeś, wygeneruj nowy przez [`PATCH .../webhooks/key`](/apis/flow/flowupdatesubmerchantwebhookkey) z `{"type": "flow"}` lub `{"type": "payment"}`. Działa to tylko dla submerchantów z własnym adresem webhooków danego typu. Po wygenerowaniu nowego klucza poprzedni przestaje być używany.