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:
Content-Digest: sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:The signature base#
Seven lines, UTF-8, joined with \n (a single line feed), no trailing newline:
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:
- Split the query into
key=valuepairs and decode each side asapplication/x-www-form-urlencoded(a+is a space). - Sort the pairs by key, then by value (by UTF-16 code units, i.e. plain string comparison).
- 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). - Join them as
key=valuewith&, and put?in front. No?at all when there are no pairs.
Worked example — the target you send, then the line you sign:
/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.
- All five headers present and well-formed → else 401
signature_missing. Content-Digestrecomputed over the raw body bytes → else 400digest_mismatch.|now − timestamp| ≤ 300 s→ else 401timestamp_out_of_window.- The Ed25519 signature over the base, with your registered public key → else 401
signature_invalid. - 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.
DELETEhas 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