Related: a runnable NestJS example lives in examples/api-channel-nestjs-relay/, and a Thai handoff checklist for the integration team inapi-channel-handoff.th.md.
Your external system
│
│ POST /api/v1/channels/{channelId}/messages (inbound — you send)
│ Authorization: Bearer sk_…
▼
OneBear API ──▶ agent console (new or existing conversation)
│ auto-reply / AI chatbot / assignment / tags
│
│ POST https://your-webhook-url (outbound — we send)
│ X-OneBear-Signature: t=…,v1=…
▼
Your external system ──▶ deliver reply to end userLINE user sends message
└─▶ your LINE webhook
└─▶ POST /api/v1/channels/{channelId}/messages externalUserId = LINE userId
└─▶ OneBear: creates conversation, runs AI / auto-reply
└─▶ POST https://your-outbound-webhook event:"message"
└─▶ call LINE push API to userIdclientMessageId (5-minute window), so it is safe to retry on network error.senderType: "agent" (human) or "bot" (AI), so you know whether to suppress your own bot.event:"handoff" so your relay can stand down.| Field | What it does |
|---|---|
| Name | Internal label shown in the console inbox. |
| Outbound webhook URL | HTTPS endpoint OneBear will POST agent replies to. Must be publicly reachable. |
| Value | Format | Shown |
|---|---|---|
Channel ID (channelId) | ac_… | Always — visible on the detail page. |
| API key | sk_… | Once only — copy it now. |
| Signing secret | whsec_… | Once only — copy it now. |
Copy the API key and signing secret before closing this dialog. They are stored hashed and will never be shown again. You can rotate them later — rotating generates a new value and immediately revokes the old one.
Authorization: Bearer sk_your_api_keychannelId in the URL is also verified against the key — a key for channel A cannot reach channel B even within the same company.POST /api/v1/channels/{channelId}/messages
Authorization: Bearer sk_…
Content-Type: application/json{
"externalUserId": "U1234567890abcdef",
"displayName": "Alice",
"pictureUrl": "https://cdn.example.com/u1.jpg",
"content": "Hello, I need help with my order",
"messageType": "text",
"clientMessageId": "your-unique-idempotency-key",
"timestamp": 1720000000000
}| Field | Type | Required | Description |
|---|---|---|---|
externalUserId | string | Yes | Stable identifier for the end user in your system (e.g. LINE userId, visitor id). OneBear creates one chat user per externalUserId + channel. |
displayName | string | No | Display name shown in the console for a new user. Ignored if the user already exists. |
content | string | Conditional | Message text. Required unless attachmentId is provided. |
messageType | string | No | "text" (default), "image", or "file". When attachmentId is provided, type is derived from the attachment's content-type. |
attachmentId | string | No | ID returned by the attachment upload endpoint. Pass instead of (or alongside) content to send a file. |
clientMessageId | string | Yes | Caller-assigned idempotency key. Duplicate values within 5 minutes return the original result without creating a duplicate message. |
timestamp | integer | No | Unix millisecond timestamp (client time). Defaults to server time when omitted. |
quoteToken | string | No | Opaque quote handle for this message (max 256 chars). Stored verbatim and echoed back in the outbound webhook when an agent quotes it. |
replyToPlatformMessageId | string | No | Set when this message quotes an earlier one: the clientMessageId of the quoted message. Renders the quote in the agent console. |
replyToPlatformMessageId = the clientMessageId of the message being quoted. OneBear resolves it to a quote preview above the new message.quoteToken on every inbound message. OneBear stores it against that message and hands the same string back in the outbound webhook when an agent replies to it — so you never have to maintain your own id map.quoteToken is opaque to OneBear: any stable, reusable handle works. A relay fronting LINE should pass LINE's own quoteToken straight through.replyToPlatformMessageId must name an id OneBear knows. For outbound replies that is the placeholder OneBear minted (the messageId on the webhook), not your platform's own id. Report the real one with POST /api/v1/channels/{channelId}/messages/{messageId}/platform-id and body { "externalUserId": "...", "platformMessageId": "..." } — both values come straight off the webhook payload, so no conversation state is needed (best-effort, idempotent; a conversations/{conversationId}/… variant exists for callers that already track it) — or keep your own id map and send OneBear's messageId instead.200 + Content-Type: application/json + body { "platformMessageId": "..." } and OneBear records it immediately — no separate call needed. An unusable body is skipped silently and never fails the delivery.Do not send a LINE replyToken. It expires in ~30 seconds and is single-use, so it cannot survive the round trip through OneBear. LINEquoteTokens never expire and are reusable — that is the one you want.
{
"conversationId": "room_abc123",
"messageId": "msg_xyz789",
"isNewConversation": true,
"replyToResolved": true
}replyToResolved: true = the quote reference matched, false = it did not and the quote was dropped (the message itself was still delivered), absent = no replyToPlatformMessageId was sent. Use it to catch a broken quote reference without reading logs.400 — missing required field, invalid format, or attachment not found.403 — channelId in the URL does not match the authenticated key.409 — ETag conflict (concurrent update on the same conversation — safe to retry).429 — rate limit exceeded.502 — transient downstream failure (safe to retry).POST /api/v1/channels/{channelId}/attachments
Authorization: Bearer sk_…
Content-Type: multipart/form-datafile (the binary file)image/png, image/jpeg, image/gif, image/webp, application/pdf.{
"id": "att_abc123",
"fileName": "receipt.pdf",
"contentType": "application/pdf",
"size": 204800
}400 no file, 413 file too large, 415 unsupported content type.POST /api/v1/channels/{channelId}/messages
{
"externalUserId": "U1234567890",
"attachmentId": "att_abc123",
"clientMessageId": "msg-001"
}messageType is derived automatically from the attachment's content-type ("image" for images, "file" for PDF and others).POST https://your-outbound-webhook.example.com/hook
Content-Type: application/json
X-OneBear-Signature: t=1720000000,v1=abc123def456…event: "message"):{
"event": "message",
"externalUserId": "U1234567890abcdef",
"messageId": "msg_xyz789",
"content": "Hi Alice, your order #1234 has shipped!",
"messageType": "text",
"senderType": "agent",
"senderName": "Alice Wong",
"senderAvatarUrl": "https://…/member-avatars/…/abc.png",
"mediaUrl": null,
"fileName": null,
"quoteToken": "the-quoteToken-you-sent-for-the-quoted-message",
"timestamp": 1720000100000
}event: "handoff"):{
"event": "handoff",
"conversationId": "room_abc123",
"externalUserId": "U1234567890abcdef",
"reason": "complex_query",
"timestamp": 1720000200000
}| Field | Description |
|---|---|
event | "message" — agent/AI reply; "handoff" — AI hands off to human. |
externalUserId | The same id you sent inbound. Use this to route the reply to the correct user. |
conversationId | OneBear room id. Provided on handoff events; omitted on message (correlate by externalUserId). |
messageId | Present on message events. Use to fetch media if mediaUrl is set. |
content | Message text. May be null for media-only messages. |
messageType | "text" | "image" | "file" |
senderType | "agent" (human) | "bot" (AI chatbot). Use this to decide whether to label the reply. |
senderName | Display name of the human agent who replied. Null on "bot" replies and when the agent has no name on file. |
senderAvatarUrl | Absolute URL of that agent's profile photo — fetchable without your Bearer key. Null on "bot" replies and when the agent has no photo. |
mediaUrl | Relative URL to fetch the media: /api/v1/channels/{channelId}/media/{messageId}. Use your Bearer key to download it. |
fileName | Original filename for file attachments. |
quoteToken | Present only when the agent/AI quoted an earlier customer message: the exact quoteToken you supplied for that message on ingest. Null otherwise. |
timestamp | Unix millisecond timestamp. |
GET /api/v1/channels/{channelId}/media/{messageId}
Authorization: Bearer sk_…Content-Type header. The endpoint enforces IDOR guards — the key can only fetch media from its own channel's conversations.X-OneBear-Signature: t={unix_seconds},v1={lowercase_hex_hmac}HMAC-SHA256(key=signingSecret, message="{t}.{rawBody}") where rawBody is the raw request body bytes as a string and t is the unix timestamp seconds from the header.t is more than 5 minutes old (replay protection).Important: always read the raw request body bytes before parsing JSON. Parsers may normalize whitespace, which changes the HMAC.
POST /api/v1/channels/{channelId}/handoff
Authorization: Bearer sk_…
Content-Type: application/json
{
"externalUserId": "U1234567890abcdef",
"reason": "complex_query",
"note": "Customer says the order hasn't arrived after 3 weeks."
}POST /api/v1/channels/{channelId}/conversations/{conversationId}/handoff
Authorization: Bearer sk_…
Content-Type: application/json
{
"reason": "human_requested"
}| Field | Required | Description |
|---|---|---|
externalUserId | Conditional | Required if conversationId is not provided. |
reason | No | Free-text reason label (e.g. "complex_query", "human_requested"). Defaults to "human_requested". |
note | No | Longer note shown to the agent. |
{
"conversationId": "room_abc123",
"isAiMuted": true,
"handoffSource": "api"
}event:"handoff" to your outbound webhook. Use this to stand down your relay's own bot.GET /api/v1/channels/{channelId}/conversations/{conversationId}/messages?pageSize=50&continuationToken=
Authorization: Bearer sk_…continuationToken from a response to fetch the next page.| Value | Sensitivity | Where it may appear |
|---|---|---|
Channel ID ac_… | Not a secret | Your config, logs, URLs — fine. |
API key sk_… | Secret (password-grade) | Environment variable / secret manager only. Never in source code or client-side. |
Signing secret whsec_… | Secret (password-grade) | Your webhook handler only. Never in source code or client-side. |
externalUserId = LINE userId — this is the stable identifier that links both directions.clientMessageId gives safe retry on failure.senderType to label messages as "agent" vs "bot" in your LINE chat.quoteToken (never replyToken) so agents can quote-reply; OneBear echoes it back verbatim.handoff, stop your relay bot from sending further bot-driven messages to that user.| Symptom | Likely cause / fix |
|---|---|
401 Unauthorized | Missing or wrong Authorization: Bearer sk_… header. Confirm the key hasn't been rotated. |
403 Forbidden | The channelId in the URL does not match the channel the key belongs to. Check the URL. |
400 VALIDATION | Missing externalUserId or clientMessageId, or invalid messageType. Check the error message field. |
400 ATTACHMENT_NOT_FOUND | The attachmentId doesn't exist or belongs to a different company. Upload it again with the same key. |
413 on upload | File exceeds 10 MB. Compress or split the file before uploading. |
415 on upload | Content type not in the allowlist. Accepted: PNG, JPEG, GIF, WebP, PDF. |
| Agent quoted a message but no quote arrived | You did not send a quoteToken on that inbound message, so there was nothing to echo back. The reply itself is still delivered — only the quote reference is missing. |
| Webhook not received | Your outbound URL must be a public HTTPS endpoint. Check it is reachable from the internet. Verify in the console settings that the URL saved correctly. |
| Signature verification fails | Ensure you pass the raw request bytes (before JSON parsing) to the HMAC. Check you're using the signing secret (whsec_…), not the API key. |
| Duplicate messages created | Add clientMessageId (a stable, unique id per message event). Duplicates within 5 minutes are silently de-duped. |
| Old API key still rejected after rotation | The old key is revoked immediately on rotation. Update ONEBEAR_API_KEY in your config and redeploy. |
channelId, API key, and signing secret, with separate inboxes in the console.externalUserId across different channels? Users are scoped per channel. The same externalUserId value in two channels creates two separate chat users and conversations.5xx, 429, or network/timeout error (the delivery client has a 10-second timeout and a circuit breaker); a 4xx is treated as terminal and is not retried. Because retries can re-deliver the same event, make your handler idempotent — de-duplicate by messageId. Return 200–299 quickly (within 10 seconds) and do heavy processing asynchronously.event:"handoff" on your webhook.curl to POST test messages.