Идемпотентность, вебхуки и ретраи: как не потерять деньги

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

Содержание 6 разделов
  1. custom_id: один на заказ покупателя
  2. Тот же id с другим телом
  3. Что повторять, а что нет
  4. Вебхук: два канала, один обработчик
  5. Три ловушки с деньгами покупателя
  6. Коды провала доставки
  7. Чек-лист

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

#custom_id: один на заказ покупателя

Идентификатор заказа придумываете вы. Повтор POST /order/create с тем же custom_id возвращает тот же заказ, повтор POST /order/pay не списывает депозит второй раз. Уникальность хранит база платформы, а не память о недавних запросах.

Новый заказ получает новый custom_id, повтор упавшего запроса идёт со старым. Нарушите первую половину правила, и покупатель получит чужой заказ. Нарушите вторую, и депозит спишется дважды.

#Тот же id с другим телом

Повтор create с тем же custom_id, но другими параметрами не вернёт ошибку: API отдаст первый заказ. Если вы переиспользовали идентификатор для новой покупки, ответ придёт успешным, а товар уйдёт по старым параметрам.

У пополнений депозита правило строже. Повтор POST /deposit/create с тем же custom_id, но другой суммой или другим способом оплаты ответит 409: старая ссылка на другую сумму оставила бы вас с недостачей без понятной причины.

order.py
import os
import time
import uuid

import requests

API = "https://hexpay.live/api/reseller/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['HEX_API_KEY']}"}


def post(path: str, body: dict, attempts: int = 4) -> requests.Response:
    for n in range(attempts):
        try:
            r = requests.post(f"{API}{path}", json=body, headers=HEADERS, timeout=15)
        except requests.RequestException:
            time.sleep(2 ** n)        # сеть оборвалась: повтор с тем же телом
            continue
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", 2 ** n)))
            continue
        if r.status_code >= 500:
            time.sleep(2 ** n)
            continue
        return r                      # 2xx и 4xx не повторяем
    raise RuntimeError(f"{path}: нет ответа после {attempts} попыток")


custom_id = str(uuid.uuid4())         # один раз на заказ покупателя
db.save_order(custom_id, status="new")  # ваша база, запись до запроса
created = post("/order/create", {
    "custom_id": custom_id,
    "product": "stars",
    "recipient_username": "buyer_username",
    "quantity": 100,
})
if not created.ok:                    # 4xx: заказа нет, платить нечего
    db.save_order(custom_id, status="error", reason=created.json().get("error_code"))
    raise RuntimeError(created.text)
paid = post("/order/pay", {"custom_id": custom_id})
if paid.status_code not in (200, 202):  # 202 первая оплата, 200 повтор
    db.save_order(custom_id, status="error", reason=paid.json().get("error_code"))

Главная строка здесь db.save_order: идентификатор создаётся до запроса и лежит в вашей базе. Если сгенерировать его внутри цикла повторов, каждый повтор станет новым заказом.

#Что повторять, а что нет

Повтор запроса по типу ответа
ОтветПовторятьЧто сделать
Таймаут, обрыв сетида, тем же custom_idответ мог потеряться после списания, повтор вернёт результат
5xxда, тем же custom_idпауза между попытками растёт
429да, через Retry-Afterне раньше, чем сказано в заголовке
400нетисправить тело запроса
401нетпроверить ключ
402 на payнетпополнить депозит; заказ уже rejected, нужен новый custom_id
409нетзаказ отменён, отклонён или позиция кончилась; обновить каталог

Ставьте потолок на число попыток и складывайте всё, что его исчерпало, в свою очередь на разбор. Рядом с ответом логируйте свой custom_id: без него инцидент не разобрать.

#Вебхук: два канала, один обработчик

Вебхук ускоряет интеграцию, но опрос не заменяет: ваш сервер мог лежать, домен не резолвился, ответ пришёл не 2xx. Опрос GET /order/{custom_id} до конечного статуса нужен всегда.

callback_url в order/create (только https) получает события своего заказа: delivered, failed, cancelled, а у асинхронных товаров ещё action_required. Адрес в кабинете, в разделе «Ключ и вебхуки», получает события по всем заказам, и только туда приходят created, paid и deposit. Тело и подпись в обоих каналах одинаковые, а если адрес совпадает, событие придёт один раз.

