---
url: /guide/signing.md
---
# 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:

::: details 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"}
```

:::

::: danger 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](/guide/errors#authentication-errors).

::: tip 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.

::: code-group

```bash [cURL]
# 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 [Node.js (18+)]
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 [Python 3]
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 7.4+]
<?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
```

::: details 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
```

::: details 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
```

::: details 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
```

::: details 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

::: danger 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`.
:::

::: danger 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.
:::

::: danger 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.
:::

::: danger Milliseconds instead of seconds
`Date.now()` in JavaScript is milliseconds. Divide by 1000 and round down. A 13-digit timestamp is always wrong.
:::

::: danger Signing the full URL
Sign the path only: `/api/companies`, not `https://api.example.com/api/companies`.
:::

::: danger 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.
:::

::: danger 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.
:::

::: danger Lowercase method
Use `GET` and `POST`, in capitals.
:::

## Related pages

* [Callbacks](/guide/callbacks): callbacks we send you are signed the same way
* [Errors](/guide/errors#authentication-errors): what each `401` means
