Как автоматически подтверждать оплату заказа через webhook

Свяжите инвойс с заказом, проверьте подпись webhook и защитите выдачу от повторных событий, недоплаты и откатов.

Обновлено:

Чтобы сайт сам отмечал заказ оплаченным, создайте для него инвойс в Cryptanio и подключите webhook — уведомление, которое сервис отправляет вашему серверу при изменении состояния платежа. Сервер проверяет подлинность сообщения, находит заказ и сверяет состояние инвойса. После этого можно выдать покупателю оплаченный доступ или передать заказ на исполнение.

Разберём пример магазина, который создаёт счёт в долларах как единице расчёта, а покупатель выбирает доступную криптовалюту и сеть на платёжной странице. Средства поступают на адреса магазина. Заказы и выдача товара остаются в приложении магазина, а Cryptanio связывает инвойс с переводом и сообщает о его состоянии. Для этой интеграции понадобится разработчик или возможность изменить серверную часть сайта.

Подготовьте проект и адреса для оплаты

В кабинете Cryptanio выберите проект и добавьте принадлежащие вам адреса получения. Подключите их к услуге Payments («Приём платежей»): обычного наблюдения за адресом для выдачи инвойсов недостаточно. Убедитесь, что адреса активны и соответствуют выбранной сети. Для реальных оплат нужны адреса рабочего режима.

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

Создайте API-ключ и сохраните его на сервере магазина. Он потребуется для обращений к https://api.cryptanio.com/v1. Ключ нельзя помещать в JavaScript страницы, мобильное приложение или общедоступный репозиторий.

Настройте адрес получения уведомлений

Откройте «Уведомления» выбранного проекта. Если включено «Использовать настройки уведомлений аккаунта», настройте Webhook через «Настройки аккаунта» либо отключите наследование и задайте собственный адрес проекта.

В карточке Webhook укажите HTTPS-адрес обработчика в поле «URL конечной точки» и нажмите «Сохранить». Обработчик должен принимать POST-запросы непосредственно: переадресация на другой URL не используется. Получите «Секрет подписи» в той же карточке и храните его на сервере отдельно от API-ключа.

Убедитесь, что Webhook не приостановлен. В своём обработчике учитывайте payment.confirmed и payment.rolled_back, а payment.pending используйте при необходимости для промежуточного статуса. Перед выполнением заказа проверяйте рабочий режим и остальные условия ниже. Отдельно включать канал Webhook у адреса инвойса не требуется.

Создайте инвойс для конкретного заказа

Сначала сохраните заказ в базе магазина. Затем сервер создаёт инвойс через POST /v1/invoices. В reference передайте свой идентификатор заказа. В этом примере цена заказа — 12.50 USD: invoice_currency и invoice_amount задают стоимость, а покупатель выбирает доступную сеть и актив на платёжной странице. Поэтому chain, asset, amount, address и tolerance в этом запросе не передаются. Указание USD не означает приём фиатных денег или обмен поступившей криптовалюты на доллары.