событиеPOST на ваш адрестело заказа и поле event
1Проверить X-SignatureHMAC-SHA256 от сырого тела
2Перечитать заказGET /order/{custom_id}
3Записать у себяпо custom_id, повтор ничего не меняет
4Ответить 2xxтяжёлую работу в фон

Подпись приходит в заголовке X-Signature: sha256=<hex>: это HMAC-SHA256 от сырого тела на секрете из кабинета. Считайте её по байтам запроса до разбора JSON и сравнивайте за постоянное время.

webhook.py
import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["HEX_WEBHOOK_SECRET"].encode()


@app.post("/hex/webhook")
def hex_webhook():
    raw = request.get_data()                  # сырые байты, до разбора JSON
    expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    got = request.headers.get("X-Signature", "")
    if not hmac.compare_digest(expected.encode(), got.encode()):
        abort(401)
    queue.put(request.get_json())             # ваша очередь: перечитать заказ в фоне
    return "", 204
Фоновая задача перечитывает заказ по custom_id и пропускает незнакомые события.

#Три ловушки с деньгами покупателя

Возврат у доставленного заказа. Если поставщик выдал меньше кодов, чем заказано, статус остаётся delivered, а разница возвращается на депозит с refunded: true. Если ваш сервис решает о возврате покупателю по одному refunded, покупатель получит деньги за товар, который уже получил. Решение принимайте по status, сумму берите из refund_amount_nano.

Доставка на проверке. delivery_in_review значит, что доставка идёт дольше обычного и её смотрит человек. Деньги не возвращались, статус не конечный, возвращать оплату покупателю рано.

Свой таймаут. У асинхронных товаров заказ живёт в processing минутами и часами, а иногда ждёт, пока покупатель подтвердит вход. Таймаут в пятнадцать минут превратит такой заказ в возврат, и покупатель получит и деньги, и товар. Ориентируйтесь на delivery_state, action_required и eta_seconds.

#Коды провала доставки

Ветвитесь по error_code, а не по тексту. Повтор после провала всегда идёт новым заказом с новым custom_id.

Коды провала доставки
error_codeЧто значитДеньгиПовтор
recipient_invalidполучателя нет или он не может принять товарвернулисьпосле исправления получателя
out_of_stockпозиция кончиласьвернулиськогда позиция вернётся с in_stock: true
product_unavailableтовар отключён целиком или для васвернулисьнет, пока товар не включат
invalid_order_dataне хватает поля или оно не той формывернулисьпосле исправления тела
delivery_rejectedпоставщик отказал по данным получателявернулисьпосле проверки данных с покупателем
delivery_failedдоставка окончательно не удаласьвернулисьда, сбой мог быть разовым
delivery_in_reviewдоставку смотрит человек, заказ живойне возвращалисьнет, ждите конечного статуса
temporary_failureвременный сбой у платформывернулисьда, позже
order_cancelledистёк неоплаченным или отменён вручнуювернулись, если был оплаченда
action_expiredпокупатель не подтвердил действие в сроквернулиськогда покупатель на связи
organization_id_rejectedподписку не подключить к аккаунту покупателя по его IDвернулись целикомкогда покупатель проверил аккаунт

Список товаров, событий и полей пополняется. Незнакомое событие вебхука пропускайте, незнакомый error_code обрабатывайте как обычный провал, новые поля в ответе не считайте нарушением схемы. Иначе интеграция будет падать на каждом обновлении API.

Если интеграцию пишет ИИ-агент, вставьте чек-лист из конца статьи ему в задание как требования. Зачем, написано в статье про интеграцию руками нейросети.

#Чек-лист

Следующий шаг

Проверьте повтор на своём ключе

Создайте заказ, отправьте pay дважды с тем же custom_id и сравните баланс до и после через GET /balance.

Читать дальше

Интеграция Reseller API: от ключа до первого заказа

Базовые запросы: ключ, каталог, create, pay и опрос статуса.

API · DigitalИнтеграция5 мин
Вебхуки и повторы: как не потерять деньги на продлениях

То же для VPN-подписок: продления, заморозка и автопродление.

VPN · APIИнтеграция6 мин
Интеграция платного API руками нейросети: что дать агенту и где он сожжёт депозит

Как передать эти правила агенту, который пишет код.

APIИнтеграция3 мин
Собираем анонимную аналитику посещений. Подробнее