Reseller API reference · v1.0
Apply for APIIntegrate with PVA Markets programmatically. Browse the catalogue, buy accounts and manage orders through our RESTful API — the same catalogue and the same balance the storefront uses, without a browser in the middle.
https://pvamarkets.com/api/v1The PVA Markets Reseller API is a RESTful API that uses JSON for request and response bodies. Every endpoint is prefixed with /api/v1 and served over HTTPS only — plain HTTP requests are refused rather than redirected, so a misconfigured client never puts a key on the wire in the clear.
Response format
All responses follow the same JSON envelope, with success, message and data fields. Read success before data — an error body carries the same three keys, so branching on the HTTP status alone is enough but branching on success is clearer.
{
"success": true,
"message": "Operation successful",
"data": { ... }
}Money is returned as a decimal string, never a float. Accounts on this marketplace go down to $0.004 apiece, so a price can carry up to six decimal places — parse it with a decimal type and format it yourself. Quantities are integers, and every timestamp is ISO 8601 in UTC.
Sandbox
Every endpoint accepts a X-Sandbox: true header. Sandbox requests read the live catalogue but never move stock or funds, and purchases return placeholder credentials. Use it to shake out an integration before the first real order.
The API uses API key authentication. Generate a key in your reseller cabinet and send it in the X-API-Key header on every request. There is no session, no OAuth dance and no token refresh — the key is the whole credential, so treat it like a password and keep it server-side.
API key header
Include the header in all requests. A missing or malformed key returns 401; a valid key that has been revoked or belongs to a suspended account returns 403.
curl https://pvamarkets.com/api/v1/user/profile \
-H "X-API-Key: YOUR_API_KEY" \
-H "Accept: application/json"Keys can be restricted to a set of IP addresses and to a scope — read for catalogue access, trade for purchases and balance movements. A key with the read scope calling POST /orders returns 403 with the forbidden code, which is the failure you want during development rather than a surprise purchase.
Errors reuse the standard envelope with success set to false. The HTTP status carries the class of failure and data.code carries the specific one, so a client can branch on the status and log the code.
{
"success": false,
"message": "Not enough stock to fill this order",
"data": {
"code": "insufficient_stock",
"available": 12
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed JSON, or a parameter of the wrong type. |
| 401 | unauthorized | The X-API-Key header is missing or the key does not exist. |
| 403 | forbidden | The key is revoked, out of scope, or calling from an unlisted IP. |
| 404 | not_found | No listing, order or category with that identifier. |
| 409 | insufficient_stock | Stock dropped below the requested quantity before the order was placed. |
| 409 | insufficient_funds | The balance does not cover the order total. |
| 422 | validation_failed | The body parsed but a field failed validation; data.errors lists them per field. |
| 429 | rate_limited | Too many requests. See Rate Limiting below. |
| 500 | server_error | Something broke on our side. Safe to retry with backoff. |
Retry only what is safe
409 and 422 will not resolve on their own — refetch the listing or fix the body first. 429 and 5xx are the only statuses worth retrying, and only with exponential backoff.
Requests are counted per API key in a rolling one-minute window. Catalogue reads get a generous allowance; purchases are held to a much tighter one because each of them moves stock and funds.
| Scope | Limit | Applies to |
|---|---|---|
| Catalogue reads | 600 / minute | Categories, listings and listing detail. |
| Account reads | 120 / minute | Profile, balance and order history. |
| Purchases | 30 / minute | POST /orders and POST /promo/validate. |
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 574
X-RateLimit-Reset: 1739452800Handling 429
A throttled response carries Retry-After in seconds. Wait it out rather than retrying immediately — repeated hammering after a 429 is what gets a key suspended.
Polling the catalogue every few seconds is the usual way resellers burn through the limit. Listings carry an updated_at field and the collection endpoints accept updated_since, so pull the delta instead of the whole catalogue.
User
/user/profileReturns the reseller account the API key belongs to, along with the scope and IP allowlist the key itself carries. Useful as a health check on startup — it is the cheapest call that proves a key is live.
{
"success": true,
"message": "Profile retrieved",
"data": {
"id": 48213,
"email": "reseller@example.com",
"username": "example_reseller",
"status": "active",
"created_at": "2024-11-02T09:14:38Z",
"api_key": {
"label": "production",
"scope": ["read", "trade"],
"allowed_ips": ["203.0.113.24"]
}
}
}/user/profileUpdates the contact details on the account. Send only the fields you are changing — omitted fields are left alone. The email address and the balance cannot be changed here; both go through the cabinet.
| Name | Type | Description |
|---|---|---|
| username | string | Display name, 3–32 characters. |
| telegram | string | Telegram handle for order notifications, with or without the leading @. |
| notify_on_order | boolean | Whether to send a notification for each API order. |
{
"username": "example_reseller",
"telegram": "@example_reseller",
"notify_on_order": true
}{
"success": true,
"message": "Profile updated",
"data": {
"id": 48213,
"username": "example_reseller",
"telegram": "@example_reseller",
"notify_on_order": true
}
}/user/balanceReturns the wallet the API buys from. Orders are settled from this balance, so check it before a batch rather than discovering the shortfall as an insufficient_funds error halfway through.
{
"success": true,
"message": "Balance retrieved",
"data": {
"currency": "USD",
"available": "418.250000",
"held": "12.400000",
"updated_at": "2026-08-13T07:22:11Z"
}
}held covers orders that are placed but not yet delivered. It is not spendable, so budget against available.
Categories
/categoriesReturns every top-level category — Instagram, Facebook, Gmail, TikTok and the rest — with a live stock count and the lowest price currently offered in each. The tree changes rarely; cache it and refresh daily.
| Name | Type | Description |
|---|---|---|
| with_counts | boolean | Include stock and min_price per category. Defaults to true. |
{
"success": true,
"message": "Categories retrieved",
"data": [
{
"id": 3,
"slug": "instagram",
"name": "Instagram",
"stock": 18420,
"min_price": "0.140000"
},
{
"id": 5,
"slug": "gmail",
"name": "GMail",
"stock": 9317,
"min_price": "0.004000"
}
]
}/categories/{slug}/subcategoriesReturns the subcategories under one category — Softreg, Aged, With Followers and so on. The slug is the same one the storefront uses in its URLs, so /catalog/instagram/pva maps to the pva subcategory here.
| Name | Type | Description |
|---|---|---|
| slugrequired | string | Path parameter. The parent category slug, e.g. instagram. |
{
"success": true,
"message": "Subcategories retrieved",
"data": [
{
"id": 31,
"slug": "pva",
"name": "Softreg",
"stock": 6204,
"min_price": "0.140000"
},
{
"id": 32,
"slug": "s-otlezhkoj",
"name": "Aged",
"stock": 2881,
"min_price": "0.910000"
}
]
}Listings
/listingsThe catalogue itself, paginated and filterable. Out of stock means out of stock: a listing with no stock returns stock 0 and no price, never a fallback figure from elsewhere in the catalogue.
| Name | Type | Description |
|---|---|---|
| category | string | Category slug, e.g. facebook. |
| subcategory | string | Subcategory slug, e.g. aged1. |
| tags | array<string> | Repeatable. Matches listings carrying all given tags, e.g. tags=2fa&tags=email. |
| price_min | decimal | Lowest per-item price to include. |
| price_max | decimal | Highest per-item price to include. |
| in_stock | boolean | Drop sold-out listings. Defaults to true. |
| updated_since | string | ISO 8601 timestamp. Returns only listings changed since then. |
| sort | string | price_asc, price_desc, stock_desc or newest. Defaults to newest. |
| page | integer | 1-based page number. Defaults to 1. |
| per_page | integer | 1–100. Defaults to 50. |
curl -G https://pvamarkets.com/api/v1/listings \
-H "X-API-Key: YOUR_API_KEY" \
-d category=instagram \
-d subcategory=pva \
-d price_max=0.5 \
-d per_page=2{
"success": true,
"message": "Listings retrieved",
"data": {
"items": [
{
"id": 90142,
"slug": "instagram-softreg-eu-2fa",
"title": "Instagram Softreg | EU IP | 2FA included",
"category": "instagram",
"subcategory": "pva",
"price": "0.320000",
"stock": 1840,
"min_quantity": 10,
"tags": ["2fa", "email", "eu"],
"updated_at": "2026-08-13T06:58:02Z"
}
],
"pagination": {
"page": 1,
"per_page": 2,
"total": 87,
"total_pages": 44
}
}
}/listings/{id}One listing in full, including the seller's description and the format the credentials are delivered in. Read format before you buy in volume — it is what your parser has to handle.
| Name | Type | Description |
|---|---|---|
| idrequired | integer | Path parameter. The listing id. |
{
"success": true,
"message": "Listing retrieved",
"data": {
"id": 90142,
"slug": "instagram-softreg-eu-2fa",
"title": "Instagram Softreg | EU IP | 2FA included",
"description": "Registered from EU residential IPs. Email included, 2FA key included.",
"category": "instagram",
"subcategory": "pva",
"price": "0.320000",
"stock": 1840,
"min_quantity": 10,
"format": "login:password:email:email_password:2fa_key",
"warranty_hours": 24,
"tags": ["2fa", "email", "eu"],
"seller": { "id": 771, "rating": 4.8 },
"updated_at": "2026-08-13T06:58:02Z"
}
}Stock is a snapshot, not a reservation. Between this call and POST /orders another buyer can take the last of it, which is what the 409 insufficient_stock response is for.
Orders
/ordersBuys a quantity from one listing and settles it against your balance. Requires a key with the trade scope. Delivery is immediate for in-stock listings, and the accounts come back on the same response.
| Name | Type | Description |
|---|---|---|
| listing_idrequired | integer | The listing to buy from. |
| quantityrequired | integer | How many accounts. Must be at least the listing's min_quantity. |
| promo_code | string | Optional. Validate it first with POST /promo/validate. |
| idempotency_keyrequired | string | A UUID you generate. Replaying the same key returns the original order instead of buying twice. |
{
"listing_id": 90142,
"quantity": 50,
"promo_code": "SUMMER10",
"idempotency_key": "5c9f2b74-1a3e-4d80-9f2a-7c1d0e6b8a41"
}{
"success": true,
"message": "Order completed",
"data": {
"order_id": 611904,
"status": "delivered",
"listing_id": 90142,
"quantity": 50,
"unit_price": "0.320000",
"discount": "1.600000",
"total": "14.400000",
"balance_after": "403.850000",
"format": "login:password:email:email_password:2fa_key",
"items": [
"user_ffa21:Pa55w0rd!:user_ffa21@rambler.ru:M4ilPass:JBSWY3DPEHPK3PXP"
],
"created_at": "2026-08-13T07:31:44Z"
}
}Send idempotency_key on every purchase. A timeout tells you nothing about whether the order went through, and retrying with the same key is the only safe way to find out.
/ordersYour order history, newest first. The items array is omitted here to keep the payload small — fetch a single order to get the credentials back.
| Name | Type | Description |
|---|---|---|
| status | string | pending, delivered, refunded or failed. |
| from | string | ISO 8601 timestamp. Orders created at or after it. |
| to | string | ISO 8601 timestamp. Orders created before it. |
| page | integer | 1-based page number. Defaults to 1. |
| per_page | integer | 1–100. Defaults to 50. |
{
"success": true,
"message": "Orders retrieved",
"data": {
"items": [
{
"order_id": 611904,
"status": "delivered",
"listing_id": 90142,
"title": "Instagram Softreg | EU IP | 2FA included",
"quantity": 50,
"total": "14.400000",
"created_at": "2026-08-13T07:31:44Z"
}
],
"pagination": {
"page": 1,
"per_page": 50,
"total": 1284,
"total_pages": 26
}
}
}/orders/{id}One order with the delivered credentials. Orders stay retrievable for 90 days, after which the items array is cleared and only the metadata remains — store what you buy on your own side.
| Name | Type | Description |
|---|---|---|
| idrequired | integer | Path parameter. The order id. |
{
"success": true,
"message": "Order retrieved",
"data": {
"order_id": 611904,
"status": "delivered",
"listing_id": 90142,
"quantity": 50,
"unit_price": "0.320000",
"total": "14.400000",
"format": "login:password:email:email_password:2fa_key",
"items": [
"user_ffa21:Pa55w0rd!:user_ffa21@rambler.ru:M4ilPass:JBSWY3DPEHPK3PXP"
],
"warranty_expires_at": "2026-08-14T07:31:44Z",
"created_at": "2026-08-13T07:31:44Z"
}
}A replacement claim inside the warranty window still goes through a support ticket; the API does not issue refunds.
Promo
/promo/validateChecks a promo code against a listing and quantity before you commit to the purchase. It reserves nothing — the same code still has to be sent on POST /orders, where it is re-checked.
| Name | Type | Description |
|---|---|---|
| coderequired | string | The promo code, case-insensitive. |
| listing_id | integer | Scopes the check to one listing. Some codes are category-bound. |
| quantity | integer | Used to check the code's minimum order value. |
{
"code": "SUMMER10",
"listing_id": 90142,
"quantity": 50
}{
"success": true,
"message": "Promo code is valid",
"data": {
"code": "SUMMER10",
"valid": true,
"discount_type": "percent",
"discount_value": "10",
"discount_amount": "1.600000",
"total_before": "16.000000",
"total_after": "14.400000",
"expires_at": "2026-08-31T23:59:59Z"
}
}An unknown or expired code returns 200 with valid set to false and a reason, not a 404 — that keeps code checking off your error path.
API access is granted per account. Open a ticket with your use case and the volume you expect, and we will issue a key with the scope you need — read-only first if you would rather test against the sandbox before trading.