#!/usr/bin/env bash
set -euo pipefail
: "${CRYPTANIO_API_KEY:?Set CRYPTANIO_API_KEY}"
: "${CRYPTANIO_OPERATION_ID:?Set CRYPTANIO_OPERATION_ID}"
: "${CRYPTANIO_PROJECT_ID:?Set CRYPTANIO_PROJECT_ID}"
json_string() {
  local text="$1" char code index output='"'
  for ((index=0; index<${#text}; index++)); do
    char="${text:index:1}"
    case "$char" in
      '"'|'\') output+="\\$char" ;;
      *) printf -v code '%d' "'$char"
         if ((code < 32)); then printf -v char '\\u%04x' "$code"; fi
         output+="$char" ;;
    esac
  done
  printf '%s"' "$output"
}
response_file="$(mktemp)"
trap 'rm -f "$response_file"' EXIT
transfer_status=0
http_status="$(curl \
  --silent --show-error --max-time 30 --request POST \
  --header "Authorization: Bearer $CRYPTANIO_API_KEY" \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header "Idempotency-Key: $CRYPTANIO_OPERATION_ID" \
  --data-binary '{"project":'"$(json_string "$CRYPTANIO_PROJECT_ID")"',"invoice_currency":"USD","invoice_amount":"12.50","reference":"order-42"}' \
  "https://api.cryptanio.com/v1/invoices" \
  --output "$response_file" --write-out '%{http_code}')" || transfer_status=$?
if [[ "$http_status" == 2?? ]]; then
  cat "$response_file"
  exit "$transfer_status"
fi
printf 'HTTP %s\n' "$http_status" >&2
cat "$response_file" >&2
exit 1

Скачать пример

Перед запуском задайте на сервере CRYPTANIO_API_KEY, CRYPTANIO_PROJECT_ID и CRYPTANIO_OPERATION_ID. Используйте рабочий API-ключ с правом создания инвойсов; CRYPTANIO_PROJECT_ID должен указывать на подготовленный проект Payments. CRYPTANIO_OPERATION_ID — сохранённый идентификатор операции создания для этого заказа: от 16 до 80 латинских букв, цифр, дефисов или подчёркиваний. Пример передаёт его в заголовке Idempotency-Key, а не в поле JSON. Для нового заказа создайте другое значение; для повтора того же запроса сохраните прежнее.

При успешном создании API возвращает HTTP 201. Сохраните id инвойса и полученный checkout_url рядом с заказом, а покупателя направьте по этой ссылке. Пока способ оплаты не выбран, сеть, адрес и криптосумма ещё не заданы. На платёжной странице покупатель выбирает доступный способ и получает актуальные реквизиты и срок оплаты. Если отображаете реквизиты в своём интерфейсе, используйте данные конкретной попытки оплаты, точную сумму из amount и срок expires_at; не округляйте сумму и не заменяйте её значением asked. Ссылки из последующих GET-запросов и списка инвойсов предназначены для просмотра; интерактивную ссылку из ответа создания храните отдельно и передавайте только нужному покупателю.

Если соединение прервалось после отправки запроса, повторите то же тело с тем же Idempotency-Key. Успешный повтор вернёт HTTP 201 и тот же id. Изменение параметров с уже использованным ключом вызывает HTTP 409. reference связывает инвойс с заказом, но не заменяет ключ однократного создания. Не меняйте ключ только из-за тайм-аута.

Проверьте подлинность входящего webhook

Cryptanio передаёт X-Timestamp, X-Signature, X-Event-Id, X-Event-Type и X-Attempt. Подпись вычисляется так:

hex(HMAC-SHA256(secret, timestamp + "." + raw_body))

raw_body — исходные байты запроса. Получите их до разбора JSON: повторная сериализация может изменить пробелы или порядок полей и нарушить проверку. Ограничьте допустимый размер тела и отклоняйте запросы без корректной подписи.

Для примера ниже выбрано окно свежести пять минут. Это настройка вашего обработчика; для её корректной работы часы сервера должны быть синхронизированы.

import hashlib
import hmac
import re
import time

def verify_signature(secret, timestamp, raw_body, signature):
    if not secret or not timestamp or not re.fullmatch(r"[0-9]{1,12}", timestamp):
        return False
    if not signature or not re.fullmatch(r"[0-9a-f]{64}", signature):
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    signed = timestamp.encode("ascii") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Функция проверяет подпись и время запроса. После успешной проверки разберите JSON, убедитесь в schema_version="v1", ожидаемом project_id, mode="live" и sandbox=false.

Для принятия решений используйте event_id и event_type из подписанного тела. Одноимённые заголовки можно сверить с ними; сами по себе эти заголовки подписью не защищены. Платёжную логику запускайте только для ожидаемого объекта payment с направлением in.

Разделяйте подтверждение перевода и оплату заказа

Основные события для магазина:

  • payment.pending — перевод замечен, подтверждение ещё ожидается;
  • payment.confirmed — перевод набрал необходимые подтверждения;
  • payment.rolled_back — ранее замеченный перевод отозван из-за изменения цепочки блоков.

Событие payment.confirmed ещё не означает, что конкретный заказ полностью оплачен. Проверьте объект invoice и сопоставьте invoice.id и invoice.reference с сохранённым заказом. Сообщение без привязанного инвойса нельзя автоматически отнести к покупке только по похожей сумме.

Перед выдачей запросите актуальное состояние через GET /v1/invoices/{id}. Сверьте id, project_id, reference, стоимость заказа в invoice_currency и invoice_amount, а также sandbox=false. Проверьте сеть, актив, адрес и выбранную попытку оплаты; при нескольких попытках учитывайте settled_attempt_id и settled_attempt. Успешной оплате соответствует статус инвойса paid; pending, seen и partial ещё не разрешают выдачу. Возврат покупателя на сайт или открытие платёжной ссылки не подтверждают оплату.

Для недоплаты предусмотрен underpaid, для частичной оплаты с ожиданием доплаты — partial, для позднего поступления — late. При переплате политика проекта может оставить инвойс в paid либо установить overpaid с needs_decision=true. Состояния underpaid, overpaid и late требуют отдельного решения, прежде чем выдавать заказ. Поля amount_raw и paid приходят строками. Для сравнения разбирайте их как точные целые значения в минимальных единицах актива; decimals задаёт масштаб. Не используйте двоичную дробь. Правила работы с разницей задайте заранее; автоматический возврат средств из этого не следует.

Обрабатывайте повторы и откаты

Доставка допускает повторные сообщения. Сохраните проверенное событие в устойчивом хранилище и используйте event_id как уникальный ключ. Повтор уже принятого события должен получать успешный ответ без повторной выдачи товара.

Одного идентификатора транзакции для отсечения всех последующих сообщений недостаточно: перевод проходит разные состояния. Для сочетания chain, tx_id, index, address и идентичности актива сохраняйте последнюю применённую payment.revision. Применяйте переход, только если revision больше уже обработанной.

Изменение заказа и регистрацию задания на выдачу фиксируйте согласованно, с защитой от параллельной обработки. У задания должен быть уникальный ключ заказа. Непосредственно перед исполнением проверьте, что заказ всё ещё разрешено выдавать. Если пришёл откат, обновите состояние и остановите ещё не выполненную выдачу; для уже предоставленного доступа предусмотрите пересмотр или приостановку.

Проверьте весь путь

До реальной оплаты проверьте разбор тела, подпись и защиту от повторов на подготовленных данных. Служебное событие webhook.test не подтверждает покупку и не заменяет проверку полного сценария.

Затем проверьте на собственном контрольном заказе весь процесс: создание инвойса, перевод, подтверждение и обновление заказа. Отдельно проверьте повтор события, недоплату и откат на подготовленных данных. HTTP 2xx возвращайте после надёжного сохранения принятого сообщения; при временной невозможности его сохранить используйте ошибку сервера, чтобы доставка могла повториться.

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

Справочник: инвойсы API v1, проверка подписи webhook.