Skip to content

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:

HeaderValueExample
x-api-keyYour whole API key, jh_key_ prefix includedjh_key_67a759397b0b364bac4d06874dea60d042d48d435af11bbf8ad711da6a717238
x-timestampThe current Unix time in seconds (not milliseconds)1793260800
x-signatureThe signature, in hex. Built below1535210c8294...

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>
PartWhat to useExample
timestampExactly the value you put in x-timestamp1793260800
METHODThe HTTP method in capitalsGET, POST
pathThe path starting at /api, including the query string if there is one. Not the full URL/api/companies
bodyThe 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 ​

  1. All three headers are present.
  2. x-timestamp is at most 60 seconds from the current time. This stops anyone from replaying an old call later.
  3. The API key is valid.
  4. 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:      1793260800

1. GET /api/companies

string to sign: 1793260800.GET./api/companies.
signature:      028f0a8cd5dfbb72928a6174a6f881e833764aaa588bf016a7c13f1134b998ed

2. POST /api/requests

method:    POST
path:      /api/requests
body:      the body from "Example: a POST" above, on one line
signature: 1535210c8294d2e20e1c671f42ac7f97912e209a7e04081b8757f56c40d25e0b
Show 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: 2a1055e97e6f65512aacc9dd9b703b8deb3136735fcb4df8ea9d24b586808101
Show 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: 5d87c30193114ccdf72e5dbc7ed35df133a9c0736823904c1b796dc52bfd6d9d
Show 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: 757603452330ab4f78ee8815b0311eeeef011025d4ecfa89e84bdca5f15976cd
Show 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);
// 028f0a8cd5dfbb72928a6174a6f881e833764aaa588bf016a7c13f1134b998ed

Common 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.

  • Callbacks: callbacks we send you are signed the same way
  • Errors: what each 401 means