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

```
<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](/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]
<?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](/guide/signing#test-vectors).

## 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](/api/get-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](/guide/getting-started#rotating-a-key), 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](/guide/getting-started#disabling-a-key), pending callbacks for its requests stop.

::: tip Reconcile once a day
Callbacks are the fast path, not the only one. Once a day, call [Get a Request](/api/get-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](#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](/api/respond-to-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](/guide/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](/api/add-files), does not change the status, so it sends none: the files show up in `files` of [Get a Request](/api/get-request) and of the next callback.

## Common mistakes

::: danger Not checking the signature
Without the check, anyone can tell your system that a request was paid. Always verify.
:::

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

::: danger Answering 200 to a bad signature
If the signature is wrong, answer `401`. A `200` tells the sender the message was accepted.
:::

::: danger Assuming one callback per request
A request usually produces several callbacks (approved, then pending payment, then completed). Handle each one.
:::

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

## Related pages

* [Request Statuses](/guide/statuses)
* [Get a Request](/api/get-request)
* [Get a File](/api/get-file)
* [Respond to a Return](/api/respond-to-return)
* [Signing Requests](/guide/signing)
