Skip to content

Full Example ​

A complete, working integration in one file. It:

  1. Looks up a company, site, cost code and approver
  2. Starts a callback server that verifies every callback
  3. Raises a payment request, safely retrying on network errors
  4. Prints every status change (and deletion) as it happens
  5. Can also check the status directly
  6. Answers a request the cashier returns to it, adding a file first if you give one

Both versions use only the standard library: nothing to install.

Setup ​

Set these environment variables:

VariableExample
JOHARI_BASE_URLhttps://api.example.com
JOHARI_API_KEYyour whole API key, e.g. jh_key_67a759397b0b364bac4d06874dea60d042d48d435af11bbf8ad711da6a717238
JOHARI_SIGNING_SECRETyour whole signing secret, e.g. jh_secret_JwJB-xiOCTBo12TCXjbjzQsbTq070L9-UWUZEpAUzWU
CALLBACK_URLthe public URL that reaches this program, e.g. https://erp.yourcompany.com/johari/callback
PORTthe port this program listens on for callbacks, e.g. 3000
RETURN_FILEoptional: a PDF to add before answering a return, e.g. stamped-invoice.pdf

Testing on your own computer

Johari must be able to reach CALLBACK_URL. When testing on a laptop, use a tunnel (such as ngrok or Cloudflare Tunnel) to get a public https:// URL that forwards to localhost:3000.

Node.js (18 or newer) ​

Save as johari-example.js and run node johari-example.js.

js
const crypto = require("crypto");
const fs = require("fs");
const http = require("http");
const path = require("path");

const BASE_URL = process.env.JOHARI_BASE_URL;
const API_KEY = process.env.JOHARI_API_KEY;
const SIGNING_SECRET = process.env.JOHARI_SIGNING_SECRET;
const CALLBACK_URL = process.env.CALLBACK_URL;
const PORT = Number(process.env.PORT || 3000);
const RETURN_FILE = process.env.RETURN_FILE; // optional

// ---------- Signing ----------

function sign(timestamp, method, path, body) {
  return crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(`${timestamp}.${method}.${path}.${body}`)
    .digest("hex");
}

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,
    signal: AbortSignal.timeout(30000),
  });

  return { httpStatus: response.status, ...(await response.json()) };
}

// ---------- Raising a request, with safe retries ----------

const sleep = (ms) => 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 deliveryIDs 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.
  • 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.