# LicenHub reseller API (v1) - documentation for AI assistants

You are integrating the LicenHub reseller REST API. Base URL: `https://licenhub.lat/api/v1`. Authentication: `Authorization: Bearer lh_live_xxx` header on every request (use the key from your server only, never in the browser).

Key rules:
- You are never charged below the LicenHub price: `price` is optional; if sent it must not be lower than the current LicenHub price (422 `price_below_minimum`); a higher value is accepted but you are still charged exactly the LicenHub price.
- Always send an `Idempotency-Key` header on `POST /orders` and reuse it when retrying the same purchase.
- Prefer webhooks (`order.updated`, HMAC-SHA256 signature with a `whsec_xxx` secret) to learn when a pending order is delivered; keep an occasional `GET /orders/:id` poll as a fallback.
- Never invent or expose real keys; use placeholders such as `lh_live_xxx` and `whsec_xxx`.

Help the user write correct integration code based on the documentation below.

# API Documentation

Use the LicenHub API to sell our products on your own site. Every order is charged from your wallet balance at the LicenHub price. Top up your wallet before you start. No commissions apply to API orders.

## Getting a key

Open the API tab in your Dashboard and create a key. It is shown only once, so copy and store it. You can have up to 3 active keys; if one leaks, revoke it from the same tab. Use it from your server only, never in browser JavaScript.

## Base URL and authentication

```text
Base URL: https://licenhub.lat/api/v1
Authorization: Bearer lh_live_...
```

Every endpoint requires the key. Requests without it return 401.

## GET /products

Lists the catalog. price is the exact amount you will be charged. stock is null when stock is unlimited.

```bash
curl https://licenhub.lat/api/v1/products \
  -H "Authorization: Bearer lh_live_YOUR_KEY"
```

```json
{
  "products": [
    {
      "id": 12,
      "name": "Netflix Premium 1M",
      "category": "Video",
      "price": 4.99,
      "currency": "USD",
      "in_stock": true,
      "stock": 25,
      "icon": "https://licenhub.lat/logos/brands/netflix.svg"
    }
  ]
}
```

## GET /services

Lists social media services (followers, likes, views...). price_per_1000 is the base USD price per 1000 units before the processing fee; the order total is ceil_to_cents((round2(quantity / 1000 × price_per_1000) + fee_fixed) / (1 − fee_percent / 100)), the same the website charges. min_order_price is that total at the minimum quantity; min and max are the allowed quantities.

```bash
curl https://licenhub.lat/api/v1/services \
  -H "Authorization: Bearer lh_live_YOUR_KEY"
```

```json
{
  "services": [
    {
      "id": 3021,
      "name": "Instagram Followers | Fast",
      "platform": "instagram",
      "type": "followers",
      "category": "Instagram Followers",
      "price_per_1000": 1.6,
      "fee_percent": 4.4,
      "fee_fixed": 0.3,
      "min_order_price": 0.49,
      "currency": "USD",
      "min": 100,
      "max": 50000,
      "refill": true
    }
  ]
}
```

## GET /balance

Returns your current wallet balance.

```bash
curl https://licenhub.lat/api/v1/balance \
  -H "Authorization: Bearer lh_live_YOUR_KEY"

# {"balance":42.5,"currency":"USD"}
```

## POST /orders

JSON body (product): `product_id` (integer, required), `quantity` (integer 1 to 10, default 1), `price` (number, optional). Your balance is debited atomically; if it is too low the API returns 402. If you enabled automatic top-up in the dashboard (Wallet tab), a shortfall first triggers an immediate off-session charge of max(your configured amount, the shortfall) to your saved card, capped at $1,000 per charge and at 3 charges / $1,000 per 24 hours, and the order is retried once; if the charge fails or is capped you get 402 insufficient_balance with a message saying so.

`price` (optional) is your LicenHub price for this line; if omitted you are charged the current LicenHub price. If sent, it must be your LicenHub price, as returned by /products (per unit) or /services (order total, see below); requests below it are rejected with 422 price_below_minimum, which states the current price. If you send a higher value it is accepted, but you are always charged exactly the LicenHub price. At most 2 decimals.

