Skip to content
NP NFTPARS
Log in with Telegram

NFTPARS API v1

Base URL https://nftpars.app/api/v1. Contract version 1.1.0 — the machine-readable spec is public-v1.yaml (OpenAPI 3.1). JSON everywhere; timestamps are UTC (RFC 3339, Z); amounts are integer nanoton (1 TON = 109).

Authentication & API keys

Create an application and an API key in the dashboard. Send it as Authorization: Bearer <key>. Keys are shown once and stored only as a keyed hash; rotate them with a grace period so clients can switch without downtime, and revoke them instantly if leaked.

The application owner must hold an active Telegram Premium subscription verified by NFTPARS (Telegram Login alone does not prove Premium). If verification lapses, requests fail with 403 PREMIUM_REQUIRED or 403 PREMIUM_VERIFICATION_STALE; nothing is deleted and access returns after verified renewal.

curl -s https://nftpars.app/api/v1/gifts/SnoopDogg-534281 \
  -H "Authorization: Bearer $NFTPARS_KEY"

Responses & errors

Every response has the same envelope. Successful calls return {"ok": true, "data": …, "meta": …}; failures return {"ok": false, "error": {"code", "message", "details"}, "meta": …}. meta.request_id (also X-Request-Id) identifies the call for support.

{
    "ok": true,
    "data": {
        "slug": "SnoopDogg-534281",
        "owner": {
            "status": "VERIFIED",
            "freshness": "FRESH",
            "…": "…"
        }
    },
    "meta": {
        "request_id": "req_01jc…",
        "generated_at": "2026-11-04T10:00:00Z",
        "api_version": "v1",
        "freshness": "FRESH",
        "billing": {
            "charged_nano": 1000000,
            "reserved_nano": 1000000
        }
    }
}

Python

import os, requests

API = "https://nftpars.app/api/v1"
headers = {"Authorization": f"Bearer {os.environ['NFTPARS_KEY']}"}

r = requests.get(f"{API}/gifts/SnoopDogg-534281", headers=headers, timeout=15)
body = r.json()
if not body["ok"]:
    raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")

owner = body["data"]["owner"]
print(owner["status"], owner["freshness"])          # e.g. VERIFIED FRESH
if owner["status"] == "VERIFIED":
    print("current owner:", owner["telegram"]["id"])
elif owner["status"] == "HISTORICAL":
    print("last known (not current) owner:", owner["telegram"]["id"])
print("charged nanoton:", r.headers["X-NFTPARS-Charged-Nano"])

JavaScript

const API = "https://nftpars.app/api/v1";
const res = await fetch(`${API}/gifts/SnoopDogg-534281`, {
  headers: { Authorization: `Bearer ${process.env.NFTPARS_KEY}` },
});
const body = await res.json();
if (!body.ok) throw new Error(`${body.error.code}: ${body.error.message}`);

const { owner } = body.data;
console.log(owner.status, owner.freshness, body.meta.request_id);

PHP

<?php
$ch = curl_init('https://nftpars.app/api/v1/gifts/SnoopDogg-534281');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => ['Authorization: Bearer '.getenv('NFTPARS_KEY')],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 15,
]);
$body = json_decode(curl_exec($ch), true);
if (! $body['ok']) {
    throw new RuntimeException($body['error']['code'].': '.$body['error']['message']);
}
echo $body['data']['owner']['status'], PHP_EOL;

Rate limits & quotas

Limits are per application (see your plan on the pricing page). Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix time). On 429 honour Retry-After.

Billing

  • Top up by sending TON with your personal memo (dashboard → Billing). Deposits are credited only after on-chain verification.
  • Each call reserves its price, then settles only for delivered units and releases the rest. Not-found items, failures and unfinished jobs are never charged.
  • Send Idempotency-Key on POSTs and refreshes: a retry with the same key replays the original response (Idempotent-Replayed: true) and is not charged again. X-NFTPARS-Charged-Nano shows what a call cost.

