On this page
The Newport Exchange API serves the same data that powers Market and News on this site. All endpoints are read-only GET requests that return JSON over HTTPS, and every URL in this reference is relative to https://api.newportexchange.org/v2.
- Posts (
/posts) — curated crypto news with search and filters. Requires an API key. - Market data (
/crypto/…) — top coins by market capitalisation with price, volume, supply and percentage change. Public. - Historical data (
/crypto/historical/…,/crypto-history/…) — daily OHLCV candles. Public. - News (
/news) — the public news feed used by this website. Public.
Timestamps in /posts responses are ISO 8601 in UTC. Public endpoints return the underlying record shapes unchanged, so some use unix seconds instead — each section says which.
1. Make a request without a key. The market-data, historical and news endpoints are public. Fetch Bitcoin’s current listing:
curl -s "https://api.newportexchange.org/v2/crypto/BTC"// quickstart-public.mjs — Node 18+ (global fetch). Run: node quickstart-public.mjs
const res = await fetch('https://api.newportexchange.org/v2/crypto/BTC', { signal: AbortSignal.timeout(10_000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const btc = await res.json(); // a bare object, not wrapped in "data"
// Several numeric fields are JSON strings — parse them before doing arithmetic.
console.log(btc.name, btc.price_usd, Number(btc.percent_change_day).toFixed(2) + '%');# quickstart_public.py — Python 3.8+, pip install requests
import requests
resp = requests.get("https://api.newportexchange.org/v2/crypto/BTC", timeout=10)
resp.raise_for_status()
btc = resp.json() # a bare object, not wrapped in "data"
# Several numeric fields are JSON strings — parse them before doing arithmetic.
print(btc["name"], btc["price_usd"], f'{float(btc["percent_change_day"]):.2f}%')You get one JSON object (see The currency object). Note that price_usd is a number but percent_change_day, market_cap_usd and several other numeric fields are strings — see Numbers and timestamps.
2. Use an API key for posts. Request an API key, store it in an environment variable on your server (e.g. NEX_API_KEY), send it in the X-API-Key header and fetch the five latest posts that mention Bitcoin:
curl -s "https://api.newportexchange.org/v2/posts?symbol=BTC&limit=5" \
-H "X-API-Key: YOUR_API_KEY"// quickstart.mjs — Node 18+ (global fetch). Run: node quickstart.mjs
const API_BASE = 'https://api.newportexchange.org/v2';
const API_KEY = process.env.NEX_API_KEY ?? 'YOUR_API_KEY';
const res = await fetch(`${API_BASE}/posts?symbol=BTC&limit=5`, {
headers: { 'X-API-Key': API_KEY },
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(`HTTP ${res.status}: ${err.message ?? res.statusText}`);
}
const { data, meta } = await res.json();
for (const post of data) console.log(post.published_at, post.title);
console.log('More pages available:', meta.has_more);# quickstart.py — Python 3.8+, pip install requests
import os
import requests
API_BASE = "https://api.newportexchange.org/v2"
API_KEY = os.environ.get("NEX_API_KEY", "YOUR_API_KEY")
resp = requests.get(
f"{API_BASE}/posts",
headers={"X-API-Key": API_KEY},
params={"symbol": "BTC", "limit": 5},
timeout=10,
)
resp.raise_for_status()
body = resp.json()
for post in body["data"]:
print(post["published_at"], post["title"])
print("More pages available:", body["meta"]["has_more"])3. Before you ship, read Numbers and timestamps, Rate limits and Handling errors in code — string-encoded numbers, unix-second timestamps and 429 handling are the three things that most often break new integrations.
Every endpoint is a read-only GET. Paths are shown in full; the Cache-Control value is what the API sends, and also how long the server may reuse a response — polling faster than that returns the same data.
| Endpoint | Access | Description |
|---|---|---|
GET /v2/ | Public | Gateway name, version and main endpoints. A connectivity check — see Base URL. |
GET /v2/posts | API key | Posts as { data, meta }, newest first by default, paged by page/limit. private, max-age=60. |
GET /v2/posts/:slug | API key | One post as { data }, body included. private, max-age=60. |
GET /v2/crypto/currencies?rankFrom=&rankTo= | Public | Coins in a rank window (top 100), bare array, rank ascending. public, max-age=30. |
GET /v2/crypto/:symbol | Public | One coin (alias /v2/crypto/currencies/:symbol), bare object. public, max-age=30. |
GET /v2/crypto/historical/:symbol?days= | Public | Most recent daily candles, newest first. public, max-age=30. |
GET /v2/crypto-history/:symbol?days=|from=&to= | Public | Daily candles in a time range, oldest first, capped by limit. public, max-age=30. |
GET /v2/news | Public | News articles, bare array, newest first, paged by page/limit. public, max-age=120. |
GET /v2/news/:alias | Public | One article by alias or numeric id, full text. public, max-age=120. |
API keys are issued by Newport Exchange to registered users. There is no self-serve dashboard yet: email [email protected] to request a key, with your name, organisation and intended use. Keys look like nex_live_… and are shown to you once — store them in a secrets manager.
Send the key in the X-API-Key request header on every call to an authenticated endpoint. Never put it in the query string: URLs end up in proxy logs, browser history and analytics.
GET /v2/posts HTTP/1.1
Host: api.newportexchange.org
X-API-Key: YOUR_API_KEYAuthentication failures return:
| Status | Message | Description |
|---|---|---|
401 | API key required | The X-API-Key header is missing or empty. |
401 | Invalid API key | The key is malformed, unknown or has been revoked. |
403 | API key lacks the required scope | The key is valid but not permitted to read this resource. Contact us to change its scopes. |
429 | Too many invalid API key attempts — please retry shortly | More than 30 failed (401) attempts from your IP address within a minute. This 429 carries no RateLimit headers: wait a minute and fix the key before retrying. |
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"success":false,"message":"API key required"}HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"success":false,"message":"Invalid API key"}Every path in this reference is relative to https://api.newportexchange.org/v2; use HTTPS. GET /v2/ returns a small JSON document with the gateway’s name, version and main endpoints — a convenient connectivity check that needs no key.
- All responses are JSON (
application/json; charset=utf-8), compressed when your client sendsAccept-Encoding(most HTTP clients do). /postswraps results indata(plusmetaon lists). The public endpoints return the record itself — a bare array or object, with no envelope.- Errors always have
success: falseand amessage— see Errors. - Ignore fields you do not recognise: new fields may be added to existing responses — see Ignoring unknown fields.
- Some numbers are strings. In the currency object,
market_cap_usd,volume_usd_day,price_btc, the supply fields and everypercent_change_*field are JSON strings (large integers can exceed JavaScript’s safe integer range). Parse them —Number(x),float(x)or a decimal library — before doing arithmetic.price_usdand all candle prices and volumes are JSON numbers. - Prices and volumes are in US dollars unless the field name says otherwise (
price_btc,volume_from). Percentages are percent values:"1.64"means +1.64 %, not 164 %. - Timestamps.
/postsuses ISO 8601 strings in UTC. Candletimeand newspublishedAtare unix seconds (multiply by 1000 for a JavaScriptDate).created_at/updated_atandlast_updatedare ISO 8601 strings in UTC — see Time conventions.
Convert the string fields once, right after fetching, and keep missing or malformed values as null — never as 0, which would read as “no change” or “no supply”. Use a decimal type (Decimal in Python, a library such as big.js in JavaScript) when you add up market caps or need exact figures; Number is fine for display and sorting.
| JSON type | Fields | Description |
|---|---|---|
number | price_usd, rank, id; every candle price and volume; candle time; news publishedAt | Use as is. |
string | price_btc, volume_usd_day, market_cap_usd, available_supply, total_supply, max_supply, percent_change_hour, percent_change_day, percent_change_week | Parse before arithmetic or sorting. As strings, "9" sorts after "10". |
string (flag) | defi | "1" or "0"; compare with === '1'. |
null | total_supply, max_supply, created_at (currency), urlToImage | Can be null; max_supply: null means uncapped. |
# Requires jq. "tonumber" converts the string fields; "? // null" keeps
# missing or malformed values as null instead of failing the whole pipeline.
curl -s "https://api.newportexchange.org/v2/crypto/currencies?rankFrom=1&rankTo=100" | jq '
[ .[] | {
symbol,
price_usd,
market_cap_usd: (.market_cap_usd | tonumber? // null),
change_24h_pct: (.percent_change_day | tonumber? // null)
} ]
| map(select(.change_24h_pct != null))
| sort_by(-.change_24h_pct) | .[:5]'// Node 18+. Parse the string fields once, right after fetching.
const NUMERIC_STRING_FIELDS = [
'price_btc', 'volume_usd_day', 'market_cap_usd',
'available_supply', 'total_supply', 'max_supply',
'percent_change_hour', 'percent_change_day', 'percent_change_week',
];
/** Finite number from a JSON number or numeric string, else null (never 0). */
const toNumber = (value) => {
if (value === null || value === undefined || value === '') return null;
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : null;
};
const res = await fetch('https://api.newportexchange.org/v2/crypto/currencies?rankFrom=1&rankTo=100', {
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const coins = (await res.json()).map((row) => {
const coin = { ...row }; // fields you don't know pass through untouched
for (const field of NUMERIC_STRING_FIELDS) coin[field] = toNumber(row[field]);
return coin;
});
// Numeric sorting now works (as strings, "9" sorts after "10"):
const topMovers = coins
.filter((c) => c.percent_change_day !== null)
.sort((a, b) => b.percent_change_day - a.percent_change_day)
.slice(0, 5);
console.log(topMovers.map((c) => `${c.symbol} ${c.percent_change_day.toFixed(2)}%`));# Python 3.8+, pip install requests
from decimal import Decimal, InvalidOperation
import requests
NUMERIC_STRING_FIELDS = (
"price_btc", "volume_usd_day", "market_cap_usd",
"available_supply", "total_supply", "max_supply",
"percent_change_hour", "percent_change_day", "percent_change_week",
)
def to_decimal(value):
"""Decimal from a JSON number or numeric string, else None (never 0)."""
if value is None or value == "":
return None
try:
return Decimal(str(value))
except InvalidOperation:
return None
resp = requests.get(
"https://api.newportexchange.org/v2/crypto/currencies",
params={"rankFrom": 1, "rankTo": 100},
timeout=10,
)
resp.raise_for_status()
coins = []
for row in resp.json():
coin = dict(row) # fields you don't know pass through untouched
for field in NUMERIC_STRING_FIELDS:
coin[field] = to_decimal(row.get(field))
coins.append(coin)
movers = sorted(
(c for c in coins if c["percent_change_day"] is not None),
key=lambda c: c["percent_change_day"],
reverse=True,
)[:5]
print([(c["symbol"], f'{c["percent_change_day"]:.2f}%') for c in movers])Every time in the API is UTC. There are two formats, and mixing up seconds and milliseconds is the most common bug: a unix-seconds value passed straight to JavaScript’s new Date() lands in January 1970.
| Field or parameter | Format | Description |
|---|---|---|
time (candle) | unix seconds | 00:00 UTC of the day the candle covers, e.g. 1790899200 = 2026-10-02. Format it in UTC, or the day label shifts by one west of Greenwich. |
publishedAt (news) | unix seconds | Publication time of a public news article. |
published_at (posts) | ISO 8601, UTC | null | e.g. 2026-09-25T14:03:00.000Z. |
created_at, updated_at, last_updated | ISO 8601, UTC | Record bookkeeping. last_updated is when CoinMarketCap last changed a quote; updated_at is when Newport Exchange last refreshed the row. |
from, to (posts) | unix seconds | ISO 8601 | Inclusive. A date without a time means 00:00 UTC for from and the end of that UTC day for to. |
from, to (crypto-history) | unix seconds | date | Inclusive. A date means 00:00 UTC for both ends. |
days (crypto-history) | integer | A rolling window of N × 24 hours back from the moment of the request — not from your local midnight. |
RateLimit-Reset, Retry-After | seconds (relative) | How long to wait, not a timestamp. |
# Candle "time" is unix seconds at 00:00 UTC; jq's todate renders it in UTC.
curl -s "https://api.newportexchange.org/v2/crypto-history/BTC?days=3" \
| jq -r '.[] | "\(.time | todate) close=\(.close)"'
# Convert one timestamp by hand (GNU date; on macOS use: date -u -r 1790899200)
date -u -d @1790899200 +%F// Node 18+. Candle `time` and news `publishedAt` are unix SECONDS;
// posts, created_at/updated_at and last_updated are ISO 8601 strings in UTC.
const res = await fetch('https://api.newportexchange.org/v2/crypto-history/BTC?days=3', {
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const candles = await res.json(); // oldest first
const utcDay = new Intl.DateTimeFormat('en-CA', { timeZone: 'UTC' }); // YYYY-MM-DD
const todayStart = Math.floor(Date.now() / 86_400_000) * 86_400; // 00:00 UTC, in seconds
for (const c of candles) {
const day = utcDay.format(new Date(c.time * 1000)); // * 1000: seconds → milliseconds
console.log(day, c.close, c.time >= todayStart ? '(today, still in progress)' : '');
}
// ISO 8601 strings parse directly; never re-interpret them in local time.
const publishedMs = Date.parse('2026-09-25T14:03:00.000Z');# Python 3.8+, pip install requests
from datetime import datetime, timezone
import requests
resp = requests.get("https://api.newportexchange.org/v2/crypto-history/BTC", params={"days": 3}, timeout=10)
resp.raise_for_status()
today = datetime.now(timezone.utc).date()
for c in resp.json(): # oldest first
day = datetime.fromtimestamp(c["time"], tz=timezone.utc).date() # seconds, UTC
print(day, c["close"], "(today, still in progress)" if day == today else "")
# ISO 8601 strings; before Python 3.11, fromisoformat() needs "Z" spelled out.
published = datetime.fromisoformat("2026-09-25T14:03:00.000Z".replace("Z", "+00:00"))- Symbols are case-insensitive:
/crypto/btcand/crypto/BTCreturn the same coin. Thesymbolfield holds the ticker as listed, e.g.BTC. /crypto/:symbolaccepts 1–50 characters of ASCII letters, digits,-and_. The historical endpoints accept 1–15 letters or digits. Anything else returns 400.- A few listed coins have tickers outside those rules (for example
币安人生). They appear in ranked lists but cannot be requested by symbol — read them from the list instead. - Tickers are not globally unique across the crypto market, and a symbol lookup returns a single coin. Use
unique_name(CoinMarketCap’s coin id) when you need a stable identity over time.
Limits are counted in fixed one-minute windows:
- Per API key on authenticated endpoints (
/posts): 60 requests per minute by default, configurable per key — ask when you request one. The budget is shared by the list and single-post endpoints and is counted per key, not per IP, so several servers using one key share it. Requests with a valid key are not subject to the per-IP limit. - Per IP address on the public endpoints: 300 requests per minute by default. When exceeded, the message is
Too many requests — please slow down.
Responses carry the IETF draft (draft-6) RateLimit header fields so you can pace yourself before you hit the limit. Header names are case-insensitive; the live API sends them in lower case over HTTP/2.
| Header | Type | Description |
|---|---|---|
RateLimit-Policy | string | The policy in force, e.g. 300;w=60 = 300 requests per 60-second window. |
RateLimit-Limit | integer | Requests allowed in the current window. |
RateLimit-Remaining | integer | Requests left in the current window. |
RateLimit-Reset | integer | Seconds until the window resets. |
Retry-After | integer | On 429 responses only: seconds to wait before retrying. |
HTTP/2 200
ratelimit-policy: 300;w=60
ratelimit-limit: 300
ratelimit-remaining: 297
ratelimit-reset: 19Over the limit, the API returns 429. Stop sending requests until Retry-After (or RateLimit-Reset) seconds have passed, then resume gradually; if you are throttled again, back off exponentially with jitter — see Retries and backoff. Every request from the same key (or IP) counts against one window, so retrying in a tight loop only produces more 429s.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
RateLimit-Policy: 60;w=60
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 18
Retry-After: 18
{"success":false,"message":"Rate limit exceeded"}Errors use standard HTTP status codes and a consistent JSON body: success is always false, message is a human-readable summary, and validation errors add a details array naming each offending field. Match on the status code, not on message text.
{
"success": false,
"message": "Invalid query parameters",
"details": [
{ "field": "limit", "issue": "must be an integer between 1 and 100" }
]
}| Status | Type | Description |
|---|---|---|
200 | OK | Success. |
400 | Bad Request | A parameter is missing, malformed or out of range. Fix the request; do not retry. |
401 | Unauthorized | Missing, unknown or revoked API key. |
403 | Forbidden | The API key is valid but lacks the scope for this endpoint. |
404 | Not Found | The resource (post, currency, article) or route does not exist. |
429 | Too Many Requests | Rate limit exceeded. Back off until the window resets. |
500 | Internal Server Error | Unexpected server error. Safe to retry with backoff. |
503 | Service Unavailable | A dependency such as the database is temporarily unavailable. Retry with backoff. |
The messages you can expect, by status. They are meant for logs and humans and may be reworded — branch on the status code.
| Status | Message | Description |
|---|---|---|
400 | Invalid query parameters | /posts filters; details lists each field and the rule it broke. |
400 | Invalid slug | /posts/:slug is not 1–190 characters of A–Z a–z 0–9 . _ -. |
400 | Invalid item format | /crypto/:symbol breaks the symbol rules. |
400 | `rankFrom` and `rankTo` must be numbers | /crypto/currencies with a non-numeric rank. |
400 | Invalid crypto symbol · Invalid coin symbol: … | A historical endpoint received a symbol that is not 1–15 letters or digits. |
400 | A start date (startTime, startDate, from, or days) is required | /crypto-history/:symbol without days or from. Related: Start date is not valid, End date is not valid, Start date must be before end date, `days` must be a positive number. |
400 | Invalid news item | /news/:alias is not 1–100 characters of A–Z a–z 0–9 . _ -. |
401 / 403 | API key required · Invalid API key · API key lacks the required scope | See Authentication. |
404 | Post not found · Currency not found · News item not found | The path is valid but the resource does not exist (or is unpublished). |
404 | No route matches GET … | The path itself is not part of the API — check for typos and the /v2 prefix. |
429 | Rate limit exceeded · Too many requests — please slow down | Per-key and per-IP limits respectively — see Rate limits. |
500 / 503 | Internal Server Error · Database is temporarily unavailable | Server-side; no internal detail is ever included. Retry with backoff. |
{"success":false,"message":"No route matches GET /v2/coins"}Route every call through one small wrapper that checks the status, reads message (and details on validation errors) for your logs, and tells the caller whether a retry can help:
- 400, 401, 403, 404 — permanent for this request. Fix the request, the key or the identifier; do not retry unchanged.
- 429, 500, 503, network errors and timeouts — transient. Retry with backoff (see Retries and backoff) and keep serving the last good response meanwhile.
- Show your own wording to end users.
messageis for developers and may be reworded at any time. - A non-JSON body (an HTML error page from a proxy in front of the API) is possible on network-level failures — parse defensively.
# -w appends the status code; branch on it, not on the message text.
curl -s -w '\nHTTP %{http_code}\n' "https://api.newportexchange.org/v2/crypto/NOT-A-COIN"
# {"success":false,"message":"Currency not found"}
# HTTP 404
# -f makes curl exit non-zero (22) on 4xx/5xx, for use in shell scripts.
curl -sf "https://api.newportexchange.org/v2/crypto/BTC" > btc.json || echo "request failed" >&2// Node 18+. One wrapper for every call: returns JSON or throws an ApiError.
const API_BASE = 'https://api.newportexchange.org/v2';
class ApiError extends Error {
constructor(status, message, details) {
super(message);
this.name = 'ApiError';
this.status = status;
this.details = details; // validation errors only: [{ field, issue }]
this.retryable = status === 429 || status >= 500;
}
}
export async function apiGet(path, { apiKey } = {}) {
const res = await fetch(`${API_BASE}${path}`, {
headers: apiKey ? { 'X-API-Key': apiKey } : {},
signal: AbortSignal.timeout(10_000),
});
const body = await res.json().catch(() => null);
if (res.ok) return body;
// Error bodies are { success: false, message, details? }.
throw new ApiError(res.status, body?.message ?? res.statusText, body?.details);
}
try {
await apiGet('/crypto/NOT-A-COIN');
} catch (error) {
if (!(error instanceof ApiError)) throw error; // network error or timeout: retry later
if (error.status === 400) console.error('Fix the request:', error.details ?? error.message);
else if (error.status === 401 || error.status === 403) console.error('Check the key and its scope');
else if (error.status === 404) console.error('No such resource');
else if (error.retryable) console.error('Retry with backoff (see Retries and backoff)');
}# Python 3.8+, pip install requests
import requests
API_BASE = "https://api.newportexchange.org/v2"
class ApiError(Exception):
def __init__(self, status, message, details=None):
super().__init__(f"HTTP {status}: {message}")
self.status = status
self.details = details # validation errors only: [{"field": ..., "issue": ...}]
self.retryable = status == 429 or status >= 500
def api_get(path, api_key=None, **params):
headers = {"X-API-Key": api_key} if api_key else {}
resp = requests.get(f"{API_BASE}{path}", headers=headers, params=params, timeout=10)
try:
body = resp.json()
except ValueError:
body = None
if resp.ok:
return body
body = body if isinstance(body, dict) else {}
# Error bodies are {"success": false, "message": ..., "details"?: [...]}.
raise ApiError(resp.status_code, body.get("message", resp.reason), body.get("details"))
try:
api_get("/crypto/NOT-A-COIN")
except ApiError as error:
if error.status == 400:
print("Fix the request:", error.details or error)
elif error.status in (401, 403):
print("Check the key and its scope")
elif error.status == 404:
print("No such resource")
elif error.retryable:
print("Retry with backoff (see Retries and backoff)")List endpoints are paginated with page (1-based) and limit. There is deliberately no total count — counting a filtered result set is expensive and goes stale while you page.
/postsreturnsmeta.has_more. Request the nextpagewhile it istrue; stop when it isfalse./newsreturns a bare array and echoes the page in theX-PageandX-Per-Pageresponse headers. Stop when a page comes back with fewer items thanlimit.
When polling for new content, prefer a time filter over deep page numbers — see Polling for new posts.
Posts are crypto news articles collected from established publishers, normalised into a clean shape with a plain-text body. Both endpoints require an API key and are sent with Cache-Control: private, max-age=60.
/v2/postsAPI key requiredReturns published posts, newest first by default (ties broken by id), as data plus a meta object with the page, limit, has_more and the normalised filters that were applied — from/to are echoed as ISO 8601, and sort and include_body are always present.
curl -s -G "https://api.newportexchange.org/v2/posts" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "q=etf" \
--data-urlencode "symbol=BTC" \
--data-urlencode "from=2026-09-01" \
--data-urlencode "sort=newest" \
--data-urlencode "limit=20" \
--data-urlencode "page=1"// Node 18+
const API_BASE = 'https://api.newportexchange.org/v2';
const API_KEY = process.env.NEX_API_KEY ?? 'YOUR_API_KEY';
const params = new URLSearchParams({
q: 'etf',
symbol: 'BTC',
from: '2026-09-01',
sort: 'newest',
limit: '20',
page: '1',
});
const res = await fetch(`${API_BASE}/posts?${params}`, {
headers: { 'X-API-Key': API_KEY },
});
const body = await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${body.message}`);
console.log(body.meta); // { page: 1, limit: 20, has_more: …, filters: { … } }
console.log(body.data.map((post) => post.title));import os
import requests
API_BASE = "https://api.newportexchange.org/v2"
API_KEY = os.environ.get("NEX_API_KEY", "YOUR_API_KEY")
resp = requests.get(
f"{API_BASE}/posts",
headers={"X-API-Key": API_KEY},
params={
"q": "etf",
"symbol": "BTC",
"from": "2026-09-01",
"sort": "newest",
"limit": 20,
"page": 1,
},
timeout=10,
)
body = resp.json()
if not resp.ok:
raise RuntimeError(f"HTTP {resp.status_code}: {body.get('message')}")
print(body["meta"])
print([post["title"] for post in body["data"]]){
"data": [
{
"id": 123,
"slug": "bitcoin-rallies-as-etf-inflows-accelerate",
"title": "Bitcoin rallies as ETF inflows accelerate",
"excerpt": "Spot bitcoin ETFs recorded their strongest week of inflows since...",
"source": "CoinDesk",
"image": "https://images.example.com/btc-rally.jpg",
"source_url": "https://www.coindesk.com/markets/2026/09/25/…",
"published_at": "2026-09-25T14:03:00.000Z"
}
],
"meta": {
"page": 1,
"limit": 20,
"has_more": true,
"filters": {
"q": "etf",
"symbol": "BTC",
"from": "2026-09-01T00:00:00.000Z",
"sort": "newest",
"include_body": false
}
}
}All query parameters are optional and can be combined. Unknown parameters are ignored; each parameter may appear only once.
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer 1–10000 | 1 | Page number. |
limit | integer 1–100 | 20 | Posts per page. |
q | string ≤ 100 chars | — | Case-insensitive substring search in the title and body. Matched literally (no wildcards). |
symbol | string | — | Coin ticker, e.g. BTC (1–15 letters or digits, case-insensitive). Returns posts whose title or body contains the ticker as a whole word. It does not match the coin’s name (ETH will not find a story that only says “Ethereum” — combine with q if you need both), and tickers that are also English words (ONE, GAS) match those words too. |
source | string ≤ 100 chars | — | Publisher or author name, exact match, case-insensitive, e.g. CoinDesk. |
from | unix seconds | ISO 8601 | — | Only posts with published_at at or after this time (inclusive), e.g. 1758808980, 2026-09-01 (midnight UTC) or 2026-09-01T12:00:00Z. |
to | unix seconds | ISO 8601 | — | Only posts with published_at at or before this time (inclusive). A date without a time means the end of that UTC day, so from=2026-09-01&to=2026-09-01 covers the whole day. Must not be earlier than from. |
sort | newest | oldest | newest | Order by published_at. |
include_body | true | false | false | Include the full body of each post. Leave it off for list views to keep responses small. |
Invalid values return 400 with a details entry per offending field (see Errors).
/v2/posts/:slugAPI key requiredReturns one post by its slug, wrapped in data. The body is always included. Unknown or unpublished slugs return 404; a slug that is not 1–190 characters of A–Z a–z 0–9 . _ - returns 400 (Invalid slug).
curl -s "https://api.newportexchange.org/v2/posts/bitcoin-rallies-as-etf-inflows-accelerate" \
-H "X-API-Key: YOUR_API_KEY"// Node 18+
const API_BASE = 'https://api.newportexchange.org/v2';
const API_KEY = process.env.NEX_API_KEY ?? 'YOUR_API_KEY';
const slug = 'bitcoin-rallies-as-etf-inflows-accelerate';
const res = await fetch(`${API_BASE}/posts/${encodeURIComponent(slug)}`, {
headers: { 'X-API-Key': API_KEY },
});
if (res.status === 404) {
console.log('Post not found');
} else if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
} else {
const { data: post } = await res.json();
const paragraphs = post.body.split('\n\n');
console.log(post.title, `(${paragraphs.length} paragraphs)`);
}import os
from urllib.parse import quote
import requests
API_BASE = "https://api.newportexchange.org/v2"
API_KEY = os.environ.get("NEX_API_KEY", "YOUR_API_KEY")
slug = "bitcoin-rallies-as-etf-inflows-accelerate"
resp = requests.get(
f"{API_BASE}/posts/{quote(slug)}",
headers={"X-API-Key": API_KEY},
timeout=10,
)
if resp.status_code == 404:
print("Post not found")
else:
resp.raise_for_status()
post = resp.json()["data"]
paragraphs = post["body"].split("\n\n")
print(post["title"], f"({len(paragraphs)} paragraphs)"){
"data": {
"id": 123,
"slug": "bitcoin-rallies-as-etf-inflows-accelerate",
"title": "Bitcoin rallies as ETF inflows accelerate",
"excerpt": "Spot bitcoin ETFs recorded their strongest week of inflows since...",
"body": "Spot bitcoin ETFs recorded their strongest week of inflows since…\n\nAnalysts attributed the move to…",
"source": "CoinDesk",
"image": "https://images.example.com/btc-rally.jpg",
"source_url": "https://www.coindesk.com/markets/2026/09/25/…",
"published_at": "2026-09-25T14:03:00.000Z"
}
}{"success":false,"message":"Post not found"}| Field | Type | Description |
|---|---|---|
id | integer | Stable numeric identifier. |
slug | string | URL-safe identifier; use it with /posts/:slug. |
title | string | Headline. |
excerpt | string | Plain-text summary: the start of the body, cut at a word boundary to about 280 characters and ending in "..." when shortened. |
body | string | Full article as plain text, paragraphs separated by \n\n, no links or markup. Only with include_body=true on the list endpoint; always on the single-post endpoint. |
source | string | null | Publisher or author name — the value the source filter matches. |
image | string (URL) | null | Lead image URL. |
source_url | string (URL) | null | Link to the original article. Attribute the publisher when you display a post. |
published_at | string (ISO 8601, UTC) | null | Publication time, e.g. 2026-09-25T14:03:00.000Z. |
Current listings for the top 100 coins by market capitalisation, sourced from CoinMarketCap. These endpoints are public, rate limited per IP, and need no key. Responses are unwrapped (a bare array or object, no data envelope) and sent with Cache-Control: public, max-age=30, stale-while-revalidate=60.
/v2/crypto/currencies?rankFrom=1&rankTo=10Public · no key · rate limited per IPReturns the coins whose current rank is between rankFrom and rankTo inclusive, ordered by rank. Both parameters are required — without them (or with only one) the path is treated as a symbol lookup for currencies and returns 404. Coins that dropped out of the listing are excluded and each rank is returned once, keeping the most recently refreshed coin. (Until that de-duplication, deployed from 2026-10-02, is live, a coin that has just left the top 100 can briefly share its old rank with its replacement — key your own lists by unique_name or symbol, not by rank.)
This rank window is the only pagination: the listing ends at rank 100, so rankFrom=1&rankTo=100 returns everything and a window that reaches past 100 simply returns fewer items. To page, request consecutive windows (1–25, 26–50, …). Rank order can change between refreshes, so a coin can move across windows while you page — fetch the whole top 100 in one request if you need a consistent snapshot.
| Parameter | Type | Default | Description |
|---|---|---|---|
rankFromrequired | integer ≥ 1 | — | Lowest rank to include (inclusive), e.g. 1. |
rankTorequired | integer ≥ 1 | — | Highest rank to include (inclusive), e.g. 100. If rankFrom is larger, the two are swapped. Non-numeric values return 400. |
curl -s "https://api.newportexchange.org/v2/crypto/currencies?rankFrom=1&rankTo=10"// Node 18+ — public endpoint, no API key
const res = await fetch('https://api.newportexchange.org/v2/crypto/currencies?rankFrom=1&rankTo=10');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const coins = await res.json(); // bare array, ordered by rank
for (const coin of coins) {
console.log(coin.rank, coin.symbol, Number(coin.price_usd).toFixed(2));
}import requests
resp = requests.get(
"https://api.newportexchange.org/v2/crypto/currencies",
params={"rankFrom": 1, "rankTo": 10},
timeout=10,
)
resp.raise_for_status()
for coin in resp.json(): # bare list, ordered by rank
print(coin["rank"], coin["symbol"], round(coin["price_usd"], 2))[
{
"rank": 99,
"symbol": "SPX",
"name": "SPX6900",
"price_usd": 0.44146466228232567,
"market_cap_usd": "411000550"
},
{
"rank": 100,
"symbol": "FF",
"name": "Falcon Finance",
"price_usd": 0.12276868276089015,
"market_cap_usd": "410422526"
}
]/v2/crypto/:symbolPublic · no key · rate limited per IPReturns one currency by ticker symbol (case-insensitive; see Coin symbols). /v2/crypto/currencies/:symbol is an equivalent alias. A malformed symbol returns 400 and an unknown one 404. Only active coins are returned: a coin that has just left the top-100 listing is still served for a while with prices from its last refresh, and returns 404 once it is deactivated. Check updated_at — listed coins are refreshed every 30 minutes, so a row last refreshed more than about an hour ago is stale.
curl -s "https://api.newportexchange.org/v2/crypto/BTC"// Node 18+ — public endpoint, no API key
const res = await fetch('https://api.newportexchange.org/v2/crypto/BTC');
if (res.status === 404) throw new Error('Unknown symbol');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const btc = await res.json(); // a single object, not wrapped in "data"
console.log(btc.name, btc.price_usd, btc.percent_change_day);import requests
resp = requests.get("https://api.newportexchange.org/v2/crypto/BTC", timeout=10)
if resp.status_code == 404:
raise SystemExit("Unknown symbol")
resp.raise_for_status()
btc = resp.json() # a single object, not wrapped in "data"
print(btc["name"], btc["price_usd"], btc["percent_change_day"]){"success":false,"message":"Currency not found"}Large integers (volume_usd_day, market_cap_usd) are returned as strings because they can exceed JavaScript’s safe integer range. price_btc, supply and percentage fields are also strings; parse them before doing arithmetic. price_usd is a JSON number (a double — round it for display).
{
"id": 12238891,
"unique_name": "1",
"name": "Bitcoin",
"alias": "bitcoin",
"symbol": "BTC",
"image": "btc.png",
"rank": 1,
"price_usd": 84790.92449061108,
"price_btc": "1",
"volume_usd_day": "32833582384",
"market_cap_usd": "1703635110768",
"available_supply": "20092187",
"total_supply": "20092187",
"max_supply": "21000000",
"percent_change_hour": "0.0643848",
"percent_change_day": "1.64656281",
"percent_change_week": "0.18529061",
"last_updated": "2026-10-02T00:29:00.000Z",
"created_at": "2026-03-27T00:30:00.000Z",
"updated_at": "2026-10-02T00:30:00.000Z",
"defi": "0"
}| Field | Type | Description |
|---|---|---|
id | integer | Internal identifier. |
unique_name | string | CoinMarketCap’s numeric coin id, as a string. |
name | string | Display name, e.g. Bitcoin. |
alias | string | URL slug, e.g. bitcoin. |
symbol | string | Ticker, e.g. BTC. |
image | string | Icon file name, e.g. btc.png. A few older rows hold an absolute URL instead. |
rank | integer | Rank by market capitalisation. |
price_usd | number | Price in US dollars. |
price_btc | string (decimal) | Price in bitcoin. |
volume_usd_day | string (integer) | 24-hour trading volume in US dollars. |
market_cap_usd | string (integer) | Market capitalisation in US dollars. |
available_supply | string (decimal) | Circulating supply. |
total_supply | string (decimal) | null | Total supply. |
max_supply | string (decimal) | null | Maximum supply; null when uncapped. |
percent_change_hour | string (decimal) | Price change over 1 hour, in percent. |
percent_change_day | string (decimal) | Price change over 24 hours, in percent. |
percent_change_week | string (decimal) | Price change over 7 days, in percent. |
last_updated | string (ISO 8601) | When CoinMarketCap last updated this quote. |
created_at | string (ISO 8601) | null | When the coin was first recorded; null for some older rows. |
updated_at | string (ISO 8601) | When Newport Exchange last refreshed the row. |
defi | string | "1" if classified as a DeFi token, otherwise "0". |
The internal status and sponsored columns are no longer part of the response (removed 2026-10-02; you may see them until the change reaches production). Do not depend on them.
Daily OHLCV (open, high, low, close, volume) candles in US dollars for coins in the top-100 listing. Depth varies by coin: history is being backfilled towards one year per coin and is retained for up to two years, so a recently listed coin may have only a few weeks. The current day’s candle is provisional (see Data freshness). A symbol without candles returns an empty array rather than 404. These endpoints are public, rate limited per IP, and need no key.
- One candle per coin per UTC day.
timeis the unix timestamp (seconds) of 00:00 UTC on the day the candle covers. - The newest candle is today’s, still in progress. It is rebuilt every 30 minutes from listing snapshots until the day ends, so its
high,low,closeand volumes change during the day. Exclude it (or label it) if you need only completed days. - Gaps are possible. Days with no stored data are simply absent — there is no placeholder candle. Do not assume consecutive items are consecutive days; key by
time.
/v2/crypto/historical/:symbol?days=30Public · no key · rate limited per IPReturns the most recent candles for one coin, newest first. The symbol must be 1–15 letters or digits (400 otherwise).
| Parameter | Type | Default | Description |
|---|---|---|---|
days | integer 1–500 | 365 | Maximum number of candles to return — the most recent N stored days. Values above 500 are capped at 500; a missing, zero or non-numeric value falls back to 365 rather than returning 400. |
curl -s "https://api.newportexchange.org/v2/crypto/historical/BTC?days=30"// Node 18+ — public endpoint, no API key
const res = await fetch('https://api.newportexchange.org/v2/crypto/historical/BTC?days=30');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const candles = await res.json(); // newest first
for (const c of candles) {
console.log(new Date(c.time * 1000).toISOString().slice(0, 10), c.close);
}from datetime import datetime, timezone
import requests
resp = requests.get("https://api.newportexchange.org/v2/crypto/historical/BTC", params={"days": 30}, timeout=10)
resp.raise_for_status()
for c in resp.json(): # newest first
day = datetime.fromtimestamp(c["time"], tz=timezone.utc).date()
print(day, c["close"])/v2/crypto-history/:symbol?from=…&to=…Public · no key · rate limited per IPReturns candles for one coin whose time falls inside a range (both ends inclusive), oldest first. Either days or from is required; a request with neither returns 400.
days=N is a rolling window: from exactly N × 24 hours before the request up to now. Because candles are stamped at 00:00 UTC, that is usually N candles, the last one being today’s in-progress candle. startTime/startDate and endTime/endDate are accepted as aliases of from and to.
Results are capped by limit and, because they are oldest first, a range longer than the cap returns its oldest candles. To read a long range, repeat the request with from set to the last time you received plus 86400.
| Parameter | Type | Default | Description |
|---|---|---|---|
days | integer ≥ 1 | — | Rolling window of the last N × 24 hours up to now. Takes precedence over from/to. Zero, negative or non-numeric values return 400. |
from | unix seconds | date | — | Range start, e.g. 1756684800 or 2026-09-01 (a date means 00:00 UTC). Required unless days is given. |
to | unix seconds | date | now | Range end. A date means 00:00 UTC, which still includes that day’s candle. Must not be earlier than from. |
limit | integer ≥ 1 | 500 | Maximum candles returned (capped at 500). |
curl -s "https://api.newportexchange.org/v2/crypto-history/ETH?from=2026-09-01&to=2026-09-26"// Node 18+ — public endpoint, no API key
const params = new URLSearchParams({ from: '2026-09-01', to: '2026-09-26' });
const res = await fetch(`https://api.newportexchange.org/v2/crypto-history/ETH?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const candles = await res.json(); // oldest first
console.log(candles.length, 'daily candles');import requests
resp = requests.get(
"https://api.newportexchange.org/v2/crypto-history/ETH",
params={"from": "2026-09-01", "to": "2026-09-26"},
timeout=10,
)
resp.raise_for_status()
candles = resp.json() # oldest first
print(len(candles), "daily candles")[
{
"id": 36705017,
"coin": "BTC",
"time": 1790812800,
"open": 83554.24882704367,
"close": 84736.36714607803,
"high": 84874.75752388303,
"low": 83317.4014195001,
"volume_from": 391680.8812353876,
"volume_to": 33189614956.461185,
"created_at": "2026-10-01T00:00:00.000Z",
"updated_at": "2026-10-01T23:30:00.000Z"
},
{
"id": 36710899,
"coin": "BTC",
"time": 1790899200,
"open": 84855.65159913615,
"close": 84790.92449061108,
"high": 84855.65159913615,
"low": 84790.92449061108,
"volume_from": 387229.9138396812,
"volume_to": 32833582384.886242,
"created_at": "2026-10-02T00:00:00.000Z",
"updated_at": "2026-10-02T00:30:00.000Z"
}
]The second candle above is the current UTC day captured at 00:30 UTC: one snapshot so far, so open equals high and close equals low. It fills in as the day progresses.
{
"id": 36705017,
"coin": "BTC",
"time": 1790812800,
"open": 83554.24882704367,
"close": 84736.36714607803,
"high": 84874.75752388303,
"low": 83317.4014195001,
"volume_from": 391680.8812353876,
"volume_to": 33189614956.461185,
"created_at": "2026-10-01T00:00:00.000Z",
"updated_at": "2026-10-01T23:30:00.000Z"
}| Field | Type | Description |
|---|---|---|
id | integer | Internal identifier. |
coin | string | Ticker symbol, upper case. |
time | integer (unix seconds) | Start of the day (00:00 UTC) the candle covers. |
open | number | Opening price, USD. |
high | number | Highest price, USD. |
low | number | Lowest price, USD. |
close | number | Closing price, USD. |
volume_from | number | Volume traded, in units of the coin. |
volume_to | number | Volume traded, in US dollars. |
created_at | string (ISO 8601) | When the candle was first stored. |
updated_at | string (ISO 8601) | When the candle was last refreshed. |
The public feed behind this site’s News page. It is public, rate limited per IP, and needs no key, and returns the stored record shape (unix-second timestamps, camelCase fields). For filtering by coin, source or date and a stable schema, use Posts instead.
/v2/newsPublic · no key · rate limited per IPReturns a bare array of articles, newest first, with description shortened to an excerpt of about 280 characters. Sent with Cache-Control: public, max-age=120, stale-while-revalidate=240.
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer ≥ 1 | 1 | Page number. |
limit | integer ≥ 1 | 50 | Articles per page (capped at 500). |
q | string | — | Case-insensitive substring match on the title and description, matched literally. Only the first 100 characters are used. |
lang | string | — | Language code, e.g. en (first 8 characters used). |
curl -s -i "https://api.newportexchange.org/v2/news?q=ethereum&limit=10&page=1"// Node 18+ — public endpoint, no API key
const res = await fetch('https://api.newportexchange.org/v2/news?q=ethereum&limit=10&page=1');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const articles = await res.json(); // bare array, newest first
console.log('page', res.headers.get('X-Page'), 'of size', res.headers.get('X-Per-Page'));
for (const a of articles) {
console.log(new Date(a.publishedAt * 1000).toISOString(), a.title);
}from datetime import datetime, timezone
import requests
resp = requests.get(
"https://api.newportexchange.org/v2/news",
params={"q": "ethereum", "limit": 10, "page": 1},
timeout=10,
)
resp.raise_for_status()
for a in resp.json(): # bare list, newest first
published = datetime.fromtimestamp(a["publishedAt"], tz=timezone.utc)
print(published.isoformat(), a["title"]){
"id": 21712,
"author": "Crypto Potato",
"title": "Bitcoin Chart Echoes Cycle That Delivered 400% Gains: Analyst",
"alias": "bitcoin-chart-echoes-cycle-that-delivered-400-gains-analyst",
"description": "Bitcoin analyst Frank Cappelleri says the OG cryptocurrency’s chart resembles the setup that preceded a 400% rally last cycle...",
"url": "https://cryptopotato.com/bitcoin-chart-echoes-cycle-that-delivered-400-gains-analyst/",
"urlToImage": "https://resources.cryptocompare.com/news/27/73372796.jpeg",
"publishedAt": 1790857884,
"lang": "en",
"created_at": "2026-10-01T13:00:00.000Z",
"updated_at": "2026-10-01T13:00:00.000Z"
}| Field | Type | Description |
|---|---|---|
id | integer | Numeric identifier. |
author | string | Author or publisher; may be empty. |
title | string | Headline. |
alias | string | URL slug; use it with /news/:alias. |
description | string | Excerpt in list responses; full article text from /news/:alias. |
url | string (URL) | Original article. Link to it and credit the publisher — see Attributing news sources. |
urlToImage | string (URL) | null | Lead image. |
publishedAt | integer (unix seconds) | Publication time. |
lang | string | Language code. |
created_at | string (ISO 8601) | When the article was ingested. |
updated_at | string (ISO 8601) | When the record last changed. |
The internal status and twitter_post columns are no longer part of the response (removed 2026-10-02; you may see them until the change reaches production). Use publishedAt, not created_at, as the article’s date.
/v2/news/:aliasPublic · no key · rate limited per IPReturns one article by alias or numeric id, with the full description. Unknown articles return 404 with News item not found; an identifier that is not 1–100 characters of A–Z a–z 0–9 . _ - returns 400 (Invalid news item).
curl -s "https://api.newportexchange.org/v2/news/bitcoin-chart-echoes-cycle-that-delivered-400-gains-analyst"// Node 18+ — public endpoint, no API key
const alias = 'bitcoin-chart-echoes-cycle-that-delivered-400-gains-analyst';
const res = await fetch(`https://api.newportexchange.org/v2/news/${encodeURIComponent(alias)}`);
if (res.status === 404) throw new Error('News item not found');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const article = await res.json(); // full description, not an excerpt
console.log(article.title);import requests
alias = "bitcoin-chart-echoes-cycle-that-delivered-400-gains-analyst"
resp = requests.get(f"https://api.newportexchange.org/v2/news/{alias}", timeout=10)
if resp.status_code == 404:
raise SystemExit("News item not found")
resp.raise_for_status()
article = resp.json() # full description, not an excerpt
print(article["title"])Newport Exchange ingests data on a schedule and serves it from its own database. Nothing is streamed live, and there is no WebSocket API. Poll no faster than the data changes:
| Dataset | Type | Description |
|---|---|---|
Market listings | CoinMarketCap | Top 100 coins by market cap, refreshed every 30 minutes. Responses may also be cached for up to 30 seconds. |
Historical candles | CoinMarketCap (CryptoCompare fallback) | One candle per coin per UTC day. Today’s candle is built from the 30-minute listing snapshots and changes during the day; yesterday’s is replaced with the provider’s official OHLCV once a day. Older history is backfilled gradually, a few coins per day. |
News and posts | NewsAPI and CryptoCompare News | Ingested twice a day. Responses may be cached for 1–2 minutes. |
- Keep keys in environment variables or a secrets manager, never in source control.
- Call authenticated endpoints only from your server. If a browser or mobile app needs posts, proxy them through your backend and cache the result.
- Use one key per application or environment so each can be revoked alone.
Respect Cache-Control: posts can be reused for 60 seconds, market data for 30 seconds and public news for 2 minutes. Since listings refresh every 30 minutes and news twice a day, caching responses for several minutes in your own layer costs you nothing in freshness and keeps you well inside the rate limits. Treat post id and slug as stable keys for your own storage.
Do not poll any endpoint faster than its max-age: the server caches responses for that long, so a faster poll returns the same data and only spends your rate limit. Older historical candles rarely change — yesterday’s is replaced once with the provider’s official values, and backfill can add missing days — so cache them generously and re-fetch only the most recent few days.
- Retry only 429, 5xx and network errors. Never retry 400, 401 or 404 unchanged — the answer will not change.
- On 429, wait at least
Retry-Afterseconds (fall back toRateLimit-Reset, then to 60 seconds, when a header is missing) before the next request. - Otherwise back off exponentially (1 s, 2 s, 4 s, … capped around 60 s) and add random jitter so many clients do not retry in lockstep. Give up after a few attempts and alert.
- Watch
RateLimit-Remainingand slow down before it reaches zero.
The polling example below includes a complete retry helper.
- Always set a client timeout — 10 seconds is ample for every endpoint. Neither
fetchnor Pythonrequeststimes out by default. - Treat a timeout like a 5xx: retry with backoff, and keep serving the last good response from your cache meanwhile.
- A 503 means a dependency (usually the database) is briefly unavailable; retry with backoff rather than failing immediately.
- In interactive apps, also cancel requests whose result is no longer wanted (the user switched coin or left the page) so a slow, older response can never overwrite a newer one.
# --max-time caps the whole request; --retry re-tries timeouts, 408, 429 and
# 5xx with exponential backoff and honours Retry-After (curl 7.66+).
curl -s --connect-timeout 5 --max-time 10 --retry 3 "https://api.newportexchange.org/v2/crypto/BTC"// Node 18+ and browsers. A timeout per request, plus cancelling a request
// whose result is no longer wanted (the user picked another coin).
const API_BASE = 'https://api.newportexchange.org/v2';
let current = null;
export async function loadCoin(symbol) {
current?.abort(); // a newer request supersedes the previous one
const controller = new AbortController();
current = controller;
const timer = setTimeout(() => controller.abort(new Error('Request timed out')), 10_000);
try {
const res = await fetch(`${API_BASE}/crypto/${encodeURIComponent(symbol)}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (error) {
if (controller !== current) return null; // superseded: drop the late result silently
throw error; // timeout, network or HTTP error: show the last good data, retry later
} finally {
clearTimeout(timer);
}
}# Python 3.8+, pip install requests. requests has NO default timeout.
import requests
session = requests.Session() # reuses connections across calls
try:
# (connect timeout, read timeout) in seconds
resp = session.get("https://api.newportexchange.org/v2/crypto/BTC", timeout=(5, 10))
resp.raise_for_status()
print(resp.json()["price_usd"])
except requests.Timeout:
print("Timed out: treat like a 5xx, keep the cached value, retry with backoff")Cross-origin requests from browsers are restricted to an allow-list of Newport Exchange origins: a page on any other origin does not receive an Access-Control-Allow-Origin header, so the browser blocks the response. Call the API from your server (or a serverless function) and serve the data to your front end from there — which is also the only safe place for an API key.
Check identifiers locally before you put them in a URL. A failed check means “use what you already have” (for example the coin’s row from a ranked list), not “send it and handle the 400”. Prefer symbols, slugs and aliases that came from the API over free-text input, and URL-encode them anyway.
| Identifier | Rule | Description |
|---|---|---|
:symbol | 1–50 of A–Z a–z 0–9 - _ | /crypto/:symbol, /crypto/currencies/:symbol. Case-insensitive. |
:symbol (history) | 1–15 of A–Z a–z 0–9 | /crypto/historical/:symbol, /crypto-history/:symbol and the symbol filter on /posts. |
:slug | 1–190 of A–Z a–z 0–9 . _ - | /posts/:slug. |
:alias | 1–100 of A–Z a–z 0–9 . _ - | /news/:alias (a numeric id also works). |
# Validate before you build the URL; a failed check never costs a request.
SYMBOL="BTC"
if [[ "$SYMBOL" =~ ^[A-Za-z0-9]{1,15}$ ]]; then
curl -s "https://api.newportexchange.org/v2/crypto-history/$SYMBOL?days=30"
else
echo "Not requestable by symbol: $SYMBOL (use the ranked list instead)" >&2
fi// The rules the API enforces (anything else returns 400).
const API_BASE = 'https://api.newportexchange.org/v2';
const SYMBOL = /^[A-Za-z0-9_-]{1,50}$/; // /crypto/:symbol
const HISTORY_SYMBOL = /^[A-Za-z0-9]{1,15}$/; // historical endpoints, posts ?symbol=
const POST_SLUG = /^[A-Za-z0-9._-]{1,190}$/; // /posts/:slug
const NEWS_ALIAS = /^[A-Za-z0-9._-]{1,100}$/; // /news/:alias
export function historyUrl(symbol, days) {
if (!HISTORY_SYMBOL.test(symbol)) return null; // e.g. "币安人生": read it from the list
const n = Math.min(500, Math.max(1, Math.trunc(days)));
return `${API_BASE}/crypto/historical/${encodeURIComponent(symbol)}?days=${n}`;
}
export const currencyUrl = (symbol) =>
SYMBOL.test(symbol) ? `${API_BASE}/crypto/${encodeURIComponent(symbol)}` : null;# The rules the API enforces (anything else returns 400).
import re
from urllib.parse import quote
API_BASE = "https://api.newportexchange.org/v2"
SYMBOL = re.compile(r"[A-Za-z0-9_-]{1,50}") # /crypto/:symbol
HISTORY_SYMBOL = re.compile(r"[A-Za-z0-9]{1,15}") # historical endpoints, posts ?symbol=
POST_SLUG = re.compile(r"[A-Za-z0-9._-]{1,190}") # /posts/:slug
NEWS_ALIAS = re.compile(r"[A-Za-z0-9._-]{1,100}") # /news/:alias
def history_url(symbol, days):
if not HISTORY_SYMBOL.fullmatch(symbol):
return None # e.g. "币安人生": read it from the ranked list instead
n = min(500, max(1, int(days)))
return f"{API_BASE}/crypto/historical/{quote(symbol)}?days={n}"Within /v2, fields can be added to any response without notice, and internal columns are removed when found (see the 2026-10-02 changelog). Write clients that:
- read only the fields documented on this page;
- accept extra fields — no strict schema validation that rejects unknown keys (Zod’s default
strip, not.strict(); Pydantic’s defaultextra="ignore", not"forbid"); - do not depend on field order, and treat a documented field that is
nullas “unknown”.
Posts and news articles are third-party journalism. When you display one:
- show the publisher —
sourceon posts,authoron news — and link the headline or a “Read more” to the original article (source_url/url); - use the excerpt (
excerpt/ listdescription) for discovery, not as a substitute for the article; - load the lead image from its own host (
image/urlToImage), keep a placeholder for when it is missing or slow, and do not re-host it; - show the publication time (
published_at/publishedAt), not the ingestion time.
Bulk redistribution of API data is restricted by the Terms of Service.
To keep a local copy of posts up to date, store the published_at of the newest post you have processed and ask only for posts from that moment on, oldest first. This makes polling idempotent: running it twice fetches nothing new, and a crash between fetching and saving just replays a few posts.
- Use
fromwithsort=oldest, so posts that arrive while you page are appended at the end instead of shifting earlier pages. fromis inclusive, so de-duplicate byidthe posts published in the boundary second.- Save the cursor only after the posts have been handled. Poll every few minutes at most — news is ingested twice a day.
# One polling step: everything published at or after a stored cursor
# (unix seconds), oldest first, so pages stay stable while new posts arrive.
SINCE=1758808980 # published_at of the newest post you have already processed
curl -s -G "https://api.newportexchange.org/v2/posts" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "from=$SINCE" \
--data-urlencode "sort=oldest" \
--data-urlencode "limit=100" \
--data-urlencode "page=1"
# Repeat with page=2, 3, … while meta.has_more is true.
# Skip ids you have already processed: `from` is inclusive of the boundary.// poll-posts.mjs — Node 18+. Fetches posts published since the last run.
// Schedule it (cron, a worker loop) no more often than once a minute.
import { readFile, writeFile } from 'node:fs/promises';
const API_BASE = 'https://api.newportexchange.org/v2';
const API_KEY = process.env.NEX_API_KEY ?? 'YOUR_API_KEY';
const STATE_FILE = './posts-cursor.json';
const MAX_RETRIES = 5;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const toUnix = (iso) => Math.floor(Date.parse(iso) / 1000);
async function getJson(path, params) {
for (let attempt = 0; ; attempt += 1) {
const res = await fetch(`${API_BASE}${path}?${new URLSearchParams(params)}`, {
headers: { 'X-API-Key': API_KEY },
signal: AbortSignal.timeout(10_000),
}).catch((error) => {
// Network error or timeout: retried below like a 5xx.
if (attempt >= MAX_RETRIES) throw error;
return null;
});
const retryable = res === null || res.status === 429 || res.status >= 500;
if (retryable && attempt < MAX_RETRIES) {
// Honour Retry-After (sent with 429s), else RateLimit-Reset, but never wait
// less than the exponential backoff. Jitter stops clients retrying in sync.
const headerSeconds = Number(
res?.headers.get('Retry-After') ?? res?.headers.get('RateLimit-Reset') ?? 0
);
const backoffMs = Math.min(60_000, 1000 * 2 ** attempt);
const headerMs = Number.isFinite(headerSeconds) ? headerSeconds * 1000 : 0;
await sleep(Math.max(headerMs, backoffMs) + Math.random() * 1000);
continue;
}
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`HTTP ${res.status}: ${body.message ?? res.statusText}`);
return body;
}
}
async function loadCursor() {
try {
return JSON.parse(await readFile(STATE_FILE, 'utf8'));
} catch {
// First run: start from 24 hours ago rather than paging the whole archive.
return { since: Math.floor(Date.now() / 1000) - 86_400, seenIds: [] };
}
}
export async function pollNewPosts(handlePost) {
const cursor = await loadCursor();
const seen = new Set(cursor.seenIds);
const fresh = [];
for (let page = 1; ; page += 1) {
const { data, meta } = await getJson('/posts', {
from: String(cursor.since),
sort: 'oldest',
limit: '100',
page: String(page),
});
for (const post of data) if (!seen.has(post.id)) fresh.push(post);
if (!meta.has_more) break;
}
for (const post of fresh) await handlePost(post);
// Persist the cursor only after the posts were handled, so a crash replays
// them instead of skipping them. Remember the ids sitting exactly on the
// boundary second, because `from` includes that second on the next run.
if (fresh.length > 0) {
const newest = Math.max(...fresh.map((p) => toUnix(p.published_at)));
const boundaryIds = fresh.filter((p) => toUnix(p.published_at) === newest).map((p) => p.id);
const seenIds = newest === cursor.since ? [...cursor.seenIds, ...boundaryIds] : boundaryIds;
await writeFile(STATE_FILE, JSON.stringify({ since: newest, seenIds }));
}
return fresh.length;
}
const count = await pollNewPosts(async (post) => {
console.log(post.published_at, post.source, post.title);
});
console.log(`${count} new post(s)`);"""poll_posts.py — fetch posts published since the last run.
Python 3.8+, pip install requests. Schedule it no more often than once a minute.
"""
import json
import os
import random
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import requests
API_BASE = "https://api.newportexchange.org/v2"
API_KEY = os.environ.get("NEX_API_KEY", "YOUR_API_KEY")
STATE_FILE = Path("posts-cursor.json")
MAX_RETRIES = 5
session = requests.Session()
session.headers["X-API-Key"] = API_KEY
def get_json(path, params):
for attempt in range(MAX_RETRIES + 1):
try:
resp = session.get(f"{API_BASE}{path}", params=params, timeout=10)
except (requests.ConnectionError, requests.Timeout):
# Network error or timeout: retried like a 5xx.
if attempt >= MAX_RETRIES:
raise
resp = None
retryable = resp is None or resp.status_code == 429 or resp.status_code >= 500
if retryable and attempt < MAX_RETRIES:
# Honour Retry-After (sent with 429s), else RateLimit-Reset, but never
# wait less than the exponential backoff; jitter avoids retrying in sync.
headers = resp.headers if resp is not None else {}
wait = headers.get("Retry-After") or headers.get("RateLimit-Reset") or ""
wait_seconds = int(wait) if wait.isdigit() else 0
backoff = min(60, 2 ** attempt)
time.sleep(max(wait_seconds, backoff) + random.uniform(0, 1))
continue
resp.raise_for_status()
return resp.json()
def to_unix(iso):
return int(datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp())
def load_cursor():
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text())
# First run: start from 24 hours ago rather than paging the whole archive.
day_ago = datetime.now(timezone.utc) - timedelta(days=1)
return {"since": int(day_ago.timestamp()), "seen_ids": []}
def poll_new_posts(handle_post):
cursor = load_cursor()
seen = set(cursor["seen_ids"])
fresh = []
page = 1
while True:
body = get_json(
"/posts",
{"from": cursor["since"], "sort": "oldest", "limit": 100, "page": page},
)
fresh.extend(p for p in body["data"] if p["id"] not in seen)
if not body["meta"]["has_more"]:
break
page += 1
for post in fresh:
handle_post(post)
# Persist only after handling, so a crash replays rather than skips.
# Keep the ids on the boundary second: `from` includes it next time.
if fresh:
newest = max(to_unix(p["published_at"]) for p in fresh)
boundary = [p["id"] for p in fresh if to_unix(p["published_at"]) == newest]
seen_ids = cursor["seen_ids"] + boundary if newest == cursor["since"] else boundary
STATE_FILE.write_text(json.dumps({"since": newest, "seen_ids": seen_ids}))
return len(fresh)
if __name__ == "__main__":
count = poll_new_posts(lambda p: print(p["published_at"], p["source"], p["title"]))
print(f"{count} new post(s)")Loop while meta.has_more is true, incrementing page and keeping every other parameter identical. Use limit=100 for bulk syncs to make the fewest requests. Do not infer the end from a short page or compute page counts — there is no total. For very large backfills, split the work into from/to windows (for example one per day) rather than paging hundreds deep.
The API version is part of the path: everything here is under /v2, and GET /v2/ reports the gateway version (currently 2.0.0). Within /v2, new endpoints, parameters and response fields can be added, so write clients that ignore fields they do not know. Changes that affect callers are recorded in the changelog below, newest first.
- Field allow-lists (rolling out): currency objects no longer include the internal
statusandsponsoredcolumns, and news articles no longer includestatusandtwitter_post. New database columns stay private until they are documented here. - One row per rank in
/v2/crypto/currencies: a coin that has just left the top 100 no longer briefly shares its old rank with its replacement. /v2/crypto/:symbolreturns only active coins, the most recently refreshed row first; a deactivated coin returns 404.- Documentation: new Endpoints at a glance, Parsing numeric strings, Time conventions, Handling errors in code, Validating input, Ignoring unknown fields and Attributing news sources sections, plus a timeout and cancellation example.
Documentation only — no change to API behaviour:
- Confirmed the production base URL
https://api.newportexchange.org/v2. - Documented the
RateLimit-PolicyandRetry-Afterheaders, the error messages, the coin-symbol rules and rank-window pagination. - Documented historical-data semantics (UTC-day candles, the in-progress current day, gaps, the oldest-first cap) and that a currency’s
created_atcan be null. - Replaced illustrative market, candle and news examples with real responses.
- New key-authenticated
GET /v2/postsandGET /v2/posts/:slugwith search, coin, source and date filters. - API keys (
X-API-Key) with per-key rate limits and standardRateLimit-*headers. - First public reference for the /v2 market-data, historical and news endpoints.
For API keys, higher rate limits, bug reports or questions, email [email protected]. When reporting a problem, include the request URL (without your key), the time in UTC, the HTTP status and the response body. Never send us your API key — tell us the name or email it was issued under instead.
