Skip to main content

NK-SIG-1 signing

The five headers, the seven-line signature base, the canonical query, and the order in which we verify a request.

Every request to /api/partners/v1/* is signed with NK-SIG-1: an Ed25519 signature over a short text that binds the method, host, path and query, a timestamp, a nonce and a digest of the body.

The five headers#

Header Value
NK-Key-Id your key id, e.g. nkp_sandbox_7f3k9q2m4x8c1v6b
NK-Timestamp Unix seconds, an integer, from your clock
NK-Nonce 16–64 random bytes, base64url without padding, unique per request per key
Content-Digest RFC 9530: sha-256=:<base64 of SHA-256(raw body)>: — on every request, including GET and DELETE
NK-Signature v1=<base64url without padding of the 64-byte Ed25519 signature> (86 characters after v1=)

Send Content-Type: application/json too. Other headers are free and not signed.

A request with no body (every GET and DELETE) still sends the digest of the empty string:

http
Content-Digest: sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:

The signature base#

Seven lines, UTF-8, joined with \n (a single line feed), no trailing newline:

text
NK-SIG-1
{METHOD}
{host}
{path}{?canonical query}
{timestamp}
{nonce}
{content-digest}
Line Rule
NK-SIG-1 the literal scheme name
method upper-case, e.g. PUT
host the host you address, lower-case, no port, e.g. novikvadrat.rs
path and query the path exactly as sent, then the canonical query (below); e.g. /api/partners/v1/connections/9b1d…/listings/REAA-12345
timestamp the NK-Timestamp value
nonce the NK-Nonce value
content digest the full Content-Digest header value

The signature is Ed25519.sign(privateKey, base); send it as v1= plus base64url without padding. Nothing else is signed.

The canonical query#

The path is signed byte for byte as you send it. The query is signed in canonical form, because HTTP stacks (ours included) re-serialise query strings before the code sees them:

  1. Split the query into key=value pairs and decode each side as application/x-www-form-urlencoded (a + is a space).
  2. Sort the pairs by key, then by value (by UTF-16 code units, i.e. plain string comparison).
  3. Percent-encode each key and value per RFC 3986: only A–Z a–z 0–9 - . _ ~ stay as they are; everything else, including a space, becomes %XX (upper-case hex).
  4. Join them as key=value with &, and put ? in front. No ? at all when there are no pairs.

Worked example — the target you send, then the line you sign:

text
/api/partners/v1/connections/9b1d6c2e-4f3a-4c8e-9a71-2d5e8f0b3c44?reason=Agency%20left%20REAA&b=2&a=1
/api/partners/v1/connections/9b1d6c2e-4f3a-4c8e-9a71-2d5e8f0b3c44?a=1&b=2&reason=Agency%20left%20REAA

(The a and b pairs are only there to show the sort.) Sending ?reason=Agency+left+REAA signs the same line, since + decodes to a space. A literal plus sign must be sent as %2B. Because the query is in the base, the ?reason= of a DELETE is covered by the signature.

How we verify#

We check in this order and stop at the first failure. The key, app and IP checks run between steps 1 and 2, the per-minute rate limit between steps 4 and 5; the full chain is on Errors.

  1. All five headers present and well-formed → else 401 signature_missing.
  2. Content-Digest recomputed over the raw body bytes → else 400 digest_mismatch.
  3. |now − timestamp| ≤ 300 s → else 401 timestamp_out_of_window.
  4. The Ed25519 signature over the base, with your registered public key → else 401 signature_invalid.
  5. The nonce is new for your key (nonces are kept for 15 minutes) → else 401 nonce_reused.

Practical rules#

  • Hash the exact bytes you send. Serialise the JSON once, hash that string, send that string. Re-serialising after hashing (pretty-printing, key reordering, a different number format) is digest_mismatch.
  • Send the path exactly as signed, and build the query line with the canonical rule above.
  • DELETE has no body. Its data travels in the signed query.
  • Keep your clock right (NTP). More than 300 seconds off is timestamp_out_of_window.
  • A fresh random nonce for every request, including retries. A retry with the same nonce is nonce_reused.
  • The host line is the host you address (novikvadrat.rs), never a proxy's.

Other languages#

The docs ship a Node reference client. In any language you need three primitives:

  • Ed25519 sign (RFC 8032; not Ed25519ph) over the UTF-8 bytes of the base;
  • SHA-256 over the raw body bytes;
  • base64 (standard, with padding) for the digest and base64url without padding for the nonce and the signature.

Check your implementation against the worked example and test vector: Ed25519 is deterministic, so your base and signature must equal ours byte for byte. When you have a working client in another language, send us a verified sample at partners@novikvadrat.rs and we'll consider publishing it.

Last updated