API channel
An API channel is different from a workspace API key (Settings → API, see API). API keys read and manage
Onix data; a channel key (onx_ch_…) can only send inbound messages to that one API channel.
Creating an API channel
- Go to Manage → Channels, click Add channel, then choose API channel (Pro/Custom plans; requires access to manage channels).
- Enter a channel name (e.g. your app's name — shown in Interaction & filters) and the webhook URL (you can fill it in later), then click Create API channel.
- Copy the channel key (
onx_ch_…) and the webhook secret (whsec_…). The channel key is shown only once — store it on your server; if you lose it, replace the key. The secret can be shown again in the channel settings. - Optional: upload an icon (square PNG/JPG/WebP, up to 512 KB) so conversations from this system are easy to spot in Interaction.
One API channel uses one channel slot; the quota is shared with WhatsApp numbers, email addresses, and livechat (Pro 4 channels). If the plan drops to Free, the API channel is paused and inbound messages are rejected (402) until the plan is active again.
Sending customer messages to Onix
Send each customer message from your system to this endpoint with the channel key in the Authorization header:
curl -s -X POST "https://onix.sassly.ai/api/v1/channel-api/messages" \
-H "Authorization: Bearer $ONIX_CHANNEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"conversation_id": "order-1029",
"contact": {"id": "u_981", "name": "Budi Santoso", "email": "[email protected]", "phone": "6281234567890"},
"message": {"id": "m_5521", "text": "Hi, my order hasn't arrived yet",
"attachments": [{"url": "https://cdn.yourstore.com/photo.jpg", "name": "photo.jpg"}]}
}'
| Field | Description |
|---|---|
conversation_id | Optional, up to 100 characters. The conversation ID in your system (e.g. an order number). Defaults to contact.id (one conversation per customer). Messages with the same conversation_id go to the same conversation while it's open. |
contact.id | Required, up to 100 characters. The customer's ID in your system. |
contact.name, contact.email, contact.phone | Optional. Customers are matched by contact.id, then by email or phone number — a customer who has chatted via WhatsApp, email, or livechat before lands on the same contact. |
message.id | Required, unique per channel, up to 100 characters. Resending a message with the same message.id is safe: it's answered with 200 and "duplicate": true and isn't recorded twice. |
message.text | Up to 4,000 characters. Required when there are no attachments. |
message.attachments | Optional, up to 5: [{"url", "name"}]. URLs must be https on a public host; Onix downloads and stores them privately (up to 10 MB per file; redirects aren't followed). The 2nd attachment onwards becomes a separate message with the ID {message.id}#2, #3, … |
message.sent_at | Optional, ISO 8601 (e.g. 2026-10-11T03:00:00Z). Defaults to the time received; future times are capped at now. |
The result is 201 Created (or 200 for a duplicate):
{"data": {"message_id": 678, "interaction_id": 45, "interaction_number": "INT-000045", "duplicate": false}}
Have the file on your own server? Send it as multipart/form-data with files[]; other fields use brackets:
curl -s -X POST "https://onix.sassly.ai/api/v1/channel-api/messages" \
-H "Authorization: Bearer $ONIX_CHANNEL_KEY" \
-F "contact[id]=u_981" -F "message[id]=m_5522" -F "message[text]=Here is the receipt" \
-F "files[][email protected]"
- Conversations follow the same rules as other channels: a message to a conversation that's already Solved/Closed opens a new conversation (a new INT number).
- The greeting & after-hours message (when turned on in the channel settings) are sent to your system through the webhook with
sender.type="auto". - Inbound messages appear in Interaction in real time, with your API channel's icon and name.
Receiving agent replies (webhook)
Every agent reply and auto-reply is sent by Onix to the webhook URL as a JSON POST (the message.created event, always
sent). The conversation.updated event (status Solved/Closed/reopened or assignee changed) can be turned on in the channel settings.
The webhook URL must be https on a public host (not an internal network address).
POST https://api.yourstore.com/onix/webhook
Content-Type: application/json
User-Agent: Onix-Webhook/1.0 (+https://onix.sassly.ai/docs/api-channel)
X-Onix-Event: message.created
X-Onix-Delivery: 0c6f7a52-2b1e-4b8e-9d0a-5d7f3c1e9a41
X-Onix-Timestamp: 1791630130
X-Onix-Signature: sha256=8f2c…
{
"event": "message.created",
"channel": {"id": 9, "name": "Partner App"},
"conversation": {"id": "order-1029", "interaction_id": 45, "number": "INT-000045", "status": "open"},
"contact": {"id": "u_981", "name": "Budi Santoso"},
"message": {
"id": 680,
"type": "document",
"text": "Hi, here's the receipt",
"attachments": [{"name": "receipt.pdf", "mime": "application/pdf", "size": 48211, "url": "https://onix.sassly.ai/api/v1/channel-api/files/…"}],
"sender": {"type": "agent", "name": "Sari"},
"sent_at": "2026-10-11T03:02:10Z"
}
}
{
"event": "conversation.updated",
"change": "status",
"channel": {"id": 9, "name": "Partner App"},
"conversation": {"id": "order-1029", "interaction_id": 45, "number": "INT-000045", "status": "solved", "assignee": {"name": "Sari"}},
"contact": {"id": "u_981", "name": "Budi Santoso"},
"updated_at": "2026-10-11T03:20:00Z"
}
conversation.idandcontact.idare the IDs from your system, so you can forward replies without storing Onix IDs.message.idis the Onix message ID (for delivered/read status).sender.type:agent(an agent reply) orauto(an auto-reply). One Onix message carries at most one attachment; the attachmenturlis signed and valid for 7 days without signing in.- Your endpoint must answer 2xx within 10 seconds. Otherwise delivery is retried after 1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours — the number of retries is set per channel (0–5, default 5). After that the message is marked Failed in Interaction with the reason, and the agent can Resend it.
- Messages are delivered in order per conversation: the next message waits until the previous one is sent or has failed.
X-Onix-Deliveryis the same on every retry — store it and ignore the ones you've already processed.- No webhook URL yet? Agent replies are still saved in Onix but marked Failed "The webhook URL is not set".
Verifying the webhook signature
X-Onix-Signature = sha256= + the HMAC-SHA256 (hex) of X-Onix-Timestamp + "." + raw body, keyed with the webhook
secret (whsec_…). Compute it from the body before parsing it as JSON, compare with a constant-time function, and
reject timestamps more than 5 minutes away from your server clock (this stops others from replaying a request).
PHP
<?php
$secret = getenv('ONIX_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input'); // raw body
$timestamp = $_SERVER['HTTP_X_ONIX_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_ONIX_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
if ($event['event'] === 'message.created') {
// Forward $event['message']['text'] to customer $event['contact']['id']
// in conversation $event['conversation']['id'].
}
http_response_code(200);
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Raw body: the signature is computed from the bytes Onix sent.
app.post('/onix/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Onix-Timestamp') || '';
const signature = req.get('X-Onix-Signature') || '';
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.ONIX_WEBHOOK_SECRET)
.update(timestamp + '.').update(req.body).digest('hex');
const valid = signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
&& Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body);
// … forward event.message to customer event.contact.id
res.sendStatus(200);
});
Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/onix/webhook")
def onix_webhook():
body = request.get_data() # raw bytes
timestamp = request.headers.get("X-Onix-Timestamp", "")
signature = request.headers.get("X-Onix-Signature", "")
expected = "sha256=" + hmac.new(os.environ["ONIX_WEBHOOK_SECRET"].encode(),
timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature) or abs(time.time() - int(timestamp or 0)) > 300:
abort(401)
event = json.loads(body)
# … forward event["message"] to customer event["contact"]["id"]
return "", 200
Delivered & read status (optional)
To let agents see their reply arrived, send POST https://onix.sassly.ai/api/v1/channel-api/messages/{id}/status with the channel key and
{"status": "delivered"} or {"status": "read"}, using the message.id from the webhook. The status only moves
forward (Sent → Delivered → Read) and appears in Interaction right away.
Settings & testing tools
| Tab | Contents |
|---|---|
| Webhook | Channel name, icon, webhook URL, events sent, number of retries, plus Test webhook (sends a ping event now and shows the HTTP code & duration) and Send test message (simulates an inbound message from your system; reply to it in Interaction to try the webhook). |
| Credentials | The incoming message endpoint, the channel key (prefix & when it was last used) with a Replace key button (the old key is rejected immediately), and the webhook secret: Show secret & Replace secret. |
| Hours & auto replies | Follow the workspace business hours or use the channel's own hours, the greeting, and the after-hours message — the same for every channel (see the business hours section of Livechat). |
| History | The last 50 webhook deliveries: time, event, status (Sent, Retrying, Failed), HTTP code, duration, and notes. |
In Interaction
- API channel conversations arrive in the Chat tab with the icon you uploaded (or the default API icon) and the channel name on the item & in the Channel filter.
- Agents reply with text, one attachment, templates, KB articles, or internal notes (notes are never sent to your system). Reactions, editing, unsending, and quoting aren't available.
- Reply status: Processing (waiting for the webhook) → Sent (your endpoint answered 2xx) → Delivered/Read when your system sends a status. Failed shows the reason (e.g. "The endpoint answered HTTP 500") with a Resend button.
- Assignment follows the usual rules (no Pick up chat queue — that's livechat only). API channels have their own response target (15 business minutes by default) — see KPIs & SLA.
Error codes
Errors use the same format as the Onix API: {"error": {"code", "message", "fields"}}.
| HTTP | Meaning |
|---|---|
401 | The channel key is missing, wrong, or has been replaced. |
402 | The workspace plan isn't Pro/Custom — the API channel is paused. |
404 | Status: the message wasn't found in this channel (it isn't an agent reply in this channel). |
409 | The API channel was deleted or isn't active. |
422 | Invalid input (per field in fields, e.g. message.id), an attachment couldn't be downloaded, or it's larger than 10 MB. |
429 | More than 120 requests per minute per channel key. Wait as indicated by the Retry-After header. |
Security
- Channel keys are stored only as a fingerprint (hash) and can be replaced at any time; the webhook secret is stored encrypted. Keep both on your server, never in a mobile app or browser JavaScript.
- Onix only contacts https URLs on public hosts (webhook URLs & attachment URLs) and doesn't follow redirects, so these fields can't be used to reach internal networks.
- Always check the webhook signature and timestamp before processing it.
- Deleting an API channel stops its key right away and cancels webhooks still waiting to be retried; the conversation history is archived.
Full specification (including the OpenAPI 3.1 webhooks section): /docs/openapi.yaml.