Approvals API

Your system asks a person to approve something on WhatsApp. They tap a button. You hear back by signed webhook, or by asking.

Nothing in it knows what is being approved, so it works for a refund over a limit, a large order, a quote, a social post, or a step your agent is about to take that cannot be undone.

Getting a key

Keys are made on Management → Approvals in the dashboard, by an owner or a manager. Give each system its own.

The secret is shown once. Jabee keeps a SHA-256 hash of it, so a lost key is revoked and replaced rather than recovered. Revoking takes effect immediately and the key starts returning 401. A key belongs to one business and can never see another's requests.

Authorization: Bearer jak_...
Base URL: https://api.jabee.ai/api/v1

Asking a question

curl -X POST https://api.jabee.ai/api/v1/approvals \
  -H "Authorization: Bearer $JABEE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "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
  }'
Field What it does
titlerequired1 to 120 characters. The bold first line on the phone.
bodyoptionalUp to 2000 characters of detail.
urloptionalAn http or https link shown under the details.
optionsoptionalOne to three of approve, skip, edit, no repeats. Defaults to ["approve","skip"]. edit asks for text and returns it as response_text.
external_idoptionalYour own id, up to 200 characters. Sending the same one again returns the request you already made, with a 200, so a retry is safe.
expires_in_minutesoptional5 to 10080, which is a week. Defaults to 1440.
metadataoptionalAny JSON object up to 16KB, handed back unchanged.

There is no recipient field. A request goes to the one phone this business has for it — the account owner's verified number, or another handset you point it at in the dashboard, under Where alerts go. To ask several people, connect your own WhatsApp number and send from there.

You get a 201 with the request on it, or a 200 if that external_id already existed. The create response also carries existing: false on a new request, true when the external_id matched one you had already made, in which case nothing was sent again. Only the create response carries it; reads do not.

Delivery happens before the response returns, so a status of failed means nobody could be reached and you know that straight away rather than a minute later.

Putting it again

When somebody answers with their own words instead of a button, the status comes back edited and the text is in response_text. If what they wrote needs an answer, put the same request again rather than creating a second one.

curl -X POST https://api.jabee.ai/api/v1/approvals/$ID/revise \
  -H "Authorization: Bearer $JABEE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "body": "The full proposal is below. You asked where the text was.",
    "expires_in_minutes": 1440
  }'

Every field is optional, but at least one of title, body or url has to be given, because a revision that changes nothing is a message sent for no reason. You get a 200 with the request pending again and round one higher. Only an edited request can be revised, and five revisions is the ceiling.

options cannot be changed, and is ignored if you send it. Outside the recipient's 24 hour window the message goes out on a template whose three buttons Meta fixed when it approved it, so a round offering fewer would still show all three: the message and the request would disagree.

A revision is another delivery from our number, so it costs what a create costs and runs the same daily limit and monthly allowance checks. Inside the recipient's 24 hour window it is an ordinary message and costs nothing extra, which most revisions are, because they follow a reply you have just received.

It is refused with 409 not_revisable if the request is still pending, where nobody has edited it yet, or already approved, skipped, expired or cancelled. Once it has been revised five times, 409 too_many_rounds: create a new request.

Revising clears the previous answer, so response_text is null on the reopened request. Read their words from the webhook or the poll before you revise. The old text stays in the request's history on the dashboard.

Withdrawing a request

If your system no longer needs the answer, because the job closed, a newer request replaced it, or somebody did it another way, withdraw it.

curl -X POST https://api.jabee.ai/api/v1/approvals/$ID/cancel \
  -H "Authorization: Bearer $JABEE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Replaced by refund-1043-b"}'

reason is optional, up to 200 characters, shown on the dashboard and kept in the history. Nothing acts on it. The request becomes cancelled with cancelled_at and cancel_reason set. Cancelling one that is already cancelled returns a 200 with it unchanged and keeps the first reason, so a retry is safe.

Only a pending or edited request can be withdrawn. Anything already answered, expired or failed comes back 409 not_cancellable with its status in the body.

