API для приёма оплат на своём сайте
Публичный REST API: создайте платёж, отправьте покупателя на оплату, примите вебхук. Приём денег, чек, автовыдача на игровой сервер и выплаты остаются на нашей стороне.
- 11
- публичных ручек
- 12
- событий вебхуков
- 8
- скоупов ключа

Обзор
Публичный API нужен, если у вас есть свой сайт, лаунчер или бот и вы хотите принимать оплату там, а не на странице магазина GoDonate.
Схема простая: вы создаёте платёж запросом к API, отправляете покупателя на полученный checkoutUrl, а после оплаты получаете вебхук. Всё остальное остаётся на нашей стороне — приём денег по СБП и картам, чек по агентской схеме, расчёт комиссии, автовыдача товара на игровой сервер по RCON или через плагин, выплаты на ваши реквизиты. Свою платёжную страницу писать не нужно, PCI DSS и реквизиты карт вас не касаются.
Что вы получаете
- Оплата стартует прямо из вашего интерфейса: корзина, кнопка, редирект на оплату, возврат на вашу страницу.
- Заказы связаны с вашей базой через
externalOrderIdиmetadata— их видно и в статусе платежа, и в вебхуке. - Каталог товаров и выдача остаются нашими: после оплаты воркер сам выполняет команды товара на игровом сервере и присылает события
delivery.*. - Песочница на тестовом ключе: полный цикл интеграции без реальных списаний.
Базовый адрес и формат
Все ручки живут под одним префиксом. Только HTTPS, тело и ответы — JSON в UTF-8. Суммы во всём API целые, в копейках (amountCents: 49900 = 499,00 ₽) — дробных рублей нет нигде, чтобы не терять копейки на округлении.
https://godonate.ru/v1/public// Успех — всегда объект с полем data
{ "data": { "id": "…", "status": "PENDING" } }
// Ошибка — объект с полем error и машинным кодом
{ "error": { "code": "ITEMS_REQUIRED", "message": "Для этого магазина оплата возможна только по товарам (укажите items)." } }Ориентируйтесь на error.code, а не на текст message: коды стабильны, а формулировки мы можем уточнять. Полный список — в разделе «Коды ошибок».
Кому API доступен
Доступ зависит от категории аккаунта, выбранной при регистрации. Ограничения не косметические: они следуют из того, как оформляется чек (54-ФЗ) и как мы отвечаем за приём платежей.
| Категория | Публичный API | Особенности |
|---|---|---|
| Игровой сервер | Доступен | Обязателен ИНН в кабинете, иначе создание платежа отвечает MERCHANT_INN_REQUIRED. В каждом платеже обязательны позиции items. |
| НКО | Доступен | Можно создавать платёж без items — как сбор произвольной суммы. |
| Стример, креатор, другое | Недоступен | Ключи не выдаются, а уже выданные перестают работать: любой запрос отвечает 403 API_NOT_AVAILABLE. Приём — через страницу на GoDonate. |
Быстрый старт
Пять шагов от пустого проекта до оплаченного заказа. Первые два делаются один раз в кабинете, остальные — код.
- 01Выпустите API-ключКабинет → API-ключи → создать. Отметьте только нужные скоупы: для магазина на своём сайте обычно хватает
payments:write,payments:readиproducts:read. Полный ключ показывается один раз — мы храним только его хеш. Для разработки создайте отдельный тестовый ключ (gd_test_…). - 02Добавьте домен возвратаАдреса, на которые мы вернём покупателя после оплаты, работают только с домена из белого списка в кабинете. Пока домен не добавлен, запрос с
returnUrlотвечаетRETURN_URL_NOT_ALLOWED. Почему нельзя передавать любой адрес — в разделе «Возврат на сайт». - 03Создайте платёжОдин POST-запрос с идентификатором магазина, суммой в копейках и позициями заказа. Свой номер заказа передавайте в
externalOrderId— он же защищает от двойного списания при повторе запроса. - 04Отправьте покупателя на checkoutUrlВ ответе приходит
checkoutUrl— страница оплаты провайдера. Сделайте на неё редирект (или откройте в новом окне). Ссылка живёт 15 минут: по истечении срока платёж перестаёт быть оплачиваемым, для новой попытки создайте новый платёж — с новымexternalOrderId. - 05Примите вебхук и выдайте заказЗаказ считается оплаченным по событию
payment.captured, а не по возврату покупателя на сайт: браузер он может закрыть в любой момент. Проверьте подпись, ответьте2xx, отметьте заказ у себя. В payload уже естьexternalOrderId,itemsиmetadata— второй запрос к API за деталями не нужен.
curl -X POST https://godonate.ru/v1/public/payments \
-H "X-API-Key: gd_live_ваш_ключ" \
-H "Content-Type: application/json" \
-d '{
"storeId": "8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de",
"amountCents": 49900,
"items": [
{ "productId": "b3c1f0a2-5d7e-4a91-8c22-7f0e5a1d9b64",
"name": "VIP на 30 дней", "priceCents": 49900, "quantity": 1 }
],
"customer": { "nickname": "Steve" },
"externalOrderId": "shop-2026-000417",
"returnUrl": "https://myserver.ru/order/417/done",
"failureUrl": "https://myserver.ru/order/417/fail"
}'{
"data": {
"id": "9c8d7e6f-4b21-4c8a-93d5-2a7e11f0c3b8",
"status": "PENDING",
"amountCents": 49900,
"feeCents": 1996,
"netCents": 47904,
"checkoutUrl": "https://securepay.example.ru/new/aBcD1234",
"returnUrl": "https://myserver.ru/order/417/done?payment_id=9c8d7e6f-…&order_id=shop-2026-000417",
"failureUrl": "https://myserver.ru/order/417/fail?payment_id=9c8d7e6f-…&order_id=shop-2026-000417"
}
}feeCents — наша комиссия приёма по вашей ставке, netCents — что попадёт на баланс (amountCents − feeCents). Ставка индивидуальна и доступна в GET /v1/public/merchant.
externalOrderId. После этого замените ключ на боевой — код менять не придётся, адреса ручек и форматы одинаковые.Аутентификация
Один секретный ключ на интеграцию. Никаких OAuth-редиректов и обновления токенов: ключ не истекает сам, если вы не задали срок при выпуске.
Заголовки
Ключ передаётся в X-API-Key. Поддерживается и Authorization: Bearer — но только для значений, начинающихся с gd_; любой другой Bearer-токен просто игнорируется. Если заданы оба заголовка, приоритет у X-API-Key.
# Основной вариант
curl https://godonate.ru/v1/public/merchant \
-H "X-API-Key: gd_live_ваш_ключ"
# Bearer — для клиентов, которым удобнее стандартный заголовок
curl https://godonate.ru/v1/public/merchant \
-H "Authorization: Bearer gd_live_ваш_ключ"Боевой и тестовый ключ
Среда зашита в сам ключ, отдельного «переключателя песочницы» нет — по префиксу видно, куда уйдут деньги:
gd_live_…— боевой ключ: платежи идут через реального провайдера, деньги списываются.gd_test_…— ключ песочницы: платёж создаётся через mock-провайдера, оплата подтверждается на тестовой странице, реального списания нет. Такой платёж помеченisTest: true— и в вебхуке это поле тоже приходит, чтобы тестовые заказы не смешались с боевыми.
Остальное в песочнице настоящее: те же адреса ручек, та же валидация, те же коды ошибок, те же вебхуки. Магазины и товары у тестового и боевого ключа общие — это один и тот же аккаунт.
Скоупы
Скоупы выбираются при выпуске ключа и не меняются потом — чтобы расширить права, выпустите новый ключ. Если у ключа нет нужного скоупа, ручка отвечает 403 API_KEY_FORBIDDEN и сообщает, какого именно скоупа не хватает.
| Скоуп | Что открывает |
|---|---|
| merchants:read | Данные аккаунта: статус, ставки комиссии, баланс |
| stores:read | Список магазинов |
| stores:write | Создание и изменение магазинов |
| products:read | Список товаров |
| products:write | Создание, изменение и удаление товаров |
| payments:read | Статус платежа |
| payments:write | Создание платежей |
| payouts:read | История выплат |
Практика: для магазина на своём сайте достаточно ключа на запись платежей и чтение товаров. Ключ для аналитики или бухгалтерии делайте отдельным — только на чтение.
Отказы аутентификации
| Код | HTTP | Причина |
|---|---|---|
| API_KEY_MISSING | 401 | Ни одного заголовка с ключом не передано |
| API_KEY_INVALID | 401 | Ключ не начинается с gd_, не найден или отозван |
| API_KEY_EXPIRED | 401 | Истёк срок, заданный при выпуске ключа |
| API_KEY_FORBIDDEN | 403 | Ключ валиден, но у него нет требуемого скоупа |
| API_NOT_AVAILABLE | 403 | Категория аккаунта — стример, креатор или «другое»: публичный API закрыт |
Лимиты частоты
- Общий лимит — 300 запросов в минуту с одного IP.
- Создание платежа (
POST /v1/public/payments) — 30 запросов в минуту с одного IP, отдельным счётчиком. - При атаке на платформу мы можем временно понизить потолки (до 60 и 10 запросов в минуту соответственно). Штатную интеграцию это не задевает, но обработчик
429должен быть.
При превышении приходит 429 с кодом RATE_LIMIT_EXCEEDED, заголовком retry-after и счётчиками x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset. Повторяйте запрос не раньше, чем через указанное в retry-after время, и обязательно с тем же externalOrderId — тогда повтор не создаст второй платёж (см. «Идемпотентность»).
Справочник ручек
Двенадцать ручек под общим префиксом /v1/public. У каждой — свой скоуп; ключ без нужного скоупа получает 403 ещё до выполнения запроса.
Платежи
/v1/public/paymentsscope: payments:writeСоздаёт платёж и возвращает ссылку на оплату. Сумма пересчитывается на сервере по реальным ценам товаров: подменить цену со стороны клиента нельзя.
| storeId | uuid | обяз. | Магазин, в который идёт оплата. Должен принадлежать вашему аккаунту и быть в статусе ACTIVE, иначе STORE_NOT_FOUND. |
| amountCents | integer > 0 | обяз. | Сумма в копейках. Если переданы items, должна совпасть с суммой позиций по серверным ценам — иначе AMOUNT_MISMATCH с полем expectedCents. |
| currency | string(3) | опц. | По умолчанию RUB. |
| items | array | опц. | Позиции заказа. Обязательны для игровых серверов: без них ответ ITEMS_REQUIRED. Состав элемента — в таблице ниже. |
| customer.email | string | опц. | Почта покупателя — на неё уходит чек. |
| customer.phone | string | опц. | Телефон покупателя. |
| customer.nickname | string ≤ 100 | опц. | Ник игрока. Подставляется в команды выдачи вместо {nickname} — для игрового магазина поле фактически обязательное. |
| externalOrderId | string ≤ 100 | опц. | Ваш номер заказа. Даёт идемпотентность и приходит обратно в вебхуке. Передавайте всегда. |
| metadata | object | опц. | Произвольные данные заказа. Возвращаются в вебхуках payment.* как есть. |
| projectTag | string ≤ 32 | опц. | Метка проекта (латиница, цифры, дефис). Приходит обратно в вебхуках полем projectTag, и на неё можно подписать отдельный адрес — удобно, когда у аккаунта несколько сборов, а обработчик у каждого свой. |
| returnUrl | https url | опц. | Куда вернуть покупателя после успешной оплаты. Домен — только из белого списка в кабинете, иначе RETURN_URL_NOT_ALLOWED. |
| failureUrl | https url | опц. | Куда вернуть покупателя, если оплата не прошла. Те же правила по домену. |
| productId | uuid | обяз. | Товар этого магазина в статусе ACTIVE. Если хотя бы один товар недоступен — INVALID_ITEMS. |
| name | string ≤ 200 | обяз. | Название. Схема его требует, но в заказ попадёт название из карточки товара. |
| priceCents | integer > 0 | обяз. | Цена. Тоже требуется схемой, но считаем мы по цене из карточки товара. |
| quantity | integer > 0 | опц. | Количество, по умолчанию 1. |
POST https://godonate.ru/v1/public/payments
{
"storeId": "8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de",
"amountCents": 74800,
"currency": "RUB",
"items": [
{ "productId": "b3c1f0a2-5d7e-4a91-8c22-7f0e5a1d9b64",
"name": "VIP на 30 дней", "priceCents": 49900, "quantity": 1 },
{ "productId": "1d2e3f4a-6b7c-48d9-90ea-5f6a7b8c9d01",
"name": "Набор ресурсов", "priceCents": 12450, "quantity": 2 }
],
"customer": { "nickname": "Steve", "email": "steve@example.com" },
"externalOrderId": "shop-2026-000417",
"returnUrl": "https://myserver.ru/order/417/done",
"failureUrl": "https://myserver.ru/order/417/fail",
"metadata": { "playerId": "1042", "source": "site" }
}{
"data": {
"id": "9c8d7e6f-4b21-4c8a-93d5-2a7e11f0c3b8",
"status": "PENDING",
"amountCents": 74800,
"feeCents": 2992,
"netCents": 71808,
"checkoutUrl": "https://securepay.example.ru/new/aBcD1234",
"returnUrl": "https://myserver.ru/order/417/done?payment_id=9c8d7e6f-…&order_id=shop-2026-000417",
"failureUrl": "https://myserver.ru/order/417/fail?payment_id=9c8d7e6f-…&order_id=shop-2026-000417"
}
}Комиссия считается округлением вверх до копейки: feeCents = ⌈amountCents × ставка⌉, netCents = amountCents − feeCents. В примере ставка 4%: 748,00 ₽ → 29,92 ₽ комиссии.
В ответе returnUrl и failureUrl — итоговые адреса возврата, уже с нашими метками заказа. Если вы их не передавали, здесь будут наши собственные страницы результата. Удобно для проверки, что домен приняли, — не поднимая логи.
Повторный запрос с уже использованным externalOrderId отвечает 200 и коротким телом с флагом idempotent: true — подробнее в разделе «Идемпотентность».
/v1/public/payments/:idscope: payments:readСтатус платежа. Нужен для страницы «проверяем оплату» и как страховка, если вебхук не доехал. Чужой платёж по этому ключу не отдаётся — будет 404 NOT_FOUND.
{
"data": {
"id": "9c8d7e6f-4b21-4c8a-93d5-2a7e11f0c3b8",
"status": "SUCCEEDED",
"amountCents": 74800,
"currency": "RUB",
"storeId": "8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de",
"customerEmail": "steve@example.com",
"customerNickname": "Steve",
"comment": null,
"externalOrderId": "shop-2026-000417",
"createdAt": "2026-07-30T11:02:41.118Z",
"capturedAt": "2026-07-30T11:03:27.544Z",
"expiresAt": "2026-07-30T11:17:41.118Z"
}
}Статусы платежа
| Статус | Что значит |
|---|---|
| PENDING | Создан, ждёт оплаты |
| PROCESSING | Покупатель перешёл на оплату |
| AUTHORIZED | Деньги зарезервированы (двустадийная оплата) |
| CAPTURED | Деньги списаны — заказ оплачен |
| SUCCEEDED | Списаны и товар выдан целиком |
| FAILED | Отклонён банком или истёк срок ссылки |
| CANCELED | Отменён до списания |
| REFUNDED / PARTIALLY_REFUNDED | Возврат полностью или частично |
| CHARGEBACK | Оспорен плательщиком в банке |
CAPTURED в SUCCEEDED. Проверка вида status === 'CAPTURED' начнёт врать сразу же, как выдача сработает быстрее вашего запроса. Считайте оплаченными оба статуса.Аккаунт
/v1/public/merchantscope: merchants:readКому принадлежит ключ: юридические данные, статус аккаунта, текущие ставки комиссии и баланс в копейках. Удобно, чтобы показывать в своей админке итог с той же комиссией, что и у нас.
{
"data": {
"id": "2b9c4d5e-7f80-4a1b-9c2d-3e4f5a6b7c8d",
"slug": "myserver",
"type": "IP",
"status": "APPROVED",
"legalName": "ИП Иванов Иван Иванович",
"payinFeePercent": 4,
"payoutFeePercent": 4,
"balanceCents": 1842300
}
}status— статус аккаунта: приUNDER_REVIEW,SUSPENDED,CLOSEDиREJECTEDприём платежей закрыт (MERCHANT_PAYIN_BLOCKED). ПриRESTRICTEDприём работает, заморожены только выплаты.payinFeePercentиpayoutFeePercent— числа в процентах (4= 4%), ставки индивидуальны.balanceCents— доступно к выводу, в копейках.
Магазины
/v1/public/storesscope: stores:readВсе магазины аккаунта. id из этого списка — то самое значение storeId для создания платежа.
{
"data": [
{
"id": "8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de",
"slug": "myserver",
"name": "MyServer — магазин",
"description": "Привилегии и наборы",
"status": "ACTIVE",
"category": "MINECRAFT",
"createdAt": "2026-05-14T08:21:03.402Z"
}
]
}/v1/public/storesscope: stores:writeСоздаёт магазин сразу в статусе ACTIVE. Нужно, если вы разворачиваете витрины программно — например, сеть проектов на одном аккаунте. Для одного магазина проще создать его в кабинете.
Через API принимаются только базовые «витринные» поля. Оформление донат-страницы, звуки алертов и настройки оверлеев остаются за кабинетом: это JSON-конфиги, которые слишком легко испортить программно.
| name | string 2–100 | обяз. | Название магазина. |
| description | string ≤ 1000 | опц. | Описание на странице магазина. |
| category | enum | опц. | MINECRAFT, RUST, CS, GTA_RP, STREAMER, NPO, CREATOR, OTHER. По умолчанию OTHER. |
| brandColor | string | опц. | Акцентный цвет в формате #RRGGBB. |
| shopEnabled | boolean | опц. | Включить витрину товаров у страницы. |
POST https://godonate.ru/v1/public/stores
{
"name": "MyServer — магазин",
"description": "Привилегии и наборы",
"category": "MINECRAFT",
"brandColor": "#F5D800",
"shopEnabled": true
}slug в запросе не принимается: адрес всегда генерируется на сервере (игровым серверам — транслит названия магазина, остальным — никнейм аккаунта) и при коллизии дополняется до уникального. Слаг глобально уникален на всю платформу, поэтому выбор его по API означал бы возможность занять чужой адрес. Готовое значение приходит в ответе — в нём возвращается полная запись магазина./v1/public/stores/:idscope: stores:writeМеняет только переданные поля, остальные не трогает. Набор полей тот же, что при создании, плюс status: DRAFT, ACTIVE, PAUSED, ARCHIVED. Платёж создаётся только в ACTIVE-магазин, поэтому PAUSED — это способ закрыть продажи, не удаляя витрину.
Значение SUSPENDED недоступно намеренно: это блокировка со стороны площадки, снимать или ставить её себе нельзя. Чужой или несуществующий магазин отвечает одинаково — 404 NOT_FOUND.
PATCH https://godonate.ru/v1/public/stores/8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de
{
"description": "Привилегии и наборы",
"status": "PAUSED"
}Товары
/v1/public/productsscope: products:readТовары всех магазинов аккаунта, свежие сверху. Это основной способ отрисовать каталог на своём сайте, не дублируя цены у себя.
| storeId | uuid | опц. | Ограничить выборку одним магазином. |
| limit | integer 1–200 | опц. | Размер страницы. По умолчанию 200 — потолок ответа. |
| cursor | uuid | опц. | nextCursor из предыдущего ответа. Отдаёт следующую страницу. |
curl "https://godonate.ru/v1/public/products?storeId=8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de" \
-H "X-API-Key: gd_live_ваш_ключ"{
"data": [
{
"id": "b3c1f0a2-5d7e-4a91-8c22-7f0e5a1d9b64",
"storeId": "8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de",
"name": "VIP на 30 дней",
"slug": "vip-30",
"description": "Доступ к /fly и цветной ник",
"priceCents": 49900,
"status": "ACTIVE",
"externalId": "shop-item-8842",
"createdAt": "2026-05-14T08:44:12.006Z"
}
],
"nextCursor": null
}В ответе появилось поле nextCursor: null — страница последняя, иначе передайте это значение в cursor и заберите следующую. Каталоги до 200 товаров, как и раньше, приходят одним ответом — менять существующие интеграции не требуется.
/v1/public/productsscope: products:writeСоздаёт товар в указанном магазине. Товар сразу активен.
| storeId | uuid | обяз. | Магазин вашего аккаунта. |
| slug | string 3–50 | обяз. | Только a-z, 0-9 и дефис. |
| name | string 2–100 | обяз. | Название товара. |
| priceCents | integer > 0 | обяз. | Цена в копейках. |
| description | string ≤ 2000 | опц. | Описание товара. |
| categoryId | uuid | опц. | Категория внутри магазина. |
| deliveryType | enum | опц. | Как выдавать: NONE (по умолчанию), RCON_COMMAND, PLUGIN_COMMAND, WEBHOOK, MANUAL. |
| deliveryCommands | string[] ≤ 20 | опц. | Команды выдачи, до 20 штук и до 512 символов каждая. Плейсхолдеры {nickname}, {player}, {username}, {user} подставляются из customer.nickname. Опасные команды отклоняются — см. врезку ниже. |
| requirePlayerOnline | boolean | опц. | Выдавать только когда игрок в сети. По умолчанию false. |
| requireServerId | uuid | null | опц. | Привязка к конкретному игровому серверу сети. По умолчанию — сервер магазина. |
| externalId | string ≤ 190 | опц. | Идентификатор товара в вашей системе. Уникален внутри магазина; повтор отвечает 409 EXTERNAL_ID_TAKEN. По нему работает PUT /products/by-external/:externalId. |
POST https://godonate.ru/v1/public/products
{
"storeId": "8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de",
"slug": "vip-30",
"name": "VIP на 30 дней",
"description": "Доступ к /fly и цветной ник",
"priceCents": 49900,
"deliveryType": "RCON_COMMAND",
"deliveryCommands": ["lp user {nickname} parent addtemp vip 30d"],
"requirePlayerOnline": false,
"externalId": "shop-item-8842"
}Команда, способная отдать сервер покупателю, отклоняется сразу — ответом 400 UNSAFE_COMMAND со списком проблемных строк и причиной по каждой. Раньше такой товар спокойно создавался и ломался уже после оплаты: деньги списаны, награда не выдана.
Блокируются: управление сервером (stop, restart, reload), права оператора и выполнение от чужого имени (op, deop, sudo), наказания игроков (ban, kick, whitelist), разведка сервера (plugins, pl, version), выполнение кода (plugman, skript, js, eval, function, datapack, execute), массовый откат мира (co rollback) и выдача администраторского уровня в плагинах прав: группы вида admin, owner, staff, op и права со звёздочкой (*, essentials.*). Namespace-префикс не помогает: minecraft:op и bukkit:pl блокируются так же.
Обычная продажа привилегий не затронута: lp user {nickname} parent add vip, lp user {nickname} permission set essentials.fly true, eco give, give, crates give, tp, broadcast — всё это проходит.
{
"error": {
"code": "UNSAFE_COMMAND",
"message": "Команды выдачи отклонены по соображениям безопасности: «lp user {nickname} parent add admin» — выдача администраторских прав: привилегированная группа или право со звёздочкой",
"commands": [
{
"command": "lp user {nickname} parent add admin",
"reason": "privilege_escalation",
"message": "выдача администраторских прав: привилегированная группа или право со звёздочкой"
}
]
}
}/v1/public/products/by-external/:externalIdscope: products:writeСоздаёт товар или обновляет уже существующий с таким externalId в этом магазине. Ручка для синхронизации каталога: гоняйте её сколько угодно раз — дублей не будет, и помнить наши id у себя не нужно. Ответ: 201, если товар создан, 200 — если обновлён; в теле есть флаг created.
Тело — полное описание товара, как при создании (externalId берётся из адреса). Это PUT: переданное состояние заменяет прежнее, поэтому не опускайте поля, которые хотите сохранить. Для точечной правки одного поля есть PATCH /products/:id.
PUT https://godonate.ru/v1/public/products/by-external/shop-item-8842
{
"storeId": "8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de",
"slug": "vip-30",
"name": "VIP на 30 дней",
"priceCents": 45900,
"deliveryType": "RCON_COMMAND",
"deliveryCommands": ["lp user {nickname} parent addtemp vip 30d"],
"requirePlayerOnline": false
}Поле status ручка не трогает: если владелец заархивировал товар в кабинете, повторная синхронизация не вернёт его на витрину. Занятый другим товаром slug отвечает 409 SLUG_TAKEN.
/v1/public/products/:idscope: products:writeМеняет переданные поля товара. Дополнительно принимает status: ACTIVE, ARCHIVED, DRAFT. Неактивный товар нельзя положить в items нового платежа.
deliveryCommands проходят ту же проверку безопасности, что и при создании (400 UNSAFE_COMMAND). externalId можно проставить уже существующему товару — так товар, заведённый руками, привязывается к вашему каталогу; null снимает привязку.
PATCH https://godonate.ru/v1/public/products/b3c1f0a2-5d7e-4a91-8c22-7f0e5a1d9b64
{
"priceCents": 39900,
"status": "ACTIVE"
}/v1/public/products/:idscope: products:writeОтвет — 204 без тела. Если по товару уже были заказы, он не удаляется, а переводится в ARCHIVED: иначе оплаченные заказы потеряли бы позиции, а с ними — историю и чеки. Внешне оба случая выглядят одинаково, поэтому не считайте 204 подтверждением физического удаления.
Выплаты
/v1/public/payoutsscope: payouts:readПоследние 100 выплат, самые свежие сверху. Суммы в копейках: amountCents — запрошенная, feeCents — комиссия вывода, netCents — сколько ушло на реквизиты. Статусы: PENDING, PROCESSING, SUCCEEDED, FAILED, CANCELED.
{
"data": [
{
"id": "5e6f7a8b-9c0d-41e2-83f4-5a6b7c8d9e0f",
"status": "SUCCEEDED",
"amountCents": 500000,
"feeCents": 20000,
"netCents": 480000,
"method": "SBP_ACCOUNT",
"createdAt": "2026-07-28T06:10:44.771Z",
"succeededAt": "2026-07-28T09:32:08.219Z",
"failedAt": null,
"failureReason": null,
"providerPayoutId": "e2c-88213004"
}
]
}В failureReason приходит публичная формулировка причины отказа — внутренние пометки по сверке и банкам наружу не отдаются.
Возврат покупателя на сайт
Необязательные returnUrl и failureUrl возвращают человека туда, откуда он ушёл платить. Домен нужно заранее внести в белый список в кабинете.
Как это работает
- Оба поля необязательные. Не передали — покупатель после оплаты остаётся на наших страницах результата.
- Принимается только
https. Домен должен быть в белом списке возвратных адресов в кабинете; его поддомены разрешаются автоматически (добавилиmyserver.ru— работает иshop.myserver.ru). Пока список пуст, любой адрес отклоняется. - Проверка идёт до создания платежа: при неподходящем адресе приходит
400 RETURN_URL_NOT_ALLOWEDс полемerror.field(returnUrlилиfailureUrl), и платёж не создаётся вовсе — висящегоPENDINGне остаётся. - При возврате мы дописываем в адрес два параметра:
payment_id— наш идентификатор платежа,order_id— вашexternalOrderId, если он передавался. Свои query-параметры в адресе сохраняются, порт и фрагмент тоже. - Итоговые адреса возврата приходят в ответе на создание платежа — в полях
returnUrlиfailureUrl.
// вы передали при создании платежа
"returnUrl": "https://myserver.ru/order/417/done"
// покупатель вернётся по адресу
https://myserver.ru/order/417/done?payment_id=9c8d7e6f-4b21-4c8a-93d5-2a7e11f0c3b8&order_id=shop-2026-000417
// если в адресе уже были параметры, наши просто добавятся
"returnUrl": "https://myserver.ru/pay?from=cart"
https://myserver.ru/pay?from=cart&payment_id=…&order_id=…
// свой payment_id в адресе мы перезапишем своим значением
"returnUrl": "https://myserver.ru/pay?payment_id=whatever"
https://myserver.ru/pay?payment_id=9c8d7e6f-…&order_id=…returnUrl. Оплату подтверждает только вебхук payment.captured с корректной подписью или явный запрос GET /v1/public/payments/:id с вашего сервера. На возвратной странице уместно показать «проверяем оплату» и опрашивать статус.Почему нельзя передать любой адрес
Это самый частый вопрос интеграторов, поэтому отвечаем подробно. Если бы мы редиректили на любой адрес из запроса, ручка создания платежа стала бы открытым редиректом на домене godonate.ru — а у платёжного сервиса это прямой инструмент фишинга.
Сценарий атаки простой. Мошенник берёт свой ключ, создаёт платёж с returnUrl на подставной сайт, копирующий наш вход в кабинет, и рассылает ссылку, которая начинается с настоящего домена платёжного сервиса. Жертва видит знакомый домен, проходит по цепочке и оказывается на клоне, где вводит пароль или платёжные данные. Формально скомпрометирован не наш сервер, но для пострадавшего это выглядит как «меня обманули через GoDonate», и разбираться будем мы.
Есть и вторая причина, менее очевидная. Возвратный адрес получает в параметрах payment_id и order_id, а браузер добавляет заголовок Referer с нашей страницы. Открытый редирект утекал бы этими данными на любой сторонний сайт, включая аналитику и рекламные сети чужих страниц.
Белый список закрывает оба случая: адрес возврата может указывать только на домен, который владелец аккаунта подтвердил в кабинете. Требование https из той же логики — поhttp идентификаторы заказа ушли бы открытым текстом, а сам редирект можно было бы подменить в незащищённой сети.
Есть и третья, чисто практическая деталь: параметр payment_id в вашем адресе мы всегда перезаписываем своим значением. Иначе чужой человек мог бы подсунуть в ссылку идентификатор другого, уже оплаченного платежа, и ваш сайт сверил бы не тот заказ.
Практическое следствие: добавьте домен до первого боевого запроса. Обычно достаточно одной записи для сайта и одной для тестового стенда, а конкретные пути (/order/417/done) внутри домена можно менять свободно — проверяется только домен и схема. Ошибку RETURN_URL_NOT_ALLOWED в интеграции стоит логировать явно, иначе она выглядит как загадочный отказ платежа — см. «Коды ошибок».
Идемпотентность
Сеть рвётся, таймауты случаются, покупатель жмёт «Оплатить» дважды. Чтобы это не превращалось в два платежа за один заказ, передавайте externalOrderId.
Как это устроено
Из externalOrderId и идентификатора вашего API-ключа мы строим внутренний ключ идемпотентности вида api:<id ключа>:<externalOrderId> и держим на нём ограничение уникальности. Перед созданием платежа проверяем, нет ли уже записи с таким ключом. Если есть — новый платёж не создаётся, а в ответ уходит уже существующий.
- Первый запрос:
201и полный объект платежа. - Повтор с тем же
externalOrderIdи тем же заказом:200, тот жеidи тот жеcheckoutUrl, плюс флагidempotent: true. - Повтор с тем же
externalOrderId, но другой суммой или другим составом позиций:409и кодIDEMPOTENCY_KEY_REUSED. Мы не подменяем заказ молча — иначе покупатель оплатил бы старую сумму, а вы считали бы закрытым новый заказ. Изменился состав — нужен новый номер. - Без
externalOrderIdзащиты нет вообще: два запроса — два платежа и два списания.
{
"data": {
"id": "9c8d7e6f-4b21-4c8a-93d5-2a7e11f0c3b8",
"status": "PENDING",
"amountCents": 74800,
"checkoutUrl": "https://securepay.example.ru/new/aBcD1234",
"idempotent": true
}
}Обратите внимание: в идемпотентном ответе нет feeCents и netCents. Если они вам нужны, возьмите их из ответа на первый запрос или запросите GET /v1/public/payments/:id.
Как пользоваться
- Создайте заказ в своей базе и получите его номер до вызова API. Номер заказа и есть
externalOrderId— не генерируйте его случайно на каждую попытку. - При таймауте, сетевой ошибке или
429повторяйте запрос с тем же значением и тем же телом. Это безопасно: второго списания не будет. - Новую попытку оплаты того же заказа (истёк 15-минутный срок ссылки, покупатель ушёл) оформляйте как новый платёж с новым значением — например
shop-2026-000417-2. Иначе вы получите старый, уже неоплачиваемыйcheckoutUrl.
externalOrderId считаются новыми заказами. То же верно и для пары «тестовый ключ / боевой ключ» — и это удобно: боевой заказ не заблокирован тестовым прогоном с тем же номером.externalOrderId тогда вернёт 200 с checkoutUrl: null — новую ссылку он не выпустит. Обработайте это как невозможность оплатить: создайте платёж заново с новым externalOrderId.Вебхуки
Единственный надёжный способ узнать, что заказ оплачен. Эндпоинт и подписку на события создаёте в кабинете, там же выдаётся секрет для проверки подписи.
Настройка
- Кабинет → Вебхуки → добавить адрес и выбрать события. Можно подписаться на
*— тогда придут все. - Секрет показывается один раз при создании эндпоинта. Он нужен только вашему серверу; храните его как пароль.
- Адрес должен быть публично доступен. Внутренние и служебные адреса (localhost, приватные сети) мы не вызываем — такая доставка блокируется и не повторяется.
- Есть кнопка тестового события и журнал доставок с кодами ответа — отлаживайте по ним, не дожидаясь реальных платежей.
Несколько проектов на одном аккаунте
У адреса есть проект (магазин). Выберите его при создании эндпоинта — и на этот адрес пойдут события только этого проекта; для второго проекта заведите второй адрес со своим секретом. Проект можно сменить в любой момент в карточке эндпоинта.
- Адрес без проекта (по умолчанию) получает события всех проектов сразу — как и раньше. Различать их можно по
data.storeId, он есть во всех платёжных событиях, событиях выдачи и споров. - События выплат (
payout.*) относятся ко всему аккаунту, а не к проекту, поэтому приходят только на адреса без проекта.
Несколько сборов на одной странице
Когда проект один, а активностей несколько (сбор на технику, розыгрыш, отдельная рубрика), заводить второй проект незачем — хватит метки в ссылке. Добавьте к ссылке параметр ?p=метка (латиница, цифры, дефис, до 32 символов) и раздайте её зрителям под нужную активность:
https://godonate.ru/@nickname?p=beer # сбор на пиво
https://godonate.ru/@nickname?p=pixel # пиксельная стена
https://godonate.ru/@nickname # обычная ссылка, метки нет- Метка приезжает в каждом платёжном событии полем
data.projectTag(null, если донат пришёл по обычной ссылке) и хранится у платежа — по ней же видно метку в карточке доната в кабинете. - В карточке эндпоинта можно указать метку — тогда на этот адрес пойдут только донаты с ней. Донаты без метки на такой адрес не приходят, поэтому держите один адрес без метки как общий.
- Платежам, созданным через
POST /v1/paymentsс API-ключом, метку можно передать полемprojectTagв теле запроса.
Запрос от нас
Всегда POST с JSON-телом. Таймаут — 10 секунд, редиректы не выполняются: адрес должен отвечать сам, 301/302 считается неудачей.
POST /godonate/webhook HTTP/1.1
Content-Type: application/json
X-GoDonate-Event: payment.captured
X-GoDonate-Delivery-Id: 41f0a7c3-5b28-4d9e-8a10-6c7d8e9f0a1b
X-GoDonate-Signature: t=1785312208,v1=6f1c3a9d…
User-Agent: GoDonate-Webhooks/1.0| X-GoDonate-Signature | string | опц. | Подпись тела: t=<unix-время отправки>,v1=<hex>. |
| X-GoDonate-Event | string | опц. | Имя события — то же, что и в теле, в поле event. |
| X-GoDonate-Delivery-Id | uuid | опц. | Идентификатор доставки. Одинаков для всех повторов — по нему удобно дедуплицировать. |
| User-Agent | string | опц. | Всегда GoDonate-Webhooks/1.0. |
Тело
Оболочка одинаковая у всех событий: имя события, время формирования и полезная нагрузка в data. Для платежей в data уже есть всё, чтобы закрыть заказ без второго запроса к API.
{
"event": "payment.captured",
"createdAt": "2026-07-30T11:03:28.061Z",
"data": {
"id": "9c8d7e6f-4b21-4c8a-93d5-2a7e11f0c3b8",
"storeId": "8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de",
"projectTag": "beer",
"amountCents": 74800,
"currency": "RUB",
"feeCents": 2992,
"netCents": 71808,
"status": "CAPTURED",
"customerNickname": "Steve",
"customerEmail": "steve@example.com",
"comment": null,
"capturedAt": "2026-07-30T11:03:27.544Z",
"externalOrderId": "shop-2026-000417",
"metadata": { "playerId": "1042", "source": "site" },
"items": [
{ "productId": "b3c1f0a2-5d7e-4a91-8c22-7f0e5a1d9b64",
"name": "VIP на 30 дней", "priceCents": 49900, "quantity": 1 }
],
"isTest": false
}
}externalOrderId— ваш номер заказа (null, если не передавали) иmetadata— данные, которые вы приложили при создании платежа.items— оплаченные позиции с серверными названиями и ценами.isTest— платёж создан тестовым ключом. В боевом обработчике на таких заказах ничего выдавать не нужно.comment— сообщение плательщика, если оно было (для оплаты по товарам обычноnull).
Проверка подписи
Считайте HMAC-SHA256 от сырого тела запроса с секретом эндпоинта в качестве ключа и сравните hex-результат со значением v1.
# Подписывается ТОЛЬКО тело запроса, как оно пришло, байт в байт.
# Ни timestamp, ни разделители, ни заголовки в подпись не входят.
v1 = HEX( HMAC_SHA256( key = webhook_secret, message = raw_body ) )t + "." + body. Значение t — служебное, для защиты от повторного проигрывания старого запроса: разумно отклонять доставки, у которых t отличается от текущего времени больше чем на несколько минут.- Берите тело до разбора JSON. Если фреймворк уже распарсил его и вы соберёте строку обратно через сериализацию, порядок ключей и пробелы поменяются — подпись не сойдётся.
- Сравнивайте подписи функцией с постоянным временем работы:
hash_equalsв PHP,crypto.timingSafeEqualв Node. - Подпись не сошлась — отвечайте
401и ничего не делайте с заказом.
Ответ и повторы
- Успех — любой код
2xx. Всё остальное, включая таймаут и обрыв соединения, считается неудачей. - Повторы: до 8 попыток, пауза удваивается начиная с 2 секунд, вся серия укладывается примерно в 4 минуты. Тело вашего ответа мы читаем и первые несколько тысяч символов сохраняем в журнал доставок — туда удобно писать причину отказа.
- Отвечайте
2xxсразу, как только записали событие себе, а долгую работу выносите в фон. Обработчик, который выдаёт товар синхронно и не успевает за 10 секунд, получит повтор и, если у вас нет дедупликации, выдаст товар дважды. - Обработчик должен быть идемпотентным: ключ дедупликации —
X-GoDonate-Delivery-Idлибо пара «событие +data.id».
GET /v1/public/payments/:id. Для магазинов с большим оборотом полезно раз в несколько минут сверять свои «висящие» заказы этим запросом — это дешёвая страховка от часа простоя вашего сервера.События
| Событие | Когда | Полезная нагрузка |
|---|---|---|
| payment.pending | Платёж создан, ссылка на оплату выдана | Полный объект платежа (см. пример выше) |
| payment.captured | Деньги списаны — заказ оплачен | Тот же объект платежа |
| payment.failed | Оплата не прошла или истёк срок | Тот же объект платежа |
| payment.refunded | Оформлен возврат | Тот же объект платежа |
| delivery.started | Началась автовыдача товара | deliveryTaskId, paymentId, storeId |
| delivery.completed | Выдача выполнена | deliveryTaskId, paymentId, storeId |
| delivery.failed | Выдача провалилась окончательно | deliveryTaskId, paymentId, storeId, reason |
| payout.created | Создана заявка на вывод | id, amountCents, feeCents, netCents, method, status, createdAt |
| payout.succeeded | Вывод исполнен | id, amountCents (сумма к зачислению), currency |
| payout.failed | Вывод отклонён, деньги вернулись на баланс | id, reason |
| dispute.opened | Покупатель открыл спор | id, paymentId, storeId, status, reason, createdAt, openedBy (всегда buyer у новых споров) |
| dispute.resolved | Спор закрыт | id, paymentId, storeId, resolution, status, resolvedAt, openedBy |
id. Исторически payout.succeeded и payout.failed называли его payoutId — это имя осталось как дубль и никуда не денется, но новые обработчики достаточно писать под id.Новые события и новые поля в существующих мы добавляем без предупреждения — это совместимое изменение. Игнорируйте незнакомые event и лишние поля вместо строгой валидации схемы, и обновления вас не затронут.
Коды ошибок
Ошибка — это всегда объект error с машинным кодом. Логируйте code вместе со своим номером заказа: почти все обращения в поддержку решаются одной этой строкой.
{
"error": {
"code": "AMOUNT_MISMATCH",
"message": "amountCents не совпадает с суммой позиций",
"expectedCents": 74800
}
}У части ошибок есть дополнительные поля: expectedCents — ожидаемая сумма позиций, limitCents и usedCents — лимит приёма и сколько из него уже израсходовано.
Аутентификация и доступ
| Код | HTTP | Когда | Что делать |
|---|---|---|---|
| API_KEY_MISSING | 401 | Заголовок с ключом не передан | Добавьте X-API-Key. |
| API_KEY_INVALID | 401 | Ключ неверного формата, не найден или отозван | Проверьте, что используете актуальный ключ, а не обрезанный префикс из кабинета. |
| API_KEY_EXPIRED | 401 | Истёк срок действия ключа | Выпустите новый ключ в кабинете. |
| API_KEY_FORBIDDEN | 403 | У ключа нет нужного скоупа | Скоупы после выпуска не меняются — выпустите ключ с нужным набором. |
| API_NOT_AVAILABLE | 403 | Категория аккаунта — стример, креатор или «другое» | Публичный API для этих категорий закрыт: принимайте через страницу на GoDonate. |
| RATE_LIMIT_EXCEEDED | 429 | Превышен лимит частоты запросов | Подождите время из заголовка retry-after (если он есть — то же число в поле error.retryAfterSeconds) и повторите с тем же externalOrderId. |
Создание платежа
| Код | HTTP | Когда | Что делать |
|---|---|---|---|
| STORE_NOT_FOUND | 404 | Магазина нет или он принадлежит другому аккаунту | Сверьте storeId со списком GET /v1/public/stores. |
| STORE_NOT_ACTIVE | 404 | Ваш магазин существует, но не принимает оплату: пауза, черновик или архив | Идентификатор верный — включите магазин в кабинете. Текущий статус придёт в поле storeStatus. |
| MERCHANT_PAYIN_BLOCKED | 403 | Аккаунт на проверке безопасности, приостановлен, закрыт или не прошёл проверку документов | Это состояние аккаунта, а не ошибка запроса: приём откроется после проверки. Напишите в поддержку. |
| MERCHANT_INN_REQUIRED | 403 | Игровой сервер без указанного ИНН | Укажите ИНН в кабинете — по агентской схеме чек оформляется на вас, без ИНН приём не открывается. |
| ITEMS_REQUIRED | 400 | Платёж без items у товарной категории | Передайте позиции заказа. Оплата свободной суммой для игровых серверов и категории «другое» недоступна. |
| INVALID_ITEMS | 400 | Товар не найден, из другого магазина или не активен | Проверьте productId и статус товаров. Архивный товар продать нельзя. |
| AMOUNT_MISMATCH | 400 | amountCents не равен сумме позиций по серверным ценам | Возьмите сумму из expectedCents — обычно это значит, что цена товара изменилась, а у вас закэширована старая. |
| RETURN_URL_NOT_ALLOWED | 400 | Домен returnUrl или failureUrl не в белом списке либо схема не https | Добавьте домен в кабинете; поддомены разрешаются сами. В ответе есть field — какой именно адрес не подошёл. Пути внутри домена не ограничены. |
| DAILY_LIMIT_EXCEEDED | 400 | Исчерпан суточный лимит приёма | Смотрите limitCents и usedCents. Лимиты растут по мере истории аккаунта. |
| MONTHLY_LIMIT_EXCEEDED | 400 | Исчерпан месячный лимит приёма | То же самое; за повышением — в поддержку. |
| PAYIN_REJECTED | 403 | Платёж отклонён комплаенсом | Срабатывает, например, при запросе с игорного домена. Подробностей по отказу мы не раскрываем. |
| IDEMPOTENCY_KEY_REUSED | 409 | Такой externalOrderId уже есть, но сумма или состав заказа другие | Повтор с тем же номером возвращает прежний платёж только при том же заказе. Изменился состав или сумма — пришлите новый externalOrderId. Идентификатор уже созданного платежа есть в ответе (existingPaymentId). |
Остальное
| Код | HTTP | Когда | Что делать |
|---|---|---|---|
| NOT_FOUND | 404 | Объект не существует или принадлежит другому аккаунту | Так отвечают запросы платежа и товара по идентификатору. |
| VALIDATION_ERROR | 400 | Тело или параметры не прошли валидацию схемы | Частые причины: сумма строкой вместо числа, не-uuid в storeId, лишний знак в копейках. Иногда приходит поле details с разбором по полям. |
| SLUG_TAKEN | 409 | Адрес страницы уже занят | Возникает при создании товара с занятым slug: он уникален внутри магазина. Магазинов не касается — там адрес всегда генерируется на сервере. |
| EXTERNAL_ID_TAKEN | 409 | В магазине уже есть товар с таким externalId | Это ответ на попытку создать второй товар с тем же внешним идентификатором. Для повторной синхронизации используйте PUT /v1/public/products/by-external/:externalId — она обновит существующий товар вместо конфликта. |
| UNSAFE_COMMAND | 400 | В deliveryCommands есть запрещённая команда | В ответе массив commands: по каждой отклонённой строке — сама команда, reason (машинный) и message (текст для человека). Исправьте команду и повторите; список правил — во врезке у POST /v1/public/products. Проверка стоит на сохранении специально: иначе товар ломался бы уже после оплаты. |
| INTERNAL_ERROR | 500 | Сбой на нашей стороне | Запрос можно повторить — см. врезку ниже. |
externalOrderId, чтобы не создать второй платёж. Если повторы не помогают дольше пары минут, напишите в поддержку и приложите время запроса и свой номер заказа.Примеры кода
Рабочие заготовки под копирование. PHP — первым: на нём написана большая часть сайтов игровых серверов.
Создание платежа
<?php
// Создание платежа. Ключ — только на сервере, в код и в git не попадает.
$apiKey = getenv('GODONATE_API_KEY');
$order = [
'storeId' => '8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de',
'amountCents' => 49900, // 499,00 ₽ — всегда копейки, целым числом
'items' => [[
'productId' => 'b3c1f0a2-5d7e-4a91-8c22-7f0e5a1d9b64',
'name' => 'VIP на 30 дней',
'priceCents' => 49900,
'quantity' => 1,
]],
'customer' => ['nickname' => $playerNick],
// Номер заказа из своей БД: он же ключ идемпотентности при повторе запроса.
'externalOrderId' => 'shop-' . $orderId,
'returnUrl' => 'https://myserver.ru/order/' . $orderId . '/done',
'failureUrl' => 'https://myserver.ru/order/' . $orderId . '/fail',
];
$ch = curl_init('https://godonate.ru/v1/public/payments');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . $apiKey,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($order, JSON_UNESCAPED_UNICODE),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$res = json_decode($raw, true);
if ($status !== 201 && $status !== 200) {
// Ориентируемся на code, а не на текст сообщения.
$code = $res['error']['code'] ?? 'UNKNOWN';
error_log('godonate: order ' . $orderId . ' failed with ' . $code);
exit('Не удалось создать платёж');
}
// 200 с флагом idempotent означает, что этот заказ уже оплачивался ранее —
// ссылка та же самая, второго списания не будет.
header('Location: ' . $res['data']['checkoutUrl']);Приём вебхука и проверка подписи
Ключевая часть — сравнение подписей. Ниже в обоих примерах используется сравнение с постоянным временем работы, и это не перестраховка.
<?php
// Обработчик вебхука: /godonate/webhook
$secret = getenv('GODONATE_WEBHOOK_SECRET');
// ВАЖНО: берём тело как есть. Если распарсить JSON и собрать строку обратно,
// изменятся пробелы и порядок ключей — подпись не сойдётся никогда.
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_GODONATE_SIGNATURE'] ?? '';
// Заголовок: t=<unix>,v1=<hex>
$parts = [];
foreach (explode(',', $header) as $chunk) {
$kv = explode('=', trim($chunk), 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
$timestamp = (int) ($parts['t'] ?? 0);
$received = $parts['v1'] ?? '';
// Подписывается ТОЛЬКО тело запроса: ни t, ни разделителей в HMAC нет.
$expected = hash_hmac('sha256', $body, $secret);
// hash_equals, а не ===: сравнение с постоянным временем работы.
// Обычное сравнение строк выходит на первом различающемся байте, поэтому по
// времени ответа можно побайтово угадывать правильную подпись (timing attack).
// hash_equals всегда проходит всю строку и такой утечки не даёт.
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
// Защита от повторного проигрывания старого запроса.
if (abs(time() - $timestamp) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
if ($event['event'] === 'payment.captured') {
$data = $event['data'];
// Тестовые платежи в боевом обработчике игнорируем.
if (!empty($data['isTest'])) {
http_response_code(200);
exit;
}
// Дедупликация: один и тот же вебхук может прийти повторно.
// Ключ — id доставки или пара «событие + id платежа».
$deliveryId = $_SERVER['HTTP_X_GODONATE_DELIVERY_ID'] ?? $data['id'];
if (already_processed($deliveryId)) {
http_response_code(200);
exit;
}
// externalOrderId уже в payload — второй запрос к API не нужен.
mark_order_paid($data['externalOrderId'], $data['amountCents']);
remember_delivery($deliveryId);
}
// Отвечаем 2xx сразу; тяжёлую работу — в фон, иначе поймаем повтор по таймауту.
http_response_code(200);hash_equals в PHP и crypto.timingSafeEqual в Node всегда проходят строку целиком, поэтому по длительности ответа ничего узнать нельзя. Правило простое: любые секреты, подписи и токены сравниваются только такими функциями.Что проверить перед запуском
- Ключ читается из переменных окружения, а не лежит в репозитории и не отдаётся в браузер.
- Тело вебхука берётся сырым (
php://input,express.raw) — до разбора JSON. - Обработчик отвечает
2xxбыстро, а выдачу и письма делает в фоне. - Повторный вебхук с тем же
X-GoDonate-Delivery-Idне выдаёт товар второй раз. - Заказы с
isTest: trueв боевом контуре игнорируются. - Домены
returnUrlиfailureUrlдобавлены в белый список в кабинете.
Полезные запросы для отладки
Если интеграция «не работает», начните с этого запроса: он показывает, что ключ жив, скоуп на месте и аккаунт в рабочем статусе.
curl -i https://godonate.ru/v1/public/merchant -H "X-API-Key: $GODONATE_API_KEY"Своя витрина, наш платёжный контур
Ключ выпускается в кабинете за минуту, песочница доступна сразу. Вопросы по интеграции — в поддержку в Telegram.
Регистрация — 5 минут. Подключение — за сутки.