Appearance
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:
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 gets400(callbackURL cannot be longer than 2048 characters). - On the live server the URL must be
https://and point to a public address. Johari refuseslocalhost, loopback, private, link-local, carrier-grade NAT (100.64.0.0/10), multicast, documentation/benchmark and other reserved IPv4 and IPv6 ranges, such as127.0.0.1,10.x.x.xor192.168.x.x. It checks this twice:- when you create the request, for URLs with an IP address and for
localhostnames (you get400,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 you create the request, for URLs with an IP address and for
- When testing from your laptop, use a tunnel (such as ngrok or Cloudflare Tunnel).
- Callbacks are optional. Without one, use Get a 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.
A status change is made by a person in Johari (an approver, finance, the cashier), or by your own response to a 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 |
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), 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 |
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 |
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 |
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. 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 |
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. There is no download link in a callback: download a file with Get a 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 |
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 returns 404 for it, and so does Get a 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).
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"
}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):
<x-timestamp>.POST.<path of your callbackURL, with its query string>.<raw body>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).
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.
Several callback URLs
If you send different callbackURLs (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.
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
2xxstatus (200is fine) to say "received". The body of your answer is ignored. - Answer
2xxalso for a callback you skip because it is a duplicate (deliveryIDalready seen) or out of date (olderchangedAt). It is genuine, so say you received it, or Johari keeps retrying it. - Redirects are not followed. A
301or302counts 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 a2xx, so fix it and the next retry will pass.)
Code
js
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
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
$SIGNING_SECRET = getenv('JOHARI_SIGNING_SECRET');
$CALLBACK_PATH = '/johari/callback'; // the path of the callbackURL you send us
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$rawBody = file_get_contents('php://input');
$expected = hash_hmac('sha256', "$timestamp.POST.$CALLBACK_PATH.$rawBody", $SIGNING_SECRET);
$fresh = ctype_digit($timestamp) && abs(time() - (int) $timestamp) < 300;
if (!hash_equals($expected, $signature) || !$fresh) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($rawBody, true);
// TODO: save it. Skip it if you have already seen $event['deliveryID'].
switch ($event['event']) {
case 'request.status-changed':
error_log("Request #{$event['requestNumber']} is now {$event['status']}");
break;
case 'request.deleted':
error_log("Request #{$event['requestNumber']} was deleted in Johari");
break;
default:
error_log("Unknown event {$event['event']}");
}
http_response_code(200);
echo 'OK';You can test your verification with test vector 3.
Retries
If your server does not answer with a 2xx within 10 seconds (it is down, slow, or returns an error), Johari tries again:
| Time since the status changed | Johari retries every |
|---|---|
| 0 to 5 minutes | 20 seconds |
| 5 to 15 minutes | 1 minute |
| 15 to 30 minutes | 3 minutes |
| 30 to 50 minutes | 5 minutes |
After 50 minutes Johari stops trying for that notification. To catch up after a long outage, call Get a Request for the requests you are following.
Each retry is signed again with a new x-timestamp, so a retry is always fresh.
If your API key is rotated, callbacks are signed with the new signing secret from then on. That includes retries of callbacks first sent before the rotation.
If your API key is disabled, pending callbacks for its requests stop.
Reconcile once a day
Callbacks are the fast path, not the only one. Once a day, call Get a Request for every request that is not yet final in your system. This catches anything missed during a long outage.
Duplicates and order
- Duplicates can happen. If your server saved the callback but the
200got lost, Johari sends it again. UsedeliveryIDto skip a notification you have already handled. Johari retries one notification for up to 50 minutes (see Retries), so keep eachdeliveryIDyou have seen for at least 1 hour. Store them in your database, not in memory, so a restart does not forget them. - Order is not guaranteed. A retry of an older notification can arrive after a newer one. Use
changedAt: if it is older than the last change you saved for that request, ignore it. Keep the lastchangedAtwith each request for as long as you follow the request.
Which changes send a callback
request.status-changed is sent when:
- the request is approved
- the request is rejected
- the request is confirmed for payment (it becomes
pending-payment) - the request is marked for partial payment
- a partial payment child request is created
- the request is paid
- the EFD receipt is uploaded
- the request is returned
- a return is answered, in Johari or through Respond to a Return (the request goes back to
pendingorpending-payment, wherever it was returned from; for an answer sent through the API,noteis your note) - the amount is nullified
- a child request is deleted (sent for the main request)
request.deleted is sent when the request is deleted in Johari. A deleted child request sends request.deleted for the child, and request.status-changed for the main request.
See Request Statuses for what each status means.
Changes that are not in these lists (edits to the description, a new comment, a different cashier) do not send a callback. Adding files, in Johari or with Add Files, does not change the status, so it sends none: the files show up in files of Get a Request and of the next callback.
Common mistakes
Not checking the signature
Without the check, anyone can tell your system that a request was paid. Always verify.
Slow work before answering
Answer 200 quickly. If you call other systems before answering and take more than 10 seconds, Johari treats it as a failure and sends the same callback again.
Answering 200 to a bad signature
If the signature is wrong, answer 401. A 200 tells the sender the message was accepted.
Assuming one callback per request
A request usually produces several callbacks (approved, then pending payment, then completed). Handle each one.
Ignoring event
Not every callback is a status change. Switch on event: a request.deleted callback means the request is gone from Johari, even though its status still shows the status it had before.