Идемпотентность, вебхуки и ретраи: как не потерять деньги
Как повторять запросы к Reseller API без второго списания, принимать вебхуки с проверкой подписи и не возвращать покупателю деньги за товар, который он получил.
Содержание 6 разделов
Первый заказ прошёл, дальше начинаются обрывы сети, повторы и вебхуки. На каждом из них интеграция может списать депозит дважды или вернуть покупателю деньги за выданный товар, и для каждого случая есть правило.
#custom_id: один на заказ покупателя
Идентификатор заказа придумываете вы. Повтор POST /order/create с тем же custom_id возвращает тот же заказ, повтор POST /order/pay не списывает депозит второй раз. Уникальность хранит база платформы, а не память о недавних запросах.
Новый заказ получает новый custom_id, повтор упавшего запроса идёт со старым. Нарушите первую половину правила, и покупатель получит чужой заказ. Нарушите вторую, и депозит спишется дважды.
#Тот же id с другим телом
Повтор create с тем же custom_id, но другими параметрами не вернёт ошибку: API отдаст первый заказ. Если вы переиспользовали идентификатор для новой покупки, ответ придёт успешным, а товар уйдёт по старым параметрам.
У пополнений депозита правило строже. Повтор POST /deposit/create с тем же custom_id, но другой суммой или другим способом оплаты ответит 409: старая ссылка на другую сумму оставила бы вас с недостачей без понятной причины.
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. Тело и подпись в обоих каналах одинаковые, а если адрес совпадает, событие придёт один раз.
Подпись приходит в заголовке X-Signature: sha256=<hex>: это HMAC-SHA256 от сырого тела на секрете из кабинета. Считайте её по байтам запроса до разбора JSON и сравнивайте за постоянное время.
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#Три ловушки с деньгами покупателя
Возврат у доставленного заказа. Если поставщик выдал меньше кодов, чем заказано, статус остаётся 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.
Читать дальше
Базовые запросы: ключ, каталог, create, pay и опрос статуса.
То же для VPN-подписок: продления, заморозка и автопродление.
Как передать эти правила агенту, который пишет код.