Перейти к содержимому
Разработчикам

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.
Почему игровому серверу нельзя платёж «просто на сумму»
Продажа привилегий проходит по агентской схеме: чек оформляется на вас как на поставщика, и в нём должны быть товарные позиции. Платёж свободной суммой такой чек обошёл бы, поэтому для товарных категорий он запрещён на уровне API. Практического неудобства это не создаёт: цену всё равно берём из карточки товара, а не из запроса.

Быстрый старт

Пять шагов от пустого проекта до оплаченного заказа. Первые два делаются один раз в кабинете, остальные — код.

  1. 01
    Выпустите API-ключ
    Кабинет → API-ключи → создать. Отметьте только нужные скоупы: для магазина на своём сайте обычно хватает payments:write, payments:read и products:read. Полный ключ показывается один раз — мы храним только его хеш. Для разработки создайте отдельный тестовый ключ (gd_test_…).
  2. 02
    Добавьте домен возврата
    Адреса, на которые мы вернём покупателя после оплаты, работают только с домена из белого списка в кабинете. Пока домен не добавлен, запрос с returnUrl отвечает RETURN_URL_NOT_ALLOWED. Почему нельзя передавать любой адрес — в разделе «Возврат на сайт».
  3. 03
    Создайте платёж
    Один POST-запрос с идентификатором магазина, суммой в копейках и позициями заказа. Свой номер заказа передавайте в externalOrderId — он же защищает от двойного списания при повторе запроса.
  4. 04
    Отправьте покупателя на checkoutUrl
    В ответе приходит checkoutUrl — страница оплаты провайдера. Сделайте на неё редирект (или откройте в новом окне). Ссылка живёт 15 минут: по истечении срока платёж перестаёт быть оплачиваемым, для новой попытки создайте новый платёж — с новым externalOrderId.
  5. 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"
  }'
