Send a WhatsApp approval from your own code, in Node or Python

Last updated 8 November 2026

By Mumen Tayyem, founder of Jabee

Published 8 November 2026.

Your code needs a person to say yes before it does something. This is the whole round trip: ask, wait, verify what comes back, act on it.

The code below uses Jabee's approvals API because that is what we built, and it needs no WhatsApp number of your own. The patterns transfer to anything else you use, and the four mistakes at the bottom happen on every implementation.

What you need

An API key, from Management then Approvals in the dashboard. It starts jak_. A webhook address, if you want to be told rather than asking.

That is it. No Meta developer account, no number to register, no template to get approved, because the number and the template are ours.

Asking

const res = await fetch("https://api.jabee.ai/api/v1/approvals", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.JABEE_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    title: "Refund AED 450 to Sara",
    body: "Order 1043 arrived damaged. She has sent photos.",
    url: "https://shop.example/orders/1043",
    options: ["approve", "skip", "edit"],
    external_id: "refund-1043",
    expires_in_minutes: 240,
  }),
});

const approval = await res.json();
// approval.status is already "failed" if nobody could be reached.
import os, requests

r = requests.post(
    "https://api.jabee.ai/api/v1/approvals",
    headers={"Authorization": f"Bearer {os.environ['JABEE_KEY']}"},
    json={
        "title": "Refund AED 450 to Sara",
        "body": "Order 1043 arrived damaged. She has sent photos.",
        "url": "https://shop.example/orders/1043",
        "options": ["approve", "skip", "edit"],
        "external_id": "refund-1043",
        "expires_in_minutes": 240,
    },
    timeout=10,
)
approval = r.json()

external_id is yours. Send the same one twice and you get the request you already made rather than asking somebody the same question again, so a retry after a timeout is safe. Use the id of the thing being approved.

edit is the option people forget. Approve and reject are not enough for a quote that needs to come down by 50 dirhams rather than be refused. Tapping Edit asks for text, and the text comes back as response_text.

Hearing back

Two ways. A webhook, or polling.

The webhook

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();
const seen = new Set();

app.post("/hooks/jabee", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.get("X-Jabee-Signature"), process.env.JABEE_WHSEC)) {
    return res.sendStatus(400);
  }

  const delivery = req.get("X-Jabee-Delivery");
  if (seen.has(delivery)) return res.sendStatus(200);
  seen.add(delivery);

  const { approval } = JSON.parse(req.body.toString("utf8"));
  if (approval.status === "approved") issueRefund(approval.external_id);
  if (approval.status === "edited") issuePartial(approval.external_id, approval.response_text);

  res.sendStatus(200);
});

function verify(rawBody, header, secret) {
  if (!header) return false;
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
import hmac, hashlib, time
from flask import Flask, request

app = Flask(__name__)
seen = set()

@app.post("/hooks/jabee")
def hook():
    raw = request.get_data()
    if not verify(raw, request.headers.get("X-Jabee-Signature", ""), WHSEC):
        return "", 400

    delivery = request.headers.get("X-Jabee-Delivery")
    if delivery in seen:
        return "", 200
    seen.add(delivery)

    approval = request.get_json()["approval"]
    if approval["status"] == "approved":
        issue_refund(approval["external_id"])
    elif approval["status"] == "edited":
        issue_partial(approval["external_id"], approval["response_text"])
    return "", 200

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        if abs(time.time() - int(parts["t"])) > 300:
            return False
        expected = hmac.new(
            secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256
        ).hexdigest()
        return hmac.compare_digest(expected, parts["v1"])
    except (KeyError, ValueError):
        return False

A set is fine for an example and wrong for production, because it grows forever and empties on restart. Use whatever you already have: Redis with a day's expiry, or a unique column.

Polling, if you would rather not run an endpoint

r = requests.get(
    "https://api.jabee.ai/api/v1/approvals",
    headers={"Authorization": f"Bearer {KEY}"},
    params={"since": last_seen_updated_at},
    timeout=10,
)
for a in r.json()["approvals"]:
    handle(a)
    last_seen_updated_at = a["updated_at"]

since returns what changed after that time, oldest change first, so keeping the last updated_at you processed is the whole of the bookkeeping. For a job that already runs every minute this is less to maintain than a public endpoint.

The four things that go wrong

You parsed the body before you checked the signature. The signature covers the exact bytes that were sent. Your framework parsed them, you re-serialised the object, and the string is not the same string. Key order moved, a space went. Capture the raw bytes on that route only.

You compared the signature with ===. A byte-by-byte comparison returns faster on an early mismatch, which leaks the signature one character at a time to anyone patient. Use timingSafeEqual or hmac.compare_digest.

You handled the same approval twice. Delivery is at least once by design, so a retry after your endpoint had a bad minute will replay the event. If "approved" issues a refund, that is a second refund. Drop on the delivery id.

You assumed an answer would arrive. It expires. The person is on a plane. Your callback gets approval.expired and your code needs a branch for it, or the thing it was gating waits forever.

Questions

Do I need my own WhatsApp number? No. Requests go from Jabee's number to people who have verified their phone with us. You need your own number only if you also want Jabee answering your customers.

How fast does an answer come back? As fast as the person taps. The message arrives in a second or two; the rest is them.

What if nobody answers? The request expires at the time you set and your webhook is told. Set the expiry to how long the decision stays useful rather than to a default.

Can I send this to a customer rather than my own team? No. It goes to your own verified people. Messaging strangers from a shared number is what gets numbers banned.

What does a race look like? Two people tapping at once is settled in one statement, so exactly one wins and the second is told what already happened. Your webhook fires once.

Is there a rate limit? 60 API calls a minute per key, and 200 new requests per business in 24 hours. A repeated external_id does not count against the daily figure.