Reseller API reference · v1
Apply for APIIntegrate with PVA Markets programmatically. Browse the catalogue, buy accounts from your balance and get the credentials back on the same response — the same catalogue and the same balance the storefront uses, without a browser in the middle.
https://pva-admin.nixsoftware.net/api/v1/resellerThe PVA Markets Reseller API lets your server browse the same catalogue the storefront shows and buy from it with your account balance — no browser, no checkout page. It is a JSON API over HTTPS, and every endpoint lives under the base URL above.
Response format
Every response — errors included — carries the same three keys: success, message and data. Branch on success; on an error, data.code says exactly what went wrong.
{
"success": true,
"message": "Items retrieved",
"data": { ... }
}Money is always a decimal string with six places, never a JSON number — accounts here go down to $0.004 apiece, and a float would round that away. Parse it with a decimal type. Quantities are integers, and every timestamp is ISO 8601.
Items, suppliers and lots
An item ("Gmail · Aged 2020 · US IP") is what the storefront lists. Several suppliers stock it, each at their own price, in lots that can differ in credential format. You buy an item from one supplier — so an order names both, and the item's offers tell you who sells it, how many, at what price and in what format.
API access is granted per account, after a short review. The whole process happens in your cabinet under API access.
Fund your account
Applying needs a balance of at least $10.00 and a confirmed email address. The balance stays yours — it is what your API orders are paid from.
Apply
Tell us what you are building, where it runs and the monthly volume you expect, and which scopes you need. Ask for read only if you want to build against the catalogue before you start buying.
Wait for review
Staff approve or refuse the application; the cabinet shows its status. A refused application can be resubmitted.
Create a key
Once approved, create a key with a label, the scopes it may use and, optionally, the IP addresses it may be called from. The key is shown once — copy it then. It starts with pvam_live_.
Make your first call
Call GET /user/profile with the key. A 200 proves the key is live and shows exactly what it may do.
From a new key to delivered credentials in four calls. Set your key in the environment first: export PVAM_KEY=pvam_live_…
curl https://pva-admin.nixsoftware.net/api/v1/reseller/user/profile \
-H "X-API-Key: $PVAM_KEY"curl -G https://pva-admin.nixsoftware.net/api/v1/reseller/items \
-H "X-API-Key: $PVAM_KEY" \
-d category=gmail \
-d sort=pricecurl https://pva-admin.nixsoftware.net/api/v1/reseller/items/gmail-aged-2020-us-ip \
-H "X-API-Key: $PVAM_KEY"curl https://pva-admin.nixsoftware.net/api/v1/reseller/orders \
-H "X-API-Key: $PVAM_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"item": "gmail-aged-2020-us-ip", "quantity": 10}'Send your key in the X-API-Key header on every request. There is no session, no OAuth and no token refresh — the key is the whole credential, so treat it like a password and keep it on your server, never in a browser or an app.
curl https://pva-admin.nixsoftware.net/api/v1/reseller/user/profile \
-H "X-API-Key: YOUR_API_KEY" \
-H "Accept: application/json"Each key carries one or both scopes, and never more than your account was granted. Build against a read key first: it cannot buy anything by accident.
| Scope | Allows |
|---|---|
| (any key) | GET /user/profile and GET /user/balance. |
| read | The catalogue — categories, items, suppliers, stock and prices. |
| trade | Buying, and reading back orders and their credentials. |
401 or 403?
401 means the key is missing or unknown — check what you are sending. 403 means the key is real but may not do this: revoked, missing the scope, called from an address outside its IP allowlist, or your API access has been withdrawn. Retrying a 403 with the same key will never work.
POST /orders requires an Idempotency-Key header — a fresh UUID for every order you mean to place. If a request times out you cannot know whether the order went through; retrying with the same key is how you find out safely.
| You send | You get |
|---|---|
| A new key | A new order. 201, header Idempotent-Replayed: false. |
| The same key and the same body | The original order again, credentials included — nothing is bought twice. 200, header Idempotent-Replayed: true. |
| The same key and a different body | 422 idempotency_key_reused. Generate a new key for a new order. |
| The key of a request that was refused | A fresh attempt. A refused purchase records nothing, so fix the cause and retry with the same key. |
const key = crypto.randomUUID(); // once per order, not per attempt
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch("https://pva-admin.nixsoftware.net/api/v1/reseller/orders", {
method: "POST",
headers: {
"X-API-Key": process.env.PVAM_KEY,
"Idempotency-Key": key,
"Content-Type": "application/json",
},
body: JSON.stringify({ item, quantity }),
}).catch(() => null);
if (res && res.status < 500 && res.status !== 429) return res.json();
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
}Errors use the same envelope with success set to false. The HTTP status gives the kind of failure and data.code the exact one; some codes carry extra fields beside it.
{
"success": false,
"message": "Your balance does not cover this order.",
"data": {
"code": "insufficient_funds",
"required": "16.000000",
"available": "9.250000"
}
}| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | The X-API-Key header is missing, or no key has that value. |
| 403 | forbidden | The key is revoked, lacks the scope, is called from an unlisted IP, or API access was withdrawn. |
| 404 | not_found | No item, category, order or route at that address, or the partner you named does not sell this item. |
| 405 | method_not_allowed | That HTTP method is not supported on this path. |
| 409 | insufficient_stock | The supplier has fewer accounts than you asked for. data.available says how many; send allow_partial to take them. |
| 409 | insufficient_funds | Your balance does not cover the order. data.required and data.available say by how much. |
| 409 | price_changed | The order would cost more than your max_total. data.total is what it would cost now. |
| 409 | not_paid | The order has not been paid, so it has no credentials yet. |
| 410 | credentials_unavailable | The download window has closed or the credentials were removed. Open a support ticket. |
| 422 | validation_failed | A field failed validation; data.errors lists the messages per field. |
| 422 | idempotency_key_reused | That Idempotency-Key was already used for a different order. |
| 429 | rate_limited | Too many requests. Wait Retry-After seconds. |
| 500 | server_error | Something broke on our side. Safe to retry with backoff. |
| 503 | service_unavailable | Purchasing is temporarily switched off. Retry later. |
Retry only what is safe
4xx errors will not fix themselves — change the request first. Retry 429, 5xx and network failures only, with exponential backoff, and always with the same Idempotency-Key when the request was a purchase.
Limits are counted per API key, per minute, in three tiers. Two keys on the same server do not share a limit.
| Tier | Limit | Applies to |
|---|---|---|
| Catalogue | 600 / minute | GET /categories, /items and /items/{slug}. |
| Account | 120 / minute | GET /user/profile, /user/balance and the order history endpoints. |
| Purchase | 30 / minute | POST /orders. |
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 574Handling 429
A throttled response carries Retry-After in seconds and X-RateLimit-Reset as a Unix timestamp. Wait it out rather than retrying at once.
The category tree changes rarely — cache it for a day. For stock and prices, read the items you actually sell rather than paging the whole catalogue on a timer.
Account
/user/profileThe account the key belongs to, with its balance and what the calling key itself may do. The cheapest call that proves a key is live — use it as a startup health check.
{
"success": true,
"message": "Profile retrieved",
"data": {
"id": 48213,
"email": "reseller@example.com",
"username": "example_reseller",
"status": "active",
"balance": "418.250000",
"created_at": "2025-11-02T09:14:38+00:00",
"api_key": {
"label": "production",
"scope": ["read", "trade"],
"allowed_ips": ["203.0.113.24"]
}
}
}/user/balanceThe balance your orders are paid from. Check it before a batch rather than finding the shortfall as an insufficient_funds error halfway through. Top it up in your cabinet.
{
"success": true,
"message": "Balance retrieved",
"data": {
"currency": "USD",
"available": "418.250000"
}
}Catalogue
/categoriesThe whole category tree in one call — top-level categories with their subcategories. Pass any slug from it as category to List Items. It changes rarely; cache it and refresh daily.
{
"success": true,
"message": "Categories retrieved",
"data": [
{
"id": 5,
"slug": "gmail",
"name": "GMail",
"items_count": 42,
"children": [
{
"id": 31,
"slug": "gmail-aged",
"name": "Aged",
"items_count": 12,
"children": []
}
]
}
]
}/itemsThe catalogue, paginated and filterable. In stock only unless you say otherwise. price is the cheapest supplier's unit price, and null when the item is out of stock — never a stale figure.
| Name | Type | Description |
|---|---|---|
| categoryquery | string | Category slug, subcategories included. An unknown slug is 404. |
| qquery | string | Words from the title, or an item id. |
| in_stockquery | boolean | Defaults to true. Send false to include sold-out items. |
| price_minquery | decimal | Lowest cheapest-price to include. |
| price_maxquery | decimal | Highest cheapest-price to include. |
| sortquery | string | newest (default), price, stock or title. |
| directionquery | string | asc or desc. Defaults to desc for newest and stock, asc otherwise. |
| pagequery | integer | 1-based. Defaults to 1. |
| per_pagequery | integer | 1–100. Defaults to 50. |
curl -G https://pva-admin.nixsoftware.net/api/v1/reseller/items \
-H "X-API-Key: YOUR_API_KEY" \
-d category=gmail \
-d price_max=0.5 \
-d sort=price{
"success": true,
"message": "Items retrieved",
"data": {
"items": [
{
"id": 1284,
"slug": "gmail-aged-2020-us-ip",
"title": "Gmail Aged 2020 | US IP | Recovery email included",
"category": { "slug": "gmail-aged", "name": "Aged" },
"price": "0.320000",
"stock": 1840,
"warranty_hours": 24,
"url": "https://pvamarkets.com/item/gmail-aged-2020-us-ip/"
}
],
"pagination": {
"page": 1,
"per_page": 50,
"total": 87,
"total_pages": 2
}
}
}/items/{slug}One item in full, with an offer from every supplier who has it on sale, cheapest first. To buy from one supplier, send the partner of its offer to Purchase; leave partner out to let us pick. Each offer's tiers break its stock down by price and credential format.
| Name | Type | Description |
|---|---|---|
| slugrequiredpath | string | The item slug. A renamed item's old slug still works; data.slug is the current one. |
{
"success": true,
"message": "Item retrieved",
"data": {
"id": 1284,
"slug": "gmail-aged-2020-us-ip",
"title": "Gmail Aged 2020 | US IP | Recovery email included",
"category": { "slug": "gmail-aged", "name": "Aged" },
"price": "0.320000",
"stock": 1840,
"warranty_hours": 24,
"url": "https://pvamarkets.com/item/gmail-aged-2020-us-ip/",
"description_html": "<p>Registered in 2020 from US residential IPs.</p>",
"offers": [
{
"partner": 771,
"stock": 1200,
"price": "0.320000",
"tiers": [
{ "price": "0.320000", "stock": 800, "format": ["email", "mailpassword", "alt_email"] },
{ "price": "0.350000", "stock": 400, "format": ["email", "mailpassword", "alt_email"] }
]
},
{
"partner": 812,
"stock": 640,
"price": "0.380000",
"tiers": [
{ "price": "0.380000", "stock": 640, "format": ["email", "mailpassword"] }
]
}
]
}
}An order fills from a supplier's cheapest tier upward. Above, 1,000 accounts from partner 771 would be 800 at 0.32 and 200 at 0.35 — a total of 326.00, not 320.00. Stock is a snapshot, not a reservation: send max_total with your order if the price matters.
Orders
/ordersBuys a quantity of one item from one supplier, pays for it from your balance and returns the credentials on the same response. All or nothing: if the purchase is refused, nothing is ordered, held or charged.
| Name | Type | Description |
|---|---|---|
| Idempotency-Keyrequiredheader | string | A new UUID per order, up to 100 characters. See Idempotency. |
| itemrequiredbody | string | The item slug. |
| partnerbody | integer | Optional. A supplier id from offers[].partner on Get Item, to buy from that supplier only. Leave it out and we choose: the cheapest supplier who can fill the whole quantity, or else the one with the most stock. |
| quantityrequiredbody | integer | 1–1,000. For more, place several orders. |
| allow_partialbody | boolean | Accept fewer than quantity if that is all the supplier has. Defaults to false, which refuses with insufficient_stock. |
| max_totalbody | decimal | Refuse with price_changed rather than pay more than this — the full amount taken from your balance, any payment fee included. Send it as a string. |
curl https://pva-admin.nixsoftware.net/api/v1/reseller/orders \
-H "X-API-Key: YOUR_API_KEY" \
-H "Idempotency-Key: 5c9f2b74-1a3e-4d80-9f2a-7c1d0e6b8a41" \
-H "Content-Type: application/json" \
-d '{
"item": "gmail-aged-2020-us-ip",
"partner": 771,
"quantity": 2,
"max_total": "1.000000"
}'{
"success": true,
"message": "Order placed",
"data": {
"id": 611904,
"number": 611904,
"status": "processing",
"paid": true,
"quantity": 2,
"delivered": 2,
"subtotal": "0.640000",
"discount_total": "0.000000",
"fee_total": "0.000000",
"total": "0.640000",
"currency": "USD",
"lines": [
{
"id": 902211,
"item": { "slug": "gmail-aged-2020-us-ip", "title": "Gmail Aged 2020 | US IP | Recovery email included" },
"partner": 771,
"quantity": 2,
"delivered": 2,
"unit_price": "0.320000",
"total": "0.640000",
"format": ["email", "mailpassword", "alt_email"]
}
],
"warranty_expires_at": "2026-09-25T07:31:44+00:00",
"download_expires_at": null,
"paid_at": "2026-09-24T07:31:44+00:00",
"created_at": "2026-09-24T07:31:44+00:00",
"credentials": [
{
"line_id": 902211,
"format": ["email", "mailpassword", "alt_email"],
"accounts": [
"jane.r.2020@gmail.com:Pa55w0rd!:recovery.jr@mail.com",
"tom.k.2020@gmail.com:S3cr3t#9:recovery.tk@mail.com"
]
}
]
}
}One order can have several lines when it fills from more than one lot, and each line has its own format — read credentials per line, never as one flat list. Values are joined with colons in format order, and a value can itself contain a colon (a profile URL, say), so split on the known number of fields. If delivered is ever short of quantity, the order is paid and delivery is finishing: fetch the credentials from the order a minute later.
/ordersYour account's orders, newest first — storefront orders included, since it is one balance. Same shape as a purchase, without credentials.
| Name | Type | Description |
|---|---|---|
| statusquery | string | pending, on_hold, processing, completed, cancelled, failed or refunded. processing and completed both mean paid. |
| fromquery | string | ISO 8601. Orders created at or after it. |
| toquery | string | ISO 8601. Orders created before it. |
| pagequery | integer | 1-based. Defaults to 1. |
| per_pagequery | integer | 1–100. Defaults to 50. |
{
"success": true,
"message": "Orders retrieved",
"data": {
"items": [
{
"id": 611904,
"number": 611904,
"status": "processing",
"paid": true,
"quantity": 2,
"delivered": 2,
"total": "0.640000",
"lines": [ ... ],
"created_at": "2026-09-24T07:31:44+00:00"
}
],
"pagination": {
"page": 1,
"per_page": 50,
"total": 1284,
"total_pages": 26
}
}
}/orders/{id}One order, in the same shape as a purchase without the credentials. Use it to check delivered, or to find the line ids for Get Credentials.
| Name | Type | Description |
|---|---|---|
| idrequiredpath | integer | The order id. |
{
"success": true,
"message": "Order retrieved",
"data": {
"id": 611904,
"number": 611904,
"status": "processing",
"paid": true,
"quantity": 2,
"delivered": 2,
"total": "0.640000",
"lines": [
{
"id": 902211,
"partner": 771,
"quantity": 2,
"delivered": 2,
"unit_price": "0.320000",
"total": "0.640000",
"format": ["email", "mailpassword", "alt_email"]
}
],
"warranty_expires_at": "2026-09-25T07:31:44+00:00",
...
}
}Another account's order returns 404, the same as one that does not exist.
/orders/{id}/lines/{line}/credentialsThe credentials delivered on one line of an order, a page at a time. Use it to fetch them again, or for storefront orders, which do not return credentials inline.
| Name | Type | Description |
|---|---|---|
| idrequiredpath | integer | The order id. |
| linerequiredpath | integer | The line id, from lines[].id. |
| pagequery | integer | 1-based. Defaults to 1. |
| per_pagequery | integer | 1–1,000. Defaults to 500. |
{
"success": true,
"message": "Credentials retrieved",
"data": {
"line_id": 902211,
"format": ["email", "mailpassword", "alt_email"],
"accounts": [
"jane.r.2020@gmail.com:Pa55w0rd!:recovery.jr@mail.com",
"tom.k.2020@gmail.com:S3cr3t#9:recovery.tk@mail.com"
],
"pagination": {
"page": 1,
"per_page": 500,
"total": 2,
"total_pages": 1
}
}
}Every read is logged against your account. A replacement for a dead account inside the warranty window goes through a support ticket; the API does not issue refunds.
API access is granted per account. Tell us your use case and the volume you expect, and we will grant the scope you need — read-only first if you would rather build against the catalogue before you start trading. Your key is then created by you, in your own cabinet, and shown to you once.