# Project — Reseller API: implementation guide

> This file is for your AI assistant (Claude Code, Cursor, ChatGPT, Copilot, Windsurf…).
> Put the file in your project (for example at `docs/project-api.md`) or attach it to the conversation, and say what you want to build.

## For the assistant

You are going to integrate a system with the **Project reseller API**. It reads the catalog and the balance, generates keys (licenses) that cost real credits, resets the HWID of keys and sends webhook notifications. The full documentation is at the end of this file, under "Full documentation". Read it before writing code, and use the fields, errors and limits exactly as described there.

The API's `message` texts are in Portuguese. Decide by the HTTP status code; show or log the `message` as it comes.

## Rules that must never be broken

1. **Token only on the server.** Read it from `PROJECT_TOKEN`. Never write the token in code, logs, error messages, the browser or an app running on the customer's computer. Never ask for the token in the conversation.
2. **API address only from a variable.** Read it from `PROJECT_API_URL`. Do not invent it or write it in code.
3. **Generating keys spends credits and there is no test environment.** Automated tests never call the real API: use a fake HTTP client. To test the connection, use read-only endpoints only (`GET /me`, `GET /products`).
4. **Idempotency-Key on every generation.** Create one UUID per sale, store it with the sale **before** calling `POST /keys`, and reuse the same value on every retry. Never create a new one to retry.
5. **Retry only when it is safe.** Retry (with the same Idempotency-Key) on network errors, timeouts, `5xx` and `409` for a request still in progress. On `429`, wait the seconds in the `Retry-After` header. Do not retry `400`, `401`, `402`, `403`, `404` or `422`: show the `message`.
6. **Prices come from the API.** Do not calculate or send prices: the `POST /keys` body only has `product_id`, `quantity` and `days`.
7. **Webhooks:** validate the HMAC-SHA256 signature over the **raw body**, compare in constant time, reject notifications older than 5 minutes, ignore a repeated `id` and answer `2xx` in under 5 seconds (process later if it takes long).
8. **Timeout** of about 30 seconds on every call.

## Suggested plan

1. A single HTTP client: builds the URL from `PROJECT_API_URL`, sets `Authorization: Bearer`, `Accept: application/json` and the timeout; reads `{ success, message, data, errors }` and, on error, throws an exception that keeps the HTTP status, the `message` and `errors`.
2. One function per endpoint the system uses (see "Reference" in the documentation).
3. The system flow (see the "Sales flow" guide), storing `generation_id`, `user_id` and `key_code` of each key.
4. If the system receives notifications: the webhook endpoint with signature validation.
5. Tests with the fake HTTP client: success, `402`, `422`, `429` and the retry with the same Idempotency-Key.

## Before saying you are done

- [ ] No token, secret or API address in the code, logs or repository.
- [ ] `.env.example` with empty `PROJECT_API_URL`, `PROJECT_TOKEN` (and `PROJECT_WEBHOOK_SECRET`, if there is a webhook).
- [ ] Every key generation uses an Idempotency-Key stored before the call.
- [ ] Errors show the API `message`; `402` says the balance is too low.
- [ ] Tests do not call the real API.
- [ ] The webhook rejects a wrong signature and an old notification.
- [ ] You told the system owner how to test with `GET /me` before generating the first key.

---

# Full documentation

# Introduction (/en/docs)

The reseller API lets your system do on its own what you do in the panel: browse the catalog and your balance, generate keys and reset HWIDs. A store, a Discord bot or an order system can deliver the key to the customer at the moment of sale, without anyone opening the panel.

<Cards>
  <Card title="List products" href="/en/docs/reference/products/list" description="The mods available and the price of each key." />
  <Card title="Generate keys" href="/en/docs/reference/keys/generate" description="Creates keys and deducts from your balance." />
  <Card title="Reset HWID" href="/en/docs/reference/keys/reset-hwid" description="Frees a key for another computer." />
  <Card title="Get balance" href="/en/docs/reference/credits/balance" description="Balance and credit statement." />
</Cards>

## API address [#api-address]

Every call starts with the same address. It is shown in the panel, under **API › Tokens**, in the usage example.

The examples in this documentation read the address and the token from two environment variables:

```bash
export PROJECT_API_URL="paste the address from the panel here"
export PROJECT_TOKEN="prj_live_..."
```

<Callout type="warn" title="There is no test environment">
  Every call uses your real balance. Only generate a key when you are about to deliver it to a customer.
</Callout>

## Response format [#response-format]

Every response is JSON and always has the same fields:

| Field | What it is |
| --- | --- |
| `success` | `true` when it worked, `false` on error. |
| `message` | A text to show or log. **The API messages are in Portuguese.** |
| `data` | The response data (only on success). |
| `errors` | The error details (only on error). |

```json title="Success"
{
  "success": true,
  "message": "Saldo carregado.",
  "data": { "balance": 480 }
}
```

```json title="Error"
{
  "success": false,
  "message": "Créditos insuficientes.",
  "errors": { "balance": 12 }
}
```

<Callout>
  Responses may include fields beyond the ones described here. Ignore the ones your system does not know. Always decide by the HTTP status code, not by the text of `message`.
</Callout>

## Dates, deadlines and amounts [#dates-deadlines-and-amounts]

- **Dates** such as `created_at` come in the `YYYY-MM-DD HH:MM:SS` format.
- **Deadlines** such as `access_end` come in seconds since 1970 (Unix).
- **Credits** are always integers.

## Next steps [#next-steps]

<Cards>
  <Card title="Quickstart" href="/en/docs/quickstart" description="From token to your first key in four steps." />
  <Card title="Sales flow" href="/en/docs/guides/sales-flow" description="How to integrate a sale end to end." />
</Cards>


---

# Quickstart (/en/docs/quickstart)

<Steps>
<Step>

### Create a token [#create-a-token]

In the panel, under **API › Tokens**, create a token with the permissions your system needs. Copy it right away: it is shown only once.

On the same screen, the usage example shows the **API address**. Copy it too.

</Step>
<Step>

### Keep the token on the server [#keep-the-token-on-the-server]

Put the address and the token in environment variables on the server that will call the API:

```bash
export PROJECT_API_URL="paste the address from the panel here"
export PROJECT_TOKEN="prj_live_..."
```

<Callout type="warn">
  Never put the token in a website, an app or a program that runs on the customer's computer: anyone could read the token and spend your balance.
</Callout>

</Step>
<Step>

### Test with `/me` [#test-with-me]

If it returns your account and the token permissions, everything is set:

```bash title="cURL"
curl -sS "$PROJECT_API_URL/me" \
  -H "Authorization: Bearer $PROJECT_TOKEN" \
  -H "Accept: application/json"
```

```js title="JavaScript"
const res = await fetch(`${process.env.PROJECT_API_URL}/me`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.PROJECT_TOKEN}`,
    Accept: "application/json",
  },
});
const json = await res.json();
if (!json.success) throw new Error(json.message);
console.log(json.data);
```

```php title="PHP"
<?php
$ch = curl_init(getenv("PROJECT_API_URL") . "/me");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("PROJECT_TOKEN"),
        "Accept: application/json",
    ],
]);
$json = json_decode(curl_exec($ch), true);
curl_close($ch);

if (!$json["success"]) {
    throw new Exception($json["message"]);
}
print_r($json["data"]);
```

```python title="Python"
import os, requests

res = requests.get(
    f"{os.environ['PROJECT_API_URL']}/me",
    headers={
        "Authorization": f"Bearer {os.environ['PROJECT_TOKEN']}",
        "Accept": "application/json",
    },
    timeout=30,
)
data = res.json()
if not data["success"]:
    raise Exception(data["message"])
