HOST=https://api.paper.novig.com
KEY_ID=8f14e45f-ceea-467a-9b1c-3f2a51c8d7e0
PEM=desk-1.pem
REQ_PATH="/v3/orders"
QUERY=""
BODY='{
"outcomeId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"price": "0.665",
"qty": 110,
"tif": "GTC",
"clientId": "0b3f5f20-9c1b-4d6e-8f3a-5c2d1e7b4a90"
}'
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\nPOST\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 POST "$HOST$REQ_PATH${QUERY:+?$QUERY}" \
-H "Content-Type: application/json" \
-H "Novig-Key-Id: $KEY_ID" \
-H "Novig-Timestamp: $TS" \
-H "Novig-Signature: $SIG" \
--data-binary "$BODY"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";
const BODY: &[u8] = br#"{
"outcomeId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"price": "0.665",
"qty": 110,
"tif": "GTC",
"clientId": "0b3f5f20-9c1b-4d6e-8f3a-5c2d1e7b4a90"
}"#;
fn main() -> anyhow::Result<()> {
let key = Key::load(KEY_ID, PEM)?;
let client = Client::new();
let path = "/v3/orders";
let url = format!("{HOST}{path}");
let req = client
.post(url)
.header("Content-Type", "application/json")
.body(BODY)
.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/orders"
query = ""
body = b"""{
"outcomeId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"price": "0.665",
"qty": 110,
"tif": "GTC",
"clientId": "0b3f5f20-9c1b-4d6e-8f3a-5c2d1e7b4a90"
}"""
ts = str(int(time.time() * 1000))
canon = "\n".join(["NOVIG-V3", ts, "POST", 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(),
"Content-Type": "application/json",
}
url = f"{HOST}{path}"
req = urllib.request.Request(
url, data=body or None,
method="POST", 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/orders";
const query = "";
const body = `{
"outcomeId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"price": "0.665",
"qty": 110,
"tif": "GTC",
"clientId": "0b3f5f20-9c1b-4d6e-8f3a-5c2d1e7b4a90"
}`;
const ts = Date.now().toString();
const hash =
createHash("sha256").update(body).digest("hex");
const canon = [
"NOVIG-V3", ts, "POST", 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"),
"Content-Type": "application/json",
};
const url = `${HOST}${path}`;
const r = await fetch(url, {
method: "POST",
headers,
body,
});
console.log(await r.text());
{
"orderId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"clientId": "0b3f5f20-9c1b-4d6e-8f3a-5c2d1e7b4a90"
}{
"code": "INVALID_REQUEST",
"message": "the request is malformed"
}{
"code": "SIGNATURE_REJECTED",
"message": "signature verification failed"
}{
"code": "KYC_REQUIRED",
"message": "KYC verification required"
}{
"code": "OUTCOME_NOT_FOUND",
"message": "outcome not found"
}{
"code": "PAYLOAD_TOO_LARGE",
"message": "request entity too large"
}{
"code": "INSUFFICIENT_BALANCE",
"message": "Insufficient balance"
}{
"code": "SELF_EXCLUDED",
"message": "You have been self-excluded from trading."
}{
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded. Please wait before retrying."
}{
"code": "GEOLOCATION_EXPIRED",
"message": "no geolocation in the last 3 days"
}Place an order
| Key | trading |
| Throttle | place |
| Cost | 1 /request |
| Answers | 201 400 401 403 404 413 422 423 429 451 |
| Idempotent | false |
201 means queued, not resting. The open event on the private stream confirms the order rests.
HOST=https://api.paper.novig.com
KEY_ID=8f14e45f-ceea-467a-9b1c-3f2a51c8d7e0
PEM=desk-1.pem
REQ_PATH="/v3/orders"
QUERY=""
BODY='{
"outcomeId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"price": "0.665",
"qty": 110,
"tif": "GTC",
"clientId": "0b3f5f20-9c1b-4d6e-8f3a-5c2d1e7b4a90"
}'
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\nPOST\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 POST "$HOST$REQ_PATH${QUERY:+?$QUERY}" \
-H "Content-Type: application/json" \
-H "Novig-Key-Id: $KEY_ID" \
-H "Novig-Timestamp: $TS" \
-H "Novig-Signature: $SIG" \
--data-binary "$BODY"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";
const BODY: &[u8] = br#"{
"outcomeId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"price": "0.665",
"qty": 110,
"tif": "GTC",
"clientId": "0b3f5f20-9c1b-4d6e-8f3a-5c2d1e7b4a90"
}"#;
fn main() -> anyhow::Result<()> {
let key = Key::load(KEY_ID, PEM)?;
let client = Client::new();
let path = "/v3/orders";
let url = format!("{HOST}{path}");
let req = client
.post(url)
.header("Content-Type", "application/json")
.body(BODY)
.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/orders"
query = ""
body = b"""{
"outcomeId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"price": "0.665",
"qty": 110,
"tif": "GTC",
"clientId": "0b3f5f20-9c1b-4d6e-8f3a-5c2d1e7b4a90"
}"""
ts = str(int(time.time() * 1000))
canon = "\n".join(["NOVIG-V3", ts, "POST", 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(),
"Content-Type": "application/json",
}
url = f"{HOST}{path}"
req = urllib.request.Request(
url, data=body or None,
method="POST", 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/orders";
const query = "";
const body = `{
"outcomeId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"price": "0.665",
"qty": 110,
"tif": "GTC",
"clientId": "0b3f5f20-9c1b-4d6e-8f3a-5c2d1e7b4a90"
}`;
const ts = Date.now().toString();
const hash =
createHash("sha256").update(body).digest("hex");
const canon = [
"NOVIG-V3", ts, "POST", 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"),
"Content-Type": "application/json",
};
const url = `${HOST}${path}`;
const r = await fetch(url, {
method: "POST",
headers,
body,
});
console.log(await r.text());
{
"orderId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"clientId": "0b3f5f20-9c1b-4d6e-8f3a-5c2d1e7b4a90"
}{
"code": "INVALID_REQUEST",
"message": "the request is malformed"
}{
"code": "SIGNATURE_REJECTED",
"message": "signature verification failed"
}{
"code": "KYC_REQUIRED",
"message": "KYC verification required"
}{
"code": "OUTCOME_NOT_FOUND",
"message": "outcome not found"
}{
"code": "PAYLOAD_TOO_LARGE",
"message": "request entity too large"
}{
"code": "INSUFFICIENT_BALANCE",
"message": "Insufficient balance"
}{
"code": "SELF_EXCLUDED",
"message": "You have been self-excluded from 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.
Body
The side you are buying. Never a market ID.
Decimal probability on the submittable grid, as a string. Three bands: 0.001–0.050 in steps of 0.001, 0.055–0.945 in steps of 0.005, and 0.950–0.999 in steps of 0.001. The exchange refuses a price off the grid with INVALID_PRICE.
"0.665"
Number of contracts. A winning contract pays full value, 1¢.
x >= 1110
ttl is required for GTT, optional for PO, forbidden otherwise.
GTC, GTT, IOC, FOK, PO Milliseconds. Required for GTT, optional for PO, forbidden otherwise.
Your label for the order. Echoed on the answer, on open, and on every fill. The exchange never checks it for uniqueness, so a replay places a second order.

