/v1/balance balance:readВаш баланс
Доступная сумма и сумма, зарезервированная под заказы, которые ещё выполняются.
{
"available": "1250.00",
"held": "45.80",
"currency": "USD"
}Покупайте ключи, подарочные карты и пополнения со своего баланса прямо из магазина или бота: каталог с вашими ценами, заказы, коды, баланс и вебхуки — по REST и JSON.
https://packetuphub.com/v1 Четыре шага от ключа до первого кода. Всё общение — HTTPS-запросы с JSON к адресу https://packetuphub.com/v1.
В кабинете, «Разработчикам → API-ключи»: выберите права и, если нужно, IP-адреса. Секрет sk_… показывается один раз — сохраните его в своём хранилище секретов.
Добавьте заголовки X-Api-Key, X-Timestamp и X-Signature (как считать подпись) и проверьте их на GET /v1/balance.
POST /v1/orders с sku из GET /v1/products и уникальным Idempotency-Key. Стоимость резервируется на балансе, заказ сразу в статусе processing.
Дождитесь вебхука order.completed (или опрашивайте заказ) и получите коды из items в GET /v1/orders/{id}.
Каждый запрос подписывается секретом ключа. Секрет по сети не передаётся: сервер считает ту же подпись и сравнивает.
| Заголовок | Значение |
|---|---|
X-Api-Key | Id ключа, kh_… |
X-Timestamp | Текущее Unix-время в секундах. Отличается от нашего больше чем на 5 минут в любую сторону — запрос отклоняется |
X-Signature | HMAC-SHA256 строки для подписи с ключом-секретом sk_…, в нижнем регистре hex (64 символа) |
Строки, соединённые переводом строки \n, без перевода строки в конце. Пятая — только у запросов с Idempotency-Key (создание заказа):
1793088000значение X-TimestampPOSTHTTP-метод заглавными буквами/v1/ordersпуть с query-строкой, без хоста: /v1/orders?limit=10df7c6ce8062ff49c5906ea4d91ae0118afb4f6225c87933ea48bc7a07dc5d94fSHA-256 тела запроса, hex в нижнем регистреshop-58213значение Idempotency-Key; без этого заголовка строки нет? и параметрами, в том же порядке и кодировке.GET и DELETE тела нет — это хеш пустой строки: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.sk_.401 unauthorized.API_KEY="kh_…" # id ключа
SECRET="sk_…" # секрет ключа, показан один раз при создании
BASE="https://packetuphub.com"
METHOD="POST"
URI="/v1/orders" # путь с query-строкой, без хоста
BODY='{"sku":"pubg-660","quantity":1,"uid":"5123456789"}' # для GET — пустая строка
IDEM="shop-58213" # ваш номер заказа; для GET — пустая строка
TS=$(date +%s)
HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $NF}')
TO_SIGN=$(printf '%s\n%s\n%s\n%s' "$TS" "$METHOD" "$URI" "$HASH")
[ -n "$IDEM" ] && TO_SIGN=$(printf '%s\n%s' "$TO_SIGN" "$IDEM")
SIG=$(printf '%s' "$TO_SIGN" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
# для GET уберите Idempotency-Key, Content-Type и --data-raw
curl -sS -X "$METHOD" "$BASE$URI" \
-H "X-Api-Key: $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG" \
-H "Idempotency-Key: $IDEM" \
-H "Content-Type: application/json" \
--data-raw "$BODY"import hashlib
import hmac
import json
import time
import requests # pip install requests
API_KEY = "kh_…"
SECRET = "sk_…"
BASE = "https://packetuphub.com"
def call(method, uri, body=None, idempotency_key=None):
raw = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
ts = str(int(time.time()))
to_sign = f"{ts}\n{method}\n{uri}\n{hashlib.sha256(raw).hexdigest()}"
if idempotency_key:
to_sign += f"\n{idempotency_key}"
sig = hmac.new(SECRET.encode(), to_sign.encode(), hashlib.sha256).hexdigest()
h = {"X-Api-Key": API_KEY, "X-Timestamp": ts, "X-Signature": sig}
if raw:
h["Content-Type"] = "application/json"
if idempotency_key:
h["Idempotency-Key"] = idempotency_key
r = requests.request(method, BASE + uri, data=raw, headers=h, timeout=30)
return r.status_code, (r.json() if r.content else None)
print(call("GET", "/v1/balance"))
print(call("POST", "/v1/orders",
{"sku": "pubg-660", "quantity": 1, "uid": "5123456789"},
"shop-58213"))// Node.js 18+, файл .mjs: fetch и crypto встроены
import crypto from "node:crypto";
const API_KEY = "kh_…";
const SECRET = "sk_…";
const BASE = "https://packetuphub.com";
async function call(method, uri, body, idempotencyKey) {
const raw = body === undefined ? "" : JSON.stringify(body);
const ts = String(Math.floor(Date.now() / 1000));
const hash = crypto.createHash("sha256").update(raw).digest("hex");
let toSign = ts + "\n" + method + "\n" + uri + "\n" + hash;
if (idempotencyKey) toSign += "\n" + idempotencyKey;
const sig = crypto.createHmac("sha256", SECRET).update(toSign).digest("hex");
const res = await fetch(BASE + uri, {
method,
headers: {
"X-Api-Key": API_KEY,
"X-Timestamp": ts,
"X-Signature": sig,
...(raw ? { "Content-Type": "application/json" } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: raw || undefined,
});
const text = await res.text();
return [res.status, text ? JSON.parse(text) : null];
}
console.log(await call("GET", "/v1/balance"));
console.log(await call("POST", "/v1/orders",
{ sku: "pubg-660", quantity: 1, uid: "5123456789" },
"shop-58213"));package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
const (
apiKey = "kh_…"
secret = "sk_…"
base = "https://packetuphub.com"
)
// call signs and sends a request; body is nil and idempotencyKey empty for GET.
func call(method, uri string, body []byte, idempotencyKey string) (int, []byte, error) {
ts := strconv.FormatInt(time.Now().Unix(), 10)
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%s\n%s\n%s\n%x", ts, method, uri, sha256.Sum256(body))
if idempotencyKey != "" {
fmt.Fprintf(mac, "\n%s", idempotencyKey)
}
req, err := http.NewRequest(method, base+uri, bytes.NewReader(body))
if err != nil {
return 0, nil, err
}
req.Header.Set("X-Api-Key", apiKey)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
return resp.StatusCode, out, err
}
func main() {
body, _ := json.Marshal(map[string]any{"sku": "pubg-660", "quantity": 1, "uid": "5123456789"})
status, out, err := call(http.MethodPost, "/v1/orders", body, "shop-58213")
if err != nil {
panic(err)
}
fmt.Println(status, string(out))
}<?php
const API_KEY = 'kh_…';
const SECRET = 'sk_…';
const BASE = 'https://packetuphub.com';
function call(string $method, string $uri, ?array $body = null, string $idempotencyKey = ''): array
{
$raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$ts = (string) time();
$lines = [$ts, $method, $uri, hash('sha256', $raw)];
if ($idempotencyKey !== '') {
$lines[] = $idempotencyKey;
}
$sig = hash_hmac('sha256', implode("\n", $lines), SECRET);
$h = ['X-Api-Key: ' . API_KEY, 'X-Timestamp: ' . $ts, 'X-Signature: ' . $sig];
if ($raw !== '') {
$h[] = 'Content-Type: application/json';
}
if ($idempotencyKey !== '') {
$h[] = 'Idempotency-Key: ' . $idempotencyKey;
}
$ch = curl_init(BASE . $uri);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $h,
CURLOPT_RETURNTRANSFER => true,
]);
if ($raw !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $raw);
}
$res = curl_exec($ch);
return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $res ? json_decode($res, true) : null];
}
var_dump(call('GET', '/v1/balance'));
var_dump(call('POST', '/v1/orders',
['sku' => 'pubg-660', 'quantity' => 1, 'uid' => '5123456789'],
'shop-58213'));С этими входными данными подпись должна получиться ровно такой — её посчитал код сервера.
sk_example1793088000POST /v1/ordersshop-58213{"sku":"pubg-660","quantity":1,"uid":"5123456789"}df7c6ce8062ff49c5906ea4d91ae0118afb4f6225c87933ea48bc7a07dc5d94fd2c0d0811a93df7401fa5a02a7e2817450ccaeeb43cc68c04b79c25491f1dc56Ключ работает только для того, на что выпущен. Права и адреса задаются при создании ключа в кабинете; чтобы их поменять, выпустите новый ключ и отзовите старый.
| Право | Методы |
|---|---|
catalog:read | GET /v1/products |
orders:write | POST /v1/orders |
orders:read | GET /v1/orders, GET /v1/orders/{id} |
balance:read | GET /v1/balance |
webhooks:write | /v1/webhook и всё под ним |
403 forbidden.203.0.113.0/24). С другого адреса — 403 ip_not_allowed. Пустой список — любой адрес.Сеть может оборваться после того, как заказ создан, но до того, как вы получили ответ. Чтобы повтор не купил второй раз, у каждого заказа есть ваш ключ — заголовок Idempotency-Key.
POST /v1/orders: 1–100 символов, пробелы по краям отбрасываются. Без заголовка — 400 invalid_request.reference. Удобно передавать номер заказа из своей системы.sku, quantity, uid и zone отвечает 201 с исходным заказом в его текущем состоянии — деньги второй раз не резервируются.409 idempotency_conflict.POST /v1/orders с тем же ключом, а не с новым.Тела запросов и ответов — JSON в UTF-8, Content-Type: application/json. Тело запроса — до 1 МиБ.
Доллары США строкой с 2–6 знаками после точки: "10.50". Не переводите их в float — считайте в десятичных.
RFC 3339 в UTC: 2026-10-28T12:00:42Z, секунды могут быть с дробной частью.
UUID: 0f8a5c2e-9b1d-4e7a-a3f6-5d2c8e1b7a40. У заказа есть и короткий number для людей.
Если значения нет, поля в ответе нет совсем, а не null.
application/problem+json по RFC 9457 со стабильным полем code — см. Ошибки.
Кроме ошибок, указанных у метода, любой запрос может получить unauthorized , ip_not_allowed , forbidden , rate_limited , internal_error .
/v1/balance balance:readДоступная сумма и сумма, зарезервированная под заказы, которые ещё выполняются.
{
"available": "1250.00",
"held": "45.80",
"currency": "USD"
}/v1/products catalog:readВесь каталог одним ответом, без страниц. Цена — уже с учётом скидки вашего уровня: по ней будет списан заказ, оформленный сейчас.
{
"items": [
{
"sku": "pubg-660",
"name": "PUBG Mobile UC · 660 UC",
"product": "PUBG Mobile UC",
"denomination": "660 UC",
"category": "topup",
"region": "GLOBAL",
"kind": "topup",
"price": "8.90",
"max_quantity": 10,
"available": true
},
{
"sku": "steam-us-20",
"name": "Steam Wallet · 20 USD",
"product": "Steam Wallet",
"denomination": "20 USD",
"category": "gift",
"region": "US",
"kind": "code",
"price": "19.40",
"max_quantity": 50,
"available": true
}
]
}/v1/orders orders:writeРезервирует стоимость на балансе и сразу отвечает заказом в статусе processing; единицы покупаются по одной. Итог придёт вебхуком или виден в GET /v1/orders/{id}. Повтор с тем же Idempotency-Key возвращает исходный заказ в его текущем состоянии.
| Параметр | Тип | Описание |
|---|---|---|
Idempotency-Key header обязательный | string | Ваш уникальный id заказа, 1–100 символов. Он же ваш номер заказа: в кабинете, CSV и вебхуках виден как reference. |
sku body обязательный | string | Позиция из GET /v1/products. |
quantity body обязательный | integer | От 1 до max_quantity товара (не больше 500). |
uid body | string | ID аккаунта игрока. Обязателен для товаров kind: topup, для kind: code передавать нельзя. |
zone body | string | Вторая часть аккаунта (зона или сервер). Обязательна, если у товара есть zone_label, иначе передавать нельзя. |
{
"id": "0f8a5c2e-9b1d-4e7a-a3f6-5d2c8e1b7a40",
"number": 10482,
"reference": "shop-58213",
"sku": "pubg-660",
"product_name": "PUBG Mobile UC · 660 UC",
"quantity": 1,
"unit_price": "8.90",
"total": "8.90",
"uid": "5123456789",
"status": "processing",
"created_at": "2026-10-28T12:00:31Z",
"items": [
{ "id": "6b1f0c9e-2a47-4d3b-9e85-3c7a1d2f4e60", "status": "processing" }
]
}/v1/orders orders:readЗаказы из API и из кабинета. Следующая страница — с before равным next_before; на последней странице next_before нет.
| Параметр | Тип | Описание |
|---|---|---|
limit query | integer | Сколько заказов на странице: 1–100, по умолчанию 50. |
before query | uuid | Заказы, оформленные раньше этого: next_before предыдущей страницы. |
{
"items": [
{
"id": "0f8a5c2e-9b1d-4e7a-a3f6-5d2c8e1b7a40",
"number": 10482,
"reference": "shop-58213",
"sku": "pubg-660",
"product_name": "PUBG Mobile UC · 660 UC",
"quantity": 1,
"unit_price": "8.90",
"total": "8.90",
"uid": "5123456789",
"status": "completed",
"created_at": "2026-10-28T12:00:31Z",
"completed_at": "2026-10-28T12:00:42Z"
}
],
"next_before": "0f8a5c2e-9b1d-4e7a-a3f6-5d2c8e1b7a40"
}/v1/orders/{id} orders:readОтсюда забирают коды: у выданных единиц в items есть code (и pin, если он есть у карты), у пополнений — operation. Чужой заказ отвечает 404, как несуществующий.
| Параметр | Тип | Описание |
|---|---|---|
id path обязательный | uuid | id заказа из ответа на создание, списка или вебхука. |
{
"id": "3c9e7a10-5b2d-4f8e-a1c6-9d0b2e4f6a81",
"number": 10483,
"reference": "shop-58214",
"sku": "steam-us-20",
"product_name": "Steam Wallet · 20 USD",
"quantity": 2,
"unit_price": "19.40",
"total": "38.80",
"status": "completed",
"created_at": "2026-10-28T12:03:10Z",
"completed_at": "2026-10-28T12:03:14Z",
"items": [
{
"id": "a4d2c6e8-1f3b-4a5c-8e7d-9b0f1a2c3d4e",
"status": "delivered",
"code": "7KXQ2-M9PLA-3RTZB",
"operation": "SUP-884120"
},
{
"id": "b5e3d7f9-2a4c-4b6d-9f8e-0c1a2b3d4e5f",
"status": "delivered",
"code": "4HWNC-Q8DTE-6YJPU",
"operation": "SUP-884121"
}
]
}/v1/webhook webhooks:writeАдрес и события. Секрета в ответе нет: он приходит один раз — при создании.
{
"url": "https://shop.example.com/packetuphub/webhook",
"events": ["order.completed", "order.failed", "order.partially_completed"],
"created_at": "2026-10-20T09:15:00Z",
"updated_at": "2026-10-27T18:02:11Z"
}/v1/webhook webhooks:writeУ компании один адрес. Ответ на создание содержит секрет подписи secret (whsec_…) — сохраните его; при изменении секрет остаётся прежним и в ответ не попадает.
| Параметр | Тип | Описание |
|---|---|---|
url body обязательный | string | Абсолютный адрес https:// без логина и пароля, до 2000 символов. Должен вести на публичный IP-адрес; редиректы не выполняются. |
events body обязательный | string[] | Хотя бы одно событие из списка в разделе «Вебхуки». ping указывать не нужно — он приходит всегда. |
{
"url": "https://shop.example.com/packetuphub/webhook",
"events": ["order.completed", "order.failed", "order.partially_completed"],
"secret": "whsec_…",
"created_at": "2026-10-28T12:10:00Z",
"updated_at": "2026-10-28T12:10:00Z"
}/v1/webhook webhooks:writeСобытия, которые ещё ждут отправки, не будут доставлены.
/v1/webhook/ping webhooks:writepingСтавит в очередь событие ping с пустым data — так проверяют адрес и проверку подписи на своей стороне.
/v1/webhook/deliveries webhooks:writeЖурнал за 30 дней: статус доставки, число попыток и ответ вашего сервера на последнюю.
| Параметр | Тип | Описание |
|---|---|---|
limit query | integer | Сколько событий вернуть: 1–100, по умолчанию 50. |
{
"items": [
{
"id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"type": "order.completed",
"status": "delivered",
"attempts": 1,
"last_status_code": 200,
"created_at": "2026-10-28T12:00:42Z",
"delivered_at": "2026-10-28T12:00:43Z"
},
{
"id": "019a2b30-11aa-7c3e-9d2f-5e6a7b8c9d0e",
"type": "balance.credited",
"status": "pending",
"attempts": 2,
"last_status_code": 503,
"last_error": "HTTP 503 Service Unavailable",
"created_at": "2026-10-28T11:58:02Z"
}
]
}Поля ответов. Money — строка с суммой в долларах, см. Форматы данных.
Balance Ответ GET /v1/balance.| Поле | Тип | Описание |
|---|---|---|
available обязательный | Money | Доступно для новых заказов. |
held обязательный | Money | Зарезервировано под заказы, которые ещё выполняются. |
currency обязательный | string | Всегда USD. |
Product Элемент items в GET /v1/products: один номинал товара.| Поле | Тип | Описание |
|---|---|---|
sku обязательный | string | Код позиции, его передают в заказе. |
name обязательный | string | Товар и номинал одной строкой: PUBG Mobile UC · 660 UC. |
product обязательный | string | Название товара. |
denomination обязательный | string | Номинал. |
category обязательный | string | Раздел каталога: gift, keys или topup. |
region обязательный | string | Регион товара, например GLOBAL. |
kind обязательный | string | code — вы получаете код; topup — пополняется аккаунт игрока, переданный в uid. |
zone_label | string | Есть у пополнений, где аккаунт игрока состоит из двух частей (зона или сервер): передайте вторую часть в zone. |
price обязательный | Money | Ваша цена: каталожная минус скидка вашего уровня. Меняется вместе с уровнем. |
max_quantity обязательный | integer | Сколько единиц можно взять одним заказом (у пополнений — предел пакетов за одну покупку у поставщика). |
available обязательный | boolean | false, пока ни один поставщик не может продать товар; заказы тогда отклоняются. |
Order Заказ: ответ на создание, GET /v1/orders/{id} и элемент items в списке заказов.| Поле | Тип | Описание |
|---|---|---|
id обязательный | uuid | Идентификатор заказа в API. |
number обязательный | integer | Короткий номер, который показывает кабинет. |
reference обязательный | string | Ваш номер заказа — Idempotency-Key запроса; пустой у заказов из кабинета без номера. |
sku обязательный | string | Позиция каталога. |
product_name обязательный | string | Товар и номинал. |
quantity обязательный | integer | Сколько единиц заказано. |
unit_price обязательный | Money | Цена единицы на момент заказа. |
total обязательный | Money | unit_price × quantity. Невыданные единицы возвращаются на баланс. |
uid | string | Аккаунт игрока, у пополнений. |
zone | string | Вторая часть аккаунта, если она есть. |
status обязательный | string | processing, completed, partial или failed — см. «Статусы заказа». |
created_at обязательный | date-time | Когда заказ оформлен. |
completed_at | date-time | Когда заказ завершён; нет, пока он выполняется. |
items | OrderItem[] | Единицы заказа. Есть в ответе на создание и в GET /v1/orders/{id}, в списке заказов их нет. |
OrderItem Одна единица заказа.| Поле | Тип | Описание |
|---|---|---|
id обязательный | uuid | Идентификатор единицы; его же называет событие order.review. |
status обязательный | string | processing, delivered или failed. Стоимость failed возвращена на баланс. |
code | string | Код товара — у выданных единиц товаров kind: code. |
pin | string | PIN — у подарочных карт, где он есть. |
operation | string | Номер покупки у поставщика, после выдачи. По нему поддержка игры находит зачисление пополнения. |
Webhook Настройки вебхука.| Поле | Тип | Описание |
|---|---|---|
url обязательный | string | Куда отправляются события. |
events обязательный | string[] | На какие события вы подписаны. |
secret | string | Секрет подписи whsec_…; только в ответе, который создал вебхук. |
created_at обязательный | date-time | Когда вебхук создан. |
updated_at обязательный | date-time | Когда изменён в последний раз. |
WebhookDelivery Элемент items в GET /v1/webhook/deliveries.| Поле | Тип | Описание |
|---|---|---|
id обязательный | uuid | Id события; приходит в заголовке X-Webhook-Id. |
type обязательный | string | Тип события. |
status обязательный | string | pending — ждёт отправки или повтора, delivered — ваш сервер ответил 2xx, failed — попытки кончились. |
attempts обязательный | integer | Сколько раз событие отправлялось. |
last_status_code | integer | HTTP-статус последней попытки; нет, если ответа не было. |
last_error | string | Почему последняя попытка не удалась. |
created_at обязательный | date-time | Когда событие произошло. |
delivered_at | date-time | Когда доставлено. |
Заказ создаётся в processing и один раз переходит в итоговый статус. Каждому итогу соответствует своё событие вебхука.
Стоимость зарезервирована на балансе (held), единицы покупаются у поставщика по одной.
Выданы все единицы.
order.completedЧасть выдана, стоимость остальных вернулась на баланс.
order.partially_completedНичего не выдано, вся сумма вернулась на баланс.
order.failedПока заказ в processing, исход покупки одной из единиц может оказаться неизвестен. Тогда приходит событие order.review: мы выясняем результат у поставщика, деньги остаются зарезервированными, заказ остаётся в processing и затем получает итоговый статус как обычно.
У каждой единицы в items свой статус: processing → delivered (есть code или operation) или failed (стоимость возвращена). Пополнения по ID выполняются строго по очереди, коды — по одному.
Вместо опроса заказов укажите HTTPS-адрес через PUT /v1/webhook или в кабинете («Разработчикам → Вебхуки»). Когда что-то происходит, мы отправляем на него POST с JSON-событием.
| Событие | Когда | data |
|---|---|---|
order.completed | Выданы все единицы заказа | |
order.partially_completed | Часть выдана, остальное возвращено | |
order.failed | Ничего не выдано, всё возвращено | |
order.review | Исход покупки единицы неизвестен, мы разбираемся; деньги зарезервированы | |
balance.credited | Зачислено пополнение баланса; reference — хеш транзакции | |
ping | Тестовое событие по запросу; приходит всегда, на него не подписываются | |
У событий о заказах reference есть, только если у заказа был ваш номер. События «тонкие»: коды в них не попадают — заберите их через GET /v1/orders/{id}.
POST /packetuphub/webhook HTTP/1.1
Content-Type: application/json
X-Webhook-Id: 019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b
X-Webhook-Event: order.completed
X-Webhook-Timestamp: 1793088042
X-Webhook-Signature: 5d0c…9a1e
{
"id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"type": "order.completed",
"created_at": "2026-10-28T12:00:42Z",
"data": {
"order_id": "0f8a5c2e-9b1d-4e7a-a3f6-5d2c8e1b7a40",
"number": 10482,
"reference": "shop-58213",
"status": "completed"
}
}| Заголовок | Значение |
|---|---|
X-Webhook-Id | Id события; при повторах тот же — по нему отбрасывайте дубликаты |
X-Webhook-Event | Тип события |
X-Webhook-Timestamp | Unix-время отправки в секундах; у каждой попытки своё |
X-Webhook-Signature | HMAC-SHA256 строки {X-Webhook-Timestamp}\n{тело} с ключом-секретом вебхука whsec_…, hex в нижнем регистре |
X-Webhook-Timestamp, например старше 5 минут: повторы подписываются заново со свежим временем, так что честный запрос всегда свежий.X-Webhook-Signature две подписи через запятую — новым и старым секретом; принимайте запрос, если совпала любая.import hashlib
import hmac
import time
from flask import Flask, abort, request
WEBHOOK_SECRET = b"whsec_…"
app = Flask(__name__)
@app.post("/packetuphub/webhook")
def webhook():
ts = request.headers.get("X-Webhook-Timestamp", "")
raw = request.get_data() # байты тела ровно как пришли
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(400)
want = hmac.new(WEBHOOK_SECRET, ts.encode() + b"\n" + raw, hashlib.sha256).hexdigest()
sigs = request.headers.get("X-Webhook-Signature", "").split(",")
if not any(hmac.compare_digest(want, s) for s in sigs):
abort(401)
event = request.get_json()
# если event["id"] уже обработан — просто ответьте 2xx; иначе поставьте в очередь
return "", 204import crypto from "node:crypto";
import express from "express";
const WEBHOOK_SECRET = "whsec_…";
const app = express();
// raw: подпись считается от байтов тела ровно как они пришли
app.post("/packetuphub/webhook", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.get("X-Webhook-Timestamp") || "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return res.sendStatus(400);
}
const want = crypto.createHmac("sha256", WEBHOOK_SECRET)
.update(ts + "\n").update(req.body).digest("hex");
const ok = (req.get("X-Webhook-Signature") || "").split(",").some((s) =>
s.length === want.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(want)));
if (!ok) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
// если event.id уже обработан — просто ответьте 2xx; иначе поставьте в очередь
res.sendStatus(204);
});
app.listen(8080);const webhookSecret = "whsec_…"
func webhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // байты тела ровно как пришли
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}
ts := r.Header.Get("X-Webhook-Timestamp")
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || time.Since(time.Unix(sec, 0)).Abs() > 5*time.Minute {
http.Error(w, "stale timestamp", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(ts + "\n"))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
ok := false
for _, sig := range strings.Split(r.Header.Get("X-Webhook-Signature"), ",") {
ok = ok || hmac.Equal([]byte(sig), []byte(want))
}
if !ok {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
var event struct {
ID string `json:"id"`
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// если event.ID уже обработан — просто ответьте 2xx; иначе поставьте в очередь
w.WriteHeader(http.StatusNoContent)
}<?php
const WEBHOOK_SECRET = 'whsec_…';
$raw = file_get_contents('php://input'); // байты тела ровно как пришли
$ts = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
http_response_code(400);
exit;
}
$want = hash_hmac('sha256', $ts . "\n" . $raw, WEBHOOK_SECRET);
$ok = false;
foreach (explode(',', $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '') as $sig) {
$ok = $ok || hash_equals($want, $sig);
}
if (!$ok) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// если $event['id'] уже обработан — просто ответьте 2xx; иначе поставьте в очередь
http_response_code(204);whsec_example1793088042{"id":"019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b","type":"order.completed","created_at":"2026-10-28T12:00:42Z","data":{"order_id":"0f8a5c2e-9b1d-4e7a-a3f6-5d2c8e1b7a40","number":10482,"reference":"shop-58213","status":"completed"}}75b3abbd2a60f111d98289f475000a1a00e9cb381a28938bdd48b9d12b14e9802xx в течение 10 секунд. Тяжёлую работу делайте после ответа, в очереди. Редиректы не выполняются: 3xx считается неудачей.failed. Журнал доставок за 30 дней с запросом и ответом — в кабинете и в GET /v1/webhook/deliveries; из кабинета событие можно отправить повторно.GET /v1/orders/{id}.ping — отправляется.Ошибки приходят как application/problem+json (RFC 9457). Решения принимайте по полю code — оно стабильно; title и detail — текст для людей и логов.
{
"type": "about:blank",
"title": "Quantity over limit",
"status": 422,
"code": "quantity_over_limit",
"detail": "quantity must be between 1 and 10 for this product"
}| code | HTTP | Что случилось |
|---|---|---|
invalid_request | 400 · 413 · 422 | Неверный JSON, параметр или заголовок, нет Idempotency-Key (400); тело больше 1 МиБ (413); Idempotency-Key из одних пробелов или длиннее 100 символов, неверный адрес или события вебхука (422). |
unauthorized | 401 | Нет заголовков подписи, X-Timestamp дальше ±5 минут от нашего времени, ключ неизвестен или отозван, подпись не совпала. |
forbidden | 403 | У ключа нет права на этот запрос. |
ip_not_allowed | 403 | Запрос пришёл с адреса, которого нет в списке ключа. |
company_frozen | 403 | Аккаунт заморожен на проверку: POST /v1/orders и GET /v1/orders/{id} недоступны до разморозки. |
api_locked | 403 | API для компании ещё не открыт. Сколько осталось — на странице «API-ключи» в кабинете. |
entry_fee_required | 402 | Пробный период закончился, а вход не оплачен: POST /v1/orders недоступен, пока вход не оплачен в кабинете. |
insufficient_funds | 402 | Доступного баланса не хватает на заказ. |
product_not_found | 404 | Нет позиции с таким sku. |
product_unavailable | 409 | Товар сейчас не продаётся (available: false). |
uid_required | 422 | Товар — пополнение: нужен uid. |
uid_not_allowed | 422 | Товар — код: uid и zone передавать нельзя. |
zone_required | 422 | У игры аккаунт из двух частей: нужна zone. |
zone_not_allowed | 422 | У игры нет зоны: zone передавать нельзя. |
quantity_over_limit | 422 | quantity вне 1…max_quantity; предел товара — в detail. |
idempotency_conflict | 409 | Idempotency-Key уже использован для заказа с другими sku, quantity, uid или zone. |
webhook_not_configured | 404 | Вебхук ещё не создан. |
not_found | 404 | Заказа нет (или он чужой), либо нет такого метода API. |
rate_limited | 429 | Превышен лимит запросов ключа; повторите через Retry-After секунд. |
internal_error | 500 | Сбой на нашей стороне. Повторите позже; если не проходит — напишите в поддержку. |
POST /v1/orders и GET /v1/orders/{id} отвечают 403 company_frozen; уже оформленные заказы ждут и продолжатся после неё. Баланс и список заказов работают, пополнения зачисляются. Напишите в поддержку.| Что | Предел | Сверх предела |
|---|---|---|
| Запросы одного ключа | 20 в секунду, всплеск до 40 | 429 rate_limited, Retry-After: 1 |
| Запросы с одного IP-адреса (сайт и API вместе; IPv6 — на сеть /64) | 50 в секунду, всплеск до 200 | 429 текстом, Retry-After: 1 |
| Тело запроса | 1 МиБ | 413 invalid_request |
Расхождение X-Timestamp | ±5 минут | 401 unauthorized |
| Единиц в заказе | max_quantity товара | 422 quantity_over_limit |
Страница списка (limit) | 1–100, по умолчанию 50 | значение приводится к пределам |
| IP-адресов и сетей у ключа | 50 | — |
| Ответ вебхука | 10 секунд | повтор по расписанию |
Получив 429, подождите Retry-After секунд. Не опрашивайте заказы в цикле без паузы — подпишитесь на вебхуки.
Пришлите метод, путь, время запроса и ответ (code и detail). Секреты sk_… и whsec_… не присылайте никому, в том числе нам.
Ежедневно 10:00–23:00 МСК