print(data["data"])
```

</Step>
<Step>

### Generate your first key [#generate-your-first-key]

Take a `product_id` from [List products](/en/docs/reference/products/list) and generate a key:

```bash title="cURL"
curl -sS -X POST "$PROJECT_API_URL/keys" \
  -H "Authorization: Bearer $PROJECT_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3f1c9e2a-7b4d-4c1e-9a0f-5d2b8c6e1a47" \
  -d '{"product_id":1001,"quantity":1,"days":30}'
```

```js title="JavaScript"
// One id per request. Store it and reuse it if you need to retry the call.
const requestId = crypto.randomUUID();

const res = await fetch(`${process.env.PROJECT_API_URL}/keys`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PROJECT_TOKEN}`,
    Accept: "application/json",
    "Content-Type": "application/json",
    "Idempotency-Key": requestId,
  },
  body: JSON.stringify({ "product_id": 1001, "quantity": 1, "days": 30 }),
});
const json = await res.json();
if (!json.success) throw new Error(json.message);
console.log(json.data);
```

```php title="PHP"
<?php
// One id per request. Store it and reuse it if you need to retry the call.
$requestId = bin2hex(random_bytes(16));

$ch = curl_init(getenv("PROJECT_API_URL") . "/keys");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("PROJECT_TOKEN"),
        "Accept: application/json",
        "Content-Type: application/json",
        "Idempotency-Key: " . $requestId,
    ],
    CURLOPT_POSTFIELDS => json_encode(["product_id" => 1001, "quantity" => 1, "days" => 30]),
]);
$json = json_decode(curl_exec($ch), true);
curl_close($ch);

if (!$json["success"]) {
    throw new Exception($json["message"]);
}
print_r($json["data"]);
```

```python title="Python"
import os, uuid, requests

# One id per request. Store it and reuse it if you need to retry the call.
request_id = str(uuid.uuid4())

res = requests.post(
    f"{os.environ['PROJECT_API_URL']}/keys",
    headers={
        "Authorization": f"Bearer {os.environ['PROJECT_TOKEN']}",
        "Accept": "application/json",
        "Idempotency-Key": request_id,
    },
    json={"product_id": 1001, "quantity": 1, "days": 30},
    timeout=30,
)
data = res.json()
if not data["success"]:
    raise Exception(data["message"])
print(data["data"])
```

The `Idempotency-Key` header is what prevents a double charge if the connection drops. [Understand idempotency](/en/docs/guides/idempotency).

</Step>
</Steps>

## What next? [#what-next]

- See the [complete sales flow](/en/docs/guides/sales-flow), from the catalog to delivering the key.
- Receive notifications in your system with [webhooks](/en/docs/guides/webhooks).
- Prefer to test without writing code? Use the [Postman collection](/en/docs/postman).


---

# Postman (/en/docs/postman)

Use the collection when you want to:

- explore the endpoints and see real responses;
- check the token and its permissions;
- reproduce an error before changing your system.

<Steps>
<Step>

### Download the collection [#download-the-collection]

<a href="/project-api.postman_collection.en.json" download className="not-prose inline-flex items-center rounded-lg bg-fd-primary px-4 py-2 text-sm font-medium text-white dark:text-fd-primary-foreground no-underline hover:opacity-90">
  Download the Postman collection
</a>

The same file works in Insomnia.

</Step>
<Step>

### Import it [#import-it]

In Postman, click **Import** and choose the downloaded file. The collection shows up with every endpoint.

</Step>
<Step>

### Fill in the variables [#fill-in-the-variables]

Open the collection, go to **Variables** and fill in:

| Variable | Value |
| --- | --- |
| `baseUrl` | the API address, shown in the panel under **API › Tokens** |
| `token` | your token (`prj_live_...`) |

<Callout type="warn">
  Do not save the token in a public workspace or send the filled-in collection to anyone.
</Callout>

</Step>
<Step>

### Make the first call [#make-the-first-call]

Open **Who am I** and click **Send**. If it returns your account, you are ready.

</Step>
</Steps>

<Callout title="Generating keys from Postman">
  In **Generate keys**, the `Idempotency-Key` gets a new value on every send. To repeat the **same** request, copy the value used and pin it in the header. Remember that generating keys spends your real balance.
</Callout>


---

# Authentication (/en/docs/security/authentication)

```http
Authorization: Bearer prj_live_...
```

## Headers [#headers]

| Header | Value | Required |
| --- | --- | --- |
| `Authorization` | `Bearer prj_live_...` | Yes |
| `Accept` | `application/json` | Recommended |
| `Content-Type` | `application/json` | Yes, when the call has a body (POST) |
| `Idempotency-Key` | a unique code per request (UUID) | Yes, on [Generate keys](/en/docs/reference/keys/generate) |

## The token [#the-token]

- It starts with `prj_live_` and is 49 characters long.
- It only works in the `Authorization` header. Do not send the token in the URL (`?token=`) or in the body.
- It can have an expiration (30 days, 90 days or 1 year) or none. You choose when creating it.
- Each account can have up to 10 active tokens.
- A deleted or expired token gets `401`. Create another one in the panel.

## Authentication responses [#authentication-responses]

| HTTP | When |
| --- | --- |
| `401` | Missing header, or an invalid, deleted or expired token. |
| `403` | The token is valid, but lacks the [permission](/en/docs/security/permissions) for that endpoint. |

```json title="403 — missing permission"
{
  "success": false,
  "message": "Este token não tem permissão para esta ação (precisa de \"keys:generate\").",
  "errors": { "required_scope": "keys:generate" }
}
```

<Callout type="warn" title="If the token leaks">
  Delete the token in the panel: it stops working immediately. Then create another one and replace it on your server. Do not share tokens in repositories, screenshots or chats.
</Callout>


---

# Permissions (/en/docs/security/permissions)

When creating a token, you choose what it can do. Grant the minimum your system needs: a bot that only reads does not need to generate keys.

| Permission | Code | What it allows | Endpoints |
| --- | --- | --- | --- |
| View catalog | `products:read` | Mods available for resale and the price of each one in credits. | `GET /products`, `GET /products/{product_id}` |
| View balance | `credits:read` | Current balance and credit statement. | `GET /credits`, `GET /credits/transactions` |
| View keys | `keys:read` | Your keys: status, HWID and time remaining. | `GET /keys` |
| Generate keys | `keys:generate` | Creates new keys. Deducts credits from your balance. | `POST /keys` |
| Reset HWID | `keys:reset-hwid` | Frees a key to be used on another computer. Costs no credits. | `POST /keys/{user_id}/reset-hwid` |

The [Who am I](/en/docs/reference/me) endpoint works with any token and shows its permissions in `auth.scopes`.

## When a permission is missing [#when-a-permission-is-missing]

The endpoint answers `403`, and `errors.required_scope` says which permission is missing. Create a token with it in the panel — the permissions of an existing token cannot be changed.

<Callout title="What no token can do">
  Creating other tokens, configuring webhooks and buying credits are panel-only. A token, even with every permission, cannot reach them. If a token leaks, the damage is limited to what it can do.
</Callout>

## Common combinations [#common-combinations]

| System | Permissions |
| --- | --- |
| Store that sells and delivers the key | View catalog, Generate keys |
| Support bot | View keys, Reset HWID |
| Finance dashboard | View balance |


---

# Best practices (/en/docs/security/best-practices)

## Tokens [#tokens]

| Do | Why |
| --- | --- |
| Use the token only on the server. | On the customer's computer, anyone could read the token and spend your balance. |
| One token per system, named after where it lives. | If one leaks, you delete only that one. |
| Check only the permissions you need. | A leaked read-only token does not spend credits. |
| Prefer tokens with an expiration. | A forgotten token stops working on its own. |

