Appearance
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:
<timestamp>.POST./api/requests.<the JSON body>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 |
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 |
requestingSite | string | Yes | A site id from List Sites, for that company |
costCode | string | Yes | A cost code value from List Cost Codes, for that company |
modeOfPayment | string | Yes | "cash" or "bank" |
admin | string | Yes | An approver id (24 hex characters) from List Approvers for requestingSite. See 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 |
callbackURL | string | No | Where to send 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 |
Any other field is ignored. Receipts cannot be attached through the API. Files can: see Attaching files.
The admin field
admin is required. It is an approver id from List 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 | |
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
filesout 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. contentis standard base64:A–Z,a–z,0–9,+and/, with=padding. Its length is a multiple of 4. Nodata:prefix (such asdata: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,.docand.xlsmust be the old Office format, and.docxand.xlsxmust be ZIP-based. Otherwise you get400(files[0].content does not match its contentType). PickcontentTypefrom the extension table above, and do not send placeholder text ascontent. - 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
contentincluded. - 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 and in 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. Download one with Get a File.
Validation rules
- All required fields must be present and of the type shown. Text must not be empty.
amountmust be greater than 0 with at most 2 decimal places.1500.5and"1,500.50"are fine;1500.555is refused. A string may only hold digits, thousands commas and one decimal part:"0x10","1e3","-5"and".5"are refused.requestingSitemust be a site ofrequestingCompany, andcostCodea cost code ofrequestingCompany.adminmust be an approver ofrequestingSite.currencyandmodeOfPaymentonly accept the values listed above, in lower case formodeOfPayment, upper case forcurrency.files, when sent, must follow the file rules.callbackURL, when sent, must be a full URL of up to 2048 characters. On the live server it must behttps://, andlocalhostor an IP address that is not public is refused. See Turning callbacks on.idempotencyKeymust not have been used before for a different request. See Idempotency.
Any rule that fails returns 400 with a message naming the field. The full list is on the 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 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 |
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
bash
BODY=$(cat <<'EOF'
{
"idempotencyKey": "b7e2c1d4-5a6f-4e3b-9c8d-1f2a3b4c5d6e",
"description": "Cement for slab",
"amount": 150000,
"requestingCompany": "6650f1a2b3c4d5e6f7a8b901",
"requestingSite": "3c9a7f52-1d4e-4b8a-9f6c-2e1d5a7b8c90",
"costCode": "1003",
"modeOfPayment": "bank",
"admin": "6650f1a2b3c4d5e6f7a8b7a1"
}
EOF
)
TS=$(date +%s)
SIG=$(printf '%s' "$TS.POST./api/requests.$BODY" \
| openssl dgst -sha256 -hmac "$SIGNING_SECRET" \
| sed 's/^.* //')
curl -X POST "$BASE_URL/api/requests" \
-H "Content-Type: application/json" \
-H "x-api-key: $API_KEY" \
-H "x-timestamp: $TS" \
-H "x-signature: $SIG" \
--data-raw "$BODY"js
const crypto = require("crypto");
// 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
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.
bash
# 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 <<EOF
{
"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": "$FILE_B64"
}
]
}
EOF
# Sign the exact bytes of body.json, then send that same file
TS=$(date +%s)
SIG=$( { printf '%s' "$TS.POST./api/requests."; cat body.json; } \
| 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-binary @body.jsonjs
const crypto = require("crypto");
const fs = require("fs");
// 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",
files: [
{
name: "invoice-1042.pdf",
contentType: "application/pdf",
content: fs.readFileSync("invoice-1042.pdf").toString("base64"),
},
],
});
console.log(result.httpStatus, result.message, result.data);python
import base64
import uuid
# call_johari is from the Signing page
with open("invoice-1042.pdf", "rb") as f:
content = base64.b64encode(f.read()).decode()
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",
"files": [
{
"name": "invoice-1042.pdf",
"contentType": "application/pdf",
"content": content,
}
],
})
print(result["httpStatus"], result["message"], result["data"])Response 200:
json
{
"status": "success",
"data": {
"requestID": "6650f1a2b3c4d5e6f7a8b9e1",
"requestNumber": "1042",
"status": "pending"
},
"message": "Request created successfully"
}Response 409, same idempotencyKey sent again:
json
{
"status": "error",
"data": {
"requestID": "6650f1a2b3c4d5e6f7a8b9e1",
"requestNumber": "1042",
"status": "pending"
},
"message": "A request with this idempotencyKey has already been processed"
}Response 400, a field is wrong:
json
{
"status": "error",
"data": "costCode is not one of this company's cost codes",
"message": "costCode is not one of this company's cost codes"
}Response 400, admin left out:
json
{
"status": "error",
"data": "Please provide an admin: an approver id from the site's approvers list",
"message": "Please provide an admin: an approver id from the site's approvers list"
}Response 400, a file that is not valid base64 (here the first file, files[0]):
json
{
"status": "error",
"data": "files[0].content must be the file encoded as base64",
"message": "files[0].content must be the file encoded as base64"
}Every possible error is listed on the Errors page.
What happens next
- People in Johari approve, confirm and pay the request.
- Every status change is sent to your
callbackURL, if you gave one. See Callbacks. - You can check the status at any time with Get a Request, and download its files with Get a File.
- If the cashier returns it to you (
statusis"returned"andreturnedTois"creator"), answer with Respond to a Return. You can add files to it at any time with Add Files.
Common mistakes
A new idempotencyKey on every retry
Retries must reuse the key, or you may create the same request twice. See Idempotency.
Leaving out admin
admin is required. Send an approver id from List Approvers for the same requestingSite. Without it you get 400 (Please provide an admin: an approver id from the site's approvers list).
Mixing IDs from different companies
requestingSite and costCode must belong to requestingCompany. Look them up with that company's ID. admin must be an approver of requestingSite.
Numbers where text is expected
costCode is text ("1003"). isUrgent is a boolean (true), not "yes".
amount in cents
amount is in normal units: 150000 means 150,000 TZS, not 1,500.00.
amount strings in other formats
A string amount may only hold digits, thousands commas and one decimal part. "1e3", "0x10", "-5" and ".5" are refused with 400. When in doubt, send a JSON number.
A data: prefix or line breaks in file content
content is plain standard base64: no data:application/pdf;base64, in front, and no line breaks. Many base64 command-line tools wrap lines; remove the line breaks (for example with tr -d '\n').
A contentType that does not match the file
Johari checks the file's first bytes against contentType. Pick contentType from the file's extension with the table above, and send the real file, not placeholder text. Otherwise you get 400 (files[0].content does not match its contentType).
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.