A cancel sends nothing: no webhook, no message, no Claude routine. The request drops off the pending list on WhatsApp, is never expired afterwards, and can no longer be answered. If the person taps a button on it anyway, nothing is recorded and Jabee tells them the system that asked withdrew it. The message already on their phone cannot be unsent.

Saying what you did

The answer is what the person tapped. What your system then did with it is something only your system knows, so you can report it back, and the dashboard then shows what actually happened rather than only what was decided.

curl -X POST https://api.jabee.ai/api/v1/approvals/$ID/outcome \
  -H "Authorization: Bearer $JABEE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"outcome": "refunded", "note": "AED 450 back to the card"}'

outcome is required: 1 to 40 characters of a-z, 0-9 and _. It is your own word, not one of ours, so posted, refunded, not_needed and replaced are all fine and Jabee never interprets any of them. note is optional, up to 500 characters.

It is allowed on any status except pending, where nothing has been decided yet, and failed, where nothing was ever delivered. Both refuse with 409 outcome_not_allowed. A cancelled request accepts one. The latest call wins, so an earlier report can be corrected, and each report is kept in the request's history.

Reporting an outcome sends nothing, and deliberately does not move updated_at, so a since poller is not handed back a request it has already acted on.

The request object

{
  "id": "3f0c6b0e-6a43-4f43-9a8e-0d7b2f5c1a11",
  "status": "edited",
  "round": 0,
  "title": "Refund AED 450 to Sara",
  "options": ["approve", "skip", "edit"],
  "external_id": "refund-1043",
  "metadata": { "order_id": 1043 },
  "response_text": "Refund AED 300, she keeps the item",
  "responded_at": "2026-11-08T10:12:31.000Z",
  "responded_by": { "type": "owner", "id": "...", "name": "Nour" },
  "delivered_via": "whatsapp",
  "outcome": "refunded",
  "outcome_note": "AED 300 back to the card",
  "outcome_at": "2026-11-08T10:20:00.000Z",
  "cancelled_at": null,
  "cancel_reason": null,
  "expires_at": "2026-11-08T14:00:00.000Z",
  "created_at": "2026-11-08T10:00:00.000Z",
  "updated_at": "2026-11-08T10:12:31.000Z"
}
statusMeans
pendingDelivered, waiting for an answer.
approved / skippedAnswered with that button.
editedAnswered with text, which is in response_text. Put it again if it needs an answer.
expiredNobody answered before expires_at.
failedCould not be delivered to anybody.
cancelledWithdrawn by your own system. cancelled_at and cancel_reason say when and why.

responded_by.type is owner or staff when it was answered on WhatsApp, and dashboard when it was answered on the Approvals page, which is how a request that fell back to email gets closed.

round counts revisions rather than deliveries: 0 is the request as first asked, 1 after one revision, and five is the ceiling. outcome, outcome_note and outcome_at are what your system reported doing, and are null until it reports. cancelled_at and cancel_reason are null unless the request was withdrawn.

Reading them back

If you would rather not run a webhook endpoint, poll.

curl "https://api.jabee.ai/api/v1/approvals?since=2026-11-08T10:00:00Z" \
  -H "Authorization: Bearer $JABEE_KEY"

since takes an ISO 8601 time and returns requests changed after it, oldest change first. Keep the last updated_at you saw and send it next time. Without since you get the newest first. has_more: true means ask again. limit is 1 to 100 and defaults to 50.

GET /approvals/:id returns one.

Hearing back without asking

Set an https address on the Approvals page and Jabee posts to it when a request is answered, expires or fails. Nothing your own system does fires one, so a revision, a withdrawal and an outcome are all silent. There is a Send a test button that posts a signed approval.test and shows you what your server returned, which is usually quicker than deploying twice.

POST <your address>
Content-Type: application/json
X-Jabee-Event: approval.answered
X-Jabee-Delivery: 7c9e...
X-Jabee-Signature: t=1762596751,v1=5d41402abc4b2a76b9719d911017c592...