## Generating keys [#generating-keys]

| Do | Why |
| --- | --- |
| Create one `Idempotency-Key` per sale and store it with the sale. | Retrying after a network error returns the same keys, without charging again. |
| Check `available` before generating. | An unavailable product does not generate keys. |
| Send `days` with one of the durations in `plans`. | A duration the product does not have is rejected. |
| Store `generation_id`, `user_id` and `key_code`. | They link the statement and support to the keys you delivered. |

## Calls [#calls]

| Do | Why |
| --- | --- |
| Use a timeout of about 30 seconds. | Without it, a stuck connection holds your system. |
| On a network error or `500`, retry with the **same** `Idempotency-Key`. | The API recognizes the retry and does not generate again. |
| On `429`, wait the seconds in `Retry-After`. | Insisting before that only causes more `429`. |
| Cache the product list for a few minutes. | The catalog rarely changes, and every call counts toward the [limit](/en/docs/rate-limits). |
| Look up specific keys with `user_ids[]`. | It is faster than fetching the whole list. |

<Callout>
  Every call is logged on our side, with date, IP and result.
</Callout>


---

# Overview (/en/docs/reference)

Each endpoint has a page with its parameters, a call example in cURL, JavaScript, PHP and Python, the response and the errors. The language you pick in one example applies to every page.

| Method | Path | What it does | Permission |
| --- | --- | --- | --- |
| `GET` | `/me` | Who am I | any token |
| `GET` | `/products` | List products | **View catalog** (`products:read`) |
| `GET` | `/products/{product_id}` | Get a product | **View catalog** (`products:read`) |
| `GET` | `/credits` | Get balance | **View balance** (`credits:read`) |
| `GET` | `/credits/transactions` | Credit statement | **View balance** (`credits:read`) |
| `GET` | `/keys` | List your keys | **View keys** (`keys:read`) |
| `POST` | `/keys` | Generate keys | **Generate keys** (`keys:generate`) — spends credits |
| `POST` | `/keys/{user_id}/reset-hwid` | Reset HWID | **Reset HWID** (`keys:reset-hwid`) |

Every path is relative to the [API address](/en/docs#api-address), and every call carries the [token](/en/docs/security/authentication) in the `Authorization` header.


---

# Who am I (/en/docs/reference/me)

`GET /me` · Token permission: any token


Confirms the token works and tells you what it can do. Use it to check the token when your system starts.

## Response [#response]

| Field | What it is |
| --- | --- |
| `user.user_id` | Your account id. |
| `user.user_login` | Your username. |
| `user.user_email` | The account e-mail. |
| `balance` | Your credit balance. Only present if the token has the **View balance** permission. |
| `auth.type` | Always `api_token` on a call with a token. |
| `auth.scopes` | The [permissions](/en/docs/security/permissions) of this token. |

## Errors [#errors]

| HTTP | When |
| --- | --- |
| `401` | Missing, invalid, deleted or expired token. |
| `429` | Over the [rate limit](/en/docs/rate-limits). |


```bash title="cURL"
curl -sS "$PROJECT_API_URL/me" \
  -H "Authorization: Bearer $PROJECT_TOKEN" \
  -H "Accept: application/json"
```

```js title="JavaScript"
const res = await fetch(`${process.env.PROJECT_API_URL}/me`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.PROJECT_TOKEN}`,
    Accept: "application/json",
  },
});
const json = await res.json();
if (!json.success) throw new Error(json.message);
console.log(json.data);
```

```php title="PHP"
<?php
$ch = curl_init(getenv("PROJECT_API_URL") . "/me");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("PROJECT_TOKEN"),
        "Accept: application/json",
    ],
]);
$json = json_decode(curl_exec($ch), true);
curl_close($ch);

if (!$json["success"]) {
    throw new Exception($json["message"]);
}
print_r($json["data"]);
```

```python title="Python"
import os, requests

res = requests.get(
    f"{os.environ['PROJECT_API_URL']}/me",
    headers={
        "Authorization": f"Bearer {os.environ['PROJECT_TOKEN']}",
        "Accept": "application/json",
    },
    timeout=30,
)
data = res.json()
if not data["success"]:
    raise Exception(data["message"])
print(data["data"])
```

```json title="Response · 200"
{
  "success": true,
  "message": "Dados do revendedor.",
  "data": {
    "user": {
      "user_id": 12,
      "user_login": "mystore",
      "user_email": "contact@mystore.com"
    },
    "balance": 480,
    "auth": {
      "type": "api_token",
      "scopes": ["products:read", "credits:read", "keys:read", "keys:generate", "keys:reset-hwid"]
    }
  }
}
```



---

# List products (/en/docs/reference/products/list)

`GET /products` · Token permission: **View catalog** (`products:read`)


Use the `product_id` from here to [generate keys](/en/docs/reference/keys/generate). Prices are already **your account's** — the same amount the generation charges.

## Query parameters [#query-parameters]

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `search` | text | No | Filters by product name. Up to 100 characters. |

## Response [#response]

| Field | What it is |
| --- | --- |
| `product_id` | The product id. It is what you send to generate keys. |
| `title` | The product name. |
| `platform` | The mod platform (e.g. Windows). |
| `credit_cost` | The price, in credits, of a key of `days` days. |
| `days` | The default duration: 30 days, or the longest the mod has if it has no 30-day plan. |
| `plans` | The durations on sale, each with its price. Send the chosen `days` when generating. |
| `status.label` | The mod status, as text (e.g. funcional, manutenção). |
| `available` | `true` when keys can be generated right now. |
| `stock` | Stock: `status` (e.g. `instock`, `outofstock`), `quantity` and whether stock is managed (`managed`). Only `instock` allows generating. |
| `image` | The product image. |
| `total` | How many products came back. |

<Callout title="Before generating">
  Only generate keys for products with `available: true`. A disabled or out-of-stock mod comes with `available: false`, and the generation is refused. The status of every mod is also on the [status](/en/status) page.
</Callout>

## Errors [#errors]

| HTTP | When |
| --- | --- |
| `401` | Missing, invalid, deleted or expired token. |
| `403` | The token lacks the **View catalog** permission. |
| `422` | `search` longer than 100 characters. |
| `429` | Over the [rate limit](/en/docs/rate-limits). |


```bash title="cURL"
curl -sS "$PROJECT_API_URL/products" \
  -H "Authorization: Bearer $PROJECT_TOKEN" \
  -H "Accept: application/json"
```

```js title="JavaScript"
const res = await fetch(`${process.env.PROJECT_API_URL}/products`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.PROJECT_TOKEN}`,
    Accept: "application/json",
  },
});
const json = await res.json();
if (!json.success) throw new Error(json.message);
console.log(json.data);
```

```php title="PHP"
<?php
$ch = curl_init(getenv("PROJECT_API_URL") . "/products");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("PROJECT_TOKEN"),
        "Accept: application/json",
    ],
]);
$json = json_decode(curl_exec($ch), true);
curl_close($ch);

if (!$json["success"]) {
    throw new Exception($json["message"]);
}
print_r($json["data"]);
```

```python title="Python"
import os, requests

res = requests.get(
    f"{os.environ['PROJECT_API_URL']}/products",
    headers={
        "Authorization": f"Bearer {os.environ['PROJECT_TOKEN']}",
        "Accept": "application/json",
    },
    timeout=30,
)
data = res.json()
if not data["success"]:
    raise Exception(data["message"])
print(data["data"])
```

