Appearance
Signing Requests
Every call to the API must be signed. This is how Johari knows the call really comes from you and that nobody changed it on the way.
It is the same idea as most payment APIs: an HMAC-SHA256 of a short string, keyed with your signing secret.
The three headers
Every call, GET or POST, must carry these three headers:
| Header | Value | Example |
|---|---|---|
x-api-key | Your whole API key, jh_key_ prefix included | jh_key_67a759397b0b364bac4d06874dea60d042d48d435af11bbf8ad711da6a717238 |
x-timestamp | The current Unix time in seconds (not milliseconds) | 1793260800 |
x-signature | The signature, in hex. Built below | 1535210c8294... |
x-signature must be exactly 64 hex characters and x-timestamp only digits: anything else gets 401 (Invalid signature or Request timestamp is too old or invalid).
POST calls also need Content-Type: application/json. On a GET it is optional and ignored.
Building the signature
Step 1. Build the string to sign. Join four parts with a dot (.):
<timestamp>.<METHOD>.<path>.<body>| Part | What to use | Example |
|---|---|---|
timestamp | Exactly the value you put in x-timestamp | 1793260800 |
METHOD | The HTTP method in capitals | GET, POST |
path | The path starting at /api, including the query string if there is one. Not the full URL | /api/companies |
body | The exact JSON text you send. For a GET, an empty string | {"idempotencyKey":"b7e2..."} |
Step 2. Make the HMAC. Compute HMAC-SHA256 of that string, using your signing secret as the key. Use the whole secret, jh_secret_ prefix included, as UTF-8 text. Do not decode it or strip the prefix.
Step 3. Hex-encode it. The result, as 64 hex characters, is your x-signature. Lowercase is standard; uppercase is also accepted.
Example: a GET
A GET has no body, so the string ends with a dot:
1793260800.GET./api/companies.Example: a POST
Say this is the body you send:
json
{
"idempotencyKey": "b7e2c1d4-5a6f-4e3b-9c8d-1f2a3b4c5d6e",
"description": "Cement for slab",
"amount": 150000,
"requestingCompany": "6650f1a2b3c4d5e6f7a8b901",
"requestingSite": "3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90",
"costCode": "1003",
"modeOfPayment": "bank",
"admin": "6650f1a2b3c4d5e6f7a8b7a1"
}The string to sign is:
1793260800.POST./api/requests.<that body, exactly as you send it>Most libraries send JSON on one line (JSON.stringify in JavaScript does). Then the string to sign is one long line:
Show the exact string
1793260800.POST./api/requests.{"idempotencyKey":"b7e2c1d4-5a6f-4e3b-9c8d-1f2a3b4c5d6e","description":"Cement for slab","amount":150000,"requestingCompany":"6650f1a2b3c4d5e6f7a8b901","requestingSite":"3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90","costCode":"1003","modeOfPayment":"bank","admin":"6650f1a2b3c4d5e6f7a8b7a1"}Sign the exact text you send
Turn your data into a JSON string once. Sign that string, and send that same string as the body. If you sign one string and your HTTP library sends a slightly different one (different spacing, different key order), the signature will not match and you get 401 Invalid signature.
What is checked
- All three headers are present.
x-timestampis at most 60 seconds from the current time. This stops anyone from replaying an old call later.- The API key is valid.
- The signature is correct.
If a check fails you get a 401. See Errors.
Keep your server clock right
Because of the 60-second window, your server's clock must be correct. Use NTP (on by default on most servers). If every call fails with Request timestamp is too old or invalid, your clock is wrong.
Code
Each of these is complete. Copy one, set the three environment variables, and it works.
bash
# Needs: curl and openssl
BASE_URL="https://api.example.com"
API_KEY="jh_key_67a759397b0b364bac4d06874dea60d042d48d435af11bbf8ad711da6a717238" # yours, whole
SIGNING_SECRET="jh_secret_JwJB-xiOCTBo12TCXjbjzQsbTq070L9-UWUZEpAUzWU" # yours, whole
# GET /api/companies
TS=$(date +%s)
SIG=$(printf '%s' "$TS.GET./api/companies." \
| openssl dgst -sha256 -hmac "$SIGNING_SECRET" \
| sed 's/^.* //')
curl "$BASE_URL/api/companies" \
-H "x-api-key: $API_KEY" \
-H "x-timestamp: $TS" \
-H "x-signature: $SIG"
# POST /api/requests
BODY=$(cat <<'EOF'
{
"idempotencyKey": "b7e2c1d4-5a6f-4e3b-9c8d-1f2a3b4c5d6e",
"description": "Cement for slab",
"amount": 150000,
"requestingCompany": "6650f1a2b3c4d5e6f7a8b901",
"requestingSite": "3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90",
"costCode": "1003",
"modeOfPayment": "bank",
"admin": "6650f1a2b3c4d5e6f7a8b7a1"
}
EOF
)
TS=$(date +%s)
SIG=$(printf '%s' "$TS.POST./api/requests.$BODY" \
| openssl dgst -sha256 -hmac "$SIGNING_SECRET" \
| sed 's/^.* //')
curl -X POST "$BASE_URL/api/requests" \
-H "Content-Type: application/json" \
-H "x-api-key: $API_KEY" \
-H "x-timestamp: $TS" \
-H "x-signature: $SIG" \
--data-raw "$BODY"js
const crypto = require("crypto");
const BASE_URL = process.env.JOHARI_BASE_URL; // e.g. https://api.example.com
const API_KEY = process.env.JOHARI_API_KEY;
const SIGNING_SECRET = process.env.JOHARI_SIGNING_SECRET;
function sign(timestamp, method, path, body) {
return crypto
.createHmac("sha256", SIGNING_SECRET)
.update(`${timestamp}.${method}.${path}.${body}`)
.digest("hex");
}
// method: "GET" or "POST". path: starts with /api. payload: an object, or undefined for GET.
async function callJohari(method, path, payload) {
const body = payload === undefined ? "" : JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000).toString();
const response = await fetch(BASE_URL + path, {
method,
headers: {
"Content-Type": "application/json",
"x-api-key": API_KEY,
"x-timestamp": timestamp,
"x-signature": sign(timestamp, method, path, body),
},
body: payload === undefined ? undefined : body,
});
const json = await response.json();
return { httpStatus: response.status, ...json };
}
// Usage
callJohari("GET", "/api/companies").then(console.log);python
import hashlib
import hmac
import json
import os
import time
import requests # pip install requests
BASE_URL = os.environ["JOHARI_BASE_URL"] # e.g. https://api.example.com
API_KEY = os.environ["JOHARI_API_KEY"]
SIGNING_SECRET = os.environ["JOHARI_SIGNING_SECRET"]
def sign(timestamp, method, path, body):
message = f"{timestamp}.{method}.{path}.{body}".encode("utf-8")
return hmac.new(SIGNING_SECRET.encode("utf-8"), message, hashlib.sha256).hexdigest()
# method: "GET" or "POST". path: starts with /api. payload: a dict, or None for GET.
def call_johari(method, path, payload=None):
body = "" if payload is None else json.dumps(payload, separators=(",", ":"))
timestamp = str(int(time.time()))
response = requests.request(
method,
BASE_URL + path,
headers={
"Content-Type": "application/json",
"x-api-key": API_KEY,
"x-timestamp": timestamp,
"x-signature": sign(timestamp, method, path, body),
},
# Send the exact bytes that were signed. Do not use json=payload here.
data=body.encode("utf-8") if payload is not None else None,
)
return {"httpStatus": response.status_code, **response.json()}
# Usage
print(call_johari("GET", "/api/companies"))php
<?php
$BASE_URL = getenv('JOHARI_BASE_URL'); // e.g. https://api.example.com
$API_KEY = getenv('JOHARI_API_KEY');
$SIGNING_SECRET = getenv('JOHARI_SIGNING_SECRET');
function johari_sign($secret, $timestamp, $method, $path, $body) {
return hash_hmac('sha256', "$timestamp.$method.$path.$body", $secret);
}
// $method: "GET" or "POST". $path: starts with /api. $payload: an array, or null for GET.
function call_johari($method, $path, $payload = null) {
global $BASE_URL, $API_KEY, $SIGNING_SECRET;
$body = $payload === null
? ''
: json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$timestamp = (string) time();
$ch = curl_init($BASE_URL . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"x-api-key: $API_KEY",
"x-timestamp: $timestamp",
'x-signature: ' . johari_sign($SIGNING_SECRET, $timestamp, $method, $path, $body),
],
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['httpStatus' => $httpStatus] + json_decode($response, true);
}
// Usage
print_r(call_johari('GET', '/api/companies'));Test vectors
Use these to check your sign function before you call the API. If your function returns exactly these values, your signing is correct.
All five use this made-up secret and timestamp. The HMAC key is the whole secret below, jh_secret_ included:
signing secret: jh_secret_JwJB-xiOCTBo12TCXjbjzQsbTq070L9-UWUZEpAUzWU
timestamp: 17932608001. GET /api/companies
string to sign: 1793260800.GET./api/companies.
signature: 028f0a8cd5dfbb72928a6174a6f881e833764aaa588bf016a7c13f1134b998ed2. POST /api/requests
method: POST
path: /api/requests
body: the body from "Example: a POST" above, on one line
signature: 1535210c8294d2e20e1c671f42ac7f97912e209a7e04081b8757f56c40d25e0bShow the exact string to sign
1793260800.POST./api/requests.{"idempotencyKey":"b7e2c1d4-5a6f-4e3b-9c8d-1f2a3b4c5d6e","description":"Cement for slab","amount":150000,"requestingCompany":"6650f1a2b3c4d5e6f7a8b901","requestingSite":"3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90","costCode":"1003","modeOfPayment":"bank","admin":"6650f1a2b3c4d5e6f7a8b7a1"}3. A callback we send to https://erp.yourcompany.com/johari/callback
method: POST
path: /johari/callback
body: the callback example on the Callbacks page, on one line
signature: 2a1055e97e6f65512aacc9dd9b703b8deb3136735fcb4df8ea9d24b586808101Show the exact string to sign
1793260800.POST./johari/callback.{"event":"request.status-changed","deliveryID":"6650f1a2b3c4d5e6f7a8b9f0","requestID":"6650f1a2b3c4d5e6f7a8b9e1","requestNumber":"1042","status":"approved","statusTitle":"Approved","description":"Cement for slab","amount":150000,"currency":"TZS","amountRemaining":null,"modeOfPayment":"bank","isUrgent":false,"company":{"id":"6650f1a2b3c4d5e6f7a8b901","name":"Example Construction Ltd"},"site":{"id":"3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90","name":"Block A Apartments"},"costCode":"1003","isChildRequest":false,"mainRequestID":null,"note":null,"createdAt":"2026-10-29T09:58:04.112Z","createdBy":"Neema Mollel (API)","approvedAt":"2026-10-29T10:12:36.571Z","approvedBy":"Amina Juma","rejectedAt":null,"rejectedBy":null,"confirmedAt":null,"confirmedBy":null,"paidAt":null,"paidBy":null,"efdAt":null,"efdBy":null,"returnedAt":null,"returnedBy":null,"returnedTo":null,"files":[{"id":"0b6f2c1e-8d4a-4f3b-9e7c-5a1d2b3c4e5f","name":"invoice-1042.pdf","type":"application/pdf","size":48213}],"changedAt":"2026-10-29T10:12:36.571Z"}4. The same callback, sent to https://erp.yourcompany.com/hooks?source=johari
The query string is part of the path you sign.
method: POST
path: /hooks?source=johari
body: the same as vector 3
signature: 5d87c30193114ccdf72e5dbc7ed35df133a9c0736823904c1b796dc52bfd6d9dShow the exact string to sign
1793260800.POST./hooks?source=johari.{"event":"request.status-changed","deliveryID":"6650f1a2b3c4d5e6f7a8b9f0","requestID":"6650f1a2b3c4d5e6f7a8b9e1","requestNumber":"1042","status":"approved","statusTitle":"Approved","description":"Cement for slab","amount":150000,"currency":"TZS","amountRemaining":null,"modeOfPayment":"bank","isUrgent":false,"company":{"id":"6650f1a2b3c4d5e6f7a8b901","name":"Example Construction Ltd"},"site":{"id":"3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90","name":"Block A Apartments"},"costCode":"1003","isChildRequest":false,"mainRequestID":null,"note":null,"createdAt":"2026-10-29T09:58:04.112Z","createdBy":"Neema Mollel (API)","approvedAt":"2026-10-29T10:12:36.571Z","approvedBy":"Amina Juma","rejectedAt":null,"rejectedBy":null,"confirmedAt":null,"confirmedBy":null,"paidAt":null,"paidBy":null,"efdAt":null,"efdBy":null,"returnedAt":null,"returnedBy":null,"returnedTo":null,"files":[{"id":"0b6f2c1e-8d4a-4f3b-9e7c-5a1d2b3c4e5f","name":"invoice-1042.pdf","type":"application/pdf","size":48213}],"changedAt":"2026-10-29T10:12:36.571Z"}5. POST /api/requests with a file attached
The base64 content is signed like any other text in the body. Here the file is the 9 bytes %PDF-1.4 plus a newline, whose base64 is JVBERi0xLjQK.
method: POST
path: /api/requests
body: the body below, on one line
signature: 757603452330ab4f78ee8815b0311eeeef011025d4ecfa89e84bdca5f15976cdShow the exact string to sign
1793260800.POST./api/requests.{"idempotencyKey":"5d1c8e2a-3f4b-4a6c-9b7d-8e0f1a2b3c4d","description":"Cement for slab","amount":150000,"requestingCompany":"6650f1a2b3c4d5e6f7a8b901","requestingSite":"3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90","costCode":"1003","modeOfPayment":"bank","admin":"6650f1a2b3c4d5e6f7a8b7a1","files":[{"name":"invoice-1042.pdf","contentType":"application/pdf","content":"JVBERi0xLjQK"}]}Quick check in Node:
js
const crypto = require("crypto");
const secret = "jh_secret_JwJB-xiOCTBo12TCXjbjzQsbTq070L9-UWUZEpAUzWU";
const signature = crypto
.createHmac("sha256", secret)
.update("1793260800.GET./api/companies.")
.digest("hex");
console.log(signature);
// 028f0a8cd5dfbb72928a6174a6f881e833764aaa588bf016a7c13f1134b998edCommon mistakes
Stripping the prefix
The API key is sent whole, starting jh_key_. The signing secret is used whole, starting jh_secret_, as UTF-8 text. Do not remove the prefix, and do not base64-decode the secret. Either mistake gives 401.
Mixing up the key and the secret
The value starting jh_key_ goes in x-api-key. The value starting jh_secret_ is the HMAC key and is never sent.
Signing a different body from the one you send
Build the JSON string once and use it for both. In Python, do not sign json.dumps(payload) and then send requests.post(..., json=payload): requests makes its own string. Send data=body instead.
Milliseconds instead of seconds
Date.now() in JavaScript is milliseconds. Divide by 1000 and round down. A 13-digit timestamp is always wrong.
Signing the full URL
Sign the path only: /api/companies, not https://api.example.com/api/companies.
Forgetting the trailing dot on a GET
The string for a GET is 1793260800.GET./api/companies. The last dot separates the path from the (empty) body. Without it, the signature will not match.
Reusing a timestamp
Make a new timestamp for every call, including retries. A retry with the old timestamp may be outside the 60-second window.
Lowercase method
Use GET and POST, in capitals.