PVA Markets API

PVA Markets API

Integrate 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.

Base URLhttps://pva-admin.nixsoftware.net/api/v1/reseller
Apply for API

Introduction

The 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.

Standard response structure
{
  "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.

Getting Access

API access is granted per account, after a short review. The whole process happens in your cabinet under API access.

  1. 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.

  2. 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.

  3. Wait for review

    Staff approve or refuse the application; the cabinet shows its status. A refused application can be resubmitted.

  4. 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_.

  5. 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.

Quick Start

From a new key to delivered credentials in four calls. Set your key in the environment first: export PVAM_KEY=pvam_live_…

1 · Check the key and the balance
curl https://pva-admin.nixsoftware.net/api/v1/reseller/user/profile \
  -H "X-API-Key: $PVAM_KEY"
2 · Find an item
curl -G https://pva-admin.nixsoftware.net/api/v1/reseller/items \
  -H "X-API-Key: $PVAM_KEY" \
  -d category=gmail \
  -d sort=price
3 · Optional: compare suppliers and prices
curl https://pva-admin.nixsoftware.net/api/v1/reseller/items/gmail-aged-2020-us-ip \
  -H "X-API-Key: $PVAM_KEY"
4 · Buy — the credentials come back on this response
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}'

Authentication

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.

Authenticated request
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.

ScopeAllows
(any key)GET /user/profile and GET /user/balance.
readThe catalogue — categories, items, suppliers, stock and prices.
tradeBuying, 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.

Idempotency

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 sendYou get
A new keyA new order. 201, header Idempotent-Replayed: false.
The same key and the same bodyThe original order again, credentials included — nothing is bought twice. 200, header Idempotent-Replayed: true.
The same key and a different body422 idempotency_key_reused. Generate a new key for a new order.
The key of a request that was refusedA fresh attempt. A refused purchase records nothing, so fix the cause and retry with the same key.
Retrying a purchase safely (Node.js)
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));
}

Error Codes

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.

Error response
{
  "success": false,
  "message": "Your balance does not cover this order.",
  "data": {
    "code": "insufficient_funds",
    "required": "16.000000",
    "available": "9.250000"
  }
}
StatusCodeMeaning
401unauthorizedThe X-API-Key header is missing, or no key has that value.
403forbiddenThe key is revoked, lacks the scope, is called from an unlisted IP, or API access was withdrawn.
404not_foundNo item, category, order or route at that address, or the partner you named does not sell this item.
405method_not_allowedThat HTTP method is not supported on this path.
409insufficient_stockThe supplier has fewer accounts than you asked for. data.available says how many; send allow_partial to take them.
409insufficient_fundsYour balance does not cover the order. data.required and data.available say by how much.
409price_changedThe order would cost more than your max_total. data.total is what it would cost now.
409not_paidThe order has not been paid, so it has no credentials yet.
410credentials_unavailableThe download window has closed or the credentials were removed. Open a support ticket.
422validation_failedA field failed validation; data.errors lists the messages per field.
422idempotency_key_reusedThat Idempotency-Key was already used for a different order.
429rate_limitedToo many requests. Wait Retry-After seconds.
500server_errorSomething broke on our side. Safe to retry with backoff.
503service_unavailablePurchasing 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.

Rate Limiting

Limits are counted per API key, per minute, in three tiers. Two keys on the same server do not share a limit.

TierLimitApplies to
Catalogue600 / minuteGET /categories, /items and /items/{slug}.
Account120 / minuteGET /user/profile, /user/balance and the order history endpoints.
Purchase30 / minutePOST /orders.
Headers on every response
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 574

Handling 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

Get Profile

GET/user/profile
Any keyAccount · 120/min

The 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.

200 OK
{
  "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"]
    }
  }
}

Get Balance

GET/user/balance
Any keyAccount · 120/min

The 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.

200 OK
{
  "success": true,
  "message": "Balance retrieved",
  "data": {
    "currency": "USD",
    "available": "418.250000"
  }
}

Catalogue

List Categories

GET/categories
Scope: readCatalogue · 600/min

The 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.

200 OK
{
  "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": []
        }
      ]
    }
  ]
}

List Items

GET/items
Scope: readCatalogue · 600/min

The 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.

Parameters

NameTypeDescription
categoryquerystringCategory slug, subcategories included. An unknown slug is 404.
qquerystringWords from the title, or an item id.
in_stockquerybooleanDefaults to true. Send false to include sold-out items.
price_minquerydecimalLowest cheapest-price to include.
price_maxquerydecimalHighest cheapest-price to include.
sortquerystringnewest (default), price, stock or title.
directionquerystringasc or desc. Defaults to desc for newest and stock, asc otherwise.
pagequeryinteger1-based. Defaults to 1.
per_pagequeryinteger1–100. Defaults to 50.
Example request
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
200 OK
{
  "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
    }
  }
}

Item Detail & Offers

GET/items/{slug}
Scope: readCatalogue · 600/min

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.

Parameters

NameTypeDescription
slugrequiredpathstringThe item slug. A renamed item's old slug still works; data.slug is the current one.
200 OK
{
  "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

Purchase

POST/orders
Scope: tradePurchase · 30/min

Buys 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.

Parameters

NameTypeDescription
Idempotency-KeyrequiredheaderstringA new UUID per order, up to 100 characters. See Idempotency.
itemrequiredbodystringThe item slug.
partnerbodyintegerOptional. 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.
quantityrequiredbodyinteger1–1,000. For more, place several orders.
allow_partialbodybooleanAccept fewer than quantity if that is all the supplier has. Defaults to false, which refuses with insufficient_stock.
max_totalbodydecimalRefuse 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.
Request
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"
  }'
201 Created
{
  "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.

List Orders

GET/orders
Scope: tradeAccount · 120/min

Your account's orders, newest first — storefront orders included, since it is one balance. Same shape as a purchase, without credentials.

Parameters

NameTypeDescription
statusquerystringpending, on_hold, processing, completed, cancelled, failed or refunded. processing and completed both mean paid.
fromquerystringISO 8601. Orders created at or after it.
toquerystringISO 8601. Orders created before it.
pagequeryinteger1-based. Defaults to 1.
per_pagequeryinteger1–100. Defaults to 50.
200 OK
{
  "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
    }
  }
}

Order Detail

GET/orders/{id}
Scope: tradeAccount · 120/min

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.

Parameters

NameTypeDescription
idrequiredpathintegerThe order id.
200 OK
{
  "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.

Get Credentials

GET/orders/{id}/lines/{line}/credentials
Scope: tradeAccount · 120/min

The 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.

Parameters

NameTypeDescription
idrequiredpathintegerThe order id.
linerequiredpathintegerThe line id, from lines[].id.
pagequeryinteger1-based. Defaults to 1.
per_pagequeryinteger1–1,000. Defaults to 500.
200 OK
{
  "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.

Ready to integrate?

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.