```json title="Response · 200"
{
  "success": true,
  "message": "Produtos de revenda.",
  "data": {
    "products": [
      {
        "product_id": 1001,
        "slug": "mod-exemplo",
        "title": "MOD EXEMPLO",
        "duration": "30 Dias",
        "platform": "Windows",
        "credit_cost": 18,
        "days": 30,
        "plans": [
          { "days": 7, "credit_cost": 8 },
          { "days": 15, "credit_cost": 12 },
          { "days": 30, "credit_cost": 18 }
        ],
        "status": { "id": 1, "label": "funcional" },
        "available": true,
        "stock": { "status": "instock", "quantity": 1000, "managed": false },
        "image": "https://exemplo.com/imagem.png"
      }
    ],
    "total": 1
  }
}
```



---

# Get a product (/en/docs/reference/products/get)

`GET /products/{product_id}` · Token permission: **View catalog** (`products:read`)


## Path parameters [#path-parameters]

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `product_id` | number | Yes | The `product_id` from the [product list](/en/docs/reference/products/list). |

## Response [#response]

The `product` fields are the same as in [List products](/en/docs/reference/products/list#response).

## Errors [#errors]

| HTTP | When |
| --- | --- |
| `401` | Missing, invalid, deleted or expired token. |
| `403` | The token lacks the **View catalog** permission. |
| `404` | The product does not exist or is not available for resale. |
| `429` | Over the [rate limit](/en/docs/rate-limits). |


```bash title="cURL"
curl -sS "$PROJECT_API_URL/products/1001" \
  -H "Authorization: Bearer $PROJECT_TOKEN" \
  -H "Accept: application/json"
```

```js title="JavaScript"
const res = await fetch(`${process.env.PROJECT_API_URL}/products/1001`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.PROJECT_TOKEN}`,
    Accept: "application/json",
  },
});
const json = await res.json();
if (!json.success) throw new Error(json.message);
console.log(json.data);
```

```php title="PHP"
<?php
$ch = curl_init(getenv("PROJECT_API_URL") . "/products/1001");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("PROJECT_TOKEN"),
        "Accept: application/json",
    ],
]);
$json = json_decode(curl_exec($ch), true);
curl_close($ch);

if (!$json["success"]) {
    throw new Exception($json["message"]);
}
print_r($json["data"]);
```

```python title="Python"
import os, requests

res = requests.get(
    f"{os.environ['PROJECT_API_URL']}/products/1001",
    headers={
        "Authorization": f"Bearer {os.environ['PROJECT_TOKEN']}",
        "Accept": "application/json",
    },
    timeout=30,
)
data = res.json()
if not data["success"]:
    raise Exception(data["message"])
print(data["data"])
```

```json title="Response · 200"
{
  "success": true,
  "message": "Produto de revenda.",
  "data": {
    "product": {
      "product_id": 1001,
      "title": "MOD EXEMPLO",
      "credit_cost": 18,
      "days": 30,
      "plans": [
        { "days": 7, "credit_cost": 8 },
        { "days": 30, "credit_cost": 18 }
      ],
      "available": true
    }
  }
}
```



---

# Get balance (/en/docs/reference/credits/balance)

`GET /credits` · Token permission: **View balance** (`credits:read`)


## Response [#response]

| Field | What it is |
| --- | --- |
| `balance` | Your balance, in credits. |

To see where each credit came from and where it went, use the [statement](/en/docs/reference/credits/transactions).

## Errors [#errors]

| HTTP | When |
| --- | --- |
| `401` | Missing, invalid, deleted or expired token. |
| `403` | The token lacks the **View balance** permission. |
| `429` | Over the [rate limit](/en/docs/rate-limits). |


```bash title="cURL"
curl -sS "$PROJECT_API_URL/credits" \
  -H "Authorization: Bearer $PROJECT_TOKEN" \
  -H "Accept: application/json"
```

```js title="JavaScript"
const res = await fetch(`${process.env.PROJECT_API_URL}/credits`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.PROJECT_TOKEN}`,
    Accept: "application/json",
  },
});
const json = await res.json();
if (!json.success) throw new Error(json.message);
console.log(json.data);
```

```php title="PHP"
<?php
$ch = curl_init(getenv("PROJECT_API_URL") . "/credits");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("PROJECT_TOKEN"),
        "Accept: application/json",
    ],
]);
$json = json_decode(curl_exec($ch), true);
curl_close($ch);

if (!$json["success"]) {
    throw new Exception($json["message"]);
}
print_r($json["data"]);
```

```python title="Python"
import os, requests

res = requests.get(
    f"{os.environ['PROJECT_API_URL']}/credits",
    headers={
        "Authorization": f"Bearer {os.environ['PROJECT_TOKEN']}",
        "Accept": "application/json",
    },
    timeout=30,
)
data = res.json()
if not data["success"]:
    raise Exception(data["message"])
print(data["data"])
```

```json title="Response · 200"
{
  "success": true,
  "message": "Saldo carregado.",
  "data": { "balance": 480 }
}
```



---

# Credit statement (/en/docs/reference/credits/transactions)

`GET /credits/transactions` · Token permission: **View balance** (`credits:read`)


## Query parameters [#query-parameters]

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | number | No | How many transactions to return, from 1 to 200. Default: 50. |
| `offset` | number | No | How many to skip, for pagination. Default: 0. |

To paginate, add `limit` to `offset` on each page until a list shorter than `limit` comes back.

## Response [#response]

| Field | What it is |
| --- | --- |
| `amount` | The transaction amount. Negative is money out; positive is money in. |
| `balance_after` | The balance right after this transaction. |
| `type` | The transaction type (table below). |
| `reference_type` / `reference_id` | What the transaction refers to (table below). |
| `notes` | A note, when there is one. |
| `created_at` | When it happened. |
| `balance` | Your current balance. |

| `type` | What it is | `reference_id` |
| --- | --- | --- |
| `key_generation` | You generated keys. | The `generation_id` returned by the generation. |
| `refund` | Refund of a failed generation. | The generation's `generation_id`. |
| `purchase` | Credit purchase. | The credit order number. |
| `manual_adjust` | Adjustment made by the team. | — |

## Errors [#errors]

| HTTP | When |
| --- | --- |
| `401` | Missing, invalid, deleted or expired token. |
| `403` | The token lacks the **View balance** permission. |
| `422` | `limit` outside 1 to 200, or a negative `offset`. |
| `429` | Over the [rate limit](/en/docs/rate-limits). |


```bash title="cURL"
curl -sS "$PROJECT_API_URL/credits/transactions?limit=20" \
  -H "Authorization: Bearer $PROJECT_TOKEN" \
  -H "Accept: application/json"
```

```js title="JavaScript"
const res = await fetch(`${process.env.PROJECT_API_URL}/credits/transactions?limit=20`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.PROJECT_TOKEN}`,
    Accept: "application/json",
  },
});
const json = await res.json();
if (!json.success) throw new Error(json.message);
console.log(json.data);
```

```php title="PHP"
<?php
$ch = curl_init(getenv("PROJECT_API_URL") . "/credits/transactions?limit=20");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("PROJECT_TOKEN"),
        "Accept: application/json",
    ],
]);
$json = json_decode(curl_exec($ch), true);
curl_close($ch);

if (!$json["success"]) {
    throw new Exception($json["message"]);
}
print_r($json["data"]);
```

```python title="Python"
import os, requests

res = requests.get(
    f"{os.environ['PROJECT_API_URL']}/credits/transactions?limit=20",
    headers={
        "Authorization": f"Bearer {os.environ['PROJECT_TOKEN']}",
        "Accept": "application/json",
    },
    timeout=30,
)
data = res.json()
if not data["success"]:
    raise Exception(data["message"])
