Actions
An Action is an outbound HTTPS request that Hiroi signs and sends to a URL you own. Actions are how data leaves Hiroi in real time — you never poll Hiroi for it. This page covers the three triggers, the exact request Hiroi sends, how to verify the signature, the event catalog, and the delivery rules your endpoint has to live with.
You configure Actions on an assistant's Intelligence tab, in the Actions card. Only organization admins can create, edit or delete them.
The three triggers
Every Action is one of three kinds. The kind is chosen when you create the Action and cannot be changed afterwards.
| Trigger | Fires when | Retries |
|---|---|---|
| Event | A platform lifecycle event happens, such as a conversation ending | Yes — up to 3 attempts |
| AI-decided | The assistant decides to call it mid-conversation | No — single attempt |
| Form | A visitor submits a form panel in the widget | No — single attempt |
Event — sync closed conversations into your CRM. You create an Action, pick the trigger
Event, select conversation.ended and conversation.resolved, and point it at
https://api.yourcompany.com/hiroi/events. Every time a widget conversation closes, your endpoint
receives a signed JSON envelope with the conversation id, session id and message count, and you
write a note against the customer record.
AI-decided — let the assistant look something up. You create an Action named check_order_status
with the description "Look up the delivery status of a customer order by order number." The
assistant sees it as a tool. When a visitor asks "where is order 44812?", the assistant calls your
endpoint, your endpoint answers, and the assistant continues the conversation with the answer in
hand. The visitor is waiting during this call, so it is single-shot and must be fast.
Form — collect a structured lead. You create an Action with the trigger Form and fields for name, email and message. The assistant presents it as a form panel inside the chat when the conversation calls for it. When the visitor submits, the values arrive at your endpoint in one signed request.
Creating an Action
- Open the assistant, go to the Intelligence tab and expand the Actions card.
- Click New action.
- Under When should this fire?, choose AI-decided, Event or Form. This cannot be changed after the Action is created.
- Fill in Name. For AI-decided also fill in Description — the assistant uses it to decide when to call the Action, and it must be at least 10 characters. For Event, pick one or more entries under Events. For Form, add at least one row under Form fields (a field key, a Label, a type, and whether it is Required).
- Set Method and Endpoint URL. The URL must be
https://. - Leave Enabled on and click Create action.
The list then shows each Action with its name, trigger, endpoint, status, and a Test button that sends a real signed request through the same pipeline as a live delivery and reports the HTTP status back to you.
Note
Use POST unless you have a specific reason not to. On a GET action the payload is not sent with the request, so your endpoint receives the signature headers with no body to check them against.
What Hiroi sends
Requests are sent with the method you configured, to the URL you configured, with these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Hiroi-Integration/1.0 |
X-Hiroi-Signature |
t=<unix seconds>,v1=<hex hmac-sha256> |
X-Hiroi-Trigger |
event, ai or form |
X-Hiroi-Trigger-Ref |
Event name, conversation id, or form submission id — depending on the trigger |
The body is compact JSON with no spaces. Its shape depends on the trigger:
Event — the envelope:
{
"id": "0c0a2b31-6f0f-4f2f-9f3e-6d5b8c1b7a4e",
"event": "conversation.started",
"timestamp": "2026-07-19T15:04:21.882913+00:00",
"data": {
"conversation_id": "b8f4…",
"session_id": "s_1a2b…",
"visitor_id": "v_9f8e…",
"referrer": "https://yourcompany.com/pricing"
},
"site_id": "…",
"org_id": "…"
}
id is a fresh UUID per event, timestamp is ISO 8601 in UTC, and the contents of data depend on
the event.
AI-decided — the arguments the assistant produced, as a flat JSON object:
{"order_number": "44812"}
Form — the submitted values plus the identifiers of the conversation they came from:
{
"values": {"name": "Jane Diaz", "email": "jane@example.com", "message": "Call me Tuesday"},
"conversation_id": "s_1a2b…",
"submission_id": "6b1f8a52-…"
}
Verifying the signature
X-Hiroi-Signature carries two comma-separated fields: t, the Unix timestamp in seconds at which
the request was signed, and v1, a hex-encoded HMAC-SHA256.
The signed string is the timestamp, a literal ., then the raw request body exactly as
received:
t + "." + raw_body
The key is the Action's signing secret. Compare your computed digest to v1 with a constant-time
comparison, and reject requests whose timestamp is too far from now.
Warning
Hash the raw bytes. Do not parse the JSON and re-serialize it — key order and whitespace will
differ and the signature will never match. Many frameworks consume the request body before your
handler runs (express.json() is the common culprit), so capture the raw body first.
Python (Flask)
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["HIROI_ACTION_SECRET"].encode("utf-8")
TOLERANCE_SECONDS = 300
@app.post("/hiroi")
def hiroi():
raw = request.get_data() # bytes, exactly as received
header = request.headers.get("X-Hiroi-Signature", "")
parts = {}
for piece in header.split(","):
key, sep, value = piece.partition("=")
if sep:
parts[key.strip()] = value.strip()
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or not signature:
abort(400)
try:
signed_at = int(timestamp)
except ValueError:
abort(400)
if abs(time.time() - signed_at) > TOLERANCE_SECONDS:
abort(400) # replay or badly skewed clock
expected = hmac.new(
SECRET,
timestamp.encode("utf-8") + b"." + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
payload = request.get_json(force=True)
handle(payload) # your logic — keep it idempotent
return "", 200
Node (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.HIROI_ACTION_SECRET;
const TOLERANCE_SECONDS = 300;
// express.raw keeps req.body as a Buffer. Do NOT mount express.json() on this route.
app.post('/hiroi', express.raw({ type: '*/*' }), (req, res) => {
const header = req.get('X-Hiroi-Signature') || '';
const parts = {};
for (const piece of header.split(',')) {
const i = piece.indexOf('=');
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) return res.sendStatus(400);
const signedAt = Number(timestamp);
if (!Number.isFinite(signedAt)) return res.sendStatus(400);
if (Math.abs(Date.now() / 1000 - signedAt) > TOLERANCE_SECONDS) return res.sendStatus(400);
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.`)
.update(req.body) // raw Buffer
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
const payload = JSON.parse(req.body.toString('utf8'));
handle(payload); // your logic — keep it idempotent
res.sendStatus(200);
});
The signing secret
Each Action gets its own high-entropy secret when it is created. It is used only to sign requests Hiroi sends you — it is never sent to a browser and it is not a credential for calling Hiroi.
Warning
The dashboard does not yet display an Action's signing secret, and there is no rotate control in
the interface. Until that ships you can confirm deliveries are arriving with Test and the
X-Hiroi-Trigger headers, but you cannot complete signature verification on your side. If your
endpoint is reachable only by Hiroi and you cannot verify yet, keep it locked down another way —
an unguessable path, an IP allowlist, or a static secret in a custom header.
When rotation does reach the dashboard, treat it as a rolling change: accept either the old or the new secret for the length of your tolerance window, then drop the old one.
Event catalog
27 events are registered for Event triggered Actions. Pick as many as you want on a single Action — it fires on any of them. Five of them are registered and selectable but are not yet emitted to Actions; they are marked (reserved — not yet firing) below, and subscribing to them delivers nothing today.
Conversation
| Event | Fires when |
|---|---|
conversation.started |
A new widget conversation begins |
conversation.ended |
A conversation session closes or expires |
conversation.resolved |
A conversation is graded against its goal |
Message
| Event | Fires when |
|---|---|
message.received |
A user sends a message |
message.sent |
The bot sends a response |
Form
| Event | Fires when |
|---|---|
form.submitted |
A form submission is collected (reserved — not yet firing) |
Contact
| Event | Fires when |
|---|---|
contact.created |
A new contact is added |
contact.updated |
Contact fields are modified |
contact.merged |
Two contact records are merged |
Escalation
| Event | Fires when |
|---|---|
escalation.triggered |
A live escalation is initiated |
Live chat
| Event | Fires when |
|---|---|
live_chat.requested |
A visitor requests live chat |
live_chat.claimed |
An agent claims a live chat conversation |
live_chat.released |
An agent releases a live chat conversation |
Call
| Event | Fires when |
|---|---|
call.completed |
A phone call ends (reserved — not yet firing) |
call.resolved |
A call is graded against its goal |
Campaign
| Event | Fires when |
|---|---|
campaign.completed |
An outbound campaign finishes |
campaign.contact.completed |
A single contact within a campaign finishes (reserved — not yet firing) |
campaign.paused |
A campaign is paused (reserved — not yet firing) |
SMS
| Event | Fires when |
|---|---|
sms.sent |
An SMS message is sent |
sms.received |
An SMS message is received |
| Event | Fires when |
|---|---|
email.sent |
An email is sent (campaign or transactional) (reserved — not yet firing) |
Appointment
| Event | Fires when |
|---|---|
appointment.booked |
An appointment is booked |
appointment.cancelled |
An appointment is cancelled |
appointment.rescheduled |
An appointment is rescheduled |
Personal assistant
| Event | Fires when |
|---|---|
personal_assistant.activated |
A personal-assistant assignment is accepted and goes active |
Compliance
| Event | Fires when |
|---|---|
compliance.conversation.archived |
A conversation is archived for compliance |
System
| Event | Fires when |
|---|---|
test.ping |
Test event sent from the dashboard |
Actions created in the dashboard belong to the assistant you created them on. That Action receives events raised by its own assistant, and it also receives organization-level events that are not tied to any assistant, such as contact changes.
Retries and idempotency
Only Event Actions retry. Up to 3 attempts are made, with a 2-second pause after the first failure and a 4-second pause after the second. AI-decided and Form Actions are single-shot, because a person is waiting on the answer.
What counts as what:
| Response | Result |
|---|---|
2xx |
Success. Done. |
4xx, except 408 and 429 |
Permanent failure. No retry, even on an event Action. |
408, 429, any 5xx |
Retried (events only). |
| Timeout or connection error | Retried (events only). |
3xx |
Failed delivery — redirects are not followed. |
Because an event can be delivered more than once, your endpoint must be idempotent on the envelope
id. Store the id, and if you see it again, acknowledge with a 2xx and do nothing else. Return
2xx as soon as you have durably accepted the payload; do the slow work afterwards.
Delivery rules and limits
| Rule | Value |
|---|---|
| Scheme | https:// only |
| Methods | GET, POST, PUT, PATCH, DELETE |
| Timeout | 5 seconds per attempt |
| Redirects | Not followed |
| Response read | First 1 MB; the rest is discarded |
| Private addresses | Blocked. The hostname is resolved and checked before the request, and the live peer IP is re-checked at connect time, so private, loopback, link-local, reserved and cloud-metadata addresses are rejected |
| Actions per organization | 50 |
| URL length | 2000 characters |
A blocked or malformed target is recorded as a failed delivery rather than silently dropped.
AI-decided Actions in detail
The assistant sees the Action as a tool: its name is the Action's slug (derived from the Name you typed), and its description is the Description field. Write the description for the model — it is the only thing telling it when calling your endpoint is the right move.
Parameters. Parameters are declared with a small JSON Schema subset: required, and per-property
type of string, integer, number or boolean, plus pattern and format: email on strings.
Arguments that fail validation are rejected before the request leaves Hiroi, and the assistant is
told which field was wrong.
Note
The New action dialog does not yet include a parameter editor, so an Action created in the dashboard today is exposed to the assistant with no parameters and your endpoint receives an empty JSON object.
Per-conversation cap. An AI-decided Action can be called at most 5 times in one conversation. On the sixth attempt the assistant is told the Action has been used the maximum number of times and to offer human help instead.
What the assistant does with your response. By default the assistant is told only that the
Action completed — your response body is not read back into the conversation. When an Action is
configured to feed its response to the assistant, the response is handed back as JSON: either the
fields named in its response field map (each pulled from your body by a dotted path such as
$.order.status), or, with no map, the top-level fields of your JSON object whose values are
strings, numbers, booleans or null. A non-JSON response is passed back as its first 500 characters.
If the request fails, the assistant is told the external service is unavailable and asked to offer to follow up — it does not invent an answer.
Every call is also recorded on the conversation with the Action name, HTTP status, latency and any error. That record is stored on the conversation but is not surfaced in the dashboard yet.
Form Actions in detail
Fields you define on the Action become the form panel the assistant presents in the chat. Each field has a key (what the assistant fills in), a Label (what the visitor reads), a type — Text, Email, Phone, Number or Long text — and a Required flag.
When the visitor submits, the widget posts the values to Hiroi, and Hiroi signs and forwards them to
your endpoint as the {"values": …, "conversation_id": …, "submission_id": …} body shown above.
submission_id is a UUID minted per submission and is also sent as X-Hiroi-Trigger-Ref, so it is
the right key to deduplicate on.
Submitted values are cleaned before they are forwarded:
- Field keys longer than 100 characters are dropped.
- Strings longer than 5000 characters are dropped.
- Numbers and booleans pass through.
- Lists of 50 items or fewer are kept, each item coerced to a string and truncated to 500 characters; longer lists are dropped.
- Anything else is dropped.
The submission endpoint is POST /api/widgets/sites/<site_id>/forms/<slug>/submit. The widget calls
it for you — it authenticates as the widget from an allowed origin and is rate-limited to 10
submissions a minute, so it is not a general-purpose intake API for your own server code.
Submissions are also delivered to the assistant's configured escalation channels, so a lead reaches a human even if your endpoint is down. If neither the endpoint nor an escalation channel accepts the submission, the widget is told the submission failed rather than showing the visitor a false success.
Delivery log
Every attempt is recorded against the Action — the payload that was sent, the HTTP status, the first 2000 characters of your response, the round-trip time, the attempt number, whether it succeeded, and the error if it did not. Pre-flight failures, such as a blocked address, are recorded too.
Note
This log is not surfaced in the dashboard yet. Today, Test is the in-dashboard way to check an endpoint: it sends a real signed request and shows you the status code or the error.
Related pages
- Developer overview — why there is no public REST API, and what is callable
- Page tools — functions on your own page that the assistant can call in the browser
- Knowledge and capabilities — where the Actions card lives