201 created
{
  "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.

Порядок проверки для новой интеграции
Сделайте полный цикл тестовым ключом: создание платежа, оплата на mock-странице, приход вебхука, проверка подписи, идемпотентный повтор того же 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_ваш_ключ"
Ключ — серверный секрет
Не встраивайте его в JavaScript на странице, в лаунчер, в конфиг игрового плагина и в мобильное приложение. Ключ даёт право создавать платежи и читать данные аккаунта, поэтому вызовы API делаются только с вашего бэкенда. Утёк ключ — отзовите его в кабинете и выпустите новый; отзыв действует сразу.

Боевой и тестовый ключ

Среда зашита в сам ключ, отдельного «переключателя песочницы» нет — по префиксу видно, куда уйдут деньги:

  • 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_MISSING401Ни одного заголовка с ключом не передано
API_KEY_INVALID401Ключ не начинается с gd_, не найден или отозван
API_KEY_EXPIRED401Истёк срок, заданный при выпуске ключа
API_KEY_FORBIDDEN403Ключ валиден, но у него нет требуемого скоупа
API_NOT_AVAILABLE403Категория аккаунта — стример, креатор или «другое»: публичный 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 ещё до выполнения запроса.

Платежи

POST/v1/public/paymentsscope: payments:write

Создаёт платёж и возвращает ссылку на оплату. Сумма пересчитывается на сервере по реальным ценам товаров: подменить цену со стороны клиента нельзя.

тело запроса
storeIduuidобяз.Магазин, в который идёт оплата. Должен принадлежать вашему аккаунту и быть в статусе ACTIVE, иначе STORE_NOT_FOUND.
amountCentsinteger > 0обяз.Сумма в копейках. Если переданы items, должна совпасть с суммой позиций по серверным ценам — иначе AMOUNT_MISMATCH с полем expectedCents.
currencystring(3)опц.По умолчанию RUB.
itemsarrayопц.Позиции заказа. Обязательны для игровых серверов: без них ответ ITEMS_REQUIRED. Состав элемента — в таблице ниже.
customer.emailstringопц.Почта покупателя — на неё уходит чек.
customer.phonestringопц.Телефон покупателя.
customer.nicknamestring ≤ 100опц.Ник игрока. Подставляется в команды выдачи вместо {nickname} — для игрового магазина поле фактически обязательное.
externalOrderIdstring ≤ 100опц.Ваш номер заказа. Даёт идемпотентность и приходит обратно в вебхуке. Передавайте всегда.
metadataobjectопц.Произвольные данные заказа. Возвращаются в вебхуках payment.* как есть.
projectTagstring ≤ 32опц.Метка проекта (латиница, цифры, дефис). Приходит обратно в вебхуках полем projectTag, и на неё можно подписать отдельный адрес — удобно, когда у аккаунта несколько сборов, а обработчик у каждого свой.
returnUrlhttps urlопц.Куда вернуть покупателя после успешной оплаты. Домен — только из белого списка в кабинете, иначе RETURN_URL_NOT_ALLOWED.
failureUrlhttps urlопц.Куда вернуть покупателя, если оплата не прошла. Те же правила по домену.
элемент items
productIduuidобяз.Товар этого магазина в статусе ACTIVE. Если хотя бы один товар недоступен — INVALID_ITEMS.
namestring ≤ 200обяз.Название. Схема его требует, но в заказ попадёт название из карточки товара.
priceCentsinteger > 0обяз.Цена. Тоже требуется схемой, но считаем мы по цене из карточки товара.
quantityinteger > 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" }
}
201 created
{
  "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 — итоговые адреса возврата, уже с нашими метками заказа. Если вы их не передавали, здесь будут наши собственные страницы результата. Удобно для проверки, что домен приняли, — не поднимая логи.

checkoutUrl не конструируется на вашей стороне
Адрес приходит от платёжного провайдера и зависит от него и от способа оплаты (для тестового ключа это наша mock-страница). Не собирайте ссылку по шаблону и не кэшируйте: она одноразовая и живёт 15 минут.

Повторный запрос с уже использованным externalOrderId отвечает 200 и коротким телом с флагом idempotent: true — подробнее в разделе «Идемпотентность».

GET/v1/public/payments/:idscope: payments:read

Статус платежа. Нужен для страницы «проверяем оплату» и как страховка, если вебхук не доехал. Чужой платёж по этому ключу не отдаётся — будет 404 NOT_FOUND.

200 ok
{
  "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
После успешной автовыдачи товара платёж переводится из CAPTURED в SUCCEEDED. Проверка вида status === 'CAPTURED' начнёт врать сразу же, как выдача сработает быстрее вашего запроса. Считайте оплаченными оба статуса.

Аккаунт

GET/v1/public/merchantscope: merchants:read

Кому принадлежит ключ: юридические данные, статус аккаунта, текущие ставки комиссии и баланс в копейках. Удобно, чтобы показывать в своей админке итог с той же комиссией, что и у нас.

200 ok
{
  "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 — доступно к выводу, в копейках.

Магазины

GET/v1/public/storesscope: stores:read

Все магазины аккаунта. id из этого списка — то самое значение storeId для создания платежа.

200 ok
{
  "data": [
    {
      "id": "8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de",
      "slug": "myserver",
      "name": "MyServer — магазин",
      "description": "Привилегии и наборы",
      "status": "ACTIVE",
      "category": "MINECRAFT",
      "createdAt": "2026-05-14T08:21:03.402Z"
    }
  ]
}
POST/v1/public/storesscope: stores:write

Создаёт магазин сразу в статусе ACTIVE. Нужно, если вы разворачиваете витрины программно — например, сеть проектов на одном аккаунте. Для одного магазина проще создать его в кабинете.

Через API принимаются только базовые «витринные» поля. Оформление донат-страницы, звуки алертов и настройки оверлеев остаются за кабинетом: это JSON-конфиги, которые слишком легко испортить программно.

тело запроса
namestring 2–100обяз.Название магазина.
descriptionstring ≤ 1000опц.Описание на странице магазина.
categoryenumопц.MINECRAFT, RUST, CS, GTA_RP, STREAMER, NPO, CREATOR, OTHER. По умолчанию OTHER.
brandColorstringопц.Акцентный цвет в формате #RRGGBB.
shopEnabledbooleanопц.Включить витрину товаров у страницы.
запрос
POST https://godonate.ru/v1/public/stores

{
  "name": "MyServer — магазин",
  "description": "Привилегии и наборы",
  "category": "MINECRAFT",
  "brandColor": "#F5D800",
  "shopEnabled": true
}
Адрес страницы задать нельзя
Поле slug в запросе не принимается: адрес всегда генерируется на сервере (игровым серверам — транслит названия магазина, остальным — никнейм аккаунта) и при коллизии дополняется до уникального. Слаг глобально уникален на всю платформу, поэтому выбор его по API означал бы возможность занять чужой адрес. Готовое значение приходит в ответе — в нём возвращается полная запись магазина.
PATCH/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"
}

Товары

GET/v1/public/productsscope: products:read

Товары всех магазинов аккаунта, свежие сверху. Это основной способ отрисовать каталог на своём сайте, не дублируя цены у себя.

параметры строки запроса
storeIduuidопц.Ограничить выборку одним магазином.
limitinteger 1–200опц.Размер страницы. По умолчанию 200 — потолок ответа.
cursoruuidопц.nextCursor из предыдущего ответа. Отдаёт следующую страницу.
запрос
curl "https://godonate.ru/v1/public/products?storeId=8f14e45f-ceea-4e7a-9d40-1c9ec3b6a1de" \
  -H "X-API-Key: gd_live_ваш_ключ"
200 ok
{
  "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 товаров, как и раньше, приходят одним ответом — менять существующие интеграции не требуется.

Служебные товары кейсов больше не приходят
Оплата кейса технически идёт через скрытый товар-«ключ», которого нет ни в кабинете, ни на витрине. Раньше такие записи попадали в этот список и выглядели как настоящие товары — теперь они отфильтрованы. Если вы обходили их у себя по названию, фильтр можно убрать.
POST/v1/public/productsscope: products:write

Создаёт товар в указанном магазине. Товар сразу активен.

тело запроса
storeIduuidобяз.Магазин вашего аккаунта.
slugstring 3–50обяз.Только a-z, 0-9 и дефис.
namestring 2–100обяз.Название товара.
priceCentsinteger > 0обяз.Цена в копейках.
descriptionstring ≤ 2000опц.Описание товара.
categoryIduuidопц.Категория внутри магазина.
deliveryTypeenumопц.Как выдавать: NONE (по умолчанию), RCON_COMMAND, PLUGIN_COMMAND, WEBHOOK, MANUAL.
deliveryCommandsstring[] ≤ 20опц.Команды выдачи, до 20 штук и до 512 символов каждая. Плейсхолдеры {nickname}, {player}, {username}, {user} подставляются из customer.nickname. Опасные команды отклоняются — см. врезку ниже.
requirePlayerOnlinebooleanопц.Выдавать только когда игрок в сети. По умолчанию false.
requireServerIduuid | nullопц.Привязка к конкретному игровому серверу сети. По умолчанию — сервер магазина.
externalIdstring ≤ 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 — всё это проходит.

400 unsafe_command
{
  "error": {
    "code": "UNSAFE_COMMAND",
    "message": "Команды выдачи отклонены по соображениям безопасности: «lp user {nickname} parent add admin» — выдача администраторских прав: привилегированная группа или право со звёздочкой",
    "commands": [
      {
        "command": "lp user {nickname} parent add admin",
        "reason": "privilege_escalation",
        "message": "выдача администраторских прав: привилегированная группа или право со звёздочкой"
      }
    ]
  }
}
PUT/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.

PATCH/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"
}
DELETE/v1/public/products/:idscope: products:write

Ответ — 204 без тела. Если по товару уже были заказы, он не удаляется, а переводится в ARCHIVED: иначе оплаченные заказы потеряли бы позиции, а с ними — историю и чеки. Внешне оба случая выглядят одинаково, поэтому не считайте 204 подтверждением физического удаления.

Выплаты

GET/v1/public/payoutsscope: payouts:read

Последние 100 выплат, самые свежие сверху. Суммы в копейках: amountCents — запрошенная, feeCents — комиссия вывода, netCents — сколько ушло на реквизиты. Статусы: PENDING, PROCESSING, SUCCEEDED, FAILED, CANCELED.

200 ok
{
  "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=…
Возврат — это UX, а не подтверждение оплаты
Параметры в адресе приходят из браузера покупателя, значит их можно подставить руками. Никогда не выдавайте товар и не отмечайте заказ оплаченным по факту захода на 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 защиты нет вообще: два запроса — два платежа и два списания.
200 — повторный запрос
{
  "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.
Область действия — конкретный ключ
Ключ идемпотентности включает идентификатор API-ключа. Если вы отозвали ключ и выпустили новый, для нового ключа те же 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-Signaturestringопц.Подпись тела: t=<unix-время отправки>,v1=<hex>.
X-GoDonate-Eventstringопц.Имя события — то же, что и в теле, в поле event.
X-GoDonate-Delivery-Iduuidопц.Идентификатор доставки. Одинаков для всех повторов — по нему удобно дедуплицировать.
User-Agentstringопц.Всегда GoDonate-Webhooks/1.0.

Тело

Оболочка одинаковая у всех событий: имя события, время формирования и полезная нагрузка в data. Для платежей в data уже есть всё, чтобы закрыть заказ без второго запроса к API.

payment.captured
{
  "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 ) )
Timestamp в подпись не входит
Здесь мы отличаемся от некоторых зарубежных сервисов: у нас подписывается только тело, без конструкции вида 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_MISSING401Заголовок с ключом не переданДобавьте X-API-Key.
API_KEY_INVALID401Ключ неверного формата, не найден или отозванПроверьте, что используете актуальный ключ, а не обрезанный префикс из кабинета.
API_KEY_EXPIRED401Истёк срок действия ключаВыпустите новый ключ в кабинете.
API_KEY_FORBIDDEN403У ключа нет нужного скоупаСкоупы после выпуска не меняются — выпустите ключ с нужным набором.
API_NOT_AVAILABLE403Категория аккаунта — стример, креатор или «другое»Публичный API для этих категорий закрыт: принимайте через страницу на GoDonate.
RATE_LIMIT_EXCEEDED429Превышен лимит частоты запросовПодождите время из заголовка retry-after (если он есть — то же число в поле error.retryAfterSeconds) и повторите с тем же externalOrderId.

Создание платежа

КодHTTPКогдаЧто делать
STORE_NOT_FOUND404Магазина нет или он принадлежит другому аккаунтуСверьте storeId со списком GET /v1/public/stores.
STORE_NOT_ACTIVE404Ваш магазин существует, но не принимает оплату: пауза, черновик или архивИдентификатор верный — включите магазин в кабинете. Текущий статус придёт в поле storeStatus.
MERCHANT_PAYIN_BLOCKED403Аккаунт на проверке безопасности, приостановлен, закрыт или не прошёл проверку документовЭто состояние аккаунта, а не ошибка запроса: приём откроется после проверки. Напишите в поддержку.
MERCHANT_INN_REQUIRED403Игровой сервер без указанного ИННУкажите ИНН в кабинете — по агентской схеме чек оформляется на вас, без ИНН приём не открывается.
ITEMS_REQUIRED400Платёж без items у товарной категорииПередайте позиции заказа. Оплата свободной суммой для игровых серверов и категории «другое» недоступна.
INVALID_ITEMS400Товар не найден, из другого магазина или не активенПроверьте productId и статус товаров. Архивный товар продать нельзя.
AMOUNT_MISMATCH400amountCents не равен сумме позиций по серверным ценамВозьмите сумму из expectedCents — обычно это значит, что цена товара изменилась, а у вас закэширована старая.
RETURN_URL_NOT_ALLOWED400Домен returnUrl или failureUrl не в белом списке либо схема не httpsДобавьте домен в кабинете; поддомены разрешаются сами. В ответе есть field — какой именно адрес не подошёл. Пути внутри домена не ограничены.
DAILY_LIMIT_EXCEEDED400Исчерпан суточный лимит приёмаСмотрите limitCents и usedCents. Лимиты растут по мере истории аккаунта.
MONTHLY_LIMIT_EXCEEDED400Исчерпан месячный лимит приёмаТо же самое; за повышением — в поддержку.
PAYIN_REJECTED403Платёж отклонён комплаенсомСрабатывает, например, при запросе с игорного домена. Подробностей по отказу мы не раскрываем.
IDEMPOTENCY_KEY_REUSED409Такой externalOrderId уже есть, но сумма или состав заказа другиеПовтор с тем же номером возвращает прежний платёж только при том же заказе. Изменился состав или сумма — пришлите новый externalOrderId. Идентификатор уже созданного платежа есть в ответе (existingPaymentId).

Остальное

КодHTTPКогдаЧто делать
NOT_FOUND404Объект не существует или принадлежит другому аккаунтуТак отвечают запросы платежа и товара по идентификатору.
VALIDATION_ERROR400Тело или параметры не прошли валидацию схемыЧастые причины: сумма строкой вместо числа, не-uuid в storeId, лишний знак в копейках. Иногда приходит поле details с разбором по полям.
SLUG_TAKEN409Адрес страницы уже занятВозникает при создании товара с занятым slug: он уникален внутри магазина. Магазинов не касается — там адрес всегда генерируется на сервере.
EXTERNAL_ID_TAKEN409В магазине уже есть товар с таким externalIdЭто ответ на попытку создать второй товар с тем же внешним идентификатором. Для повторной синхронизации используйте PUT /v1/public/products/by-external/:externalId — она обновит существующий товар вместо конфликта.
UNSAFE_COMMAND400В deliveryCommands есть запрещённая командаВ ответе массив commands: по каждой отклонённой строке — сама команда, reason (машинный) и message (текст для человека). Исправьте команду и повторите; список правил — во врезке у POST /v1/public/products. Проверка стоит на сохранении специально: иначе товар ломался бы уже после оплаты.
INTERNAL_ERROR500Сбой на нашей сторонеЗапрос можно повторить — см. врезку ниже.
5xx — это про нас
Пятисотые ответы означают сбой на нашей стороне. Их можно повторять — с тем же 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, а не ===
Обычное сравнение строк оптимизировано: оно возвращает результат на первом различающемся байте. Значит, время ответа вашего обработчика зависит от того, сколько первых символов подписи атакующий угадал. Отправляя запросы с перебором первого байта, потом второго и так далее, подпись можно подобрать за линейное, а не за экспоненциальное число попыток — это классическая атака по времени. 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 минут. Подключение — за сутки.