{ "id": "7c9e...", "event": "approval.answered", "approval": { ... } }

Any 2xx counts as success. A non-2xx, a timeout after ten seconds, or a redirect counts as a failure and is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, an hour, three hours and six hours. That is eight tries over about eleven hours, and then it gives up and records that in the request's history. Redirects are never followed.

Delivery is at least once, so drop duplicates on X-Jabee-Delivery.

Checking the signature

v1 is the hex HMAC-SHA256 of "<t>.<raw body>" under your webhook's signing secret, which starts whsec_ and is on the Approvals page. Compute it over the raw bytes before anything parses the JSON, compare in constant time, and refuse a t more than five minutes old.

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

function verify(rawBody, header, secret) {
  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

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    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"])

Frameworks that parse JSON for you will have thrown the raw bytes away by the time your handler runs, and re-serialising the parsed object produces a different string. In Express, mount express.raw() on this route only.

Limits

Per key60 requests a minute across every endpoint. Over it you get a 429 with Retry-After.
Per business200 new requests in any 24 hours. Over it, 429 daily_limit_reached. A repeated external_id does not count against it; a revision does, because it is another delivery.

The daily limit exists because every request is a message from a number every Jabee business shares, and one number's standing with Meta is everybody's. If 200 a day is genuinely not enough for you, email hello@jabee.ai and we will raise yours.

Errors come back as JSON with error, detail and sometimes field. The codes are 400 invalid_request, 401 unauthorised, 402 allowance_used_up, 404 not_found, 409 not_revisable, 409 too_many_rounds, 409 not_cancellable, 409 outcome_not_allowed, 429 rate_limited and 429 daily_limit_reached.

What it looks like on the phone

It arrives from Jabee's number, the same one owner alerts come from. Inside the person's 24 hour window it is a real message with tappable buttons. Outside it, an approved template carries the same choices. If their phone is unreachable it falls back to email, with the same three buttons on a page.

Tapping Edit asks for text, and the next thing they send becomes response_text. Edit is one way: their words go to your system and nothing of ours answers them on the WhatsApp thread. If they have asked a question there, you are the one who answers it, by putting the request again.

Two people tapping at once is settled in one statement, so exactly one wins and the second is told what already happened.

Questions

Do I need my own WhatsApp number?

No. Requests go from Jabee's number to people who have verified their phone with us. There is nothing to register and no template to get approved. You only need your own number if you also want Jabee answering your customers.

What if the person has not linked a phone?

It falls back to email, with the same buttons on a page in the dashboard. The status then comes back with responded_by.type of dashboard.

How do I test the webhook without deploying?

The Approvals page has a Send a test button. It posts a signed approval.test event to your address and shows you the status code and body your server returned.

Can I retry a create safely?

Yes, as long as you send the same external_id. The second call returns the existing request with a 200 instead of asking somebody the same question twice.

Why did my request come back as failed immediately?

Delivery is attempted before the response returns, so failed means nobody could be reached at all — no verified phone and no email on the account.

Somebody answered with text instead of tapping. What now?

The status is edited and their words are in response_text. If what they wrote needs an answer, POST to /approvals/:id/revise with a new title, body or url and the same request goes out again, pending, with round one higher. Five revisions is the ceiling.

Can I withdraw a request I no longer need?

Yes. POST to /approvals/:id/cancel, with an optional reason up to 200 characters. It works while a request is pending or edited, the status becomes cancelled, and the request drops off the pending list on WhatsApp so it can no longer be answered. The message already on the person's phone cannot be unsent.

Can I record what my system did with the answer?

Yes. POST to /approvals/:id/outcome with your own one-word outcome and an optional note, and the dashboard shows it beside what the person tapped. It is allowed on any status except pending and failed, the latest call wins, and it does not move updated_at, so a since poller is not handed back a request it already acted on.

Is there an MCP server?

Yes. Run it with npx @jabee/mcp and one environment variable, and your agent gets three tools: ask_person, check_approval and tell_person. It needs no WhatsApp number of your own. How to set it up.