Перейти к содержанию

REST API, подпись и повторы webhook

Оглавление серии

API позволяет интеграции запросить состояние магазина, webhook сообщает о событии. Эти механизмы дополняют друг друга: уведомление может запоздать или повториться, поэтому важное действие часто требует сверки actual Order через API. Для «Полки» подготовим серверное чтение completed orders и получатель подписанных callbacks без копирования всего billing payload на диск.

После урока вы сможете различить wc/v3 credentials, customer-facing Store API и webhook secret. Код не запускался, keys не создавались, callback delivery не выполнялась. Local receiver сохраняет минимальный inbox, но не исполняет полезный worker и не обновляет реальные внешние сервисы.

Две области API

WooCommerce REST API wc/v3 предоставляет управленческие данные магазина в пределах разрешений key и связанного пользователя. Для нашего чтения создайте отдельный read-only key в Settings → Advanced → REST API. Не используйте credentials администратора напрямую и не отдавайте consumer secret браузеру. Серверная интеграция хранит values в собственном .env.

Store API относится к customer-facing catalog, cart и checkout. Его область не равна привилегированному списку всех заказов. Запросы, изменяющие cart/checkout, используют соответствующие tokens/nonces текущего механизма. Нельзя вставить wc/v3 key в storefront JavaScript как «удобный способ оформить покупку»: это перенесёт более широкие полномочия в публичный client.

Primary references — WooCommerce REST API и Store API. В этой серии мы не переписываем native Cart/Checkout blocks; они остаются обычным клиентским путём. Наш модуль выполняет только серверное чтение known Order fields.

Полный integration/read-orders.mjs запрашивает completed, ограничивает порцию десятью и выводит id/status/total/currency. Это ожидаемый server-side example, не готовая утилита выгрузки всех личных данных.

