--- url: /guide/getting-started.md --- # Getting Started This page explains what you need before you write any code, and how the whole integration fits together. ## What you need You need three things: | Item | What it is | Example | |---|---|---| | **Base URL** | Where the API lives. Your Johari administrator gives it to you | `https://api.example.com` | | **API key** | Says **who** is calling. Sent on every call, in the `x-api-key` header. `jh_key_` followed by 64 hex characters | `jh_key_67a759397b0b364bac4d06874dea60d042d48d435af11bbf8ad711da6a717238` | | **Signing secret** | Proves the call is really from you. **Never sent**, only used to sign. `jh_secret_` followed by 43 base64url characters (`A`–`Z`, `a`–`z`, `0`–`9`, `-`, `_`) | `jh_secret_JwJB-xiOCTBo12TCXjbjzQsbTq070L9-UWUZEpAUzWU` | The examples above are made up. Use your own. A machine-readable OpenAPI 3.1 spec of the whole API is at [`/openapi.yaml`](/openapi.yaml), for generating clients or agent tools. ::: warning Use the key and secret whole Use both values exactly as you were given them, **prefix included**. The API key, `jh_key_` and all, is the `x-api-key` header. The signing secret, `jh_secret_` and all, is the HMAC-SHA256 key (as UTF-8 text). Never strip the prefix. The two look different on purpose, so you cannot mix them up: the one starting `jh_key_` is sent, the one starting `jh_secret_` never is. ::: ::: tip Replace the base URL Examples in this guide use `https://api.example.com`. Use the base URL your administrator gave you. The test server and the live server have different base URLs and different keys. ::: ### Getting a key A Johari user with the permission **Users → "Manage API Keys"** generates keys. They open the page of the user the integration will act as, go to the **API Keys** tab, and generate a key there. ::: danger The key and secret are shown only once The API key and signing secret are shown one time, when they are generated. They cannot be shown again. If you lose them, have the key [rotated](#rotating-a-key) to get a new pair. Keep the signing secret on your **server**. Never put it in a browser, a mobile app, or a git repository. ::: The key's owner gets a WhatsApp message whenever a key is generated or rotated on their account, and when one is disabled. ### Who you are acting as Every API key belongs to **one Johari user**: the user whose page it was generated on. Requests made with the key run as that user, with their permissions and limits: * You only see the companies and sites that user can raise requests for. * Requests you raise are shown in Johari as created by that user, with ` (API)` after their name (for example `Neema Mollel (API)`), so people can see a system raised them. The same goes for returns you answer and files you add. ### Rotating a key Rotating generates a **new API key and signing secret** for the same key. Use it when a pair is lost or may have leaked. * The old pair stops working **immediately**. Switch your system to the new pair straight away. * Every request raised with the key stays yours. [Get a Request](/api/get-request), [Get a File](/api/get-file) and callbacks keep working with the new pair. * From then on, callbacks are signed with the **new** secret. That includes retries of callbacks first sent before the rotation. ### Disabling a key Disabling is **permanent**. A disabled key cannot be turned back on. * Every call with that key returns `401` (`Invalid or disabled API key`). * Pending callbacks for its requests stop. Requests stay tied to the key that raised them. A different key cannot see them: [Get a Request](/api/get-request) with it returns `404`. Check the status of any open requests in Johari itself. ::: tip If a secret may have leaked **Rotate** the key. Your requests and callbacks carry on with the new pair. Only disable a key you will never use again. ::: ## How it works The whole integration is six steps. The last one is only needed when the cashier returns a request to you: ``` Step 1: Look up the IDs you need GET /api/companies GET /api/companies/:companyID/sites GET /api/companies/:companyID/cost-codes GET /api/sites/:siteID/approvers ↓ Step 2: Raise the payment request POST /api/requests Include an idempotencyKey and an admin (an approver id), a callbackURL if you want updates, and up to 5 files if you have any. ↓ Step 3: Johari answers straight away You get back requestID, requestNumber and status ("pending" or "approved"). ↓ Step 4: People in Johari approve, pay or reject it This can take minutes or days. ↓ Step 5: You find out about every status change Either: we POST to your callbackURL (recommended) Or: you call GET /api/requests/:requestID Download any file on the request with GET /api/requests/:requestID/files/:fileID ↓ Step 6: If the cashier returns it to you, answer (status is "returned" and returnedTo is "creator") POST /api/requests/:requestID/files (optional: add a document first) POST /api/requests/:requestID/respond-to-return ``` ## Quick start These six calls take you from nothing to a raised request. Each one is explained in detail on its own page. **1. List the companies you can raise requests for** ``` GET /api/companies ``` ```json { "status": "success", "data": [ { "id": "6650f1a2b3c4d5e6f7a8b901", "name": "Example Construction Ltd" } ], "message": "Companies fetched successfully" } ``` **2. List that company's sites** ``` GET /api/companies/6650f1a2b3c4d5e6f7a8b901/sites ``` ```json { "status": "success", "data": [ { "id": "3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90", "name": "Block A Apartments" } ], "message": "Sites fetched successfully" } ``` **3. List that company's cost codes** ``` GET /api/companies/6650f1a2b3c4d5e6f7a8b901/cost-codes ``` ```json { "status": "success", "data": [ { "value": "1003", "label": "1003 - Security & Guards", "category": "contractor" } ], "message": "Cost codes fetched successfully" } ``` **4. List that site's approvers** ``` GET /api/sites/3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90/approvers ``` ```json { "status": "success", "data": [ { "id": "6650f1a2b3c4d5e6f7a8b7a1", "name": "Amina Juma" } ], "message": "Approvers fetched successfully" } ``` **5. Raise the request** `admin` is required: an approver `id` from step 4. ``` POST /api/requests ``` ```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", "callbackURL": "https://erp.yourcompany.com/johari/callback" } ``` ```json { "status": "success", "data": { "requestID": "6650f1a2b3c4d5e6f7a8b9e1", "requestNumber": "1042", "status": "pending" }, "message": "Request created successfully" } ``` **6. Wait for callbacks, or check the status yourself** ``` GET /api/requests/6650f1a2b3c4d5e6f7a8b9e1 ``` Every one of these calls must carry three headers (`x-api-key`, `x-timestamp`, `x-signature`). See [Signing Requests](/guide/signing). ## Response envelope Every response uses the same wrapper: ```json { "status": "success", "data": {}, "message": "Human-readable description" } ``` | Field | Meaning | |---|---| | `status` | `"success"` for HTTP `200`. `"error"` for everything else | | `data` | The result on success. On an error it is usually the error text, `null`, or (for `409`) what the first call with that `idempotencyKey` returned | | `message` | A short description, safe to log | **Always check the HTTP status code first.** `200` means it worked. Anything else is an error, explained on the [Errors](/guide/errors) page. The endpoint pages show the whole response, wrapper included. ## Conventions | Thing | Format | Example | |---|---|---| | IDs | Strings. Company, request and user (approver) IDs are 24 hex characters. Site IDs are UUIDs | `"6650f1a2b3c4d5e6f7a8b901"`, `"3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90"` | | Cost codes | Strings, even though they look like numbers | `"1003"` | | Amounts | A JSON number, or a string of digits with optional thousands commas and an optional decimal part. Spaces before or after the string are ignored. Nothing else in a string (no `"1e3"`, `"0x10"`, `"-5"`, `".5"`) | `150000`, `1500.5`, `"150,000"`, `"150,000.50"` | | Dates | ISO 8601 strings in UTC | `"2026-10-29T10:12:36.571Z"` | | Request numbers | Strings | `"1042"` | | Files | Sent inside the JSON body as standard base64 text. See [Attaching files](/api/create-request#attaching-files) | | | Body | JSON, sent with `Content-Type: application/json`. At most 35 MB. A larger body gets `413` | | | Lists | Not paginated. Each list endpoint returns everything your key's user can raise requests for, in one response | | | Rate limits | Per API key, per server, per minute: at most **120 calls** in total, and at most **30 writes** within those. Writes are creates, responses to returns and file uploads together (every `POST`). Over a limit you get `429` with a `Retry-After` header (seconds until the minute resets). Wait that long, then retry; for a `POST`, with the **same** `idempotencyKey` and body. After 30 failed sign-ins (any `401`) from one address within a minute, every call from that address gets `429` for the rest of that minute, even with a valid key. See [Errors](/guide/errors#too-many-requests) | | ## Integration checklist * \[ ] Get the base URL from your Johari administrator, and an API key and signing secret ([Getting a key](#getting-a-key)) * \[ ] Store the key and secret as server-side secrets (environment variables, a vault) * \[ ] Write the signing function and check it against the [test vectors](/guide/signing#test-vectors) * \[ ] Call `GET /api/companies` and get a `200`. If you do, your signing works * \[ ] Map your own projects and cost centres to Johari's company, site and cost code IDs * \[ ] Raise a request with a fresh `idempotencyKey` and an `admin` from [List Approvers](/api/approvers) * \[ ] Build a callback endpoint that verifies the signature, answers `200`, and handles both `event` values (`request.status-changed` and `request.deleted`) * \[ ] Handle a request returned to you (`status` `"returned"`, `returnedTo` `"creator"`): answer it with [Respond to a Return](/api/respond-to-return), adding any document first with [Add Files](/api/add-files) * \[ ] Test it end to end on the test server: raise a request, have someone approve it in Johari, check that your callback receives it * \[ ] Switch to the live base URL and live credentials ## What's next * [Signing Requests](/guide/signing): how to build the three headers * [Respond to a Return](/api/respond-to-return) and [Add Files](/api/add-files): what to do when the cashier returns a request to you * [Full Example](/guide/full-example): a complete working integration you can copy --- --- 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 (`.`): ``` ... ``` | 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. ``` 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+] $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 --- --- url: /guide/idempotency.md --- # Idempotency Networks fail. Sometimes you send a request and never get the answer: the connection drops, or your server times out. You don't know if Johari created the payment request or not. If you just send it again, you might create **two payment requests for the same thing**, and someone might pay twice. The `idempotencyKey` solves this. It is a unique ID **you** choose for each payment request. Johari remembers it. If the same key arrives again, Johari does not create a second request. All three calls that change something use `idempotencyKey` the same way: [Create a Request](/api/create-request), [Respond to a Return](/api/respond-to-return) and [Add Files](/api/add-files). A retry never answers a return twice or adds the same files twice. ## The rule > **One payment request = one idempotency key.** Keep the key until you get a clear answer. Use a new key only for a new payment request. > > The same goes for the other two writes: a new key for every new response to a return, and for every new upload of files. Reuse a key only to retry that same call. ## How to use it 1. Before you raise a request, create a new key. A UUID is perfect: `b7e2c1d4-5a6f-4e3b-9c8d-1f2a3b4c5d6e`. 2. **Save it** with your own record (your purchase, invoice or task) **before** you call Johari. 3. Send it in the body as `idempotencyKey`. 4. If the call fails with a network error or a timeout, send **the same body with the same key** again. 5. Once you have a `200` or a `409`, you are done. Never send that key again for a different request. ::: tip Use your own ID if you have one If every payment request in your system already has a unique ID (for example `PO-2026-00123-PAY-1`), you can use that as the key. Any string up to 255 characters works. ::: ## What Johari answers | Situation | HTTP | `data` | What to do | |---|---|---|---| | New key | `200` | The result. For a create, the new request | Save `requestID` | | Key already used by a call that **succeeded** | `409` | What that call returned. For a create, the request it created | Treat it as success. Save `requestID` from `data` | | Key already in use by a call that is **still running** | `409` | `null` | Wait a few seconds and send it again | | Key already used for a **different** call (any field differs, or another `requestID` in the URL) | `400` | The error text | A bug in your code: every new create, response or upload needs a new key | | Key used by a call that **failed** (e.g. `400`) | (as normal) | | Fix the data and send it again **with the same key**. A failed call does not use up the key | ### The `409` after success ```json { "status": "error", "data": { "requestID": "6650f1a2b3c4d5e6f7a8b9e1", "requestNumber": "1042", "status": "pending" }, "message": "A request with this idempotencyKey has already been processed" } ``` This is **good news**: your first call worked. The `data` is exactly what the first call returned, so you can carry on as if you got the `200`. The same holds for [Respond to a Return](/api/respond-to-return) and [Add Files](/api/add-files): `data` is what their first call returned. ::: warning The `status` in a `409` may be out of date `data.status` is the status **when the first call ran** (for a create, when the request was created), not now. If the request has been approved or rejected since then, the `409` still shows the old status. Use `requestID` from it, and call [Get a Request](/api/get-request) for the current status. ::: ### The `409` while still running ```json { "status": "error", "data": null, "message": "A request with this idempotencyKey is still being processed" } ``` Two copies of your call arrived at the same moment. One is doing the work (for a create, creating the request), the other was stopped. Wait a few seconds and retry: you will get the `200`-style `409` above. ## Example: a safe retry loop ::: code-group ```js [Node.js] const crypto = require("crypto"); // callJohari is the function from the Signing page. async function raiseRequest(fields) { // In a real system, save this key with your own record before the first try. const idempotencyKey = crypto.randomUUID(); const payload = { ...fields, idempotencyKey }; for (let attempt = 1; attempt <= 5; attempt++) { try { const result = await callJohari("POST", "/api/requests", payload); if (result.httpStatus === 200) return result.data; // created if (result.httpStatus === 409 && result.data) return result.data; // already created earlier if (result.httpStatus === 409) { await new Promise((r) => setTimeout(r, 2000)); // still being processed continue; } if (result.httpStatus === 429) { // Over the per-minute limit: it resets within a minute await new Promise((r) => setTimeout(r, 60000)); continue; } if (result.httpStatus >= 500) { await new Promise((r) => setTimeout(r, 2000 * attempt)); continue; } // 400, 401, 403: retrying the same data will not help throw new Error(`${result.httpStatus}: ${result.message} ${JSON.stringify(result.data)}`); } catch (error) { if (error.message.match(/^\d{3}:/)) throw error; // Network error or timeout: retry with the SAME payload and key await new Promise((r) => setTimeout(r, 2000 * attempt)); } } throw new Error("Could not reach Johari; retry later with the same idempotencyKey"); } ``` ```python [Python] import time import uuid # call_johari is the function from the Signing page. def raise_request(fields): # In a real system, save this key with your own record before the first try. payload = {**fields, "idempotencyKey": str(uuid.uuid4())} for attempt in range(1, 6): try: result = call_johari("POST", "/api/requests", payload) except Exception: # Network error or timeout: retry with the SAME payload and key time.sleep(2 * attempt) continue status = result["httpStatus"] if status == 200: return result["data"] # created if status == 409 and result["data"]: return result["data"] # already created earlier if status == 429: time.sleep(60) # over the per-minute limit: it resets within a minute continue if status == 409 or status >= 500: time.sleep(2 * attempt) # still being processed, or a server error continue # 400, 401, 403: retrying the same data will not help raise RuntimeError(f"{status}: {result['message']} {result['data']}") raise RuntimeError("Could not reach Johari; retry later with the same idempotencyKey") ``` ::: ## Details * **Required.** Every `POST` must have an `idempotencyKey`: [Create a Request](/api/create-request), [Respond to a Return](/api/respond-to-return) and [Add Files](/api/add-files). Without one you get `400`. * **Checked first.** The key is checked before any other field, and before the request in the URL is looked up. * **Tied to the body.** A retry must send the same body, `files` included (the same names, types and base64 content). The order of the fields does not matter, but every value must be the same. The same key with any different value gets `400`. * **Tied to the request in the URL.** For Respond to a Return and Add Files, the key is tied to the `requestID` in the URL as well as to the body. The same key and body sent for a different request gets `400` (`This idempotencyKey was already used for a different request`). * **One key, one call.** Keys are shared by all three writes. A key used to create a request cannot be used to respond or to add files, and the other way round. * **A string of 1 to 255 characters.** A number or an object gets `400`. * **Scoped to your API key.** Your keys can never clash with another system's keys. * **Kept forever.** A key that worked can never be used for anything else, no matter how much later it arrives. * **Signed.** The key is in the body, and the body is signed, so nobody can change it on the way. ## Common mistakes ::: danger Making a new key on every retry If each retry gets a new key, Johari sees each one as a new request and creates duplicates. The key must be created **once**, before the first try, and reused for every retry of that request. ::: ::: danger Reusing a key for a different request If you send a new, different request with an old key, you get `400` (`This idempotencyKey was already used for a different request`) and **no request is created**. Every new payment request needs a new key. The same goes for responses and uploads. A request can be returned more than once: answer each return with a new key. Sending the same key and body for another request gets the same `400`. On a retry, send exactly the same values, files included. Even a change like `150000` to `"150,000"` counts as a different request. ::: ::: danger Treating `409` as a failure A `409` with `data` means your first call **worked**: the request was created, the return answered, or the files added. For a create, save the `requestID` from `data` and carry on. ::: ## Related pages * [Create a Request](/api/create-request) * [Respond to a Return](/api/respond-to-return) * [Add Files](/api/add-files) * [Errors](/guide/errors) --- --- url: /guide/callbacks.md --- # Callbacks A payment request moves through several steps in Johari: approved, paid, maybe returned or rejected. Instead of asking Johari again and again, you can give a **callback URL** and Johari will `POST` to it **every time the status changes**, and when the request is deleted. ## Turning callbacks on Add `callbackURL` when you [create the request](/api/create-request): ```json { "idempotencyKey": "b7e2c1d4-5a6f-4e3b-9c8d-1f2a3b4c5d6e", "description": "Cement for slab", "admin": "6650f1a2b3c4d5e6f7a8b7a1", "callbackURL": "https://erp.yourcompany.com/johari/callback", ... } ``` * Each request has its own `callbackURL`. You can use the same URL for all of them. * Use a public `https://` URL of up to 2048 characters. A longer one gets `400` (`callbackURL cannot be longer than 2048 characters`). * On the live server the URL must be `https://` and point to a **public** address. Johari refuses `localhost`, loopback, private, link-local, carrier-grade NAT (`100.64.0.0/10`), multicast, documentation/benchmark and other reserved IPv4 and IPv6 ranges, such as `127.0.0.1`, `10.x.x.x` or `192.168.x.x`. It checks this twice: * when you create the request, for URLs with an IP address and for `localhost` names (you get `400`, `Please provide a valid callbackURL`); * every time it sends a callback: it checks the addresses the name resolves to, and connects only to the address it checked. If the name resolves to such an address, you get no callbacks: the delivery is stopped. * When testing from your laptop, use a tunnel (such as ngrok or Cloudflare Tunnel). * Callbacks are optional. Without one, use [Get a Request](/api/get-request) to check the status. ## When we send it The first attempt goes out straight away, usually less than a second after the status changes. If it fails, Johari [retries](#retries). A status change is made by a person in Johari (an approver, finance, the cashier), or by your own [response to a return](/api/respond-to-return). To test your callback endpoint, raise a request on the test server and ask your Johari contact to approve or reject it. ## What we send ``` POST https://erp.yourcompany.com/johari/callback Content-Type: application/json x-timestamp: 1793260800 x-signature: 2a1055e97e6f65512aacc9dd9b703b8deb3136735fcb4df8ea9d24b586808101 ``` | Header | Value | |---|---| | `Content-Type` | `application/json` | | `x-timestamp` | When this attempt was sent, Unix time in seconds | | `x-signature` | HMAC-SHA256 in lowercase hex. See [Verifying the signature](#verifying-the-signature) | There is **no `x-api-key`** on callbacks: the signature is what proves it is Johari. The body is `event`, `deliveryID`, then every field of the request summary (the same fields, in the same order, as [Get a Request](/api/get-request#response)), then `changedAt`: ```json { "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" } ``` | Field | Type | Description | |---|---|---| | `event` | `string` | `"request.status-changed"` or `"request.deleted"`. See [Events](#events) | | `deliveryID` | `string` | ID of **this** notification. Stays the same on every retry of it. Use it to ignore duplicates | | `requestID` | `string` | The request this is about | | `requestNumber` | `string` | Its number, as people see it in Johari | | `status` | `string` | The new status. For `request.deleted`, the status it had just before it was deleted. See [Request Statuses](/guide/statuses) | | `statusTitle` | `string` | The status as people see it in Johari, e.g. `"Pending Payment"` | | `description` | `string` | What the payment is for | | `amount` | `number` | The request amount | | `currency` | `string` | e.g. `"TZS"` | | `amountRemaining` | `number \| null` | Only for requests being paid in parts: how much is still unpaid. Otherwise `null` | | `modeOfPayment` | `string` | `"cash"` or `"bank"` | | `isUrgent` | `boolean` | `true` if the request is marked urgent | | `company` | `object` | The requesting company: `{ "id": string, "name": string }` | | `site` | `object` | The requesting site: `{ "id": string, "name": string }` | | `costCode` | `string` | The cost code `value` | | `isChildRequest` | `boolean` | `true` if this is one part of a request being paid in parts. See [Partial payments](/guide/statuses#partial-payments) | | `mainRequestID` | `string \| null` | For a child request, the request it belongs to. Otherwise `null` | | `note` | `string \| null` | Only the note written with the **latest** status change, e.g. the rejection reason, the return reason, a payment note or an approval comment. `null` if that change had no note. Never an older note | | `createdAt` | `string` | When the request was created (ISO 8601, UTC) | | `createdBy` | `string` | Name of the person in Johari who created it. For requests you raise, the API key's user's name followed by ` (API)`, e.g. `"Neema Mollel (API)"`. Johari shows the same in the request, its log and the WhatsApp notifications, so people can see a system raised it | | `approvedAt` | `string \| null` | When it was approved (ISO 8601, UTC) | | `approvedBy` | `string \| null` | Name of the person who approved it. If the request was approved automatically (because the key's user may approve it themselves), it is also the key's user's name followed by ` (API)` | | `rejectedAt` | `string \| null` | When it was rejected (ISO 8601, UTC) | | `rejectedBy` | `string \| null` | Name of the person who rejected it | | `confirmedAt` | `string \| null` | When accounts confirmed it, sending it for payment (ISO 8601, UTC) | | `confirmedBy` | `string \| null` | Name of the person who confirmed it | | `paidAt` | `string \| null` | When it was paid (ISO 8601, UTC) | | `paidBy` | `string \| null` | Name of the person who paid it | | `efdAt` | `string \| null` | When the EFD (tax) receipt was uploaded (ISO 8601, UTC) | | `efdBy` | `string \| null` | Name of the person who uploaded the EFD receipt | | `returnedAt` | `string \| null` | When it was returned (ISO 8601, UTC) | | `returnedBy` | `string \| null` | Name of the person who returned it | | `returnedTo` | `string \| null` | Who the cashier returned it to: `"creator"`, `"approver"` or `"confirmer"`. `null` if it was never returned. While `status` is `"returned"` and this is `"creator"`, the request is waiting for **your** system to answer: see [Respond to a Return](/api/respond-to-return). When it is `"approver"` or `"confirmer"`, that person answers it in Johari and you just receive the callbacks. Keeps its last value after the request moves on, so always check `status` too | | `files` | `array` | Every file on the request: `[{ "id": string, "name": string, "type": string \| null, "size": number \| null }]`. `size` is in bytes. See [Files](/api/get-request#files) | | `changedAt` | `string` | When the change happened (ISO 8601, UTC) | The `...At` fields say when that step happened. The `...By` fields are the name of the person in Johari who did it. Each one is `null` until that step happens. `note` is only ever the note from the latest status change. Johari's other internal notes are not shared. `files` lists the files sent when the request was created, the proof of payment the cashier uploads when paying, the EFD receipt, any file people attach to the request in Johari (for example an accountant's upload), and any file your system adds with [Add Files](/api/add-files). There is no download link in a callback: download a file with [Get a File](/api/get-file), using its `id`. ## Events **Switch on `event`.** It is one of: | `event` | When it is sent | |---|---| | `request.status-changed` | The request's status changed. See [Which changes send a callback](#which-changes-send-a-callback) | | `request.deleted` | The request was deleted in Johari | ### `request.deleted` The body has the same fields as above. It carries the request **as it was just before it was deleted**: its `status` is the status it had then. After this, [Get a Request](/api/get-request) returns `404` for it, and so does [Get a File](/api/get-file) for its files. If a **child request** is deleted in Johari, you get `request.deleted` for the child, plus `request.status-changed` for its main request (see [Partial payments](/guide/statuses#partial-payments)). Example: ```json { "event": "request.deleted", "deliveryID": "6650f1a2b3c4d5e6f7a8bb02", "requestID": "6650f1a2b3c4d5e6f7a8b9e7", "requestNumber": "1048", "status": "pending", "statusTitle": "Pending Approval", "description": "Sand, 10 trips", "amount": 900000, "currency": "TZS", "amountRemaining": null, "modeOfPayment": "cash", "isUrgent": false, "company": { "id": "6650f1a2b3c4d5e6f7a8b901", "name": "Example Construction Ltd" }, "site": { "id": "3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90", "name": "Block A Apartments" }, "costCode": "1801", "isChildRequest": false, "mainRequestID": null, "note": null, "createdAt": "2026-10-31T06:42:10.385Z", "createdBy": "Neema Mollel (API)", "approvedAt": null, "approvedBy": null, "rejectedAt": null, "rejectedBy": null, "confirmedAt": null, "confirmedBy": null, "paidAt": null, "paidBy": null, "efdAt": null, "efdBy": null, "returnedAt": null, "returnedBy": null, "returnedTo": null, "files": [], "changedAt": "2026-10-31T09:15:27.640Z" } ``` ::: tip Handle unknown events New events may be added in future. If you receive an `event` you do not know, answer `200` and log it. Do not crash. ::: ## Verifying the signature **Always verify before you trust a callback.** Anyone who knows your callback URL could send you fake data. Only Johari has your signing secret. Callbacks are signed exactly like your own calls, with the same signing secret (the whole secret, `jh_secret_` prefix included): ``` .POST.. ``` For `https://erp.yourcompany.com/johari/callback` the path is `/johari/callback`. If your callbackURL is `https://erp.yourcompany.com/hooks?source=johari`, the path is `/hooks?source=johari` (see [test vector 4](/guide/signing#test-vectors)). ::: tip Behind a proxy or load balancer Johari signs the path **of the callbackURL you sent us**, not whatever path your application sees after a proxy rewrites it. The simplest approach: keep the path as a constant in your code (like `CALLBACK_PATH` below) and sign with that, instead of reading it from the incoming request. ::: ::: tip Several callback URLs If you send different `callbackURL`s (for example one per tenant, or with a different query string), each callback is signed with the path of **the URL you sent with that request**. Save the `callbackURL` with the `requestID` when you create the request. When a callback arrives, read `requestID` from the raw body, look up the URL you saved for it, and verify with that URL's path and query string. Trust nothing else in the body until the signature passes. ::: **Check the timestamp too.** Reject callbacks whose `x-timestamp` is more than **5 minutes (300 seconds)** from your clock. Every attempt is signed with a new timestamp, so a genuine callback is always fresh; the 5 minutes only allows for clocks that are slightly off. This stops someone from replaying an old callback they captured. ::: danger Use the raw body Compute the signature over the body **exactly as it arrived**, as text. If your framework parses the JSON first and you turn it back into a string, it may come out different and the signature will not match. The examples below show how to get the raw body. ::: ## Answering * Answer with **any `2xx` status** (`200` is fine) to say "received". The body of your answer is ignored. * Answer `2xx` also for a callback you skip because it is a duplicate (`deliveryID` already seen) or out of date (older `changedAt`). It is genuine, so say you received it, or Johari keeps retrying it. * Redirects are **not followed**. A `301` or `302` counts as a failed attempt, so give the final URL. * Answer within **10 seconds**. Save the callback and process it later if your work is slow. * If the signature is wrong, answer `401`. (If **every** callback fails the check, your signing secret or callback path is wrong. Johari keeps retrying until it gets a `2xx`, so fix it and the next retry will pass.) ## Code ::: code-group ```js [Node.js (Express)] const crypto = require("crypto"); const express = require("express"); const SIGNING_SECRET = process.env.JOHARI_SIGNING_SECRET; const CALLBACK_PATH = "/johari/callback"; // the path of the callbackURL you send us const app = express(); // express.raw keeps the body as bytes so we can check the signature on it. app.post(CALLBACK_PATH, express.raw({ type: "application/json" }), (req, res) => { const timestamp = req.get("x-timestamp") || ""; const signature = req.get("x-signature") || ""; const rawBody = req.body.toString("utf8"); const expected = crypto .createHmac("sha256", SIGNING_SECRET) .update(`${timestamp}.POST.${CALLBACK_PATH}.${rawBody}`) .digest("hex"); const valid = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; if (!valid || !fresh) return res.status(401).send("Invalid signature"); const event = JSON.parse(rawBody); // TODO: save it. Skip it if you have already seen event.deliveryID. switch (event.event) { case "request.status-changed": console.log(`Request #${event.requestNumber} is now ${event.status}`); break; case "request.deleted": console.log(`Request #${event.requestNumber} was deleted in Johari`); break; default: console.log(`Unknown event ${event.event}`); } res.status(200).send("OK"); }); app.listen(3000); ``` ```python [Python (Flask)] import hashlib import hmac import json import os import time from flask import Flask, request # pip install flask SIGNING_SECRET = os.environ["JOHARI_SIGNING_SECRET"] CALLBACK_PATH = "/johari/callback" # the path of the callbackURL you send us app = Flask(__name__) @app.post(CALLBACK_PATH) def johari_callback(): timestamp = request.headers.get("x-timestamp", "") signature = request.headers.get("x-signature", "") raw_body = request.get_data(as_text=True) message = f"{timestamp}.POST.{CALLBACK_PATH}.{raw_body}".encode("utf-8") expected = hmac.new(SIGNING_SECRET.encode("utf-8"), message, hashlib.sha256).hexdigest() fresh = timestamp.isdigit() and abs(time.time() - int(timestamp)) < 300 if not hmac.compare_digest(signature, expected) or not fresh: return "Invalid signature", 401 event = json.loads(raw_body) # TODO: save it. Skip it if you have already seen event["deliveryID"]. if event["event"] == "request.status-changed": print(f"Request #{event['requestNumber']} is now {event['status']}") elif event["event"] == "request.deleted": print(f"Request #{event['requestNumber']} was deleted in Johari") else: print(f"Unknown event {event['event']}") return "OK", 200 app.run(port=3000) ``` ```php [PHP] new Promise((resolve) => setTimeout(resolve, ms)); async function raiseRequest(fields) { // One key per payment request, reused on every retry. const payload = { ...fields, idempotencyKey: crypto.randomUUID() }; for (let attempt = 1; attempt <= 5; attempt++) { let result; try { result = await callJohari("POST", "/api/requests", payload); } catch (error) { console.log(`Network error (${error.message}), retrying with the same key...`); await sleep(2000 * attempt); continue; } if (result.httpStatus === 200) return result.data; if (result.httpStatus === 409 && result.data) return result.data; if (result.httpStatus === 429) { await sleep(60000); // over the per-minute limit: it resets within a minute continue; } if (result.httpStatus === 409 || result.httpStatus >= 500) { await sleep(2000 * attempt); continue; } throw new Error(`${result.httpStatus} ${result.message}: ${JSON.stringify(result.data)}`); } throw new Error("Johari could not be reached. Try again later with the same idempotencyKey."); } // ---------- Answering a return ---------- // Called with a callback body or with Get a Request's data: both have the same fields. function onStatus(summary) { // Returned to us (its creator): we answer. Returned to the approver or // confirmer: they answer in Johari, and we just get the callbacks. if (summary.status === "returned" && summary.returnedTo === "creator") { answerReturn(summary).catch((error) => console.error(error.message)); } } async function answerReturn(summary) { const url = `/api/requests/${summary.requestID}`; // In a real system, save each key with your record and retry with it, // as raiseRequest does. if (RETURN_FILE) { const added = await callJohari("POST", `${url}/files`, { idempotencyKey: crypto.randomUUID(), files: [ { name: path.basename(RETURN_FILE), contentType: "application/pdf", content: fs.readFileSync(RETURN_FILE).toString("base64"), }, ], }); if (added.httpStatus !== 200) throw new Error(`Adding the file failed: ${added.message}`); console.log(`Added ${RETURN_FILE} to request #${summary.requestNumber}`); } const answered = await callJohari("POST", `${url}/respond-to-return`, { idempotencyKey: crypto.randomUUID(), note: "Checked: the details are correct. Please go ahead and pay.", }); console.log( `Answered the return on request #${summary.requestNumber}: ${answered.httpStatus} ${answered.message}`, ); } // ---------- Callback server ---------- // The path Johari signs is the path of CALLBACK_URL. This example also uses it // to route, which assumes no proxy in front rewrites the path. const callbackPath = new URL(CALLBACK_URL).pathname + new URL(CALLBACK_URL).search; const seenDeliveries = new Set(); function startCallbackServer() { const server = http.createServer((req, res) => { if (req.method !== "POST" || req.url !== callbackPath) { res.writeHead(404).end(); return; } let rawBody = ""; req.setEncoding("utf8"); req.on("data", (chunk) => (rawBody += chunk)); req.on("end", () => { const timestamp = req.headers["x-timestamp"] || ""; const signature = req.headers["x-signature"] || ""; const expected = sign(timestamp, "POST", callbackPath, rawBody); const valid = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; if (!valid || !fresh) { console.log("Rejected a callback with a bad signature"); res.writeHead(401).end("Invalid signature"); return; } const event = JSON.parse(rawBody); res.writeHead(200).end("OK"); // answer first, then do the work if (seenDeliveries.has(event.deliveryID)) return; // a retry we already handled seenDeliveries.add(event.deliveryID); if (event.event === "request.deleted") { console.log(`Callback: request #${event.requestNumber} was deleted in Johari`); } else if (event.event === "request.status-changed") { console.log( `Callback: request #${event.requestNumber} is now "${event.status}"` + (event.note ? ` (note: ${event.note})` : ""), ); onStatus(event); } }); }); server.listen(PORT, () => console.log(`Listening for callbacks on port ${PORT}`)); } // ---------- Main ---------- async function main() { startCallbackServer(); const companies = await callJohari("GET", "/api/companies"); if (companies.httpStatus !== 200) { throw new Error(`${companies.httpStatus} ${JSON.stringify(companies.data)}`); } const company = companies.data[0]; console.log("Company:", company.name); const sites = await callJohari("GET", `/api/companies/${company.id}/sites`); const site = sites.data[0]; console.log("Site:", site.name); const costCodes = await callJohari("GET", `/api/companies/${company.id}/cost-codes`); const costCode = costCodes.data[0]; console.log("Cost code:", costCode.label); const approvers = await callJohari("GET", `/api/sites/${site.id}/approvers`); const approver = approvers.data[0]; console.log("Approver:", approver.name); const created = await raiseRequest({ description: "Cement for slab (API example)", amount: 150000, requestingCompany: company.id, requestingSite: site.id, costCode: costCode.value, modeOfPayment: "bank", admin: approver.id, // required callbackURL: CALLBACK_URL, }); console.log( `Raised request #${created.requestNumber} (${created.requestID}),`, `status "${created.status}"`, ); const current = await callJohari("GET", `/api/requests/${created.requestID}`); console.log(`Checked: request #${current.data.requestNumber} is "${current.data.status}"`); onStatus(current.data); console.log("Now approve or reject it in Johari and watch the callbacks arrive."); } main().catch((error) => { console.error(error.message); process.exit(1); }); ``` ## Python 3 Save as `johari_example.py` and run `python johari_example.py`. ```python import base64 import hashlib import hmac import json import os import threading import time import urllib.error import urllib.request import uuid from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlsplit BASE_URL = os.environ["JOHARI_BASE_URL"] API_KEY = os.environ["JOHARI_API_KEY"] SIGNING_SECRET = os.environ["JOHARI_SIGNING_SECRET"] CALLBACK_URL = os.environ["CALLBACK_URL"] PORT = int(os.environ.get("PORT", "3000")) RETURN_FILE = os.environ.get("RETURN_FILE") # optional # ---------- Signing ---------- 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() def call_johari(method, path, payload=None): body = "" if payload is None else json.dumps(payload, separators=(",", ":")) timestamp = str(int(time.time())) request = urllib.request.Request( BASE_URL + path, method=method, data=body.encode("utf-8") if payload is not None else None, headers={ "Content-Type": "application/json", "x-api-key": API_KEY, "x-timestamp": timestamp, "x-signature": sign(timestamp, method, path, body), }, ) try: with urllib.request.urlopen(request, timeout=30) as response: return {"httpStatus": response.status, **json.loads(response.read())} except urllib.error.HTTPError as error: # 4xx and 5xx still carry the JSON envelope return {"httpStatus": error.code, **json.loads(error.read() or b"{}")} # ---------- Raising a request, with safe retries ---------- def raise_request(fields): # One key per payment request, reused on every retry. payload = {**fields, "idempotencyKey": str(uuid.uuid4())} for attempt in range(1, 6): try: result = call_johari("POST", "/api/requests", payload) except (urllib.error.URLError, TimeoutError) as error: print(f"Network error ({error}), retrying with the same key...") time.sleep(2 * attempt) continue status = result["httpStatus"] if status == 200: return result["data"] if status == 409 and result.get("data"): return result["data"] if status == 429: time.sleep(60) # over the per-minute limit: it resets within a minute continue if status == 409 or status >= 500: time.sleep(2 * attempt) continue raise RuntimeError(f"{status} {result.get('message')}: {result.get('data')}") raise RuntimeError("Johari could not be reached. Try again later with the same idempotencyKey.") # ---------- Answering a return ---------- def on_status(summary): """Called with a callback body or with Get a Request's data: both have the same fields.""" # Returned to us (its creator): we answer. Returned to the approver or # confirmer: they answer in Johari, and we just get the callbacks. if summary["status"] == "returned" and summary["returnedTo"] == "creator": answer_return(summary) def answer_return(summary): url = f"/api/requests/{summary['requestID']}" # In a real system, save each key with your record and retry with it, # as raise_request does. if RETURN_FILE: with open(RETURN_FILE, "rb") as f: content = base64.b64encode(f.read()).decode() added = call_johari("POST", f"{url}/files", { "idempotencyKey": str(uuid.uuid4()), "files": [ { "name": os.path.basename(RETURN_FILE), "contentType": "application/pdf", "content": content, } ], }) if added["httpStatus"] != 200: raise RuntimeError(f"Adding the file failed: {added.get('message')}") print(f'Added {RETURN_FILE} to request #{summary["requestNumber"]}') answered = call_johari("POST", f"{url}/respond-to-return", { "idempotencyKey": str(uuid.uuid4()), "note": "Checked: the details are correct. Please go ahead and pay.", }) print( f'Answered the return on request #{summary["requestNumber"]}:', answered["httpStatus"], answered.get("message"), ) # ---------- Callback server ---------- parts = urlsplit(CALLBACK_URL) CALLBACK_PATH = parts.path + (f"?{parts.query}" if parts.query else "") seen_deliveries = set() class CallbackHandler(BaseHTTPRequestHandler): def do_POST(self): if self.path != CALLBACK_PATH: self.send_response(404) self.end_headers() return raw_body = self.rfile.read(int(self.headers.get("Content-Length", 0))).decode("utf-8") timestamp = self.headers.get("x-timestamp", "") signature = self.headers.get("x-signature", "") expected = sign(timestamp, "POST", CALLBACK_PATH, raw_body) fresh = timestamp.isdigit() and abs(time.time() - int(timestamp)) < 300 if not hmac.compare_digest(signature, expected) or not fresh: print("Rejected a callback with a bad signature") self.send_response(401) self.end_headers() return event = json.loads(raw_body) self.send_response(200) # answer first, then do the work self.end_headers() self.wfile.write(b"OK") if event["deliveryID"] in seen_deliveries: return # a retry we already handled seen_deliveries.add(event["deliveryID"]) if event["event"] == "request.deleted": print(f'Callback: request #{event["requestNumber"]} was deleted in Johari') elif event["event"] == "request.status-changed": note = f" (note: {event['note']})" if event.get("note") else "" print(f'Callback: request #{event["requestNumber"]} is now "{event["status"]}"{note}') # In a thread, so this handler is not held up by the calls to Johari threading.Thread(target=on_status, args=(event,), daemon=True).start() def log_message(self, *args): pass # keep the output clean def start_callback_server(): server = HTTPServer(("", PORT), CallbackHandler) threading.Thread(target=server.serve_forever, daemon=True).start() print(f"Listening for callbacks on port {PORT}") # ---------- Main ---------- def main(): start_callback_server() companies = call_johari("GET", "/api/companies") if companies["httpStatus"] != 200: raise RuntimeError(f"{companies['httpStatus']} {companies.get('data')}") company = companies["data"][0] print("Company:", company["name"]) site = call_johari("GET", f"/api/companies/{company['id']}/sites")["data"][0] print("Site:", site["name"]) cost_code = call_johari("GET", f"/api/companies/{company['id']}/cost-codes")["data"][0] print("Cost code:", cost_code["label"]) approver = call_johari("GET", f"/api/sites/{site['id']}/approvers")["data"][0] print("Approver:", approver["name"]) created = raise_request({ "description": "Cement for slab (API example)", "amount": 150000, "requestingCompany": company["id"], "requestingSite": site["id"], "costCode": cost_code["value"], "modeOfPayment": "bank", "admin": approver["id"], # required "callbackURL": CALLBACK_URL, }) print( f'Raised request #{created["requestNumber"]} ({created["requestID"]}),', f'status "{created["status"]}"', ) current = call_johari("GET", f"/api/requests/{created['requestID']}") print(f'Checked: request #{current["data"]["requestNumber"]} is "{current["data"]["status"]}"') on_status(current["data"]) print("Now approve or reject it in Johari and watch the callbacks arrive. Ctrl+C to stop.") while True: time.sleep(1) if __name__ == "__main__": main() ``` ## What you should see ``` Listening for callbacks on port 3000 Company: Example Construction Ltd Site: Block A Apartments Cost code: 1001 - Site Utilities Approver: Amina Juma Raised request #1042 (6650f1a2b3c4d5e6f7a8b9e1), status "pending" Checked: request #1042 is "pending" Now approve or reject it in Johari and watch the callbacks arrive. Callback: request #1042 is now "approved" Callback: request #1042 is now "pending-payment" Callback: request #1042 is now "completed" ``` If the cashier returns the request to you instead, the program answers it (here with `RETURN_FILE` set): ``` Callback: request #1042 is now "returned" (note: Please attach the stamped invoice) Added stamped-invoice.pdf to request #1042 Answered the return on request #1042: 200 Response submitted successfully Callback: request #1042 is now "pending-payment" (note: Checked: the details are correct. Please go ahead and pay.) ``` ## Making it production-ready The example keeps everything in memory to stay short. In a real system: * **Save the `idempotencyKey`** with your own record before the first call, so a crash and restart retries with the same key. * **Save the `requestID`** you get back, to match callbacks and to check status later. * **Save `deliveryID`s** in your database, not in memory, to skip duplicate callbacks after a restart. * **Compare `changedAt`** with the last change you stored, and ignore older ones. See [Duplicates and order](/guide/callbacks#duplicates-and-order). * **Choose the company, site, cost code and approver (`admin`)** from your own data (a mapping table), not the first item in the list. * **Handle `request.deleted`** callbacks: mark the request as deleted in your system and stop following it. * **Answer returns with your own note.** Write a note that answers the cashier's reason (in `note`), and add the document they asked for. Save the `idempotencyKey` of each response and upload, and retry with it, the same way as for raising a request. * **Keep the secret in a secrets store**, never in code. ## Related pages * [Signing Requests](/guide/signing) * [Idempotency](/guide/idempotency) * [Callbacks](/guide/callbacks) * [Respond to a Return](/api/respond-to-return) * [Add Files](/api/add-files) --- --- url: /api/companies.md --- # List Companies Returns the companies your API key's user can raise payment requests for. Use an `id` from here as `requestingCompany` when you [create a request](/api/create-request). ## Endpoint | Method | URL | |---|---| | `GET` | `/api/companies` | No query parameters, no body. Sign it like any `GET`: ``` .GET./api/companies. ``` ## Response `data` is an array of companies (complete in one response, not paginated): | Field | Type | Description | |---|---|---| | `id` | `string` | Company ID. Use it as `requestingCompany` | | `name` | `string` | Company name | Only companies where the user can **create** requests at one or more sites are listed. If the user cannot create requests anywhere, `data` is `[]`. ## Example ::: code-group ```bash [cURL] 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" ``` ```js [Node.js] // callJohari is from the Signing page const result = await callJohari("GET", "/api/companies"); console.log(result.data); ``` ```python [Python] # call_johari is from the Signing page result = call_johari("GET", "/api/companies") print(result["data"]) ``` ::: Response `200`: ```json { "status": "success", "data": [ { "id": "6650f1a2b3c4d5e6f7a8b901", "name": "Example Construction Ltd" }, { "id": "6650f1a2b3c4d5e6f7a8b902", "name": "Example Developers Ltd" } ], "message": "Companies fetched successfully" } ``` ## Common mistakes ::: danger Hard-coding IDs from another environment The test server and the live server have different IDs. Look them up on each environment, or keep a separate mapping for each. ::: ::: tip This is your first test `GET /api/companies` is the simplest signed call. If it returns `200`, your key, secret, clock and signing are all correct. ::: ## Related pages * [List Sites](/api/sites) * [List Cost Codes](/api/cost-codes) --- --- url: /api/sites.md --- # List Sites Returns a company's sites where your API key's user can raise payment requests. Use an `id` from here as `requestingSite` when you [create a request](/api/create-request). A site is a project location, for example one building project. ## Endpoint | Method | URL | |---|---| | `GET` | `/api/companies/:companyID/sites` | | Path parameter | Description | |---|---| | `companyID` | An `id` from [List Companies](/api/companies) | Sign the full path, with the ID in it: ``` .GET./api/companies/6650f1a2b3c4d5e6f7a8b901/sites. ``` ## Response `data` is an array of sites (complete in one response, not paginated): | Field | Type | Description | |---|---|---| | `id` | `string` | Site ID (a UUID). Use it as `requestingSite` | | `name` | `string` | Site name | Only the sites where the user can **create** requests are listed, not every site of the company. ## Example ::: code-group ```bash [cURL] COMPANY_ID="6650f1a2b3c4d5e6f7a8b901" TS=$(date +%s) SIG=$(printf '%s' "$TS.GET./api/companies/$COMPANY_ID/sites." \ | openssl dgst -sha256 -hmac "$SIGNING_SECRET" \ | sed 's/^.* //') curl "$BASE_URL/api/companies/$COMPANY_ID/sites" \ -H "x-api-key: $API_KEY" \ -H "x-timestamp: $TS" \ -H "x-signature: $SIG" ``` ```js [Node.js] const companyID = "6650f1a2b3c4d5e6f7a8b901"; const result = await callJohari("GET", `/api/companies/${companyID}/sites`); console.log(result.data); ``` ```python [Python] company_id = "6650f1a2b3c4d5e6f7a8b901" result = call_johari("GET", f"/api/companies/{company_id}/sites") print(result["data"]) ``` ::: Response `200`: ```json { "status": "success", "data": [ { "id": "3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90", "name": "Block A Apartments" }, { "id": "8e1b4c7d-2a3f-4d5e-8b9c-0a1b2c3d4e5f", "name": "Riverside Warehouse" } ], "message": "Sites fetched successfully" } ``` Response `404`, when the company ID is wrong or the user cannot raise requests for it: ```json { "status": "error", "data": null, "message": "Company not found" } ``` ## Common mistakes ::: danger Mixing a site with the wrong company A site belongs to one company. When you create a request, `requestingSite` must be a site of `requestingCompany`, or you get `400`. ::: ## Related pages * [List Companies](/api/companies) * [List Approvers](/api/approvers) --- --- url: /api/cost-codes.md --- # List Cost Codes Returns the cost codes a company uses. A cost code says **what the money is for** (security, plaster, site utilities...). Use a `value` from here as `costCode` when you [create a request](/api/create-request). ## Endpoint | Method | URL | |---|---| | `GET` | `/api/companies/:companyID/cost-codes` | | Path parameter | Description | |---|---| | `companyID` | An `id` from [List Companies](/api/companies) | ``` .GET./api/companies/6650f1a2b3c4d5e6f7a8b901/cost-codes. ``` ## Response `data` is an array of cost codes, sorted by `value` (complete in one response, not paginated): | Field | Type | Description | |---|---|---| | `value` | `string` | The cost code. Use it as `costCode` | | `label` | `string` | Code and name together, good for showing to people | | `category` | `string` | The group the code belongs to, e.g. `"contractor"` or `"developer"`. The groups differ between companies and new ones can be added, so do not hard-code a list. For display and filtering only; you never send it back | ## Example ::: code-group ```bash [cURL] COMPANY_ID="6650f1a2b3c4d5e6f7a8b901" TS=$(date +%s) SIG=$(printf '%s' "$TS.GET./api/companies/$COMPANY_ID/cost-codes." \ | openssl dgst -sha256 -hmac "$SIGNING_SECRET" \ | sed 's/^.* //') curl "$BASE_URL/api/companies/$COMPANY_ID/cost-codes" \ -H "x-api-key: $API_KEY" \ -H "x-timestamp: $TS" \ -H "x-signature: $SIG" ``` ```js [Node.js] const companyID = "6650f1a2b3c4d5e6f7a8b901"; const result = await callJohari("GET", `/api/companies/${companyID}/cost-codes`); console.log(result.data); ``` ```python [Python] company_id = "6650f1a2b3c4d5e6f7a8b901" result = call_johari("GET", f"/api/companies/{company_id}/cost-codes") print(result["data"]) ``` ::: Response `200`: ```json { "status": "success", "data": [ { "value": "1001", "label": "1001 - Site Utilities", "category": "contractor" }, { "value": "1003", "label": "1003 - Security & Guards", "category": "contractor" }, { "value": "1801", "label": "1801 - Screed", "category": "contractor" }, { "value": "1802", "label": "1802 - Plaster", "category": "contractor" } ], "message": "Cost codes fetched successfully" } ``` Response `404`, when the company ID is wrong or the user cannot raise requests for it: ```json { "status": "error", "data": null, "message": "Company not found" } ``` ## Common mistakes ::: danger Sending the cost code as a number `costCode` must be a string: `"1003"`, not `1003`. ::: ::: danger Sending the label Send `value` (`"1003"`), not `label` (`"1003 - Security & Guards"`). ::: ::: danger Using another company's cost code Each company has its own set. Use a `value` from **the same company** as `requestingCompany`. ::: ## Related pages * [Create a Request](/api/create-request) --- --- url: /api/approvers.md --- # List Approvers Returns the people who can approve payment requests at a site. Use an `id` from here as the **required** `admin` field when you [create a request](/api/create-request). ::: tip How admin is used Johari still routes the request through the site's approval rules first. `admin` is the approver used when **no one** in those rules can approve the request (for example, the amount is above everyone's limit). It is always required, the same as the approver field on the web form. ::: ## Endpoint | Method | URL | |---|---| | `GET` | `/api/sites/:siteID/approvers` | | Path parameter | Description | |---|---| | `siteID` | An `id` from [List Sites](/api/sites) | ``` .GET./api/sites/3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90/approvers. ``` ## Response `data` is an array of people (complete in one response, not paginated): | Field | Type | Description | |---|---|---| | `id` | `string` | The person's ID (24 hex characters). Use it as `admin` | | `name` | `string` | Their name | ## Example ::: code-group ```bash [cURL] SITE_ID="3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90" TS=$(date +%s) SIG=$(printf '%s' "$TS.GET./api/sites/$SITE_ID/approvers." \ | openssl dgst -sha256 -hmac "$SIGNING_SECRET" \ | sed 's/^.* //') curl "$BASE_URL/api/sites/$SITE_ID/approvers" \ -H "x-api-key: $API_KEY" \ -H "x-timestamp: $TS" \ -H "x-signature: $SIG" ``` ```js [Node.js] const siteID = "3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90"; const result = await callJohari("GET", `/api/sites/${siteID}/approvers`); console.log(result.data); ``` ```python [Python] site_id = "3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90" result = call_johari("GET", f"/api/sites/{site_id}/approvers") print(result["data"]) ``` ::: Response `200`: ```json { "status": "success", "data": [ { "id": "6650f1a2b3c4d5e6f7a8b7a1", "name": "Amina Juma" }, { "id": "6650f1a2b3c4d5e6f7a8b7a2", "name": "Peter Mushi" } ], "message": "Approvers fetched successfully" } ``` The list can include the API key's own user, if they are allowed to approve at that site. Response `404`, when the site ID is wrong or the user cannot raise requests there: ```json { "status": "error", "data": null, "message": "Site not found" } ``` ## Common mistakes ::: danger Leaving out admin `admin` is required on every request. Without it, or with a value that is not a 24-hex-character ID, you get `400` (`Please provide an admin: an approver id from the site's approvers list`). ::: ::: danger Using an approver from another site `admin` must be an approver **of the site** in `requestingSite`. Otherwise you get `400` (`admin is not one of this site's approvers`). ::: ## Related pages * [Create a Request](/api/create-request) * [List Sites](/api/sites) --- --- url: /api/create-request.md --- # Create a Request Raises a new payment request in Johari. It appears in Johari like a request typed in by a person, and goes through the same approval and payment steps. Its creator is shown as the API key's user with ` (API)` after the name, e.g. `Neema Mollel (API)`, so people can see a system raised it. ## Endpoint | Method | URL | |---|---| | `POST` | `/api/requests` | Send the body as JSON with `Content-Type: application/json`, and sign the exact JSON text: ``` .POST./api/requests. ``` ## Body fields | Field | Type | Required | Description | |---|---|---|---| | `idempotencyKey` | `string` | **Yes** | Your unique ID for this request, 1 to 255 characters. A UUID is ideal. See [Idempotency](/guide/idempotency) | | `description` | `string` | **Yes** | What the payment is for, e.g. `"Cement for slab, Block B"`. Up to 500 characters | | `amount` | `number` or `string` | **Yes** | Greater than 0, at most 2 decimal places. A JSON number (`150000`, `1500.5`), or a string of digits with optional thousands commas and an optional decimal part (`"150,000"`, `"150,000.50"`). Any other string is refused, e.g. `"0x10"`, `"1e3"`, `"-5"`, `".5"` | | `requestingCompany` | `string` | **Yes** | A company `id` from [List Companies](/api/companies) | | `requestingSite` | `string` | **Yes** | A site `id` from [List Sites](/api/sites), for that company | | `costCode` | `string` | **Yes** | A cost code `value` from [List Cost Codes](/api/cost-codes), for that company | | `modeOfPayment` | `string` | **Yes** | `"cash"` or `"bank"` | | `admin` | `string` | **Yes** | An approver `id` (24 hex characters) from [List Approvers](/api/approvers) for `requestingSite`. See [The admin field](#the-admin-field) | | `currency` | `string` | No | `"TZS"` (default), `"USD"`, `"KES"`, `"ZAR"`, `"AED"` or `"GBP"` | | `isUrgent` | `boolean` | No | `true` to mark it urgent. Default `false` | | `notes` | `string` | No | A note shown on the request in Johari. Up to 2000 characters | | `files` | `array` | No | Up to 5 files to attach to the request. See [Attaching files](#attaching-files) | | `callbackURL` | `string` | No | Where to send [callbacks](/guide/callbacks) when the status changes. Up to 2048 characters. On the live server it must be `https://` and point to a public address. See [Turning callbacks on](/guide/callbacks#turning-callbacks-on) | Any other field is ignored. Receipts cannot be attached through the API. Files can: see [Attaching files](#attaching-files). ## The admin field `admin` is **required**. It is an approver `id` from [List Approvers](/api/approvers) for the site in `requestingSite`. Johari still routes the request through the site's approval rules first. `admin` is the approver used when **no one** in those rules can approve it (for example, the amount is above everyone's limit). This is the same as the web form, where the approver field is always required. ## Attaching files `files` is an optional list of up to **5** files. They are attached to the request, and approvers see them in Johari like files attached on the web form. Each item in `files` is an object: | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | **Yes** | The file name shown in Johari, e.g. `"invoice-1042.pdf"`. Up to 255 characters. No `/` or `\`, and no control or invisible formatting characters (for example right-to-left marks) | | `contentType` | `string` | **Yes** | The file type. One of the values in the table below | | `content` | `string` | **Yes** | The file's bytes, encoded as standard base64. The bytes must really be of the type in `contentType` | Accepted `contentType` values. When you attach a file from disk, pick `contentType` from its extension with this table; any other extension cannot be attached: | `contentType` | File | File extensions | |---|---|---| | `application/pdf` | PDF | `.pdf` | | `image/jpeg` | JPEG image | `.jpg`, `.jpeg` | | `image/png` | PNG image | `.png` | | `image/webp` | WebP image | `.webp` | | `image/heic` | HEIC image | `.heic` | | `application/msword` | Word | `.doc` | | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | Word | `.docx` | | `application/vnd.ms-excel` | Excel | `.xls` | | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` | Excel | `.xlsx` | Rules: * At most **5** files. Leaving `files` out and sending `"files": []` are the same: no files. * All files together at most **25 MB**, counted after decoding. That keeps the whole JSON body under the **35 MB** limit; a larger body gets `413`. * `content` is standard base64: `A`–`Z`, `a`–`z`, `0`–`9`, `+` and `/`, with `=` padding. Its length is a multiple of 4. No `data:` prefix (such as `data:application/pdf;base64,`), and no line breaks. * The file's bytes must really be of the declared `contentType`. Johari checks the file's first bytes: a PDF must contain `%PDF-` in its first 1024 bytes, JPEG, PNG, WebP and HEIC images are checked by their standard signatures, `.doc` and `.xls` must be the old Office format, and `.docx` and `.xlsx` must be ZIP-based. Otherwise you get `400` (`files[0].content does not match its contentType`). Pick `contentType` from the extension table above, and do not send placeholder text as `content`. * Johari's server uploads the files. There is no separate upload step and no presigned URL. * Signing does not change. The signature covers the whole JSON body, base64 `content` included. * [Idempotency](/guide/idempotency) still applies. A retry must send the identical body, files included. How to read a file and base64-encode it: | Language | Code | |---|---| | Node.js | `fs.readFileSync(path).toString("base64")` | | Python | `base64.b64encode(open(path, "rb").read()).decode()` | | bash | `base64 < invoice-1042.pdf \| tr -d '\n'` | After the request is created, every file on it is listed in `files` in [Get a Request](/api/get-request) and in [callbacks](/guide/callbacks): the files sent when creating it, the cashier's proof of payment, the EFD receipt, any file people attach to the request in Johari, and any file you add later with [Add Files](/api/add-files). Download one with [Get a File](/api/get-file). ## Validation rules * All required fields must be present and of the type shown. Text must not be empty. * `amount` must be greater than 0 with at most 2 decimal places. `1500.5` and `"1,500.50"` are fine; `1500.555` is refused. A string may only hold digits, thousands commas and one decimal part: `"0x10"`, `"1e3"`, `"-5"` and `".5"` are refused. * `requestingSite` must be a site **of** `requestingCompany`, and `costCode` a cost code **of** `requestingCompany`. * `admin` must be an approver **of** `requestingSite`. * `currency` and `modeOfPayment` only accept the values listed above, in lower case for `modeOfPayment`, upper case for `currency`. * `files`, when sent, must follow the [file rules](#attaching-files). * `callbackURL`, when sent, must be a full URL of up to 2048 characters. On the live server it must be `https://`, and `localhost` or an IP address that is not public is refused. See [Turning callbacks on](/guide/callbacks#turning-callbacks-on). * `idempotencyKey` must not have been used before for a **different** request. See [Idempotency](/guide/idempotency). Any rule that fails returns `400` with a message naming the field. The full list is on the [Errors](/guide/errors#create-request-errors) page. ## Response `data` on success: | Field | Type | Description | |---|---|---| | `requestID` | `string` | The new request's ID. **Save it**: you need it for [Get a Request](/api/get-request) and to match callbacks | | `requestNumber` | `string` | The number people see in Johari, e.g. `"1042"` | | `status` | `string` | `"pending"` (waiting for approval) or `"approved"` (approved straight away). See [Request Statuses](/guide/statuses) | ## Examples ### The smallest valid request ```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" } ``` ### Every field The `content` is shortened here. Send the whole file. ```json { "idempotencyKey": "PO-2026-00123-PAY-1", "description": "Steel bars, 12mm, 200 pieces", "amount": "4,250,000", "currency": "TZS", "requestingCompany": "6650f1a2b3c4d5e6f7a8b901", "requestingSite": "3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90", "costCode": "1801", "modeOfPayment": "bank", "isUrgent": true, "notes": "Supplier needs payment before Friday delivery", "admin": "6650f1a2b3c4d5e6f7a8b7a1", "files": [ { "name": "quote-steel-200.pdf", "contentType": "application/pdf", "content": "JVBERi0xLjQK" } ], "callbackURL": "https://erp.yourcompany.com/johari/callback" } ``` ### A US dollar request ```json { "idempotencyKey": "3a1f9c2e-7b4d-4e8a-a6c1-5d2e9f0b8c71", "description": "Imported tiles, deposit", "amount": 1200.5, "currency": "USD", "requestingCompany": "6650f1a2b3c4d5e6f7a8b901", "requestingSite": "3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90", "costCode": "1802", "modeOfPayment": "bank", "admin": "6650f1a2b3c4d5e6f7a8b7a1" } ``` ### A request with one file The `content` is shortened here. Send the whole file, base64-encoded. ```json { "idempotencyKey": "c4d8e2f1-6b3a-4c9d-8e7f-2a1b3c4d5e6f", "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" } ] } ``` ### Code ::: code-group ```bash [cURL] 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] const crypto = require("crypto"); // callJohari is from the Signing page const result = await callJohari("POST", "/api/requests", { idempotencyKey: crypto.randomUUID(), description: "Cement for slab", amount: 150000, requestingCompany: "6650f1a2b3c4d5e6f7a8b901", requestingSite: "3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90", costCode: "1003", modeOfPayment: "bank", admin: "6650f1a2b3c4d5e6f7a8b7a1", callbackURL: "https://erp.yourcompany.com/johari/callback", }); if (result.httpStatus === 200) { console.log("Created request", result.data.requestNumber, result.data.requestID); } else { console.error(result.httpStatus, result.message, result.data); } ``` ```python [Python] import uuid # call_johari is from the Signing page result = call_johari("POST", "/api/requests", { "idempotencyKey": str(uuid.uuid4()), "description": "Cement for slab", "amount": 150000, "requestingCompany": "6650f1a2b3c4d5e6f7a8b901", "requestingSite": "3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90", "costCode": "1003", "modeOfPayment": "bank", "admin": "6650f1a2b3c4d5e6f7a8b7a1", "callbackURL": "https://erp.yourcompany.com/johari/callback", }) if result["httpStatus"] == 200: print("Created request", result["data"]["requestNumber"], result["data"]["requestID"]) else: print(result["httpStatus"], result["message"], result["data"]) ``` ::: ### Code: attaching a file Read the file, base64-encode it, and put it in `files`. Then sign and send the body as usual. ::: code-group ```bash [cURL] # The file as base64, on one line FILE_B64=$(base64 < invoice-1042.pdf | tr -d '\n') # Build the body in a file: with a file inside, it is too long for a command-line argument cat > body.json <.GET./api/requests/6650f1a2b3c4d5e6f7a8b9e1. ``` You can only see requests raised with **your** API key. That includes [child requests](/guide/statuses#partial-payments) made from them. ## Response `data` is the request summary. It has the same fields as a [callback](/guide/callbacks#what-we-send), in the same order, without `event`, `deliveryID` and `changedAt`: | Field | Type | Description | |---|---|---| | `requestID` | `string` | The request's ID | | `requestNumber` | `string` | Its number in Johari | | `status` | `string` | Current status. See [Request Statuses](/guide/statuses) | | `statusTitle` | `string` | Status as people see it in Johari, e.g. `"Pending Payment"` | | `description` | `string` | What the payment is for | | `amount` | `number` | The request amount | | `currency` | `string` | e.g. `"TZS"` | | `amountRemaining` | `number \| null` | Only for a request being paid in parts: how much is still unpaid. Otherwise `null` | | `modeOfPayment` | `string` | `"cash"` or `"bank"` | | `isUrgent` | `boolean` | `true` if the request is marked urgent | | `company` | `object` | The requesting company: `{ "id": string, "name": string }` | | `site` | `object` | The requesting site: `{ "id": string, "name": string }` | | `costCode` | `string` | The cost code `value` | | `isChildRequest` | `boolean` | `true` for one part of a request being paid in parts. See [Partial payments](/guide/statuses#partial-payments) | | `mainRequestID` | `string \| null` | For a child request, the request it belongs to. Otherwise `null` | | `note` | `string \| null` | Only the note written with the **latest** status change, e.g. the rejection reason, the return reason, a payment note or an approval comment. `null` if that change had no note. Never an older note | | `createdAt` | `string` | When the request was created (ISO 8601, UTC) | | `createdBy` | `string` | Name of the person in Johari who created it. For requests you raise, the API key's user's name followed by ` (API)`, e.g. `"Neema Mollel (API)"`. Johari shows the same in the request, its log and the WhatsApp notifications, so people can see a system raised it | | `approvedAt` | `string \| null` | When it was approved (ISO 8601, UTC) | | `approvedBy` | `string \| null` | Name of the person who approved it. If the request was approved automatically (because the key's user may approve it themselves), it is also the key's user's name followed by ` (API)` | | `rejectedAt` | `string \| null` | When it was rejected (ISO 8601, UTC) | | `rejectedBy` | `string \| null` | Name of the person who rejected it | | `confirmedAt` | `string \| null` | When accounts confirmed it, sending it for payment (ISO 8601, UTC) | | `confirmedBy` | `string \| null` | Name of the person who confirmed it | | `paidAt` | `string \| null` | When it was paid (ISO 8601, UTC) | | `paidBy` | `string \| null` | Name of the person who paid it | | `efdAt` | `string \| null` | When the EFD (tax) receipt was uploaded (ISO 8601, UTC) | | `efdBy` | `string \| null` | Name of the person who uploaded the EFD receipt | | `returnedAt` | `string \| null` | When it was returned (ISO 8601, UTC) | | `returnedBy` | `string \| null` | Name of the person who returned it | | `returnedTo` | `string \| null` | Who the cashier returned it to: `"creator"`, `"approver"` or `"confirmer"`. `null` if it was never returned. While `status` is `"returned"` and this is `"creator"`, the request is waiting for **your** system to answer. Keeps its last value after the request moves on, so always check `status` too. See [Returned requests](#returned-requests) | | `files` | `array` | Every file on the request. See [Files](#files) | The `...At` fields say when that step happened. The `...By` fields are the name of the person in Johari who did it. Each one is `null` until that step happens. `note` is only ever the note from the latest status change. Johari's other internal notes are not shared. ### Returned requests `returnedTo` says who the cashier returned the request to. * While `status` is `"returned"` and `returnedTo` is `"creator"`, the request is waiting for **your** system to answer. See [Respond to a Return](/api/respond-to-return). * When it is `"approver"` or `"confirmer"`, that person answers it in Johari. You just receive the callbacks. `returnedTo` keeps its last value after the request moves on, so always check `status` too. See [Returned](/guide/statuses#returned). ### Files `files` lists every file on the request: * the files sent when the request was [created](/api/create-request#attaching-files), * the proof of payment the cashier uploads when paying, * the EFD receipt, * any file people attach to the request in Johari (for example an accountant's upload), * any file your system adds with [Add Files](/api/add-files). Each item: | Field | Type | Description | |---|---|---| | `id` | `string` | The file's ID. Use it with [Get a File](/api/get-file) to download it | | `name` | `string` | The file name, e.g. `"invoice-1042.pdf"` | | `type` | `string \| null` | The file type, e.g. `"application/pdf"`, or `null` | | `size` | `number \| null` | The size in bytes, or `null` | There is no download link in `files`. Ask [Get a File](/api/get-file) for a fresh link each time you need one. Internal storage paths are never shown. ## Example ::: code-group ```bash [cURL] REQUEST_ID="6650f1a2b3c4d5e6f7a8b9e1" TS=$(date +%s) SIG=$(printf '%s' "$TS.GET./api/requests/$REQUEST_ID." \ | openssl dgst -sha256 -hmac "$SIGNING_SECRET" \ | sed 's/^.* //') curl "$BASE_URL/api/requests/$REQUEST_ID" \ -H "x-api-key: $API_KEY" \ -H "x-timestamp: $TS" \ -H "x-signature: $SIG" ``` ```js [Node.js] const requestID = "6650f1a2b3c4d5e6f7a8b9e1"; const result = await callJohari("GET", `/api/requests/${requestID}`); console.log(result.data.status); ``` ```python [Python] request_id = "6650f1a2b3c4d5e6f7a8b9e1" result = call_johari("GET", f"/api/requests/{request_id}") print(result["data"]["status"]) ``` ::: Response `200`, for a request that has been paid and completed: ```json { "status": "success", "data": { "requestID": "6650f1a2b3c4d5e6f7a8b9e1", "requestNumber": "1042", "status": "completed", "statusTitle": "Completed", "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": "2026-10-29T13:40:18.230Z", "confirmedBy": "Grace Kimaro", "paidAt": "2026-10-30T07:55:02.918Z", "paidBy": "Joseph Mwakyusa", "efdAt": "2026-10-30T11:20:45.067Z", "efdBy": "Joseph Mwakyusa", "returnedAt": null, "returnedBy": null, "returnedTo": null, "files": [ { "id": "0b6f2c1e-8d4a-4f3b-9e7c-5a1d2b3c4e5f", "name": "invoice-1042.pdf", "type": "application/pdf", "size": 48213 }, { "id": "5e9a1b7c-3d2f-4a6e-8b1c-9d0e2f3a4b5c", "name": "payment-slip-1042.pdf", "type": "application/pdf", "size": 102944 }, { "id": "a2c4e6f8-1b3d-4f5a-9c7e-0d2b4f6a8c1e", "name": "efd-receipt-1042.jpg", "type": "image/jpeg", "size": 231570 } ] }, "message": "Request fetched successfully" } ``` Response `404`: ```json { "status": "error", "data": null, "message": "Request not found" } ``` You get `404` when the ID is wrong, when the request was raised with a different API key, or when it was deleted in Johari. If the request has a `callbackURL`, a deletion also sends a [`request.deleted` callback](/guide/callbacks#events). ## Polling If you rely on this endpoint instead of callbacks, do not call it in a tight loop. Requests move at human speed: checking each open request every **5 to 15 minutes** is plenty. Stop checking once the status is final (`completed`, `rejected` or `master-partial-payment-completed`), or once you get `404` because the request was deleted. ## Related pages * [Get a File](/api/get-file) * [Respond to a Return](/api/respond-to-return) * [Add Files](/api/add-files) * [Callbacks](/guide/callbacks) * [Request Statuses](/guide/statuses) --- --- url: /api/get-file.md --- # Get a File Returns a short-lived download link for one file on a request you raised. Use it to download: * the files you sent when you [created the request](/api/create-request#attaching-files), * the proof of payment the cashier uploads when paying, * the EFD receipt, * any file people attach to the request in Johari (for example an accountant's upload), * any file your system adds with [Add Files](/api/add-files). ## Endpoint | Method | URL | |---|---| | `GET` | `/api/requests/:requestID/files/:fileID` | | Path parameter | Description | |---|---| | `requestID` | The request's `requestID` | | `fileID` | A `files[].id` from [Get a Request](/api/get-request#files) or from a [callback](/guide/callbacks#what-we-send) | Sign it like any `GET`, with an empty body: ``` .GET./api/requests/6650f1a2b3c4d5e6f7a8b9e1/files/0b6f2c1e-8d4a-4f3b-9e7c-5a1d2b3c4e5f. ``` You can only see requests raised with **your** API key, and their files. ## Response `data` on success: | Field | Type | Description | |---|---|---| | `id` | `string` | The file's ID | | `name` | `string` | The file name, e.g. `"invoice-1042.pdf"` | | `type` | `string \| null` | The file type, e.g. `"application/pdf"`, or `null` | | `size` | `number \| null` | The size in bytes, or `null` | | `url` | `string` | A direct download link. Valid for **5 minutes** | | `expiresAt` | `string` | When `url` stops working (ISO 8601, UTC) | ## Downloading the file `url` is a pre-signed storage link. Download it with a plain `GET` **straight away**: * Send **no** Johari headers: no `x-api-key`, `x-timestamp` or `x-signature`. Do not sign it. * It works for 5 minutes, until `expiresAt`. * Do not store `url`. Ask this endpoint for a fresh link each time you need the file. ## Example ::: code-group ```bash [cURL] REQUEST_ID="6650f1a2b3c4d5e6f7a8b9e1" FILE_ID="0b6f2c1e-8d4a-4f3b-9e7c-5a1d2b3c4e5f" TS=$(date +%s) SIG=$(printf '%s' "$TS.GET./api/requests/$REQUEST_ID/files/$FILE_ID." \ | openssl dgst -sha256 -hmac "$SIGNING_SECRET" \ | sed 's/^.* //') curl "$BASE_URL/api/requests/$REQUEST_ID/files/$FILE_ID" \ -H "x-api-key: $API_KEY" \ -H "x-timestamp: $TS" \ -H "x-signature: $SIG" # Then download the link from data.url, with no Johari headers: # curl -o invoice-1042.pdf "" ``` ```js [Node.js] const fs = require("fs"); const requestID = "6650f1a2b3c4d5e6f7a8b9e1"; const fileID = "0b6f2c1e-8d4a-4f3b-9e7c-5a1d2b3c4e5f"; // callJohari is from the Signing page const result = await callJohari("GET", `/api/requests/${requestID}/files/${fileID}`); if (result.httpStatus === 200) { // A plain GET, with no Johari headers const download = await fetch(result.data.url); fs.writeFileSync(result.data.name, Buffer.from(await download.arrayBuffer())); } else { console.error(result.httpStatus, result.message); } ``` ```python [Python] import requests # pip install requests request_id = "6650f1a2b3c4d5e6f7a8b9e1" file_id = "0b6f2c1e-8d4a-4f3b-9e7c-5a1d2b3c4e5f" # call_johari is from the Signing page result = call_johari("GET", f"/api/requests/{request_id}/files/{file_id}") if result["httpStatus"] == 200: # A plain GET, with no Johari headers download = requests.get(result["data"]["url"]) with open(result["data"]["name"], "wb") as f: f.write(download.content) else: print(result["httpStatus"], result["message"]) ``` ::: Response `200`: ```json { "status": "success", "data": { "id": "0b6f2c1e-8d4a-4f3b-9e7c-5a1d2b3c4e5f", "name": "invoice-1042.pdf", "type": "application/pdf", "size": 48213, "url": "https://storage.example.com/...", "expiresAt": "2026-10-30T12:05:00.000Z" }, "message": "File link created successfully" } ``` The `url` above is shortened. Use the whole value you receive, exactly as it is. Response `404`, request not found: ```json { "status": "error", "data": null, "message": "Request not found" } ``` You get `Request not found` when the request ID is wrong, when the request was raised with a different API key, or when it was deleted in Johari. Response `404`, file not found: ```json { "status": "error", "data": null, "message": "File not found" } ``` You get `File not found` when the request exists but has no file with that `fileID`. The usual `401` errors apply too. See [Errors](/guide/errors#authentication-errors). ## Common mistakes ::: danger Sending Johari headers to the download link `url` is a pre-signed storage link, not a Johari API call. Download it with a plain `GET`. Do not sign it, and send no `x-api-key`, `x-timestamp` or `x-signature` headers. Only the call to `/api/requests/:requestID/files/:fileID` is signed. ::: ::: danger Storing the link The link stops working after 5 minutes. Store the `fileID`, and ask for a fresh link each time. ::: ## Related pages * [Get a Request](/api/get-request) * [Create a Request](/api/create-request#attaching-files) * [Add Files](/api/add-files) * [Callbacks](/guide/callbacks) --- --- url: /api/respond-to-return.md --- # Respond to a Return Answers a request that was returned to **you**. It does exactly what a person does when they answer a return in Johari: the request goes back to whoever returned it, with your answer. Use it when a request you raised has `status: "returned"` and `returnedTo: "creator"`. The callback that tells you it was returned carries both, and its `note` is the reason it was returned. See [Returned](/guide/statuses#returned). ## Endpoint | Method | URL | |---|---| | `POST` | `/api/requests/:requestID/respond-to-return` | | Path parameter | Description | |---|---| | `requestID` | The request's `requestID`, from [Create a Request](/api/create-request) or a callback | Send the body as JSON with `Content-Type: application/json`, and sign the exact JSON text. The path you sign includes the request ID: ``` .POST./api/requests/6650f1a2b3c4d5e6f7a8b9e1/respond-to-return. ``` ## When you can use it All three must be true: * The request was raised with **your** API key. That includes [child requests](/guide/statuses#partial-payments) made from it. * Its `status` is `"returned"`. * It was returned to your key's user: `returnedTo` is `"creator"`. If it was returned to someone else (its approver or confirmer), you get `403`. That person answers it in Johari, and you get the callbacks. ## Body fields | Field | Type | Required | Description | |---|---|---|---| | `idempotencyKey` | `string` | **Yes** | Your unique ID for this response, 1 to 255 characters. A UUID is ideal. Use a new one for each new response, and reuse it only to retry the same response. See [Idempotency](/guide/idempotency) | | `note` | `string` | **Yes** | Your answer, e.g. `"The stamped invoice is now attached. The amount is correct."`. Not empty, up to 2000 characters | Any other field is ignored. ## What happens Exactly what happens when a person answers the return in Johari: 1. The request goes back to the stage it was returned from: * **`pending`** (waiting for approval) if an approver returned it before approving it. The approver gets their usual WhatsApp message asking them to approve it. * **`pending-payment`** if the cashier returned it before paying it. The cashier gets their usual WhatsApp message. 2. Your `note` is added to the request, shown as written by your key's user with ` (API)` after the name, e.g. `Neema Mollel (API)`. 3. You get a `request.status-changed` [callback](/guide/callbacks) with the new `status` and your note as `note` (if the request has a `callbackURL`). [Get a Request](/api/get-request) shows the same. ::: tip Asked for a document? Add it first with [Add Files](/api/add-files), then respond. ::: ## Response `data` on success: | Field | Type | Description | |---|---|---| | `requestID` | `string` | The request's ID | | `requestNumber` | `string` | Its number in Johari, e.g. `"1042"` | | `status` | `string` | Where the request went back to: `"pending"` (an approver returned it, it is waiting for approval again) or `"pending-payment"` (the cashier returned it, it is waiting for payment again) | ## Example The body: ```json { "idempotencyKey": "9f3b2a1c-4d5e-4f6a-8b7c-0d1e2f3a4b5c", "note": "The stamped invoice is now attached. The amount is correct." } ``` ::: code-group ```bash [cURL] REQUEST_ID="6650f1a2b3c4d5e6f7a8b9e1" BODY=$(cat <<'EOF' { "idempotencyKey": "9f3b2a1c-4d5e-4f6a-8b7c-0d1e2f3a4b5c", "note": "The stamped invoice is now attached. The amount is correct." } EOF ) TS=$(date +%s) SIG=$(printf '%s' "$TS.POST./api/requests/$REQUEST_ID/respond-to-return.$BODY" \ | openssl dgst -sha256 -hmac "$SIGNING_SECRET" \ | sed 's/^.* //') curl -X POST "$BASE_URL/api/requests/$REQUEST_ID/respond-to-return" \ -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] const crypto = require("crypto"); const requestID = "6650f1a2b3c4d5e6f7a8b9e1"; // callJohari is from the Signing page const result = await callJohari("POST", `/api/requests/${requestID}/respond-to-return`, { idempotencyKey: crypto.randomUUID(), note: "The stamped invoice is now attached. The amount is correct.", }); if (result.httpStatus === 200) { console.log("Request", result.data.requestNumber, "is now", result.data.status); } else { console.error(result.httpStatus, result.message, result.data); } ``` ```python [Python] import uuid request_id = "6650f1a2b3c4d5e6f7a8b9e1" # call_johari is from the Signing page result = call_johari("POST", f"/api/requests/{request_id}/respond-to-return", { "idempotencyKey": str(uuid.uuid4()), "note": "The stamped invoice is now attached. The amount is correct.", }) if result["httpStatus"] == 200: print("Request", result["data"]["requestNumber"], "is now", result["data"]["status"]) else: print(result["httpStatus"], result["message"], result["data"]) ``` ::: Response `200`: ```json { "status": "success", "data": { "requestID": "6650f1a2b3c4d5e6f7a8b9e1", "requestNumber": "1042", "status": "pending-payment" }, "message": "Response submitted successfully" } ``` Response `409`, same `idempotencyKey` sent again after it worked: ```json { "status": "error", "data": { "requestID": "6650f1a2b3c4d5e6f7a8b9e1", "requestNumber": "1042", "status": "pending-payment" }, "message": "A request with this idempotencyKey has already been processed" } ``` Response `403`, the request was returned to someone else: ```json { "status": "error", "data": "This request was returned to its confirmer (Amina Juma), so only they can respond to it", "message": "This request was returned to its confirmer (Amina Juma), so only they can respond to it" } ``` Response `400`, the request is not returned (or the return was already answered): ```json { "status": "error", "data": "This request is not in returned status", "message": "This request is not in returned status" } ``` Response `404`, wrong ID, or the request was not raised with your key: ```json { "status": "error", "data": "Request not found", "message": "Request not found" } ``` The usual `401` errors apply too. Every possible error is listed on the [Errors](/guide/errors#respond-to-a-return-errors) page. ## Common mistakes ::: danger Responding to a request returned to someone else Only answer when `returnedTo` is `"creator"`. When it is `"approver"` or `"confirmer"`, you get `403`: that person answers it in Johari. ::: ::: danger Checking `returnedTo` without `status` `returnedTo` keeps its last value after the request moves on. Only `status: "returned"` means the request is waiting for an answer now. ::: ::: danger A new idempotencyKey on every retry Retries must reuse the key. If your first call worked but the answer was lost, a retry with a new key gets `400` (`This request is not in returned status`) instead of the `409` that tells you it worked. If the cashier returned the request again in the meantime, it would even answer the new return with your old note. ::: ::: danger Reusing the key of an earlier response A request can be returned more than once. Answer each return with a **new** key. An earlier key gets the `409` for the earlier response (same note) or `400` (a different note), and nothing is sent to the cashier. ::: ## Related pages * [Request Statuses](/guide/statuses#returned) * [Add Files](/api/add-files) * [Get a Request](/api/get-request) * [Callbacks](/guide/callbacks) * [Idempotency](/guide/idempotency) --- --- url: /api/add-files.md --- # Add Files Adds up to 3 files to a request you raised. It is the same as uploading files on the request's page in Johari. Use it, for example, when the cashier returned a request asking for a document: add the file, then [respond to the return](/api/respond-to-return). ## Endpoint | Method | URL | |---|---| | `POST` | `/api/requests/:requestID/files` | | Path parameter | Description | |---|---| | `requestID` | The request's `requestID`, from [Create a Request](/api/create-request) or a callback | Send the body as JSON with `Content-Type: application/json`, and sign the exact JSON text. The path you sign includes the request ID: ``` .POST./api/requests/6650f1a2b3c4d5e6f7a8b9e1/files. ``` You can only add files to a request raised with **your** API key (including [child requests](/guide/statuses#partial-payments) made from it). It works in any status. ## Body fields | Field | Type | Required | Description | |---|---|---|---| | `idempotencyKey` | `string` | **Yes** | Your unique ID for this upload, 1 to 255 characters. A UUID is ideal. Use a new one for each new upload, and reuse it only to retry the same upload. See [Idempotency](/guide/idempotency) | | `files` | `array` | **Yes** | 1 to 3 files. Each item is exactly like a `files` item when you [create a request](/api/create-request#attaching-files): `name`, `contentType` and `content` | Any other field is ignored. Rules: * **1 to 3** files per call. Leaving `files` out, or sending `[]`, gets `400` (`Please provide a file`). For more than 3, make another call with a new key. * Everything else is the same as when you create a request: the same 9 `contentType` values, the check of each file's first bytes, standard base64 with no `data:` prefix and no line breaks, and at most **25 MB** for all files together. See [Attaching files](/api/create-request#attaching-files). * The signature covers the whole body, base64 `content` included. ## What happens Exactly what happens when someone uploads files on the request's page in Johari: * The files are added to the request. Files already on it stay. * The request's log records that your key's user uploaded a file, with ` (API)` after the name, e.g. `Neema Mollel (API) uploaded a file`. * The status does **not** change, so no callback is sent for it. Nobody is notified. * The files appear in `files` in [Get a Request](/api/get-request#files) straight away, and in the next callbacks. Download one with [Get a File](/api/get-file). ## Response `data` on success: | Field | Type | Description | |---|---|---| | `requestID` | `string` | The request's ID | | `requestNumber` | `string` | Its number in Johari, e.g. `"1042"` | | `files` | `array` | Only the files this call added: `[{ "id": string, "name": string, "type": string, "size": number }]`. `size` is in bytes. Use `id` with [Get a File](/api/get-file) | ## Example The body (the `content` is shortened here; send the whole file, base64-encoded): ```json { "idempotencyKey": "e2a7c9d1-3b5f-4e8a-9c6d-7f0a1b2c3d4e", "files": [ { "name": "stamped-invoice.pdf", "contentType": "application/pdf", "content": "JVBERi0xLjQK" } ] } ``` ::: code-group ```bash [cURL] REQUEST_ID="6650f1a2b3c4d5e6f7a8b9e1" # The file as base64, on one line FILE_B64=$(base64 < stamped-invoice.pdf | tr -d '\n') # Build the body in a file: with a file inside, it is too long for a command-line argument cat > body.json < file.id)); } else { console.error(result.httpStatus, result.message, result.data); } ``` ```python [Python] import base64 import uuid request_id = "6650f1a2b3c4d5e6f7a8b9e1" # call_johari is from the Signing page with open("stamped-invoice.pdf", "rb") as f: content = base64.b64encode(f.read()).decode() result = call_johari("POST", f"/api/requests/{request_id}/files", { "idempotencyKey": str(uuid.uuid4()), "files": [ { "name": "stamped-invoice.pdf", "contentType": "application/pdf", "content": content, } ], }) if result["httpStatus"] == 200: print("Added", [file["id"] for file in result["data"]["files"]]) else: print(result["httpStatus"], result["message"], result["data"]) ``` ::: Response `200`: ```json { "status": "success", "data": { "requestID": "6650f1a2b3c4d5e6f7a8b9e1", "requestNumber": "1042", "files": [ { "id": "7c1e9a3f-5b2d-4e6a-8f0c-1d3b5a7c9e2f", "name": "stamped-invoice.pdf", "type": "application/pdf", "size": 48213 } ] }, "message": "File uploaded successfully" } ``` Response `409`, same `idempotencyKey` sent again after it worked (`data` is what the first call returned): ```json { "status": "error", "data": { "requestID": "6650f1a2b3c4d5e6f7a8b9e1", "requestNumber": "1042", "files": [ { "id": "7c1e9a3f-5b2d-4e6a-8f0c-1d3b5a7c9e2f", "name": "stamped-invoice.pdf", "type": "application/pdf", "size": 48213 } ] }, "message": "A request with this idempotencyKey has already been processed" } ``` Response `400`, more than 3 files: ```json { "status": "error", "data": "You can only upload up to 3 files", "message": "You can only upload up to 3 files" } ``` Response `404`, wrong ID, or the request was not raised with your key: ```json { "status": "error", "data": "Request not found", "message": "Request not found" } ``` The usual `401` errors apply too. Every possible error is listed on the [Errors](/guide/errors#add-files-errors) page. ## Common mistakes ::: danger Waiting for a callback Adding files does not change the status, so no callback is sent. The files show up in `files` of [Get a Request](/api/get-request#files) and of the next callback. ::: ::: danger Mixing it up with Get a File `POST /api/requests/:requestID/files` adds files. `GET /api/requests/:requestID/files/:fileID` gets a download link for one. Sign the path you call. ::: ::: danger More than 3 files in one call Send at most 3. For more, make another call with a **new** `idempotencyKey`. ::: ::: danger A different file on a retry The files are part of the signed body. A retry with the same `idempotencyKey` must send exactly the same files, with the same names, types and content. ::: ## Related pages * [Create a Request: Attaching files](/api/create-request#attaching-files) * [Respond to a Return](/api/respond-to-return) * [Get a Request](/api/get-request#files) * [Get a File](/api/get-file) * [Idempotency](/guide/idempotency)