API для реселлеров

Покупайте товары LH Лавки со своего баланса через HTTP и продавайте у себя: в своём боте, на сайте или в магазине. Цены те же, что в Маркете, товар выдаётся сразу в ответе API.

Быстрый старт

  1. Откройте бота, нажмите «API» → «Получить ключ». Ключ показывается один раз — сохраните его.
  2. Пополните баланс кнопкой «Пополнить». Все покупки через API списываются с него.
  3. Возьмите каталог GET /products и купите товар POST /orders — товар придёт сразу в ответе.

Адрес API

…/api/v1

Ответы — JSON в UTF-8, в каждом есть ok. Деньги — в копейках (*_cents) и в рублях. Время — UTC, ISO 8601.

OpenAPI-спецификация: openapi.json — импортируйте в Postman или Insomnia, или сгенерируйте клиент для своего языка.

Авторизация

Authorization: Bearer lh_ваш_ключ

Ключ даёт доступ к вашему балансу — храните его только на сервере. Если ключ утёк, перевыпустите его в боте: старый перестанет работать сразу, настройки сохранятся. Для защиты можно разрешить запросы только с ваших IP (настройки).

Лимит — 5 запросов в секунду (короткие всплески до 30). При превышении — ошибка rate_limited с полем retry_after в секундах.

Каталог

GET/products?q=  ?brand=  ?all=1

Товары, которые можно купить прямо сейчас. q — поиск по названию, brand — ключ бренда, all=1 — показать и то, чего нет в наличии (available: false). Обновляется каждые 15 секунд.

{"ok": true, "count": 1, "products": [
  {"id": 17, "name": "Claude Pro — 1 месяц", "title": "Claude Pro — 1 месяц",
   "brand": "claude", "brand_title": "Claude", "tag": "Ключ активации",
   "price": 2199.0, "price_cents": 219900, "available": true, "stock": 8,
   "delivery": "instant", "preorder_hours": null, "input": null}
]}
ПолеЗначение
stockСколько штук в наличии; null — без ограничения.
deliveryinstant — выдача сразу; preorder — в течение preorder_hours часов; manual — выдаёт администратор.
inputЕсли не null — товару нужны данные покупателя (например, ссылка на Discord-сервер). Передайте их в поле input при покупке; подсказка — в input.hint.
GET/products/{id}

Один товар, вместе с описанием (description).

GET/brands

Бренды, у которых сейчас есть товары: key, title, количество товаров. Удобно для меню в своём боте.

Покупка

POST/orders
{"product_id": 17, "idempotency_key": "order-10045", "quantity": 1, "max_price_cents": 219900}
ПолеОписание
product_idID товара. Обязательно.
idempotency_keyВаш уникальный ID покупки, до 64 символов, без #. Обязательно. Повтор с тем же ключом не спишет деньги второй раз, а вернёт те же заказы — смело повторяйте запрос после таймаута или обрыва связи.
quantityСколько штук, от 1 до 10. По умолчанию 1. Каждая штука — отдельный заказ.
max_price_centsЦена за штуку, выше которой не покупать. Защита от изменения цены между каталогом и покупкой.
inputДанные покупателя, если у товара input не null.
dry_runtrue — только проверить (товар, наличие, баланс, данные) и посчитать сумму, ничего не покупая.
{"ok": true, "total_cents": 219900, "balance_cents": 130100,
 "order": { …первый заказ… },
 "orders": [{
   "id": "3f2c…", "idempotency_key": "order-10045", "product_id": 17,
   "title": "Claude Pro — 1 месяц", "price": 2199.0, "price_cents": 219900,
   "state": "delivered", "created_at": "2026-11-12T09:30:00Z",
   "delivered_at": "2026-11-12T09:30:02Z", "refunded_at": null,
   "items": [{"type": "text", "text": "https://…"}, {"type": "text", "text": "Инструкция: …"}]
 }]}

Перед покупкой проверяется, хватит ли денег и товара на всё количество — если нет, ничего не списывается. Если товар закончился посреди покупки нескольких штук, придут уже купленные заказы с "partial": true и причиной в error.

Для quantity больше 1 ключи штук — ваш_ключ, ваш_ключ#1, ваш_ключ#2…

Заказы

GET/orders/{id}?wait=25

Заказ и выданный товар. Если заказ ещё в processing, передайте wait — ответ придёт, как только товар будет выдан (но не позже чем через указанное число секунд, максимум 25).

stateЧто значит
deliveredТовар выдан, он в items.
processingОплачено, получаем товар у поставщика — обычно секунды.
waitingПредзаказ или ручная выдача — ждём администратора.
refundedВозврат: деньги вернулись на баланс.

Файлы в items приходят как {"type": "file", "download": "/orders/{id}/items/{n}"} — скачайте их тем же ключом.

GET/orders?limit ?offset ?state ?idempotency_key ?source=api

Ваши покупки, новые первыми (до 100 за запрос). Найти заказ по своему ключу — ?idempotency_key=order-10045. Только покупки через API — ?source=api.

GET/me

Баланс: balance, balance_cents.

Вебхуки