print(data["data"])
```

```json title="Response · 200"
{
  "success": true,
  "message": "Extrato carregado.",
  "data": {
    "transactions": [
      {
        "id": 931,
        "amount": -18,
        "balance_after": 480,
        "type": "key_generation",
        "reference_type": "generation",
        "reference_id": 57,
        "notes": null,
        "created_at": "2026-09-27 21:14:05"
      },
      {
        "id": 930,
        "amount": 200,
        "balance_after": 498,
        "type": "purchase",
        "reference_type": "order",
        "reference_id": 88120,
        "notes": null,
        "created_at": "2026-09-27 20:02:41"
      }
    ],
    "balance": 480
  }
}
```



---

# List your keys (/en/docs/reference/keys/list)

`GET /keys` · Token permission: **View keys** (`keys:read`)


## Query parameters [#query-parameters]

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `user_ids[]` | list of numbers | No | Returns only these keys (the `user_id` of each one). Up to 200. |

Without `user_ids[]`, all your keys come back. To track specific keys, repeat the parameter: `/keys?user_ids[]=204496&user_ids[]=204497`.

## Response [#response]

| Field | What it is |
| --- | --- |
| `user_id` | The key id in the API. It is what the [HWID reset](/en/docs/reference/keys/reset-hwid) uses. |
| `key_code` | The key the customer types. |
| `product_id` / `product_name` | The key's product. |
| `activated` | `true` after the customer activated the key. |
| `status` | The key status (table below). |
| `hwid_status` | The HWID status (table below). |
| `access_end` | When access ends, in seconds since 1970 (Unix). `0` when there is no access. |
| `seconds_remaining` | Time remaining, in seconds. `0` if the key has not been activated yet. |
| `created_at` | When the key was generated. |
| `credits_spent` | What the key cost when it was generated. `null` if there is no record. |
| `days` | The purchased duration, in days. |

| `status` | Meaning |
| --- | --- |
| `DISPONIVEL` | Generated, not yet activated by the customer. |
| `ATIVA` | In use. |
| `SEM_ACESSO` | The key no longer has access. |

| `hwid_status` | Meaning |
| --- | --- |
| `HWID_ATIVO` | Bound to the customer's computer. |
| `HWID_RESETADO` | Free to be used on another computer. |
| `N/A` | The key has not been activated yet. |

## Errors [#errors]

| HTTP | When |
| --- | --- |
| `401` | Missing, invalid, deleted or expired token. |
| `403` | The token lacks the **View keys** permission. |
| `422` | More than 200 ids in `user_ids[]`, or an id that is not a number. |
| `429` | Over the [rate limit](/en/docs/rate-limits). |


```bash title="cURL"
curl -sS "$PROJECT_API_URL/keys" \
  -H "Authorization: Bearer $PROJECT_TOKEN" \
  -H "Accept: application/json"
```

```js title="JavaScript"
const res = await fetch(`${process.env.PROJECT_API_URL}/keys`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.PROJECT_TOKEN}`,
    Accept: "application/json",
  },
});
const json = await res.json();
if (!json.success) throw new Error(json.message);
console.log(json.data);
```

```php title="PHP"
<?php
$ch = curl_init(getenv("PROJECT_API_URL") . "/keys");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("PROJECT_TOKEN"),
        "Accept: application/json",
    ],
]);
$json = json_decode(curl_exec($ch), true);
curl_close($ch);

if (!$json["success"]) {
    throw new Exception($json["message"]);
}
print_r($json["data"]);
```

```python title="Python"
import os, requests

res = requests.get(
    f"{os.environ['PROJECT_API_URL']}/keys",
    headers={
        "Authorization": f"Bearer {os.environ['PROJECT_TOKEN']}",
        "Accept": "application/json",
    },
    timeout=30,
)
data = res.json()
if not data["success"]:
    raise Exception(data["message"])
print(data["data"])
```

```json title="Response · 200"
{
  "success": true,
  "message": "Keys carregadas.",
  "data": {
    "keys": [
      {
        "user_id": 204496,
        "key_code": "0a1b2c3d-4e5f6a7b-8c9d0e1f",
        "product_id": 1001,
        "product_name": "MOD EXEMPLO",
        "activated": true,
        "status": "ATIVA",
        "hwid_status": "HWID_ATIVO",
        "access_end": 1793105851,
        "seconds_remaining": 2591200,
        "created_at": "2026-09-27 21:14:05",
        "credits_spent": 18,
        "days": 30
      }
    ]
  }
}
```



---

# Generate keys (/en/docs/reference/keys/generate)

`POST /keys` · Token permission: **Generate keys** (`keys:generate`) — spends credits


Charges the price of the chosen duration times the quantity. Without enough balance, nothing is generated or charged.

<Callout type="warn" title="Idempotency-Key required">
  Every call carries the `Idempotency-Key` header, a unique code per request. Repeating with the same code, you get the same keys without paying again. [Why?](/en/docs/guides/idempotency)
</Callout>

## Headers [#headers]

| Header | Required | Description |
| --- | --- | --- |
| `Idempotency-Key` | Yes | A unique code per request (a UUID works). Up to 100 characters: letters, digits and `_ - : .` |
| `Content-Type` | Yes | `application/json` |

## Body (JSON) [#body-json]

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `product_id` | number | Yes | The product, from the [product list](/en/docs/reference/products/list). |
| `quantity` | number | Yes | How many keys to generate, from 1 to 50. |
| `days` | number | No | The duration, among the product `plans`. Without it, 30 days. If the product has no 30-day plan, `days` is required. |

The price is always calculated on our side, from the duration and the quantity. Any other field in the body is ignored.

## Response [#response]

| Field | What it is |
| --- | --- |
| `generation_id` | The id of this batch. It shows up in the [statement](/en/docs/reference/credits/transactions) as `reference_id`. |
| `keys[].key_code` | The key to deliver to the customer. |
| `keys[].user_id` | The key id in the API (to [look it up](/en/docs/reference/keys/list) and [reset its HWID](/en/docs/reference/keys/reset-hwid)). |
| `days` | The duration of the generated keys. |
| `credits_spent` | The total charged. |
| `balance` | Your balance after the charge. |

### Repeating the same request [#repeating-the-same-request]

Repeating with the same `Idempotency-Key` and the same body, the response brings **the same keys** and does not charge again. It comes with `"idempotent_replay": true` in `data` and the `Idempotent-Replayed: true` header.

## Errors [#errors]

| HTTP | When | What to do |
| --- | --- | --- |
| `402` | Not enough balance. Nothing was generated or charged. | `errors.balance` has your balance. Buy credits and try again. |
| `403` | The token lacks the **Generate keys** permission. | Create a token with it. |
| `409` | The `Idempotency-Key` was already used for a different request, or the first request is still running. | New request: use a new code. Same request: wait a few seconds and retry. |
| `422` | Invalid data, missing or malformed `Idempotency-Key`, unavailable product, or a duration the product does not have. | The `message` says what to fix. A wrong duration lists the options in `errors.days`. |
| `429` | Over the [limit](/en/docs/rate-limits). | Wait the seconds in `Retry-After` and try again. |
| `500` | Failure on our side while generating. | The credits go back to your balance (`errors.credits_refunded` says how much). Retry with the same `Idempotency-Key`. If the message mentions a partial refund, or it keeps failing, contact support. |


```bash title="cURL"
curl -sS -X POST "$PROJECT_API_URL/keys" \
  -H "Authorization: Bearer $PROJECT_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3f1c9e2a-7b4d-4c1e-9a0f-5d2b8c6e1a47" \
  -d '{"product_id":1001,"quantity":1,"days":30}'
```