Fast vs fresh

Fast (default) returns the indexed value immediately with freshness FRESH, STALE or PENDING; ?max_age= sets what counts as fresh. Fresh (?refresh=true) asks the engine for a new observation and returns 202 with a job; poll GET /jobs/{id}, or subscribe to job.completed webhooks / the jobs WebSocket channel.

import time, uuid, requests

# Fresh mode: ask for a new observation. Use an Idempotency-Key so a retry never charges twice.
r = requests.get(f"{API}/gifts/SnoopDogg-534281/owner", params={"refresh": "true"},
                 headers={**headers, "Idempotency-Key": str(uuid.uuid4())}, timeout=15)
job = r.json()["data"]["job"]                       # HTTP 202

while job["status"] in ("QUEUED", "RUNNING"):
    time.sleep(2)
    job = requests.get(f"{API}/jobs/{job['id']}", headers=headers, timeout=15).json()["data"]

print(job["status"], job["billing"])                # only SUCCEEDED items are charged

Batch jobs

POST /gifts/batch with up to your plan's batch limit of distinct slugs. mode: "cached" answers immediately and charges only found items; mode: "fresh" creates a job and charges only items that succeeded.

const start = await fetch(`${API}/gifts/batch`, {
  method: "POST",
  headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({ mode: "fresh", slugs: ["SnoopDogg-534281", "PlushPepe-1234"] }),
});
let { data: { job } } = await start.json();          // 202 Accepted
while (["QUEUED", "RUNNING"].includes(job.status)) {
  await new Promise((r) => setTimeout(r, 2000));
  job = (await (await fetch(`${API}/jobs/${job.id}`, { headers: { Authorization: `Bearer ${KEY}` } })).json()).data;
}

Owner semantics

  • VERIFIED — current Telegram owner, verified at verified_at.
  • HISTORICAL — the last known Telegram owner; not a current owner.
  • WALLET_ONLY — held by a TON wallet; no Telegram identity is implied.
  • HIDDEN, UNKNOWN, CONFLICT — no reliable current identity (conflicts list each candidate observation).

Freshness is separate from status. Each owner carries sources (provenance) and observed_at.

Offer filters

Filter profiles restrict your application's offer stream (webhooks and the offers WebSocket channel) and market/search results: country, self-declared gender, collections, price range, gift number range, owner status, minimum freshness and market status. Temporary filters (1 h, 2 h, 6 h, 24 h or a custom duration) override the persistent profile and restore it automatically on expiry. Country and gender exist only for owners who self-declared them with consent; values that are unknown are excluded unless you set unknown.<field> = "include". Check coverage first.

# Two-hour temporary filter; the previous profile is restored automatically.
curl -s -X POST https://nftpars.app/api/v1/applications/$APP_ID/filters/temporary \
  -H "Authorization: Bearer $NFTPARS_KEY" -H "Content-Type: application/json" \
  -d '{"criteria": {"country": "RU", "gender": "female", "unknown": {"country": "exclude", "gender": "exclude"}},
       "duration_minutes": 120}'

# How many indexed owners actually have reliable (self-declared, consented) attributes?
curl -s "https://nftpars.app/api/v1/filters/coverage?country=RU&gender=female" -H "Authorization: Bearer $NFTPARS_KEY"

Webhooks

Events: gift.discovered, gift.owner_changed, market.listing_created, market.listing_updated (price changes), market.listing_removed, market.sale, collection.updated, job.completed, job.failed. HTTPS endpoints only. Each delivery is signed — X-NFTPARS-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "t.body")> — and retried with exponential backoff; after the maximum attempts it is dead-lettered and visible in your delivery log. Reject signatures older than 5 minutes and de-duplicate on X-NFTPARS-Delivery.

Python

import hmac, hashlib, time

def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts = int(parts["t"])
    if abs(time.time() - ts) > tolerance:            # replay protection
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

