On this page
Overview
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.
Quickstart
- Request an API key and store it in an environment variable on your server, e.g.
NEX_API_KEY. - Send it in the
X-API-Keyheader 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"])The market-data, historical and news endpoints need no key — try curl https://api.newportexchange.org/v2/crypto/BTC.
Authentication
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 | Type | 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 | Too many 401s from your IP address within a minute. 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"}Rate limits
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 RateLimit header fields so you can pace yourself before you hit the limit:
| Header | Type | Description |
|---|---|---|
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. |
HTTP/1.1 200 OK
RateLimit-Limit: 60
RateLimit-Remaining: 57
RateLimit-Reset: 42Over the limit, the API returns 429. Stop sending requests until RateLimit-Reset seconds have passed, then retry with exponential backoff and jitter — see Retries and backoff.
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 18
{"success":false,"message":"Rate limit exceeded"}Errors
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. |
Pagination
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
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.
List posts
/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
}
}
}Filters
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).
Get a single post
/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"}The post object
| 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. |
Market data
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.
List currencies by rank
/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 the path is treated as a symbol lookup and returns 404. Coins that dropped out of the latest listing are excluded, so every rank appears at most once.
| Parameter | Type | Default | Description |
|---|---|---|---|
rankFromrequired | integer | — | Lowest rank to include, e.g. 1. |
rankTorequired | integer | — | Highest rank to include, e.g. 100. Listings cover the top 100. |
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))Get a single currency
/v2/crypto/:symbolPublic · no key · rate limited per IPReturns one currency by ticker symbol (case-insensitive; letters, digits, - and _). /v2/crypto/currencies/:symbol is an equivalent alias. Unknown symbols return 404. Check status: a coin that has left the top-100 listing can still be returned for a while, with status: 0 and prices from its last refresh; coins that stay out of the listing are eventually removed and then return 404.
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"}The currency object
Large integers (volume_usd_day, market_cap_usd) are returned as strings because they can exceed JavaScript’s safe integer range. Supply and percentage fields are also strings; parse them before doing arithmetic.
{
"id": 1,
"unique_name": "1",
"name": "Bitcoin",
"alias": "bitcoin",
"symbol": "BTC",
"image": "btc.png",
"rank": 1,
"price_usd": 64250.12,
"price_btc": "1",
"volume_usd_day": "28456789234",
"market_cap_usd": "1268345678901",
"available_supply": "19741234",
"total_supply": "19741234",
"max_supply": "21000000",
"percent_change_hour": "0.12",
"percent_change_day": "1.85",
"percent_change_week": "-2.4",
"last_updated": "2026-09-26T13:29:00.000Z",
"created_at": "2021-03-04T10:00:00.000Z",
"updated_at": "2026-09-26T13:30:04.000Z",
"status": 1,
"sponsored": 0,
"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) | When the coin was first recorded. |
updated_at | string (ISO 8601) | When Newport Exchange last refreshed the row. |
status | integer | 1 = in the current listing, 0 = inactive. |
sponsored | integer | 1 if the listing is sponsored, otherwise 0. |
defi | string | "1" if classified as a DeFi token, otherwise "0". |
Historical data
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.
Most recent candles
/v2/crypto/historical/:symbol?days=30Public · no key · rate limited per IPReturns the most recent candles for one coin, newest first.
| Parameter | Type | Default | Description |
|---|---|---|---|
days | integer ≥ 1 | 365 | Maximum number of daily candles to return (capped at 500). Because there is one candle per day, this is the look-back in days. |
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"])Candles by date range
/v2/crypto-history/:symbol?from=…&to=…Public · no key · rate limited per IPReturns candles for one coin inside a time range, oldest first. Either days or from is required; a request with neither returns 400.
| Parameter | Type | Default | Description |
|---|---|---|---|
days | integer ≥ 1 | — | Range = the last N days up to now. Takes precedence over from/to. |
from | unix seconds | date | — | Range start, e.g. 1756684800 or 2026-09-01. Required unless days is given. |
to | unix seconds | date | now | Range end. 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")The candle object
{
"id": 48211,
"coin": "BTC",
"time": 1758844800,
"open": 63120.55,
"close": 64250.12,
"high": 64810.02,
"low": 62890.4,
"volume_from": 21873.6,
"volume_to": 1398472211.8,
"created_at": "2026-09-26T06:00:05.000Z",
"updated_at": "2026-09-26T06:00:05.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. |
News (public)
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.
List news articles
/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 ≤ 100 chars | — | Substring match on the title and description. |
lang | string | — | Language code, e.g. en. |
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": 9876,
"author": "CoinDesk",
"title": "Ethereum developers set date for next network upgrade",
"alias": "ethereum-developers-set-date-next-network-upgrade",
"description": "Core developers agreed on a mainnet activation date for...",
"url": "https://www.coindesk.com/tech/2026/09/25/…",
"urlToImage": "https://images.example.com/eth-upgrade.jpg",
"publishedAt": 1758808980,
"lang": "en",
"status": 1,
"twitter_post": 0,
"created_at": "2026-09-25T20:00:12.000Z",
"updated_at": "2026-09-25T20:00:12.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. |
urlToImage | string (URL) | null | Lead image. |
publishedAt | integer (unix seconds) | Publication time. |
lang | string | Language code. |
status | integer | Always 1 (published) in responses. |
twitter_post | integer | Internal flag; ignore. |
created_at | string (ISO 8601) | When the article was ingested. |
updated_at | string (ISO 8601) | When the record last changed. |
Get a news article
/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.
curl -s "https://api.newportexchange.org/v2/news/ethereum-developers-set-date-next-network-upgrade"// Node 18+ — public endpoint, no API key
const alias = 'ethereum-developers-set-date-next-network-upgrade';
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 = "ethereum-developers-set-date-next-network-upgrade"
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"])Data freshness & sources
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. |
Best practices
Keep keys server-side
- 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.
- Browser access from third-party origins is not guaranteed for any endpoint (CORS is restricted), which is one more reason to call the API server-side.
Caching
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.
Retries and backoff
- Retry only 429, 5xx and network errors. Never retry 400, 401 or 404 unchanged — the answer will not change.
- On 429, wait at least
RateLimit-Resetseconds 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.
Polling for new posts
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 },
});
const retryable = res.status === 429 || res.status >= 500;
if (retryable && attempt < MAX_RETRIES) {
// Wait for the rate-limit window to reset, or exponential backoff —
// whichever is longer — plus random jitter so clients don't sync up.
const resetSeconds = Number(res.headers.get('RateLimit-Reset'));
const backoffMs = Math.min(60_000, 1000 * 2 ** attempt);
const resetMs = Number.isFinite(resetSeconds) ? resetSeconds * 1000 : 0;
await sleep(Math.max(resetMs, 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):
resp = session.get(f"{API_BASE}{path}", params=params, timeout=15)
retryable = resp.status_code == 429 or resp.status_code >= 500
if retryable and attempt < MAX_RETRIES:
# Wait for the rate-limit window to reset, or exponential backoff,
# whichever is longer, plus random jitter.
reset = resp.headers.get("RateLimit-Reset", "")
reset_seconds = int(reset) if reset.isdigit() else 0
backoff = min(60, 2 ** attempt)
time.sleep(max(reset_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)")Handling has_more
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.
Changelog
- 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.
- New key-authenticated
Support
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.