```js title="JavaScript"
// One id per request. Store it and reuse it if you need to retry the call.
const requestId = crypto.randomUUID();

const res = await fetch(`${process.env.PROJECT_API_URL}/keys`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PROJECT_TOKEN}`,
    Accept: "application/json",
    "Content-Type": "application/json",
    "Idempotency-Key": requestId,
  },
  body: JSON.stringify({ "product_id": 1001, "quantity": 1, "days": 30 }),
});
const json = await res.json();
if (!json.success) throw new Error(json.message);
console.log(json.data);
```

```php title="PHP"
<?php
// One id per request. Store it and reuse it if you need to retry the call.
$requestId = bin2hex(random_bytes(16));

$ch = curl_init(getenv("PROJECT_API_URL") . "/keys");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("PROJECT_TOKEN"),
        "Accept: application/json",
        "Content-Type: application/json",
        "Idempotency-Key: " . $requestId,
    ],
    CURLOPT_POSTFIELDS => json_encode(["product_id" => 1001, "quantity" => 1, "days" => 30]),
]);
$json = json_decode(curl_exec($ch), true);
curl_close($ch);

if (!$json["success"]) {
    throw new Exception($json["message"]);
}
print_r($json["data"]);
```

```python title="Python"
import os, uuid, requests

# One id per request. Store it and reuse it if you need to retry the call.
request_id = str(uuid.uuid4())

res = requests.post(
    f"{os.environ['PROJECT_API_URL']}/keys",
    headers={
        "Authorization": f"Bearer {os.environ['PROJECT_TOKEN']}",
        "Accept": "application/json",
        "Idempotency-Key": request_id,
    },
    json={"product_id": 1001, "quantity": 1, "days": 30},
    timeout=30,
)
data = res.json()
if not data["success"]:
    raise Exception(data["message"])
print(data["data"])
```

```json title="Response · 200"
{
  "success": true,
  "message": "Keys geradas.",
  "data": {
    "generation_id": 57,
    "keys": [
      { "key_code": "0a1b2c3d-4e5f6a7b-8c9d0e1f", "user_id": 204496 }
    ],
    "days": 30,
    "credits_spent": 18,
    "balance": 480
  }
}
```

```json title="Response · 402 — not enough balance"
{
  "success": false,
  "message": "Créditos insuficientes.",
  "errors": { "balance": 12 }
}
```

```json title="Response · 422 — duration the product does not have"
{
  "success": false,
  "message": "MOD EXEMPLO não tem key de 60 dias. Durações: 7, 15 e 30 dias.",
  "errors": { "days": [7, 15, 30] }
}
```



---

# Reset HWID (/en/docs/reference/keys/reset-hwid)

`POST /keys/{user_id}/reset-hwid` · Token permission: **Reset HWID** (`keys:reset-hwid`)


When the customer activates the key, it gets bound to their computer (HWID). If they change PCs or reinstall, reset the HWID: the key works again on the next machine it is used on. **It costs no credits.**

## Path parameters [#path-parameters]

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `user_id` | number | Yes | The key's `user_id` — it comes in the [generation](/en/docs/reference/keys/generate) and in the [key list](/en/docs/reference/keys/list). |

The call has no body.

## Response [#response]

After the reset, the key shows `hwid_status: "HWID_RESETADO"` in the [key list](/en/docs/reference/keys/list).

## Errors [#errors]

| HTTP | When |
| --- | --- |
| `401` | Missing, invalid, deleted or expired token. |
| `403` | The token lacks the **Reset HWID** permission, or the key is not yours. |
| `404` | The key has no access to reset. |
| `429` | Over the [limit](/en/docs/rate-limits). Resetting HWID counts toward the per-account action limit. |


```bash title="cURL"
curl -sS -X POST "$PROJECT_API_URL/keys/204496/reset-hwid" \
  -H "Authorization: Bearer $PROJECT_TOKEN" \
  -H "Accept: application/json"
```

```js title="JavaScript"
const res = await fetch(`${process.env.PROJECT_API_URL}/keys/204496/reset-hwid`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PROJECT_TOKEN}`,
    Accept: "application/json",
  },
});
const json = await res.json();
if (!json.success) throw new Error(json.message);
console.log(json.data);
```

```php title="PHP"
<?php
$ch = curl_init(getenv("PROJECT_API_URL") . "/keys/204496/reset-hwid");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("PROJECT_TOKEN"),
        "Accept: application/json",
    ],
]);
$json = json_decode(curl_exec($ch), true);
curl_close($ch);

if (!$json["success"]) {
    throw new Exception($json["message"]);
}
print_r($json["data"]);
```

```python title="Python"
import os, requests

res = requests.post(
    f"{os.environ['PROJECT_API_URL']}/keys/204496/reset-hwid",
    headers={
        "Authorization": f"Bearer {os.environ['PROJECT_TOKEN']}",
        "Accept": "application/json",
    },
    timeout=30,
)
data = res.json()
if not data["success"]:
    raise Exception(data["message"])
print(data["data"])
```

```json title="Response · 200"
{
  "success": true,
  "message": "HWID resetado.",
  "data": []
}
```



---

# Sales flow (/en/docs/guides/sales-flow)

This is the path a store or a bot follows to sell and deliver keys without anyone opening the panel.

<Steps>
<Step>

### Build the storefront [#build-the-storefront]

Fetch the products with [List products](/en/docs/reference/products/list) and cache the list for a few minutes. Show only the ones with `available: true`, with the durations from `plans`.

</Step>
<Step>

### Record the sale with a code [#record-the-sale-with-a-code]

When the customer pays, create an `Idempotency-Key` (a UUID) and store it **with the order**, before calling the API. That code is what guarantees the sale is never charged twice.

</Step>
<Step>

### Generate the key [#generate-the-key]

Call [Generate keys](/en/docs/reference/keys/generate) with the chosen `product_id`, `quantity` and `days`.

- **It worked:** store `generation_id`, and for each key its `user_id` and `key_code`.
- **Network error, timeout or `500`:** retry with the **same** `Idempotency-Key`. If the first one had worked, you get the same keys without a new charge.
- **`402`:** balance too low. Nothing was charged; tell the customer delivery will be delayed and buy credits.
- **`422`:** the product became unavailable or the duration does not exist. Do not retry: check the storefront.

</Step>
<Step>

### Deliver it to the customer [#deliver-it-to-the-customer]

Send the `key_code` to the customer. The key stays at `status: DISPONIVEL` until they activate it.

</Step>
<Step>

### Handle support [#handle-support]

- **"I changed my PC":** call [Reset HWID](/en/docs/reference/keys/reset-hwid) with the key's `user_id`.
- **"My key does not work":** look the key up with [List your keys](/en/docs/reference/keys/list) and `user_ids[]` — check `status`, `hwid_status` and `seconds_remaining`.

</Step>
<Step>

### Reconcile [#reconcile]

In the [statement](/en/docs/reference/credits/transactions), each generation shows up with `type: key_generation` and `reference_id` equal to the `generation_id`. That is how each credit spent links to the sale that caused it.

</Step>
</Steps>

## Get notified instead of asking [#get-notified-instead-of-asking]

With [webhooks](/en/docs/guides/webhooks), your system receives a notification for every key generated, HWID reset or balance change — including the ones made in the panel.


---

# Idempotency (/en/docs/guides/idempotency)

If the connection drops in the middle of "generate keys", your system does not know whether the keys came out. Repeating the request without protection would generate and charge again.

That is why [Generate keys](/en/docs/reference/keys/generate) requires the `Idempotency-Key` header: a unique code you create for each request. Repeating with the same code, the API returns **the same keys** and does not charge again.

```http
Idempotency-Key: 3f1c9e2a-7b4d-4c1e-9a0f-5d2b8c6e1a47
```

## The code [#the-code]

