Skip to content

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 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 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
HeaderValue
Content-Typeapplication/json
x-timestampWhen this attempt was sent, Unix time in seconds
x-signatureHMAC-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"
}
FieldTypeDescription
eventstring"request.status-changed" or "request.deleted". See Events
deliveryIDstringID of this notification. Stays the same on every retry of it. Use it to ignore duplicates
requestIDstringThe request this is about
requestNumberstringIts number, as people see it in Johari
statusstringThe new status. For request.deleted, the status it had just before it was deleted. See Request Statuses
statusTitlestringThe status as people see it in Johari, e.g. "Pending Payment"
descriptionstringWhat the payment is for
amountnumberThe request amount
currencystringe.g. "TZS"
amountRemainingnumber | nullOnly for requests being paid in parts: how much is still unpaid. Otherwise null
modeOfPaymentstring"cash" or "bank"
isUrgentbooleantrue if the request is marked urgent
companyobjectThe requesting company: { "id": string, "name": string }
siteobjectThe requesting site: { "id": string, "name": string }
costCodestringThe cost code value
isChildRequestbooleantrue if this is one part of a request being paid in parts. See Partial payments
mainRequestIDstring | nullFor a child request, the request it belongs to. Otherwise null
notestring | nullOnly 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
createdAtstringWhen the request was created (ISO 8601, UTC)
createdBystringName 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
approvedAtstring | nullWhen it was approved (ISO 8601, UTC)
approvedBystring | nullName 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)
rejectedAtstring | nullWhen it was rejected (ISO 8601, UTC)
rejectedBystring | nullName of the person who rejected it
confirmedAtstring | nullWhen accounts confirmed it, sending it for payment (ISO 8601, UTC)
confirmedBystring | nullName of the person who confirmed it
paidAtstring | nullWhen it was paid (ISO 8601, UTC)
paidBystring | nullName of the person who paid it
efdAtstring | nullWhen the EFD (tax) receipt was uploaded (ISO 8601, UTC)
efdBystring | nullName of the person who uploaded the EFD receipt
returnedAtstring | nullWhen it was returned (ISO 8601, UTC)
returnedBystring | nullName of the person who returned it
returnedTostring | nullWho 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
filesarrayEvery file on the request: [{ "id": string, "name": string, "type": string | null, "size": number | null }]. size is in bytes. See Files
changedAtstringWhen 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:

eventWhen it is sent
request.status-changedThe request's status changed. See Which changes send a callback
request.deletedThe 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 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 ​

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 changedJohari retries every
0 to 5 minutes20 seconds
5 to 15 minutes1 minute
15 to 30 minutes3 minutes
30 to 50 minutes5 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 200 got lost, Johari sends it again. Use deliveryID to skip a notification you have already handled. Johari retries one notification for up to 50 minutes (see Retries), so keep each deliveryID you 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 last changedAt with 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 pending or pending-payment, wherever it was returned from; for an answer sent through the API, note is 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.