# Webhook EasyPay: структура данных платежей Stripe

> **Этот документ описывает webhook, который EasyPay отправляет на ваш сервер при платёжных событиях Stripe.** Если вам нужно получить платёжные объекты по запросу (а не push-уведомлением), см. [Endpoint: Получение платёжных объектов Stripe](https://docs.thenextgen.store/s/635a43c1-37fa-463b-bc7e-7ede7884fce2/doc/endpoint-poluchenie-platyozhnyh-obuektov-stripe-tu4gaOUuSJ).

## Общая информация

EasyPay получает данные из API Stripe и передаёт их партнёру, сохраняя оригинальную структуру объектов Stripe.

### Передача id клиента/заказа через форму оплаты

Вы можете передать свой идентификатор клиента или заказа через параметр `client_reference_id` в URL платёжной ссылки:

```
https://short.appsign.me/test_xxx?client_reference_id=your_order_id
```

Подробнее: [Stripe URL Parameters](https://docs.stripe.com/payment-links/url-parameters#streamline-reconciliation-with-a-url-parameter)

### Принцип формирования payload

Мы получаем необходимые объекты через Stripe API и объединяем их в единый JSON, размещая каждый объект в отдельное поле. Итоговая структура содержит исчерпывающую информацию о событии, хотя и имеет некоторое дублирование данных между объектами.

## Как передаётся webhook

Мы отправляем данные на ваш webhook endpoint:

| Параметр | Значение |
|----------|----------|
| **Метод** | `POST`   |
| **Content-Type** | `application/json` |
| **Timeout** | 10 секунд на ответ |
| **Успешный ответ** | любой `2xx` |
| **Повторы** | до 6 попыток с нарастающим интервалом: \~1 → 3 → 9 → 27 → 30 минут (всего \~70 минут) |

Как убедиться, что запрос действительно пришёл от EasyPay (HMAC-подпись в заголовках, включается после настройки секрета), — [Webhook EasyPay: проверка подписи](https://docs.thenextgen.store/s/635a43c1-37fa-463b-bc7e-7ede7884fce2/doc/webhook-easypay-proverka-podpisi-YS2uy0j2It).

### Когда мы повторяем отправку

Повторяем, если повтор способен помочь:

* ответ `5xx`, обрыв соединения или отсутствие ответа за 10 секунд;
* ответ `429` (слишком много запросов), а также `408`, `409`, `425`.

На остальные `4xx` (`400`, `401`, `404` и подобные) повторов не будет — повтор их не исправит. Вместо этого срабатывает внутренний алерт, и с вами свяжется команда EasyPay.

Если вы отдаёте заголовок `Retry-After` (например, вместе с `429` или `503`), мы его учитываем, но ждём не дольше 30 минут.

### Идемпотентность на вашей стороне обязательна

Если ответ не пришёл за 10 секунд, мы не знаем, дошёл ли запрос, и повторим отправку. Доставка гарантируется по принципу «хотя бы один раз», поэтому одно и то же событие может прийти к вам дважды.

Повтор несёт ровно то же тело, что и первая отправка. Для дедупликации используйте пару «`Event` + идентификатор объекта, к которому относится событие»:

* `Charge.id` — для платежей, возвратов и диспутов;
* `Subscription.id` — для событий подписки.

Отдельный случай — `Stripe.payment_intent.payment_failed`: если платёж отклонён до того, как списание было создано (например, сработала защита от фрода), объект `Charge` приходит без `id`.

Если событие уже обработано, просто верните `2xx`.

## Поддерживаемые события

Каждый payload содержит поле `Event`, которое указывает тип события в формате `Stripe.<event_type>`.

| Event | Описание |
|-------|----------|
| `Stripe.checkout.session.completed` | Завершена оплата через Checkout (только для one-time payment) |
| `Stripe.invoice.payment_succeeded` | Успешный платёж по инвойсу (первый и повторные платежи подписки) |
| `Stripe.customer.subscription.created` | Создана новая подписка |
| `Stripe.customer.subscription.updated` | Подписка обновлена (изменение плана, статуса и т.д.) |
| `Stripe.customer.subscription.deleted` | Подписка отменена/удалена |
| `Stripe.payment_intent.payment_failed` | Платёж не прошёл |
| `Stripe.charge.refunded` | Выполнен возврат средств |
| `Stripe.charge.dispute.funds_withdrawn` | Открыт диспут — средства списаны |
| `Stripe.charge.dispute.funds_reinstated` | Диспут выигран — средства возвращены |

## Структура payload по типам событий

### Успешный платёж

Отправляется при событиях:

* `Stripe.checkout.session.completed`
* `Stripe.invoice.payment_succeeded`

#### Структура payload

```json
{
  "Event": "Stripe.checkout.session.completed",
  "Initial_Checkout_Session": <Checkout Session Object>,
  "Subscription": <Subscription Object>,
  "Invoice": <Invoice Object>,
  "Charge": <Charge Object>,
  "Balance_Transaction": <Balance Transaction Object>
}
```

#### Описание полей

| Поле | Тип | Описание |
|------|-----|----------|
| `Event` | string | Тип события |
| `Initial_Checkout_Session` | object | Объект сессии оплаты (для первого платежа). Содержит `client_reference_id`, данные клиента, параметры оплаты |
| `Subscription` | object | Сырой объект подписки Stripe (Subscription object). `null` для one-time payments |
| `Invoice` | object | Инвойс платежа (для подписок). Содержит детали выставленного счёта. `null` для one-time payments |
| `Charge` | object | Детали списания. Содержит информацию о карте, статус, результат проверок |
| `Balance_Transaction` | object | Транзакция баланса. Содержит комиссии Stripe, нетто-сумму |

#### Пример payload

### Подписка

Отправляется при событиях:

* `Stripe.customer.subscription.created`
* `Stripe.customer.subscription.updated`
* `Stripe.customer.subscription.deleted`

#### Структура payload

```json
{
  "Event": "Stripe.customer.subscription.created",
  "Initial_Checkout_Session": <Checkout Session Object>,
  "Subscription": <Subscription Object>,
  "Subscription_Product": <Product Object>,
  "Subscription_from_db": <Subscription Summary Object>
}
```

#### Описание полей

| Поле | Тип | Описание |
|------|-----|----------|
| `Event` | string | Тип события |
| `Initial_Checkout_Session` | object | Объект сессии оплаты (может быть пустым `{}` для update/delete событий) |
| `Subscription` | object | Полный объект подписки из Stripe |
| `Subscription_Product` | object | Объект продукта подписки |
| `Subscription_from_db` | object | Сокращённый объект с ключевой информацией о подписке |

#### Структура Subscription_from_db

```json
{
  "subscription_id": "sub_1ROFheLoVAqE08foy7R5vWgT",
  "easypay_partner_name": "your_partner_name",
  "creation_date": "2025-05-13T12:04:54.855Z",
  "customer_id": "cus_SIrQVLdMX8FBGs",
  "customer_name": "Test Customer",
  "customer_email": "test@example.com",
  "subscription_product_name": "Premium Plan",
  "subscription_product_id": "prod_S3bQuNWvDDRHEf",
  "amount_in_cents": 999,
  "currency": "usd",
  "source": "Stripe",
  "current_status": "active",
  "cancel_requested_at": null,
  "ended_at": null
}
```

#### Пример payload

### Неуспешный платёж

Отправляется при событии:

* `Stripe.payment_intent.payment_failed`

#### Структура payload

```json
{
  "Event": "Stripe.payment_intent.payment_failed",
  "Initial_Checkout_Session": <Checkout Session Object>,
  "Subscription": <Subscription Object>,
  "Invoice": <Invoice Object>,
  "Charge": <Charge Object>,
  "Balance_Transaction": {}
}
```

#### Описание полей

| Поле | Тип | Описание |
|------|-----|----------|
| `Event` | string | Тип события |
| `Initial_Checkout_Session` | object | Объект сессии оплаты (может быть пустым `null` / `{}` для событий) |
| `Subscription` | object | Сырой объект подписки Stripe (Subscription object). `null` для one-time payments |
| `Invoice` | object | Инвойс (для подписок) |
| `Charge` | object | Детали неуспешного списания. **Содержит информацию об ошибке** |
| `Balance_Transaction` | object | Пустой объект `{}` (транзакция не создаётся при неуспешном платеже) |

#### Ключевые поля для обработки ошибки

Информация об ошибке находится в объекте `Charge` (или в Payment Intent):

```json
{
  "failure_code": "card_declined",
  "failure_message": "Your card was declined.",
  "outcome": {
    "type": "issuer_declined",
    "network_decline_code": "05",
    "seller_message": "The bank returned the decline code `do_not_honor`."
  }
}
```

#### Пример payload

### Возврат (Refund)

Отправляется при событии:

* `Stripe.charge.refunded`

#### Структура payload

```json
{
  "Event": "Stripe.charge.refunded",
  "Initial_Checkout_Session": <Checkout Session Object>,
  "Subscription": <Subscription Object>,
  "Invoice": <Invoice Object>,
  "Charge": <Charge Object>,
  "Balance_Transaction": <Balance Transaction Object>,
  "Refund": <Refund Object>,
  "Dispute": {}
}
```

#### Описание полей

| Поле | Тип | Описание |
|------|-----|----------|
| `Event` | string | Тип события |
| `Initial_Checkout_Session` | object | Объект сессии оплаты (может быть пустым `null` / `{}` для событий) |
| `Subscription` | object | Объект подписки (если возврат связан с подпиской) |
| `Invoice` | object | Инвойс оригинального платежа |
| `Charge` | object | Детали оригинального списания. `amount_refunded` содержит сумму возврата |
| `Balance_Transaction` | object | Транзакция возврата (отрицательная сумма) |
| `Refund` | object | Детали возврата — ID, сумма, причина, статус |
| `Dispute` | object | Пустой объект `{}` |

#### Пример payload

### Диспут (Dispute)

Отправляется при событиях:

* `Stripe.charge.dispute.funds_withdrawn` — открыт диспут, средства списаны
* `Stripe.charge.dispute.funds_reinstated` — диспут выигран, средства возвращены

#### Структура payload

```json
{
  "Event": "Stripe.charge.dispute.funds_withdrawn",
  "Initial_Checkout_Session": <Checkout Session Object>,
  "Subscription": <Subscription Object>,
  "Invoice": <Invoice Object>,
  "Charge": <Charge Object>,
  "Balance_Transaction": <Balance Transaction Object>,
  "Refund": {},
  "Dispute": <Dispute Object>
}
```

#### Описание полей

| Поле | Тип | Описание |
|------|-----|----------|
| `Event` | string | Тип события |
| `Initial_Checkout_Session` | object | Объект сессии оплаты (может быть пустым `null` / `{}` для событий) |
| `Subscription` | object | Объект подписки (если диспут связан с подпиской) |
| `Invoice` | object | Инвойс оригинального платежа |
| `Charge` | object | Детали оригинального списания. `disputed: true` |
| `Balance_Transaction` | object | Транзакция диспута (списание или возврат средств) |
| `Refund` | object | Пустой объект `{}` |
| `Dispute` | object | **Детали диспута** — причина, статус, сроки, доказательства |

#### Ключевые поля Dispute объекта

```json
{
  "id": "du_1S7FL2LoVAqE08foJsFPwcD0",
  "amount": 1999,
  "reason": "fraudulent",
  "status": "needs_response",
  "evidence_details": {
    "due_by": 1759276799,
    "has_evidence": false,
    "past_due": false
  }
}
```

| Поле | Описание |
|------|----------|
| `reason` | Причина диспута: `fraudulent`, `duplicate`, `product_not_received`, `subscription_canceled`, и др. |
| `status` | Статус: `needs_response`, `under_review`, `won`, `lost` |
| `evidence_details.due_by` | Дедлайн для предоставления доказательств (timestamp) |

#### Пример payload

## Оценка риска платежа (risk score)

Stripe Radar оценивает каждый платёж: `risk_score` от 0 до 100 (чем выше — тем подозрительнее) и категория `risk_level`. Оценка приходит в объекте `Charge`, в поле `outcome`:

```json
{
  "Charge": {
    "outcome": {
      "type": "authorized",
      "network_status": "approved_by_network",
      "risk_level": "normal",
      "risk_score": 12,
      "seller_message": "Payment complete."
    }
  }
}
```

| Поле | Описание |
|------|----------|
| `outcome.risk_score` | Оценка риска 0–100. Чем выше — тем подозрительнее платёж |
| `outcome.risk_level` | Категория риска: `normal`, `elevated`, `highest` |
| `outcome.type` | Итог проверки: `authorized`, `issuer_declined`, `blocked`, `invalid` |
| `outcome.seller_message` | Пояснение итога. Для неуспешных платежей: `One of your rules blocked this payment` — сработало правило Radar; `Stripe blocked this payment as too risky` — Stripe заблокировал платёж сам |

Для неуспешных платежей (`Stripe.payment_intent.payment_failed`) поля `risk_score`, `risk_level` и `seller_message` дополнительно продублированы на верхнем уровне объекта `Charge`. Если платёж отклонён до создания списания (например, сработала защита от фрода), объект `Charge` приходит без `id`, и risk-поля могут быть `null`.

Как использовать risk score, чтобы защитить выдачу товара от оплат крадеными картами, — [Защита от фрода: работа с risk score в Stripe](https://docs.thenextgen.store/s/635a43c1-37fa-463b-bc7e-7ede7884fce2/doc/zashita-ot-froda-rabota-s-risk-score-v-stripe-rgiUFYt9Fr).

## Справочник объектов Stripe

| Объект | Документация Stripe |
|--------|---------------------|
| Checkout Session | [docs.stripe.com/api/checkout/sessions](https://docs.stripe.com/api/checkout/sessions) |
| Payment Intent | [docs.stripe.com/api/payment_intents/object](https://docs.stripe.com/api/payment_intents/object) |
| Charge | [docs.stripe.com/api/charges/object](https://docs.stripe.com/api/charges/object) |
| Balance Transaction | [docs.stripe.com/api/balance_transactions](https://docs.stripe.com/api/balance_transactions) |
| Subscription | [docs.stripe.com/api/subscriptions](https://docs.stripe.com/api/subscriptions) |
| Invoice | [docs.stripe.com/api/invoices/object](https://docs.stripe.com/api/invoices/object) |
| Refund | [docs.stripe.com/api/refunds/object](https://docs.stripe.com/api/refunds/object) |
| Dispute | [docs.stripe.com/api/disputes/object](https://docs.stripe.com/api/disputes/object) |

## FAQ

### Как отличить one-time payment от подписки?

* Если `Subscription` не пустой — это платёж по подписке
* Если `Invoice` содержит `billing_reason: "subscription_create"` — это первый платёж подписки
* Если `Invoice` содержит `billing_reason: "subscription_cycle"` — это повторный платёж подписки

### Как получить мой client_reference_id?

Поле `client_reference_id` находится в объекте `Initial_Checkout_Session`:

```json
{
  "Initial_Checkout_Session": {
    "client_reference_id": "your_order_id",
    ...
  }
}
```

### Как получить комиссию Stripe?

Комиссия находится в объекте `Balance_Transaction`:

```json
{
  "Balance_Transaction": {
    "fee": 2057,
    "fee_details": [
      {
        "amount": 2057,
        "description": "Stripe processing fees",
        "type": "stripe_fee"
      }
    ],
    "net": 67843
  }
}
```

* `fee` — общая комиссия в центах
* `net` — сумма к зачислению после вычета комиссии