- A UUID works, and it is the simplest (`crypto.randomUUID()`, `uuid.uuid4()`).
- Up to 100 characters: letters, digits and `_ - : .` — no spaces or accents.
- One code per **sale**, created before calling the API and stored with it.

## What happens when you repeat [#what-happens-when-you-repeat]

| Situation | Response |
| --- | --- |
| Same code and same request, and the first one has finished | The **same** keys, without a charge, with `"idempotent_replay": true` and the `Idempotent-Replayed: true` header. |
| Same code, and the first one is still running | `409` — wait a few seconds and retry. |
| Same code with a different request (another product, quantity or duration) | `409` — use a new code for a new request. |
| The first one failed (no balance, invalid data, error) | Nothing was charged; the code is free to try again. |

<Callout type="warn">
  Reuse the code only to repeat **the same request**. Never create a new code to retry: the API then treats it as another sale and charges again.
</Callout>

## Safe retry example [#safe-retry-example]

```js title="JavaScript"
async function generateKeys(order) {
  // The code is born with the order and stays stored in it.
  order.idempotencyKey ??= crypto.randomUUID();
  await saveOrder(order);

  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const res = await fetch(`${process.env.PROJECT_API_URL}/keys`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.PROJECT_TOKEN}`,
          'Content-Type': 'application/json',
          Accept: 'application/json',
          'Idempotency-Key': order.idempotencyKey,
        },
        body: JSON.stringify({ product_id: order.productId, quantity: order.quantity, days: order.days }),
        signal: AbortSignal.timeout(30_000),
      });
      // 5xx and 409 (still running): wait and retry with the SAME code.
      if (res.status >= 500 || res.status === 409) throw new Error(`HTTP ${res.status}`);
      return await res.json();
    } catch (error) {
      if (attempt === 3) throw error;
      await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
    }
  }
}
```


---

# Webhooks (/en/docs/guides/webhooks)

Instead of polling the API, your system can **receive** notifications: on every event, our server sends a `POST` with a signed JSON to the URL you register.

## How to register [#how-to-register]

In the panel, under **Notificações e logs** (Notifications and logs), each channel accepts one URL:

- **A Discord webhook URL:** the channel receives a ready-made message, formatted for Discord.
- **Any other `https` URL:** your system receives the JSON described on this page.

The same screen has the **Test** button, the delivery history and each channel's **secret**, used to [validate the signature](/en/docs/guides/webhooks/signature).

## Channels [#channels]

| Channel | When it notifies | Events |
| --- | --- | --- |
| `keys` | You generated keys (through the API or the panel). | `key.generated` |
| `hwid` | An HWID was reset. | `key.hwid_reset` |
| `credits` | Your balance changed: purchase or adjustment. | `credits.purchased`, `credits.added`, `credits.removed` |
| `mods` | A changelog was published for a reseller mod. | `changelog.published` |
| `games` | The game of a reseller mod was updated. | `game.updated` |

On the `mods` and `games` channels you can choose, in the panel, which mods or games you want to hear about. With no choice, you receive all of them.

## URL requirements [#url-requirements]

- `https`, on the default port (443), at a public address. Internal network addresses are refused.
- No username or password in the URL. To know the notification came from us, [validate the signature](/en/docs/guides/webhooks/signature).
- Answer `2xx` within 5 seconds. Ideally `204` with no body; a response over 1 MB counts as a failure.
- Redirects are not followed: register the final URL.

## Headers [#headers]

| Header | What it is |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Project-Event` | The event (same as the `event` field). |
| `X-Project-Delivery` | The notification id (same as the `id` field). Identical on a retry. |
| `X-Project-Timestamp` | When it was sent, in seconds since 1970 (Unix). |
| `X-Project-Signature-256` | `sha256=` followed by the hex HMAC-SHA256 of `"{timestamp}.{body}"` with the channel secret. |

## Body [#body]

Every notification has the same envelope; what changes is the `data` of each event.

```json
{
  "id": "6f1c2a4e-9d3b-4e7a-8c51-2b0d9f7a1e33",
  "event": "key.generated",
  "channel": "keys",
  "version": 1,
  "created_at": "2026-10-15T14:03:00-03:00",
  "data": {
    "generation_id": 57,
    "product": { "id": 1001, "title": "MOD EXEMPLO" },
    "quantity": 1,
    "days": 30,
    "keys": [{ "id": 204496, "code": "0a1b2c3d…c9d0e1f" }],
    "credits": { "spent": 18, "balance": 480 },
    "reseller": { "id": 12, "login": "mystore" }
  }
}
```

| Field | What it is |
| --- | --- |
| `id` | The unique notification id. Use it to ignore repeats. |
| `event` | The event (table below). |
| `channel` | The channel that received it. |
| `version` | The envelope format version. Currently `1`. |
| `created_at` | When the event happened (ISO 8601). |
| `data` | The event data. |

## Events [#events]

| Event | Fields in `data` |
| --- | --- |
| `key.generated` | `generation_id`, `product {id, title}`, `quantity`, `days`, `keys [{id, code}]`, `credits {spent, balance}`, `reseller {id, login}` |
| `key.hwid_reset` | `key {id, code}`, `reseller {id, login}` |
| `credits.purchased` | `order_id`, `credits`, `balance`, `reseller {id, login}` |
| `credits.added` | `amount`, `balance`, `reseller {id, login}` |
| `credits.removed` | `amount`, `balance`, `reason`, `reseller {id, login}` |
| `changelog.published` | `product {id, title, image}`, `changelog {id, items [], text_pt, text_en}` |
| `game.updated` | `game {appid, name, version, link, image}`, `products [{id, title}]` |
| `webhook.test` | `message`, `reseller {id, login}` — sent by the **Test** button in the panel. |

<Callout>
  In notifications, keys come **masked** (the start and end of the code). The full key comes from the API: [List your keys](/en/docs/reference/keys/list), with the `id` from `keys[]` in `user_ids[]`.
</Callout>

## Responses and failures [#responses-and-failures]

| Your response | What happens |
| --- | --- |
| `2xx` | Delivered. |
| `3xx` or `4xx` (except `429`) | Failure. The notification is not resent. |
| `5xx`, `429` or no response within 5 seconds | Failure. The notification may be resent a few times, with the same `id`. |

Every failure shows up in the delivery history in the panel.

<Callout type="warn">
  After 10 failures in a row, the channel is disabled. Fix your server and save the URL again in the panel to re-enable it.
</Callout>

## Next step [#next-step]

[Validate the signature](/en/docs/guides/webhooks/signature) of every notification before trusting it.


---

# Validate the signature (/en/docs/guides/webhooks/signature)

Anyone who finds your URL can send a `POST` to it. The signature is what separates a real notification from a fake one.

## How it works [#how-it-works]

1. Get the channel secret in the panel, under **Notificações e logs** (**Show** / **Generate another**). Keep it on the server as `PROJECT_WEBHOOK_SECRET`.
2. Compute the HMAC-SHA256 of `"{X-Project-Timestamp}.{raw body}"` with the secret, in hex, and prefix it with `sha256=`.
3. Compare it with `X-Project-Signature-256` in **constant time**.
4. Reject notifications older than 5 minutes (`X-Project-Timestamp`), so no one can replay an old one.
5. Store the `id` of each notification and ignore one you have already processed.

<Callout type="warn" title="Use the raw body">
  The signature covers the exact bytes that arrived. Parsing to JSON and turning it back into text changes spaces and key order, and the signature no longer matches — the most common mistake when integrating webhooks.
</Callout>

## Examples [#examples]

<Tabs groupId="language" persist items={['JavaScript', 'PHP', 'Python']}>
<Tab value="JavaScript">