Social media services: `type` = `"service"`, `service_id`, `link`, `quantity` (between the service min and max), `price` (optional). Here price is the total of the whole order, processing fee included: ceil_to_cents((round2(quantity / 1000 × price_per_1000) + fee_fixed) / (1 − fee_percent / 100)). type defaults to "product". A service order becomes fulfilled once sent to the provider, or pending while queued until it can be dispatched (already charged); poll GET /orders/:id.

`Idempotency-Key` (optional header, 1 to 64 characters: letters, digits, _ . : -). Repeating the same key with the same product and quantity never charges twice: you get the original result with status 200 and the header Idempotent-Replayed: true. If the original is still processing it returns 409 request_in_progress; if the key was used with a different product or quantity it returns 422 idempotency_key_reused. Always use it when retrying after a timeout.

```bash
curl -X POST https://licenhub.lat/api/v1/orders \
  -H "Authorization: Bearer lh_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1001" \
  -d '{"product_id": 12, "quantity": 1, "price": 4.99}'
```

Response 201:

```json
{
  "order_id": "api_clx..._order-1001",
  "items": [
    {
      "id": "clz9...",
      "order_code": "ABC123",
      "type": "product",
      "product_id": 12,
      "product_name": "Netflix Premium 1M",
      "status": "fulfilled",
      "delivered_item": "email:password",
      "price": 4.99,
      "created_at": "2026-10-10T12:00:00.000Z"
    }
  ],
  "charged": 4.99,
  "refunded": 0,
  "balance": 37.51
}
```

Service order example:

```bash
# 2,000 followers -> base 2000 / 1000 * 1.60 = 3.20; order total = ceil_to_cents((3.20 + 0.30) / (1 - 0.044)) = 3.67 USD
curl -X POST https://licenhub.lat/api/v1/orders \
  -H "Authorization: Bearer lh_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1002" \
  -d '{"type": "service", "service_id": 3021, "link": "https://instagram.com/your_customer", "quantity": 2000, "price": 3.67}'
```

```json
{
  "order_id": "api_clx..._order-1002",
  "items": [
    {
      "id": "clz9...",
      "order_code": "LH-LX2K9-AB12",
      "type": "service",
      "service_id": 3021,
      "service_name": "Instagram Followers | Fast (2,000)",
      "link": "https://instagram.com/your_customer",
      "quantity": 2000,
      "status": "fulfilled",
      "status_text": "sent_to_provider",
      "delivered_item": null,
      "price": 3.67,
      "created_at": "2026-10-10T12:00:00.000Z"
    }
  ],
  "charged": 3.67,
  "refunded": 0,
  "balance": 39.3
}
```

charged is the amount debited and refunded is what was automatically returned to your wallet for failed units.

## GET /orders/:id

Fetches one of your own orders by the item id (items[].id). Use it to poll pending orders until they become fulfilled.

```bash
curl https://licenhub.lat/api/v1/orders/clz9... \
  -H "Authorization: Bearer lh_live_YOUR_KEY"

# {"order":{"id":"clz9...","status":"pending","delivered_item":null, ...}}
```

## Order statuses

- `fulfilled` delivered; delivered_item contains the license.
- `pending` already charged and delivered later; delivered_item is null. Poll GET /orders/:id.
- `failed` could not be delivered; automatically refunded to your wallet.

## Webhooks

Instead of polling GET /orders/:id, register an HTTPS URL in the API tab of your Dashboard and LicenHub will send a POST when an order changes state after it was created.

### Events

- `order.updated` sent when an order that was pending is later delivered (fulfilled) or otherwise changes state (for example after being processed manually). The data field has the same shape as an item from GET /orders/:id. It is not sent during the initial POST /orders: that response already carries the result.
- `ping` test event from the "Send test event" button in the Dashboard (single attempt).

```json
{
  "id": "evt_9f2c41d0a7b35e8c6d1f02ab",
  "event": "order.updated",
  "created_at": "2026-10-11T09:30:00.000Z",
  "data": {
    "id": "clz9...",
    "order_code": "ABC123",
    "type": "product",
    "product_id": 12,
    "product_name": "Netflix Premium 1M",
    "status": "fulfilled",
    "delivered_item": "email:password",
    "price": 4.99,
    "created_at": "2026-10-10T12:00:00.000Z"
  }
}
```

### Headers