const base = new URL(process.env.WOO_URL || 'http://127.0.0.1:8086');
const url = new URL('/wp-json/wc/v3/orders', base);
url.searchParams.set('per_page', '10');
url.searchParams.set('status', 'completed');
const credential = Buffer.from(`${process.env.WOO_KEY}:${process.env.WOO_SECRET}`).toString('base64');
const response = await fetch(url, {
  headers: { Authorization: `Basic ${credential}` },
  signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`WooCommerce HTTP ${response.status}`);
const orders = await response.json();
for (const order of orders) {
  console.log(order.id, order.status, order.total, order.currency);
}

HTTP допустим здесь только как адрес отдельной loopback-лаборатории. При внешней передаче credentials нужен подходящий защищённый transport. В курсе нет external publishing и настоящих consumer keys. Параметр status фильтрует выборку, но не определяет право прочитать чужой Order: это задача permissions ключа.

Подпись относится к исходным bytes

В Settings → Advanced → Webhooks создайте Order updated callback. Задайте независимый Secret, отличный от consumer secret integration. WooCommerce отправляет X-WC-Webhook-Signature — base64 HMAC-SHA256 исходного payload. Получатель должен вычислить HMAC по тем же bytes, до JSON parse/serialize, иначе пробелы и порядок полей могут изменить результат.

Заголовки hook ID, topic и delivery ID помогают диагностике, но body signature не следует считать подписью всех заголовков. Поэтому наш inbox key зависит от signed body bytes, а delivery ID хранится как вспомогательное значение. Нельзя основывать полномочие полезного действия только на неподписанном topic header.

Полный integration/receiver.mjs использует стандартные модули Node 24 и не имеет сторонних dependencies.

import { createServer } from 'node:http';
import { createHmac, createHash, timingSafeEqual } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';

const server = createServer(async (request, response) => {
  try {
    if (request.method !== 'POST' || request.url !== '/webhooks/woo') {
      response.writeHead(404).end('Not found'); return;
    }
    const chunks = [];
    let bytes = 0;
    for await (const chunk of request) {
      bytes += chunk.length;
      if (bytes > 262144) { response.writeHead(413).end('Too large'); return; }
      chunks.push(chunk);
    }
    const raw = Buffer.concat(chunks);
    const signature = request.headers['x-wc-webhook-signature'];
    if (!process.env.WOO_WEBHOOK_SECRET || typeof signature !== 'string') {
      response.writeHead(401).end('Missing signature'); return;
    }
    const expected = createHmac('sha256', process.env.WOO_WEBHOOK_SECRET).update(raw).digest();
    const supplied = Buffer.from(signature, 'base64');
    if (supplied.length !== expected.length || !timingSafeEqual(expected, supplied)) {
      response.writeHead(401).end('Invalid signature'); return;
    }
    let event;
    try { event = JSON.parse(raw.toString('utf8')); }
    catch {
      // WooCommerce activation ping may use a form-encoded body.
      const ping = new URLSearchParams(raw.toString('utf8'));
      if (ping.has('webhook_id')) { response.writeHead(204).end(); return; }
      response.writeHead(400).end('Invalid JSON'); return;
    }
    if (!Number.isInteger(event.id) || !event.status) {
      response.writeHead(204).end(); return;
    }
    // Header delivery ID is useful for diagnostics, but is not covered by body HMAC.
    // The inbox key therefore depends only on the signed body bytes.
    const key = createHash('sha256').update(raw).digest('hex');
    const directory = resolve('.state', 'inbox');
    await mkdir(directory, { recursive: true });
    const minimal = {
      orderId: event.id, status: event.status,
      modified: event.date_modified_gmt || null,
      delivery: String(request.headers['x-wc-webhook-delivery-id'] || ''),
      bodyHash: createHash('sha256').update(raw).digest('hex'),
    };
    try {
      await writeFile(resolve(directory, `${key}.json`), JSON.stringify(minimal, null, 2), { flag: 'wx' });
    } catch (error) {
      if (error.code !== 'EEXIST') throw error;
    }
    response.writeHead(202).end('Accepted');
  } catch {
    response.writeHead(503).end('Receiver unavailable');
  }
});

server.listen(Number(process.env.RECEIVER_PORT || 4186), process.env.RECEIVER_HOST || '127.0.0.1');

Перед comparison проверяется длина decoded signature. TimingSafeEqual не должен получить buffers разной длины. Также ограничивается размер body, чтобы маленькая integration не принимала бесконечный запрос. Корректная signature подтверждает possession secret и целостность bytes, но не обещает актуальность состояния заказа.

Первое сохранение Active webhook может отправить activation ping. Receiver отдельно распознаёт ожидаемую простую form-encoded форму после signature check и отвечает без Order inbox. Форматы и логи сверяются по Webhooks. Не принимайте любой неизвестный payload как продажу только ради успешного ответа.

Durable приём не равен выполнению

Receiver сохраняет только orderId, status, modified, delivery и bodyHash. Billing email, адрес и полное тело не пишутся. Такой минимум достаточен, чтобы будущий worker знал, какой Order перечитать. В лаборатории inputs вымышлены, но подход помогает не превращать integration в лишнюю копию всех данных магазина.

Имя inbox-файла — SHA-256 исходного signed body. Запись использует wx; существующий file означает точный повтор. Оба случая получают 202 после сохранения или обнаружения дубля. Set в памяти не подходит для durable знания: после restart повтор снова выглядел бы новым. Файл переживает перезапуск одного процесса, но не создаёт промышленную очередь.

Одинаковая business change может иметь другое body, поэтому raw hash не устраняет все семантические повторы. Worker должен выполнять идемпотентное действие, например привести локальную запись к current Order state. «Добавить ещё одну продажу при каждом callback» создаст лишние rows даже при корректном приёме. Полезное действие требует собственного ключа и состояния.

Callbacks могут прийти не по времени изменения. Если после Refunded пришло запоздавшее Completed event, worker не должен вернуть продажу в прежнее состояние только по payload. Он перечитывает actual Order через API и сверяет modified/status. Network error при сверке означает retry, а не отсутствие заказа. В примере worker не добавлен, поэтому это следующий явный результат разработки.

Локальная сеть также требует понятного адреса

По умолчанию receiver слушает 127.0.0.1:4186. WordPress в Docker не видит loopback хоста как собственный host loopback. На Docker Desktop адрес хоста обычно host.docker.internal; для учебного callback потребуется разрешённый локальный listen и host firewall, описанные в README. Не создавайте внешний tunnel только ради попадания в receiver, если задача остаётся локальной.

Доступность такого маршрута мы не проверяли. Поэтому создание webhook form в панели не считается подтверждением доставки. После будущего запуска оператор сравнит Woo logs и receiver inbox, затем отправит повтор known body и проверит одну запись. Неверный secret должен дать 401, malformed JSON — отказ, oversized body — 413.

Представьте две одинаковые доставки события о Completed. Первая сохраняет минимальную запись и получает ответ 202, вторая находит уже существующий файл с тем же хешем тела. Заказ при этом остаётся одним заказом, а не двумя продажами. Но сохранение в inbox ещё не означает отправку файла или изменение отчёта: таких действий receiver не выполняет. В дальнейшем обработчик должен хранить собственный результат операции, чтобы после перезапуска понимать, что уже сделано. Точный повтор тела и повтор полезного действия относятся к разным уровням состояния.

В конечном результате integration хранит ключи на сервере, проверяет настоящую body signature, сохраняет минимум и не выдаёт 202 за завершение полезной операции. Этого достаточно для учебного контракта, но не для обещания exactly-once reports. В следующей главе используем отдельно заданные синтетические продажи, чтобы понять показатели без подмены наблюдений случайным числом callbacks.