Documentation

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

  1. Open the assistant, go to the Intelligence tab and expand the Actions card.
  2. Click New action.
  3. Under When should this fire?, choose AI-decided, Event or Form. This cannot be changed after the Action is created.
  4. 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).
  5. Set Method and Endpoint URL. The URL must be https://.
  6. 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

Email

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.