# Then de-duplicate on the X-NFTPARS-Delivery header (stable across retries).

JavaScript

import crypto from "node:crypto";

export function verify(secret, rawBody, header, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const ts = Number(parts.t);
  if (Math.abs(Date.now() / 1000 - ts) > tolerance) return false;   // replay protection
  const expected = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

PHP

<?php
function nftpars_verify(string $secret, string $rawBody, string $header, int $tolerance = 300): bool
{
    parse_str(str_replace(',', '&', $header), $parts);
    $ts = (int) ($parts['t'] ?? 0);
    if (abs(time() - $ts) > $tolerance) {
        return false; // replay protection
    }
    $expected = hash_hmac('sha256', $ts.'.'.$rawBody, $secret);

    return hash_equals($expected, (string) ($parts['v1'] ?? ''));
}

WebSocket

Pusher protocol v7 at wss://nftpars.app/app/<key>. Configure channels first (dashboard or POST /ws/subscriptions): gift:<slug>, collection:<slug>, market:<collection>, offers (filtered) and jobs. Private channels are authorized by POST /ws/auth with your API key, from your server. Connections, frame size and message rate are capped; clients cannot publish.

import Pusher from "pusher-js";

// 1) GET https://nftpars.app/api/v1/ws/config for host/key, 2) add channels via POST https://nftpars.app/api/v1/ws/subscriptions
const pusher = new Pusher(APP_KEY, {
  wsHost: "nftpars.app", wsPort: 443, forceTLS: true, enabledTransports: ["ws", "wss"], cluster: "",
  channelAuthorization: {
    endpoint: "https://nftpars.app/api/v1/ws/auth",
    headers: { Authorization: `Bearer ${KEY}` },   // server-side only — never ship keys to browsers
  },
});
pusher.subscribe("private-market.PlushPepe").bind("nftpars.event", (event) => console.log(event.type, event.data));
pusher.subscribe(`private-application.${APP_ID}.offers`).bind("nftpars.event", console.log); // filtered offer stream

Endpoint reference

GET /status

Public service status (no auth, not billed)

No authentication required.

GET /pricing

Active operation prices for this application (not billed)

GET /gifts/{slug}
ParameterInDescription
slug *path
refreshquerytrue = request a fresh observation. Returns 202 with a job when the cached value is not fresh enough.
max_agequerySeconds; data older than this is STALE.
Idempotency-Keyheader
GET /gifts/{slug}/owner
ParameterInDescription
slug *path
refreshquerytrue = request a fresh observation. Returns 202 with a job when the cached value is not fresh enough.
max_agequerySeconds; data older than this is STALE.
Idempotency-Keyheader
GET /gifts/{slug}/history
ParameterInDescription
slug *path
cursorquery
limitquery
POST /gifts/batch
ParameterInDescription
Idempotency-Keyheader
GET /collections
ParameterInDescription
cursorquery
limitquery
GET /collections/{collection_slug}
ParameterInDescription
collection_slug *path
GET /search

Application filters (persistent + active temporary override) are applied automatically.

ParameterInDescription
qquery
collectionquery
modelquery
backdropquery
symbolquery
owner_statusquery
cursorquery
limitquery
GET /owners/{telegram_id}/gifts
ParameterInDescription
telegram_id *path
include_historicalquery
cursorquery
limitquery
GET /wallets/{address}/gifts
ParameterInDescription
address *path
cursorquery
limitquery
GET /market/listings
ParameterInDescription
collectionquery
gift_slugquery
marketplacequery
statusquery
price_min_nanoquery
price_max_nanoquery
sortquery
cursorquery
limitquery
GET /jobs/{job_id}
ParameterInDescription
job_id *path
GET /applications/{id}/usage
ParameterInDescription
id *path
fromquery
toquery
GET /applications/{id}/filters
ParameterInDescription
id *path
PUT /applications/{id}/filters
ParameterInDescription
id *path
POST /applications/{id}/filters/temporary

Temporary filter, e.g. { "country": "RU", "gender": "female", "duration_minutes": 120 } or (1.1) { "profile_id": "...", "duration_minutes": 60 } or { "criteria": FilterCriteria, ... }; previous configuration is restored on expiry

ParameterInDescription
id *path
DELETE /applications/{id}/filters/temporary
ParameterInDescription
id *path
GET /applications/{id}/filter-profiles

(1.1) Saved filter profiles

ParameterInDescription
id *path
POST /applications/{id}/filter-profiles

(1.1) Create a filter profile

ParameterInDescription
id *path
PATCH /applications/{id}/filter-profiles/{profile_id}
ParameterInDescription
id *path
profile_id *path
DELETE /applications/{id}/filter-profiles/{profile_id}
ParameterInDescription
id *path
profile_id *path
POST /applications/{id}/filters/activate

(1.1) Make a profile the persistent filter (profile_id null = no filter)

ParameterInDescription
id *path
GET /applications/{id}/filters/history

(1.1) Filter change history (who, what, when)

ParameterInDescription
id *path
GET /jobs

(1.1) Recent jobs of this application

ParameterInDescription
statusquery
limitquery
GET /filters/coverage
ParameterInDescription
countryquery
genderquery
GET /webhooks
POST /webhooks
GET /webhooks/{id}
ParameterInDescription
id *path
PATCH /webhooks/{id}
ParameterInDescription
id *path
DELETE /webhooks/{id}
ParameterInDescription
id *path
POST /webhooks/{id}/rotate-secret
ParameterInDescription
id *path
POST /webhooks/{id}/test
ParameterInDescription
id *path
GET /webhooks/{id}/deliveries
ParameterInDescription
id *path
GET /ws/config

WebSocket connection parameters (Pusher protocol v7, served by Laravel Reverb)

Connect to `wss://nftpars.app/app/<app_key>?protocol=7`. Subscribe to private channels: `private-gift.<slug>`, `private-collection.<slug>`, `private-market.<collection_slug>`, `private-application.<application_id>.jobs`. Each event is named `nftpars.event` and carries the webhook-delivery body (events/v1/webhook-delivery.json).

POST /ws/auth

Authorize a private channel subscription (standard Pusher private-channel auth)

GET /stats/indexing

Error codes

CodeHTTPMeaning
UNAUTHENTICATED401No API key was sent.
INVALID_API_KEY401The key does not exist.
API_KEY_REVOKED401The key was revoked or has expired (e.g. after rotation).
PREMIUM_REQUIRED403The owner has no verified, active Telegram Premium. Access resumes after verified renewal.
PREMIUM_VERIFICATION_STALE403Premium could not be re-verified within the grace window. Re-verify in the bot.
API_ACCESS_DISABLED403An administrator disabled API access for the account.
APPLICATION_SUSPENDED403The application is disabled or suspended.
WS_CHANNEL_NOT_CONFIGURED403Add the WebSocket channel to the application first.
NOT_FOUND / GIFT_NOT_FOUND404Unknown endpoint or a gift that is not indexed. Not charged.
VALIDATION_FAILED422Invalid input; see error.details.errors.
BATCH_TOO_LARGE422More distinct slugs than the plan allows.
IDEMPOTENCY_KEY_REUSED422Same Idempotency-Key, different request.
IDEMPOTENCY_IN_PROGRESS409The original request is still running; retry shortly.
INSUFFICIENT_BALANCE402Top up and retry (402s are never pinned to an idempotency key).
RATE_LIMITED429Per-minute limit hit; honour Retry-After.
QUOTA_EXCEEDED429Monthly request quota used up.
PRICING_NOT_CONFIGURED503The operation has no published price yet.
UPSTREAM_UNAVAILABLE503Indexing engine unavailable. Not charged; retry with backoff.
UPSTREAM_CONTRACT_VIOLATION502Engine data failed integrity checks (e.g. an unverified owner claimed as verified). Not charged.