Authentication — signing requests
Every Tenant API request is signed with your credential's secret. The secret never travels: IDM checks that the signature matches what it computes from the same request.
The four headers
| Header | Value |
|---|---|
X-Intellisol-Key-Id | Your key ID, for example pk_live_… |
X-Intellisol-Timestamp | The current Unix time in whole seconds |
X-Intellisol-Nonce | A fresh random string, 16 to 64 characters of A–Z a–z 0–9 _ -, never reused |
Authorization | Signature keyId="<key id>",algorithm="hmac-sha256",signature="<hex>" |
What you sign
Build the canonical string from five lines joined by a single newline (\n), with no newline at the end:
<METHOD in upper case>
<request URI: the path and query string exactly as sent>
<lower-case hex SHA-256 of the raw request body; of the empty string when there is no body>
<the timestamp header value>
<the nonce header value>
The signature is the lower-case hex HMAC-SHA256 of that string, keyed with your secret exactly as it was issued (the whole sk_… string).
Serialise the JSON body once, sign those bytes and send those same bytes. Re-serialising after signing (a pretty-printer, a different key order) changes the digest and the signature fails.
Test your implementation
With these inputs your code must produce exactly this signature:
| Input | Value |
|---|---|
| Method | POST |
| Request URI | /v1/delivery/quote?x=1 |
| Body | {"a":1} |
| Timestamp | 1789312301 |
| Nonce | 7c1e0a6b5d4f3a2b1c0d |
| Secret | sk_test_secret |
| Body digest | 015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862 |
| Signature | 059168a68799f8d2c27ec2497d63bc5b0e89aaa4c927c4161010c504ef137276 |
The digest of an empty body is e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
Code
Node.js
import crypto from 'node:crypto';
export async function idmFetch(host, method, uri, bodyObject, { keyId, secret, idempotencyKey } = {}) {
const body = bodyObject === undefined ? '' : JSON.stringify(bodyObject);
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(16).toString('hex');
const digest = crypto.createHash('sha256').update(body).digest('hex');
const canonical = [method.toUpperCase(), uri, digest, timestamp, nonce].join('\n');
const signature = crypto.createHmac('sha256', secret).update(canonical).digest('hex');
const headers = {
'content-type': 'application/json',
'X-Intellisol-Key-Id': keyId,
'X-Intellisol-Timestamp': timestamp,
'X-Intellisol-Nonce': nonce,
Authorization: `Signature keyId="${keyId}",algorithm="hmac-sha256",signature="${signature}"`,
};
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
return fetch(`https://${host}${uri}`, { method, headers, body: body || undefined });
}
Python
import hashlib, hmac, json, secrets, time
import requests
def idm_request(host, method, uri, body_obj=None, *, key_id, secret, idempotency_key=None):
body = b"" if body_obj is None else json.dumps(body_obj, separators=(",", ":")).encode()
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
digest = hashlib.sha256(body).hexdigest()
canonical = "\n".join([method.upper(), uri, digest, timestamp, nonce])
signature = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
headers = {
"content-type": "application/json",
"X-Intellisol-Key-Id": key_id,
"X-Intellisol-Timestamp": timestamp,
"X-Intellisol-Nonce": nonce,
"Authorization": f'Signature keyId="{key_id}",algorithm="hmac-sha256",signature="{signature}"',
}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
return requests.request(method, f"https://{host}{uri}", data=body or None, headers=headers, timeout=10)
Go
func sign(req *http.Request, body []byte, keyID, secret string) {
ts := strconv.FormatInt(time.Now().Unix(), 10)
nb := make([]byte, 16)
_, _ = rand.Read(nb)
nonce := hex.EncodeToString(nb)
digest := sha256.Sum256(body)
canonical := strings.Join([]string{
strings.ToUpper(req.Method), req.URL.RequestURI(), hex.EncodeToString(digest[:]), ts, nonce,
}, "\n")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(canonical))
sig := hex.EncodeToString(mac.Sum(nil))
req.Header.Set("X-Intellisol-Key-Id", keyID)
req.Header.Set("X-Intellisol-Timestamp", ts)
req.Header.Set("X-Intellisol-Nonce", nonce)
req.Header.Set("Authorization", fmt.Sprintf(`Signature keyId=%q,algorithm="hmac-sha256",signature=%q`, keyID, sig))
}
Shell (curl and openssl)
BODY='{"latitude":5.6037,"longitude":-0.1870}'
URI='/v1/geography/resolve'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
DIGEST=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
SIG=$(printf 'POST\n%s\n%s\n%s\n%s' "$URI" "$DIGEST" "$TS" "$NONCE" \
| openssl dgst -sha256 -hmac "$IDM_SECRET" -hex | sed 's/^.* //')
curl -sS "https://$IDM_HOST$URI" -X POST \
-H 'content-type: application/json' \
-H "X-Intellisol-Key-Id: $IDM_KEY_ID" -H "X-Intellisol-Timestamp: $TS" -H "X-Intellisol-Nonce: $NONCE" \
-H "Authorization: Signature keyId=\"$IDM_KEY_ID\",algorithm=\"hmac-sha256\",signature=\"$SIG\"" \
--data-raw "$BODY"
When a signature is refused
The answer is 401 SIGNATURE_INVALID, and detail.reason tells you which part failed.
detail.reason | Cause | Fix |
|---|---|---|
timestamp | Your clock is too far from IDM's. The allowed window is set by Intellisol (five minutes at launch). | Sync your servers with NTP. Send seconds, not milliseconds. |
nonce | The nonce was seen before inside the window, or is malformed. | Generate a new random nonce for every request, including retries. |
signature | The canonical string differs. | Check the method is upper case, the URI includes the query string exactly as sent, and the digest is of the exact bytes you sent. Compare against the test vector above. |
401 INVALID_API_CREDENTIAL means the key itself is unknown, revoked or expired, or belongs to the other environment. Do not retry it.
Rotating and revoking
- Ask Intellisol (or your tenant administrator in IDM Admin) for a new credential before you retire the old one. Both work during the overlap window, so you can deploy without downtime.
- Revocation is immediate. There is no grace period once a key is revoked, and a
credential.revokedwebhook is sent to every endpoint you have. - A key used from a browser is revoked automatically. See Get started.