Разработчикам/Интеграция

Вебхуки

Вебхук — это сообщение, которое CardV отправляет на ваш сервер, когда заказ завершён. С ним не нужно постоянно опрашивать API. Кодов в вебхуке никогда нет: получив его, запросите заказ через GET/api/v1/orders/{order_id}.

Адреса вебхуков настраиваются в личном кабинете (Owner, Integrations → Webhooks), отдельно для Live и Sandbox. Через API это сделать нельзя.

См. также: Каталог и заказы · Безопасность

#События

СобытиеКогда приходит: заказ перешёл в статус
order.succeededsucceeded
order.partially_succeededpartially_succeeded
order.failedfailed (это ещё не возврат денег)
order.refundedrefunded (деньги вернулись в кошелёк)
  • Каждое событие отправляется не больше одного раза на заказ и адрес вебхука (не считая повторных попыток).
  • Для accepted и processing, а также для пополнения кошелька событий нет.

#Содержимое сообщения

CardV отправляет POST на ваш HTTPS-адрес:

HTTP
POST /cardv/webhook HTTP/1.1
Content-Type: application/json
User-Agent: CardV-B2B-Webhook/1.0
X-CardV-Event: order.succeeded
X-CardV-Delivery: 5521
X-CardV-Timestamp: 1790000100
X-CardV-Signature: t=1790000100,v1=2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

Тело (здесь отформатировано для удобства, CardV присылает его одной строкой):

JSON
{
  "event": "order.succeeded",
  "order": {
    "id": "O-00000001",
    "order_id": "O-00000001",
    "external_order_id": "TEST-0001",
    "status": "succeeded",
    "currency": "USD",
    "total_amount": "9.2500",
    "items": [
      {
        "sku_id": "S000001",
        "product_name": "Example Card",
        "quantity": 1,
        "delivery_count": 1
      }
    ]
  }
}
  • order.order_id — номер заказа CardV. В order.id то же самое значение.
  • order.external_order_id — ваш номер заказа.
  • order.status — статус на момент события. Он мог уже устареть.
  • X-CardV-Delivery — ID этого сообщения. Кабинет показывает его в истории отправок.

#Проверка подписи

Каждый вебхук подписывается секретом подписи вашего вебхука (whsec_...). Кабинет показывает секрет только один раз — при создании вебхука или при сбросе секрета.

Как устроена подпись:

Text
X-CardV-Signature: t=<время Unix в секундах>,v1=<строчный hex>
v1 = HMAC-SHA256(ключ = секрет подписи, сообщение = "<t>" + "." + исходные байты тела)

Порядок проверки:

  1. Прочитайте исходные байты тела до разбора JSON.
  2. Возьмите t и v1 из заголовка.
  3. Отклоните сообщение, если t расходится с вашими часами больше чем на 300 секунд.
  4. Вычислите ожидаемый v1 и сравните его с полученным. Используйте сравнение за постоянное время (constant-time).
  5. Только после этого разбирайте JSON.

Подписаны только t и тело. Тип события берите из поля event в теле, а не из заголовка X-CardV-Event.

Тестовый пример (секрет ненастоящий):

Text
секрет   whsec_TEST_ONLY_not_a_real_secret_000000000000
t        1790000100
v1       2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

Тело для этого примера — ровно эта строка (266 байт, без перевода строки в конце):

JSON
{"event":"order.succeeded","order":{"id":"O-00000001","order_id":"O-00000001","external_order_id":"TEST-0001","status":"succeeded","currency":"USD","total_amount":"9.2500","items":[{"sku_id":"S000001","product_name":"Example Card","quantity":1,"delivery_count":1}]}}

Python (Flask)

Python
import hashlib, hmac, os, time
from flask import Flask, request, abort

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


def verify(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    try:
        parts = dict(item.split("=", 1) for item in header.split(","))
        t, v1 = parts["t"].strip(), parts["v1"].strip()
    except (KeyError, ValueError):
        return False
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v1.encode())  # constant time


@app.post("/cardv/webhook")
def cardv_webhook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    if not verify(raw, request.headers.get("X-CardV-Signature", "")):
        abort(400)
    event = request.get_json()
    store_event(request.headers["X-CardV-Delivery"], event)  # your code
    return "", 204  # answer fast; do the work later

Node.js (Express)

Node.js
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.CARDV_WEBHOOK_SECRET;

function verify(rawBody, header, toleranceSeconds = 300) {
  const parts = {};
  for (const p of String(header || "").split(",")) {
    const i = p.indexOf("=");
    parts[p.slice(0, i).trim()] = p.slice(i + 1).trim();
  }
  const { t, v1 } = parts;
  if (!t || !v1 || !/^\d+$/.test(t)) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(t)) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", SECRET)
    .update(Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]))
    .digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the exact bytes. Do not use express.json() on this route.
const rawJson = express.raw({ type: "application/json" });

app.post("/cardv/webhook", rawJson, async (req, res) => {
  if (!verify(req.body, req.get("X-CardV-Signature"))) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  await storeEvent(req.get("X-CardV-Delivery"), event); // your code
  res.sendStatus(204);
});

Оба примера проходят проверку на тестовом примере.

#Повторные попытки

  • Ответьте любым статусом 2xx в течение 15 секунд, после того как сохранили событие. Долгую работу (запрос заказа, отправку кодов) делайте потом.
  • Всё остальное считается ошибкой: другой статус, тайм-аут или сбой соединения.
  • CardV не переходит по редиректам. Ответ 3xx — тоже ошибка. Указывайте точный конечный адрес.
  • Неудачное сообщение отправляется повторно, всего не больше 6 попыток:
ПопыткаКогда
1Сразу после события
2Примерно через 1 минуту после неудачной попытки 1
3Примерно через 5 минут после неудачной попытки 2
4Примерно через 15 минут после неудачной попытки 3
5Примерно через 30 минут после неудачной попытки 4
6Примерно через 60 минут после неудачной попытки 5

Попытка может прийти на минуту позже. После 6-й неудачи CardV прекращает отправку. Все сообщения видны в истории отправок в кабинете, оттуда же их можно отправить заново.

#Дубликаты

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

  • Пропускайте событие, которое уже обработали. Сверяйте пару order.order_id и event из проверенного тела. Сообщение, отправленное повторно из кабинета, получает новый ID X-CardV-Delivery, поэтому одного этого ID недостаточно.
  • Тело — это снимок на момент события. Всегда запрашивайте заказ заново и действуйте по его текущему статусу.
  • Вебхуки могут теряться. Дополнительно запустите задачу, которая проверяет незавершённые заказы старше нескольких минут.

#Требования к адресу

  • Адрес должен начинаться с https:// и вести на публичный адрес в интернете. Частные и локальные адреса не принимаются.
  • Можно подписаться на все четыре события или только на некоторые.
  • Сброс секрета подписи действует сразу. Старый секрет перестаёт работать. Чтобы перейти без простоя, добавьте второй вебхук с новым секретом, установите его у себя, а затем отключите старый.
  • CardV сохраняет первые 1 000 символов вашего ответа для разбора проблем. Не включайте в ответ секреты и персональные данные.

Вопросы по интеграции? Напишите на [email protected], указав ваш Merchant ID и ID заказа или запроса.