cURL
HOST=https://api.paper.novig.com
KEY_ID=8f14e45f-ceea-467a-9b1c-3f2a51c8d7e0
PEM=desk-1.pem
REQ_PATH="/v3/catalog/markets"
QUERY="league=NFL"
BODY=''
TS=$(date +%s000)
HASH=$(printf %s "$BODY" \
| openssl dgst -sha256 -r | cut -d" " -f1)
# openssl signs a file, not a pipe. base64 -A never wraps.
printf 'NOVIG-V3\n%s\nGET\n%s\n%s\n%s' \
"$TS" "$REQ_PATH" "$QUERY" "$HASH" > canon.bin
SIG=$(openssl pkeyutl -sign -rawin -inkey "$PEM" \
-in canon.bin | openssl base64 -A)
curl -s -X GET "$HOST$REQ_PATH${QUERY:+?$QUERY}" \
-H "Novig-Key-Id: $KEY_ID" \
-H "Novig-Timestamp: $TS" \
-H "Novig-Signature: $SIG"
use base64::prelude::*;
use ed25519_dalek::pkcs8::DecodePrivateKey;
use ed25519_dalek::{Signer, SigningKey};
use reqwest::blocking::{Client, Request};
use reqwest::{Method, Url};
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
const HOST: &str = "https://api.paper.novig.com";
const KEY_ID: &str =
"8f14e45f-ceea-467a-9b1c-3f2a51c8d7e0";
const PEM: &str = "desk-1.pem";
fn main() -> anyhow::Result<()> {
let key = Key::load(KEY_ID, PEM)?;
let client = Client::new();
let path = "/v3/catalog/markets";
let url = format!("{HOST}{path}?league=NFL");
let req = client
.get(url)
.build()?
.sign(&key)?;
println!("{}", client.execute(req)?.text()?);
Ok(())
}
/// An API key: the id the server looks up, and the
/// private half that signs.
struct Key {
id: &'static str,
signer: SigningKey,
}
impl Key {
fn load(id: &'static str, pem: &str) -> anyhow::Result<Self> {
let signer =
SigningKey::read_pkcs8_pem_file(pem)?;
Ok(Self { id, signer })
}
}
/// Signs a built request over the method, path, query and
/// body it will send, so the two can never disagree.
trait Sign: Sized {
fn sign(self, key: &Key) -> anyhow::Result<Self>;
}
impl Sign for Request {
fn sign(mut self, key: &Key) -> anyhow::Result<Self> {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)?
.as_millis()
.to_string();
let body = self.body().and_then(|b| b.as_bytes());
let body = body.unwrap_or_default();
let canon = Canonical::new(
&ts,
self.method(),
self.url(),
body,
);
let sig = key.signer.sign(canon.0.as_bytes());
let sig = BASE64_STANDARD.encode(sig.to_bytes());
let headers = self.headers_mut();
headers.insert("Novig-Key-Id", key.id.parse()?);
headers.insert("Novig-Timestamp", ts.parse()?);
headers.insert("Novig-Signature", sig.parse()?);
Ok(self)
}
}
/// The six NOVIG-V3 lines, joined by LF.
struct Canonical(String);
impl Canonical {
fn new(
ts: &str,
method: &Method,
url: &Url,
body: &[u8],
) -> Self {
let query =
Query::from(url.query().unwrap_or(""));
let hash = format!("{:x}", Sha256::digest(body));
let method = method.as_str();
let path = url.path();
let lines = [
"NOVIG-V3", ts, method, path, &query.0, &hash,
];
Self(lines.join("\n"))
}
}
/// Each part decoded and re-encoded, then sorted by
/// name and value.
struct Query(String);
impl From<&str> for Query {
fn from(raw: &str) -> Self {
let mut pairs = raw
.split('&')
.filter(|pair| !pair.is_empty())
.map(|pair| {
pair.split_once('=').unwrap_or((pair, ""))
})
.map(|(k, v)| {
(Self::encode(k), Self::encode(v))
})
.collect::<Vec<_>>();
pairs.sort();
let pairs =
pairs.iter().map(|(k, v)| format!("{k}={v}"));
Self(pairs.collect::<Vec<_>>().join("&"))
}
}
impl Query {
fn encode(part: &str) -> String {
Self::decode(part)
.iter()
.map(|&b| match b {
b'-' | b'.' | b'_' | b'~' => {
(b as char).to_string()
}
_ if b.is_ascii_alphanumeric() => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
/// Only `%XX` decodes. A bare `+` stays a `+`.
fn decode(part: &str) -> Vec<u8> {
let raw = part.as_bytes();
let mut out = Vec::with_capacity(raw.len());
let mut i = 0;
while i < raw.len() {
let escape =
raw.get(i + 1..i + 3).and_then(Self::hex);
match (raw[i], escape) {
(b'%', Some(byte)) => {
out.push(byte);
i += 3;
}
(byte, _) => {
out.push(byte);
i += 1;
}
}
}
out
}
fn hex(pair: &[u8]) -> Option<u8> {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
Some((hi * 16 + lo) as u8)
}
}
import base64, hashlib, time, urllib.request
from cryptography.hazmat.primitives.serialization import (
load_pem_private_key)
HOST = "https://api.paper.novig.com"
KEY_ID = "8f14e45f-ceea-467a-9b1c-3f2a51c8d7e0"
PEM = "desk-1.pem"
path = "/v3/catalog/markets"
query = "league=NFL"
body = b""
ts = str(int(time.time() * 1000))
canon = "\n".join(["NOVIG-V3", ts, "GET", path, query,
hashlib.sha256(body).hexdigest()])
key = load_pem_private_key(open(PEM, "rb").read(), None)
headers = {
"Novig-Key-Id": KEY_ID,
"Novig-Timestamp": ts,
"Novig-Signature": base64.b64encode(
key.sign(canon.encode())).decode(),
}
url = f"{HOST}{path}?{query}"
req = urllib.request.Request(
url, data=body or None,
method="GET", headers=headers)
print(urllib.request.urlopen(req).read().decode())
import {
createHash, createPrivateKey, sign,
} from "node:crypto";
import { readFileSync } from "node:fs";
const HOST = "https://api.paper.novig.com";
const KEY_ID = "8f14e45f-ceea-467a-9b1c-3f2a51c8d7e0";
const PEM = "desk-1.pem";
const path = "/v3/catalog/markets";
const query = "league=NFL";
const body = "";
const ts = Date.now().toString();
const hash =
createHash("sha256").update(body).digest("hex");
const canon = [
"NOVIG-V3", ts, "GET", path, query, hash,
].join("\n");
const key = createPrivateKey(readFileSync(PEM));
const headers: Record<string, string> = {
"Novig-Key-Id": KEY_ID,
"Novig-Timestamp": ts,
"Novig-Signature": sign(null, Buffer.from(canon), key)
.toString("base64"),
};
const url = `${HOST}${path}?${query}`;
const r = await fetch(url, {
method: "GET",
headers,
});
console.log(await r.text());
{
"items": [
{
"marketId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"eventId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"marketType": "MONEY",
"status": "OPEN",
"voids": "PUSH",
"description": "<string>",
"startsTs": 123,
"fee": {
"coefficient": "0.03",
"makerCredit": "0.5",
"charged": "ALWAYS"
},
"outcomes": [
{
"outcomeId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"name": "<string>",
"status": "TBD"
}
],
"strike": "-3.5"
}
],
"next": "<string>"
}{
"code": "INVALID_REQUEST",
"message": "the request is malformed"
}{
"code": "SIGNATURE_REJECTED",
"message": "signature verification failed"
}{
"code": "SIGNATURE_REJECTED",
"message": "api key scope is insufficient for this route"
}{
"code": "ACCOUNT_LOCKED",
"message": "the account is locked out of trading"
}{
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded. Please wait before retrying."
}{
"code": "GEOLOCATION_EXPIRED",
"message": "no geolocation in the last 3 days"
}List markets
| Key | trading trading::read |
| Throttle | read |
| Cost | 1 /request |
| Answers | 200 400 401 403 423 429 451 |
| Idempotent | true |
Also served without a signature at GET /v3/public/catalog/markets. The edge throttles it per IP.
GET
/
v3
/
catalog
/
markets
cURL
HOST=https://api.paper.novig.com
KEY_ID=8f14e45f-ceea-467a-9b1c-3f2a51c8d7e0
PEM=desk-1.pem
REQ_PATH="/v3/catalog/markets"
QUERY="league=NFL"
BODY=''
TS=$(date +%s000)
HASH=$(printf %s "$BODY" \
| openssl dgst -sha256 -r | cut -d" " -f1)
# openssl signs a file, not a pipe. base64 -A never wraps.
printf 'NOVIG-V3\n%s\nGET\n%s\n%s\n%s' \
"$TS" "$REQ_PATH" "$QUERY" "$HASH" > canon.bin
SIG=$(openssl pkeyutl -sign -rawin -inkey "$PEM" \
-in canon.bin | openssl base64 -A)
curl -s -X GET "$HOST$REQ_PATH${QUERY:+?$QUERY}" \
-H "Novig-Key-Id: $KEY_ID" \
-H "Novig-Timestamp: $TS" \
-H "Novig-Signature: $SIG"
use base64::prelude::*;
use ed25519_dalek::pkcs8::DecodePrivateKey;
use ed25519_dalek::{Signer, SigningKey};
use reqwest::blocking::{Client, Request};
use reqwest::{Method, Url};
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
const HOST: &str = "https://api.paper.novig.com";
const KEY_ID: &str =
"8f14e45f-ceea-467a-9b1c-3f2a51c8d7e0";
const PEM: &str = "desk-1.pem";
fn main() -> anyhow::Result<()> {
let key = Key::load(KEY_ID, PEM)?;
let client = Client::new();
let path = "/v3/catalog/markets";
let url = format!("{HOST}{path}?league=NFL");
let req = client
.get(url)
.build()?
.sign(&key)?;
println!("{}", client.execute(req)?.text()?);
Ok(())
}
/// An API key: the id the server looks up, and the
/// private half that signs.
struct Key {
id: &'static str,
signer: SigningKey,
}
impl Key {
fn load(id: &'static str, pem: &str) -> anyhow::Result<Self> {
let signer =
SigningKey::read_pkcs8_pem_file(pem)?;
Ok(Self { id, signer })
}
}
/// Signs a built request over the method, path, query and
/// body it will send, so the two can never disagree.
trait Sign: Sized {
fn sign(self, key: &Key) -> anyhow::Result<Self>;
}
impl Sign for Request {
fn sign(mut self, key: &Key) -> anyhow::Result<Self> {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)?
.as_millis()
.to_string();
let body = self.body().and_then(|b| b.as_bytes());
let body = body.unwrap_or_default();
let canon = Canonical::new(
&ts,
self.method(),
self.url(),
body,
);
let sig = key.signer.sign(canon.0.as_bytes());
let sig = BASE64_STANDARD.encode(sig.to_bytes());
let headers = self.headers_mut();
headers.insert("Novig-Key-Id", key.id.parse()?);
headers.insert("Novig-Timestamp", ts.parse()?);
headers.insert("Novig-Signature", sig.parse()?);
Ok(self)
}
}
/// The six NOVIG-V3 lines, joined by LF.
struct Canonical(String);
impl Canonical {
fn new(
ts: &str,
method: &Method,
url: &Url,
body: &[u8],
) -> Self {
let query =
Query::from(url.query().unwrap_or(""));
let hash = format!("{:x}", Sha256::digest(body));
let method = method.as_str();
let path = url.path();
let lines = [
"NOVIG-V3", ts, method, path, &query.0, &hash,
];
Self(lines.join("\n"))
}
}
/// Each part decoded and re-encoded, then sorted by
/// name and value.
struct Query(String);
impl From<&str> for Query {
fn from(raw: &str) -> Self {
let mut pairs = raw
.split('&')
.filter(|pair| !pair.is_empty())
.map(|pair| {
pair.split_once('=').unwrap_or((pair, ""))
})
.map(|(k, v)| {
(Self::encode(k), Self::encode(v))
})
.collect::<Vec<_>>();
pairs.sort();
let pairs =
pairs.iter().map(|(k, v)| format!("{k}={v}"));
Self(pairs.collect::<Vec<_>>().join("&"))
}
}
impl Query {
fn encode(part: &str) -> String {
Self::decode(part)
.iter()
.map(|&b| match b {
b'-' | b'.' | b'_' | b'~' => {
(b as char).to_string()
}
_ if b.is_ascii_alphanumeric() => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
/// Only `%XX` decodes. A bare `+` stays a `+`.
fn decode(part: &str) -> Vec<u8> {
let raw = part.as_bytes();
let mut out = Vec::with_capacity(raw.len());
let mut i = 0;
while i < raw.len() {
let escape =
raw.get(i + 1..i + 3).and_then(Self::hex);
match (raw[i], escape) {
(b'%', Some(byte)) => {
out.push(byte);
i += 3;
}
(byte, _) => {
out.push(byte);
i += 1;
}
}
}
out
}
fn hex(pair: &[u8]) -> Option<u8> {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
Some((hi * 16 + lo) as u8)
}
}
import base64, hashlib, time, urllib.request
from cryptography.hazmat.primitives.serialization import (
load_pem_private_key)
HOST = "https://api.paper.novig.com"
KEY_ID = "8f14e45f-ceea-467a-9b1c-3f2a51c8d7e0"
PEM = "desk-1.pem"
path = "/v3/catalog/markets"
query = "league=NFL"
body = b""
ts = str(int(time.time() * 1000))
canon = "\n".join(["NOVIG-V3", ts, "GET", path, query,
hashlib.sha256(body).hexdigest()])
key = load_pem_private_key(open(PEM, "rb").read(), None)
headers = {
"Novig-Key-Id": KEY_ID,
"Novig-Timestamp": ts,
"Novig-Signature": base64.b64encode(
key.sign(canon.encode())).decode(),
}
url = f"{HOST}{path}?{query}"
req = urllib.request.Request(
url, data=body or None,
method="GET", headers=headers)
print(urllib.request.urlopen(req).read().decode())
import {
createHash, createPrivateKey, sign,
} from "node:crypto";
import { readFileSync } from "node:fs";
const HOST = "https://api.paper.novig.com";
const KEY_ID = "8f14e45f-ceea-467a-9b1c-3f2a51c8d7e0";
const PEM = "desk-1.pem";
const path = "/v3/catalog/markets";
const query = "league=NFL";
const body = "";
const ts = Date.now().toString();
const hash =
createHash("sha256").update(body).digest("hex");
const canon = [
"NOVIG-V3", ts, "GET", path, query, hash,
].join("\n");
const key = createPrivateKey(readFileSync(PEM));
const headers: Record<string, string> = {
"Novig-Key-Id": KEY_ID,
"Novig-Timestamp": ts,
"Novig-Signature": sign(null, Buffer.from(canon), key)
.toString("base64"),
};
const url = `${HOST}${path}?${query}`;
const r = await fetch(url, {
method: "GET",
headers,
});
console.log(await r.text());
{
"items": [
{
"marketId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"eventId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"marketType": "MONEY",
"status": "OPEN",
"voids": "PUSH",
"description": "<string>",
"startsTs": 123,
"fee": {
"coefficient": "0.03",
"makerCredit": "0.5",
"charged": "ALWAYS"
},
"outcomes": [
{
"outcomeId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"name": "<string>",
"status": "TBD"
}
],
"strike": "-3.5"
}
],
"next": "<string>"
}{
"code": "INVALID_REQUEST",
"message": "the request is malformed"
}{
"code": "SIGNATURE_REJECTED",
"message": "signature verification failed"
}{
"code": "SIGNATURE_REJECTED",
"message": "api key scope is insufficient for this route"
}{
"code": "ACCOUNT_LOCKED",
"message": "the account is locked out of trading"
}{
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded. Please wait before retrying."
}{
"code": "GEOLOCATION_EXPIRED",
"message": "no geolocation in the last 3 days"
}Authorizations
The key's UUID.
Unix milliseconds. ±30 s.
Standard padded base64 of the NOVIG-V3 signature.
Query Parameters
Comma-separated canonical league names.
Comma-separated canonical market types.
Comma-separated statuses required of the owning event.
Every market of one event.
Exclusive lower bound on the event's scheduled start. Unix milliseconds.
Exclusive upper bound on the event's scheduled start. Unix milliseconds.
Page size. Defaults to 500, not the maximum. 1 to 5000. A value outside that range answers 400.
Required range:
1 <= x <= 5000The next cursor from the previous page. Opaque.

