Decision webhook
Answer mid-call consultations automatically.
How consultations work
When the voice agent hits something mid-call that needs your approval, such as a price above max_price_ils or a different time from the one you asked for, it opens a consultation: one question, a short context and the options (approve, decline, ask_user). A call has at most one open consultation at a time.
There are several ways to answer, and the first answer before the deadline wins:
- The agent connected over MCP, with the
respond_to_consultationtool. - Your code, with
POST /v1/consultations/:id/response. - Your webhook, automatically, as described on this page.
- You, from the call screen on the site.
If no answer arrives in time, the voice agent continues without committing, and the decision waits for you.
How the webhook works
When the voice agent needs a decision mid-call, your agent can answer on its own, without anyone clicking.
- Mid-call, the voice agent hits something that needs your approval, such as a price above the cap. She tells the other side “one moment, I’m checking with the customer”.
- OneLi immediately sends a signed POST request to the URL you set.
- Your server answers in the response body:
approve,declineorask_user. - The voice agent continues according to the answer. If you did not answer in time, she continues without committing and hands the decision back to you.
You can also answer later, from the call screen on the site or from the connected agent (Claude). The first answer that arrives before the deadline wins.
Setup
- On the agent connection page, in the Automatic decisions card (החלטות אוטומטיות), paste a public https URL and click Turn on.
- A secret starting with
whsec_appears. It is shown once only. Store it as an environment variable on your server, for exampleONELI_WEBHOOK_SECRET. - Updating the URL creates a new secret and the old one stops working. “Turn off” disables the channel.
You can also set it from code, with an API key:
curl -X PUT https://api.oneli.io/v1/decision-webhook \
-H "Authorization: Bearer $ONELI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-agent.example.com/oneli" }'
# → { "url": "https://your-agent.example.com/oneli", "secret": "whsec_…" } (the secret is shown once)BAD_WEBHOOK_URL). Redirects are not followed.What you receive
POST https://your-agent.example.com/oneli
content-type: application/json
x-oneli-timestamp: 1792586400
x-oneli-signature: sha256=5f1c…
user-agent: OneLi-Webhook/1
{
"type": "consultation.requested",
"consultation": {
"id": "con_3d48e1a72832d769",
"task_id": "task_44be8f8d43da4748",
"question": "האם לאשר החלפת שמן ב-450 ש״ח?",
"options": ["approve", "decline", "ask_user"],
"context_summary": "יש תור מחר ב-17:30. המחיר לא כולל מע״מ.",
"deadline_at": "2026-10-21T12:00:09.073Z"
},
"task": {
"goal": "לברר מחיר להחלפת שמן ולקבוע תור",
"to_number": "972501234567",
"constraints": { "max_price_ils": 300 }
}
}consultation.question: the decision needed, in one sentence.consultation.context_summary: the facts the voice agent has gathered so far.consultation.options: the answers allowed for this question.consultation.deadline_at: until when the answer still changes the call.task: the goal, number and constraints you set for the call.
Headers: x-oneli-timestamp (Unix seconds), x-oneli-signature and user-agent: OneLi-Webhook/1.
Verifying the signature
Every request is signed with HMAC-SHA256 using your secret, over the string {timestamp}.{raw body}, formatted as sha256=<hex>. Verify the signature on the original body bytes before parsing JSON, and reject requests whose timestamp is more than 5 minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const SECRET = process.env.ONELI_WEBHOOK_SECRET; // whsec_… (shown once when you turn it on)
const app = express();
// Keep the raw body: the signature is over the exact bytes OneLi sent.
app.post("/oneli", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.get("x-oneli-timestamp") ?? "";
const sig = req.get("x-oneli-signature") ?? "";
const expected = "sha256=" + createHmac("sha256", SECRET).update(`${ts}.${req.body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300; // reject replays older than 5 minutes
const ok = fresh && sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.status(401).end();
const { consultation, task } = JSON.parse(req.body);
// Your rules (or your own AI) decide here. Answer fast: the person on the line is waiting.
const price = Number((consultation.question.match(/\d+/) ?? [])[0]);
const limit = task.constraints?.max_price_ils;
const decision = limit && price && price <= limit * 1.5 ? "approve" : "ask_user";
res.json({ decision, reason: decision === "approve" ? "בתוך הטווח" : "צריך את אישורך" });
});
app.listen(3000);import hmac, hashlib, json, os, time
from flask import Flask, request, abort, jsonify
SECRET = os.environ["ONELI_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/oneli")
def oneli():
ts = request.headers.get("x-oneli-timestamp", "")
sig = request.headers.get("x-oneli-signature", "")
body = request.get_data() # raw bytes
expected = "sha256=" + hmac.new(SECRET, f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
if abs(time.time() - int(ts or 0)) > 300 or not hmac.compare_digest(sig, expected):
abort(401)
data = json.loads(body)
return jsonify(decision="ask_user", reason="דורש אישור אישי")How to answer
HTTP/1.1 200 OK
content-type: application/json
{ "decision": "approve", "reason": "מתאים, אפשר לקבוע" }approve: the voice agent agrees and continues, for example books the slot.decline: the voice agent says it does not suit right now, without revealing the budget.ask_user: no commitment. The voice agent collects details and the decision waits for you on the site.reason(optional, up to 500 characters) is saved in the call log. The voice agent does not read it out to the other side.
Any other answer, a non-2xx status or a non-JSON body counts as “no answer”.
Timing and failures
- Each consultation has a window of a few seconds, until
deadline_at. The other side is waiting on the line, so answer within a second or two. - OneLi waits at most 10 seconds for your response, and less if the deadline is close (half a second before it).
- There is no retry. If your server did not answer in time, the voice agent continues without committing and the decision appears for you on the site.
- The “Automatic decisions” card shows the last delivery time and its status, for troubleshooting. The same is in
GET /v1/decision-webhook(last_status,last_delivery_at).
Testing
- Turn on the channel with a public URL. For local development you can use ngrok or Cloudflare Tunnel.
- Open a call with a low maximum price (
max_price_ils) so the voice agent has to consult. - On the call page you will see the question, your server’s answer and where it came from. In the events,
consultation.answeredshowsanswered_via: "webhook".