Skip to main content
Every request carries three headers, GET requests included. One header holds a signature. You sign a canonical string: six lines of text built from the request, in a fixed format. Clock skew is how far your clock can differ from ours.

Headers

  • Write the timestamp as an unpadded decimal. It must be within ±30 s of our clock.
  • Encode the raw signature bytes as standard base64, with padding.
We read the algorithm from the stored key, so no header carries it.

The canonical string

  1. The literal NOVIG-V3.
  2. The same value as Novig-Timestamp. A leading zero changes the string.
  3. The method, in uppercase ASCII.
  4. The path, never the full URL. Start at the leading / and include the mount prefix, verbatim.
  5. The canonical query, or an empty string. The line is always present.
  6. The SHA-256 of the raw body bytes, in lowercase hex.
Never normalize or percent-decode the path. /v3/keys and /v3/keys/ are different paths. With no body, line 6 is the hash of zero bytes: e3b0c442…7852b855. A literal {} body gives a different hash.
Hash the exact bytes you send, and send them with Content-Type: application/json. Re-serialized JSON changes the hash.
Without a content type, the server never captures the body. It hashes zero bytes instead. Here’s the string for GET /v3/keys at timestamp 1755000000000:
1NOVIG-V3
21755000000000
3GET
4/v3/keys
5 
6e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Joined by LF. No trailing newline.

The canonical query

Build line 5 from the raw query string in six steps:
  1. Split the raw query on &.
  2. Split each pair at the first =. A pair with no = has an empty value.
  3. URI-decode both parts, reading %XX as UTF-8. A bare + stays a literal +.
  4. Re-encode both parts. Keep ALPHA DIGIT - . _ ~ as is, and write everything else as %XX in uppercase hex.
  5. Sort bytewise by name, then by value, keeping repeats. Z (0x5A) sorts before a (0x61).
  6. Join the pairs with &.
Our edge strips Expires, Key-Pair-Id, Policy, and Signature from the query. A request that signs one of them fails, and nothing shows why.

Algorithms

Both algorithms sign the canonical string itself. A P-256 signature is DER-encoded as SEQUENCE { r, s }.
WebCrypto’s ECDSA returns raw r‖s, not DER, and a raw signature never verifies. Convert it to DER, or sign with Ed25519.

Sign in Rust

request.sign(&key) signs a built reqwest request, using the method, path, query, and body it will send. It signs with Ed25519, and its canonical string matches all test vectors.
sign.rs

Test vectors

A test vector is a sample request with its expected canonical string. Use them to check your signer.

signing-vectors.json

vectors and two test keypairs, for Ed25519 and P-256. They cover every case in the table below.
Anyone can read the private half of each published keypair. Never register a published keypair.
The vectors catch these common mistakes:

Test your signature

POST /v3/echo returns your request body byte for byte. Like every route, echo requires a valid signature. A 200 proves your host, key, clock, and canonical string are all correct. Echo doesn’t return the canonical string. When echo returns 401, sign a test vector and diff your canonical string against its string_to_sign. The first byte that differs is the bug. For a full walkthrough, see Quickstart.