```js title="Node.js (Express)"
const crypto = require('crypto');
const express = require('express');
const app = express();

// The RAW body (express.raw): the signature covers the exact text that arrived.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-Project-Timestamp') || '';
  const received = req.get('X-Project-Signature-256') || '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.PROJECT_WEBHOOK_SECRET)
    .update(timestamp + '.' + req.body)
    .digest('hex');

  const valid = received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  const recent = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
  if (!valid || !recent) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  // event.id is the same on a retry: ignore what you already processed.
  res.sendStatus(204);
});
```

</Tab>
<Tab value="PHP">

```php
<?php
$body = file_get_contents('php://input'); // the raw body
$timestamp = $_SERVER['HTTP_X_PROJECT_TIMESTAMP'] ?? '';
$received = $_SERVER['HTTP_X_PROJECT_SIGNATURE_256'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, getenv('PROJECT_WEBHOOK_SECRET'));

if (! hash_equals($expected, $received) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
// $event['id'] is the same on a retry: ignore what you already processed.
http_response_code(204);
```

</Tab>
<Tab value="Python">

```python title="Python (Flask)"
import hashlib, hmac, os, time
from flask import Flask, abort, request

app = Flask(__name__)

@app.post('/webhook')
def webhook():
    body = request.get_data()  # the raw body
    timestamp = request.headers.get('X-Project-Timestamp', '')
    received = request.headers.get('X-Project-Signature-256', '')
    secret = os.environ['PROJECT_WEBHOOK_SECRET'].encode()
    expected = 'sha256=' + hmac.new(secret, timestamp.encode() + b'.' + body, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, received) or abs(time.time() - int(timestamp or 0)) > 300:
        abort(401)

    event = request.get_json()
    # event['id'] is the same on a retry: ignore what you already processed.
    return '', 204
```

</Tab>
</Tabs>

## Rotating the secret [#rotating-the-secret]

Under **Notificações e logs**, **Generate another** creates a new secret for the channel. Update `PROJECT_WEBHOOK_SECRET` on your server right after: from then on, notifications are signed with the new secret.


---

# Errors (/en/docs/errors)

```json title="Example — not enough balance (402)"
{
  "success": false,
  "message": "Créditos insuficientes.",
  "errors": { "balance": 12 }
}
```

- **Decide by the HTTP code.** It is what tells you the kind of error.
- **Show or log the `message`.** It explains what happened. It comes in Portuguese; field details in `errors` may come in English.
- **Read `errors` for the details.** It may be empty, and it may include fields beyond the ones described here (such as a numeric `status_code`, which is for support only).

## HTTP codes [#http-codes]

| HTTP | Meaning | What to do |
| --- | --- | --- |
| `400` | Malformed request. | Check the JSON you sent. |
| `401` | Missing, invalid, deleted or expired token. | Check the `Authorization` header. If the token was deleted, create another one in the panel. |
| `402` | Not enough balance to generate the keys. | Nothing was charged. `errors.balance` has your balance; buy credits and try again. |
| `403` | The token lacks the endpoint's permission, or the key is not yours. | `errors.required_scope` says the missing permission. Create a token with it. |
| `404` | Endpoint, product or key not found. | Check the id and the address. |
| `409` | `Idempotency-Key` reused for a different request, or the first request is still running. | New request: use a new code. Same request: wait a few seconds and retry. |
| `422` | Invalid or missing data. | The `message` says what to fix; on field errors, `errors` lists them per field. |
| `429` | Rate limit reached. | Wait the seconds in the `Retry-After` header and try again. See the [limits](/en/docs/rate-limits). |
| `500` | Error on our side. | Retry with the same `Idempotency-Key`. If it persists, contact support. |

## Field errors (422) [#field-errors-422]

When a field is wrong, `errors` lists each field with its problems:

```json
{
  "success": false,
  "message": "Dados inválidos.",
  "errors": {
    "quantity": ["The quantity field must not be greater than 50."]
  }
}
```

## Retry or not [#retry-or-not]

| Retry | Do not retry |
| --- | --- |
| Network error, timeout, `500` — with the same `Idempotency-Key`. | `400`, `401`, `402`, `403`, `404`, `422` — fix it first. |
| `409` for a request still running — after a few seconds. | `409` for a code used in another request — create a new code. |
| `429` — after waiting. | |


---

# Rate limits (/en/docs/rate-limits)

| Limit | Value |
| --- | --- |
| Calls per token | 120 per minute, across all endpoints |
| Actions per account ([generate keys](/en/docs/reference/keys/generate) and [reset HWID](/en/docs/reference/keys/reset-hwid)) | 30 per minute, across the panel and every token |
| Keys per generation call | 50 |
| Transactions per [statement](/en/docs/reference/credits/transactions) page | 200 |
| Keys per lookup with `user_ids[]` | 200 |
| Active tokens per account | 10 |

## When you go over [#when-you-go-over]

The response is `429`, with the `Retry-After` header saying how many seconds to wait. The `message` says the same time (in Portuguese):

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 55
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
```

```json
{
  "success": false,
  "message": "Muitas requisições. Tente de novo em 55 segundo(s).",
  "errors": []
}
```

Successful responses carry `X-RateLimit-Limit` (the limit) and `X-RateLimit-Remaining` (what is left) for the limit closest to running out. Use `Remaining` to slow down before reaching `429`.

## How to stay under the limit [#how-to-stay-under-the-limit]

- Cache the product list for a few minutes instead of fetching it on every sale.
- Look up only the keys you need, with `user_ids[]`.
- Prefer [webhooks](/en/docs/guides/webhooks) to polling the API.
- For large batches, generate up to 50 keys per call instead of one at a time.


---

# FAQ (/en/docs/faq)

<Accordions>
<Accordion title="Is there a test environment?">

No. Every call uses your real account and balance. To test without spending, use the read-only endpoints ([Who am I](/en/docs/reference/me), [List products](/en/docs/reference/products/list), [Get balance](/en/docs/reference/credits/balance)) and only generate keys when you are about to deliver them.

</Accordion>
<Accordion title="Where do I find the API address?">

In the panel, under **API › Tokens**, in the usage example.

</Accordion>
<Accordion title="Can I use the token in my website or app?">

No. The token stays only on your server. In the browser or on the customer's computer, anyone could read the token and spend your balance.

</Accordion>
<Accordion title="My token leaked. What now?">

Delete the token in the panel: it stops working immediately. Create another one and replace it on your server.

</Accordion>
<Accordion title="The generate keys call timed out. Was I charged?">

Maybe. Repeat the request with the **same** `Idempotency-Key`: if the first call worked, you get the same keys without a new charge; if it did not, the generation happens now. See [Idempotency](/en/docs/guides/idempotency).

</Accordion>
<Accordion title="Is a generated key ready to use?">

It is ready to deliver, with `status: DISPONIVEL`. When the customer activates it, it becomes `ATIVA`, and `seconds_remaining` in [List your keys](/en/docs/reference/keys/list) shows how much time is left.

</Accordion>
<Accordion title="Does resetting HWID cost credits?">

No. The reset is free, but it counts toward the account limit of 30 actions per minute.

</Accordion>
<Accordion title="Why did I get 403?">

Either the token lacks the permission for that endpoint (`errors.required_scope` says which one), or the key is not yours. See [Permissions](/en/docs/security/permissions).

</Accordion>
<Accordion title="Why are the API messages in Portuguese?">

The API answers in Portuguese. Decide by the HTTP status code and the fields documented here, and show or log the `message` as it comes.

</Accordion>
<Accordion title="Can I change a token's permissions?">

No. Create a new token with the right permissions and delete the old one.

</Accordion>
</Accordions>