```text
Content-Type: application/json
User-Agent: LicenHub-Webhooks/1
LicenHub-Event: order.updated
LicenHub-Delivery: evt_9f2c41d0a7b35e8c6d1f02ab
LicenHub-Signature: t=1760175000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

LicenHub-Signature carries the Unix timestamp (t) and the hex HMAC-SHA256 (v1) of `"<t>.<raw body>"` computed with your whsec_... secret. Always verify the signature over the raw body and reject timestamps more than 5 minutes off.

### Delivery and retries

- Respond with a 2xx status as soon as possible (10 second timeout). Any other response, redirect or error counts as a failure; redirects are not followed.
- Retries: after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours (6 attempts in total). After that the event is marked failed.
- The same event can arrive more than once. Use the LicenHub-Delivery header (the event id) to ignore duplicates.
- Only public https URLs are accepted: credentials in the URL and private or local addresses are not allowed.
- Recommendation: keep an occasional GET /orders/:id poll as a fallback in case your server was down longer than the retry window.

### Verifying the signature (Node.js)

```js
import http from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.LICENHUB_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 5 * 60;
const seen = new Set(); // use a database or Redis in production

function verify(rawBody, header) {
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
  const t = Number(parts.t);
  if (!t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; // replay protection

  const expected = createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && timingSafeEqual(a, b);
}

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', c => chunks.push(c));
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks).toString('utf8'); // sign the RAW body, not re-serialized JSON
    if (!verify(rawBody, req.headers['licenhub-signature'] ?? '')) {
      res.writeHead(400).end();
      return;
    }

    const deliveryId = req.headers['licenhub-delivery'];
    if (!seen.has(deliveryId)) {
      seen.add(deliveryId);
      const event = JSON.parse(rawBody);
      if (event.event === 'order.updated' && event.data.status === 'fulfilled') {
        // deliver event.data.delivered_item to your customer
      }
    }
    res.writeHead(200).end(); // respond 2xx quickly, do heavy work asynchronously
  });
}).listen(3000);
```

## Errors

All errors use this shape:

```json
{
  "error": {
    "code": "insufficient_balance",
    "message": "Insufficient wallet balance for this order."
  }
}
```

| HTTP | code | Meaning |
| --- | --- | --- |
| 400 | `invalid_request` | Malformed body, invalid product_id/quantity or Idempotency-Key. |
| 400 | `invalid_product` | Unknown product_id. |
| 400 | `invalid_service` | Unknown service_id. |
| 401 | `unauthorized` | Missing, invalid or revoked API key. |
| 402 | `insufficient_balance` | Wallet balance is lower than the order total (and automatic top-up, if enabled, could not cover it). |
| 404 | `not_found` | Order not found (or it belongs to another account). |
| 409 | `out_of_stock` | Not enough stock for the requested quantity. |
| 409 | `request_in_progress` | A request with the same Idempotency-Key is still processing. Retry shortly. |
| 422 | `price_below_minimum` | price is lower than the current LicenHub price (the message includes it). |
| 422 | `idempotency_key_reused` | The Idempotency-Key was used with a different request (product/service, quantity, link or price). |
| 429 | `rate_limited` | Too many requests. See the Retry-After header. |
| 500 | `internal_error` | Unexpected server error. |
| 503 | `catalog_unavailable` | Product catalog temporarily unavailable. |

## Rate limits

Per key: 60 requests per minute overall and 10 orders per minute on POST /orders. Exceeding them returns 429 with a Retry-After header (seconds).

## Node.js example

```js
import { randomUUID } from 'node:crypto';

const res = await fetch('https://licenhub.lat/api/v1/orders', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.LICENHUB_API_KEY}`,
    'Content-Type': 'application/json',
    // Reuse the same key when retrying the same purchase.
    'Idempotency-Key': randomUUID(),
  },
  // price (optional): your LicenHub price per unit, as returned by GET /products. Lower values are rejected.
  body: JSON.stringify({ product_id: 12, quantity: 1, price: 4.99 }),
});

const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);

for (const item of data.items) {
  console.log(item.status, item.delivered_item); // delivered_item is null while pending
}

// Social media service: price is the TOTAL LicenHub price of the order, processing fee included (see GET /services).
const order = await fetch('https://licenhub.lat/api/v1/orders', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.LICENHUB_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': randomUUID(),
  },
  body: JSON.stringify({
    type: 'service',
    service_id: 3021,
    link: 'https://instagram.com/your_customer',
    quantity: 2000,
    price: 3.67,
  }),
});
console.log(await order.json());
```