Чтобы не опрашивать заказы, задайте адрес — мы сами пришлём POST, когда заказ выдан, ушёл в ожидание или по нему сделан возврат:

PATCH /settings
{"webhook_url": "https://example.com/lh-webhook"}

В ответе — webhook_secret для проверки подписи. Тело события:

POST https://example.com/lh-webhook
X-LH-Event: order.delivered
X-LH-Timestamp: 1762939802
X-LH-Signature: sha256=5d41…

{"event": "order.delivered", "created_at": "2026-11-12T09:30:02Z", "order": { …как в GET /orders/{id}… }}
СобытиеКогда
order.deliveredТовар выдан (в том числе сразу при покупке).
order.waitingПредзаказ или ручная выдача.
order.refundedВозврат на баланс.
pingТест: POST /webhook/test.

Подпись: HMAC-SHA256 от строки X-LH-Timestamp + "." + тело с ключом webhook_secret. Отвечайте кодом 2xx в течение 8 секунд. Если не получилось — повторим до 10 раз с растущей паузой (до часа). Одно событие может прийти дважды — сверяйте по order.id и state. Шлём только на публичные адреса.

import hmac, hashlib

def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest("sha256=" + expected, signature)

Настройки

GET/settings
PATCH/settings

Передайте только поля, которые меняете:

ПолеОписание
webhook_urlАдрес для вебхуков; null — выключить.
chat_copiestrue — дублировать каждую API-покупку сообщением в чат с ботом. По умолчанию выключено (то же переключается в боте).
ip_allowlistСписок IP или подсетей, с которых разрешены запросы, например ["203.0.113.5", "198.51.100.0/24"]. Пустой список — с любых.
low_balance_alert_centsКогда баланс опустится ниже этой суммы, бот пришлёт предупреждение. null — выключить.
POST/settings/webhook-secret

Перевыпустить секрет подписи.

Ошибки

При ошибке ok = false, в error — код, текст на русском и иногда подробности (например, stock или required_cents). Если покупка вернула ошибку — деньги не списаны.

{"ok": false, "error": {"code": "insufficient_balance", "message": "Недостаточно средств: нужно 2199.0 ₽, на балансе 1500.0 ₽.",
                        "balance_cents": 150000, "required_cents": 219900}}
HTTPcodeКогда
400bad_request, input_required, bad_input, bad_webhook_urlНеверные параметры.
401unauthorizedНет ключа, ключ неверный или перевыпущен.
402insufficient_balanceНе хватает денег на всё количество.
403ip_not_allowedЗапрос не с разрешённого IP.
404not_foundНет такого товара, заказа или метода.
409product_unavailable, out_of_stock, price_changed, purchase_failedКупить сейчас нельзя.
429rate_limitedБольше 5 запросов в секунду.
5xxinternal и др.Сбой у нас. Повторите запрос с тем же idempotency_key.

Примеры

curl

curl -s BASE/products?q=claude -H "Authorization: Bearer $LH_KEY"

curl -s -X POST BASE/orders \
  -H "Authorization: Bearer $LH_KEY" -H "Content-Type: application/json" \
  -d '{"product_id": 17, "idempotency_key": "order-10045"}'

Python

import requests

BASE = "BASE"
S = requests.Session()
S.headers["Authorization"] = "Bearer lh_ваш_ключ"

def buy(product_id: int, my_order_id: str, quantity: int = 1) -> list[dict]:
    """my_order_id — ID заказа в вашей системе: повтор не спишет деньги второй раз."""
    for attempt in range(3):
        try:
            r = S.post(f"{BASE}/orders", timeout=60, json={
                "product_id": product_id, "idempotency_key": my_order_id, "quantity": quantity}).json()
            break
        except requests.RequestException:
            continue                      # сеть — повторяем с тем же ключом
    else:
        raise RuntimeError("API недоступно")
    if not r["ok"]:
        raise RuntimeError(r["error"]["message"])
    orders = r["orders"]
    for i, order in enumerate(orders):    # редкий случай: товар ещё в пути у поставщика
        while order["state"] == "processing":
            order = S.get(f"{BASE}/orders/{order['id']}", params={"wait": 25}, timeout=40).json()["order"]
        orders[i] = order
    return orders

for order in buy(17, "my-order-10045"):
    print(order["state"], [item.get("text") for item in order["items"]])

Node.js

const BASE = "BASE";
const headers = { Authorization: `Bearer ${process.env.LH_KEY}`, "Content-Type": "application/json" };

const res = await fetch(`${BASE}/orders`, { method: "POST", headers,
  body: JSON.stringify({ product_id: 17, idempotency_key: "my-order-10045" }) });
const data = await res.json();
if (!data.ok) throw new Error(data.error.message);
console.log(data.order.state, data.order.items);

PHP

$ch = curl_init("BASE/orders");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("LH_KEY"), "Content-Type: application/json"],
  CURLOPT_POSTFIELDS => json_encode(["product_id" => 17, "idempotency_key" => "my-order-10045"])]);
$data = json_decode(curl_exec($ch), true);
if (!$data["ok"]) throw new Exception($data["error"]["message"]);
print_r($data["order"]["items"]);