Alltimer

Custom channels

Bridge your own messaging app into Alltimer — post customer messages to the API, receive your agent's replies as signed webhooks, and report delivery back.

A custom channel connects a messaging app Alltimer does not support natively — your own chat app, an in-house support tool, a provider we have no integration for. You run a small service (a bridge) between that app and Alltimer:

  1. When a customer writes in your app, your bridge POSTs the message to the Alltimer API.
  2. The message lands in the shared inbox. Your agent answers it, or an operator takes over — exactly as on any other channel.
  3. Alltimer POSTs the reply to your webhook URL, signed. Your bridge sends it to the customer.
  4. Your bridge reports delivery (delivered, read or failed) back to the API.

Text only, for now

Custom channels carry text in both directions. A message with attachments is refused with 422 unsupported_content.

Setup

Create the channel

In Channels → Add channel → Custom channel, name the channel, choose the agent that answers it, and enter your webhook URL (where replies are POSTed; public HTTPS). You are shown two credentials once:

  • an ingest key your bridge sends as the x-api-key header. It works for this channel only: a key for channel A cannot post into channel B, even in the same workspace;
  • a signing secret (whsec_…) your bridge uses to verify our webhooks.

Store both in your secret manager. Neither can be shown again — rotate if you lose one.

Send a test event

Press Send test event. Within about ten seconds we POST a signed test event to your webhook URL. When your bridge answers 2xx, the channel is activated.

Until then, messages your bridge posts are refused with 409 channel_not_activated and no replies go out. Changing the webhook URL deactivates the channel until a test reaches the new URL.

Point your bridge at the ingest endpoint

The channel's Bridge tab shows the exact ingest URL. Use it rather than guessing a host: the dashboard's own address does not serve the API.

Sending a customer message

POST /v1/custom-channels/{connectionId}/messages
x-api-key: <ingest key>
content-type: application/json
{
  "conversation": { "id": "thread-123", "name": "optional" },
  "sender": {
    "id": "user-42",
    "name": "Ana",
    "email": "ana@example.com",
    "phone": "+14155550123",
    "metadata": { "plan": "pro" }
  },
  "message": {
    "id": "msg-9001",
    "text": "Hi, do you ship to Canada?",
    "sentAt": "2026-11-05T10:00:00Z"
  }
}
  • conversation.id is your stable thread id; messages with the same id land in the same conversation.
  • sender.id is your stable user id — never a display name that can change. It maps to one contact.
  • message.id is your stable message id and the idempotency key.
FieldRule
conversation.id, sender.id, message.id1–256 printable characters
message.text1–8,000 characters after trimming
sender.metadataa flat JSON object, at most 50 keys and 4 KB
sender.phoneE.164 when present
message.sentAtISO 8601, not more than 5 minutes in the future (optional)
message.attachmentsempty or omitted
Request bodyat most 64 KB
StatusBodyMeaning
202{"status":"accepted","inboundEventId":"…"}Queued for the agent. Not answered yet.
200{"status":"duplicate","inboundEventId":"…"}Same message.id, same body. Safe to retry.
409idempotency_conflictSame message.id with a different body.
409channel_not_activatedNo test event has reached the current webhook URL. Nothing was stored.
409channel_inactiveThe channel is disabled.
422unsupported_contentAttachments were sent.
400validation_errorThe body broke a rule above.
401unauthorizedMissing or revoked key, or a key for another channel. Same body either way.
429rate_limitedOver the key's limit (120 requests a minute by default). Wait Retry-After seconds.

Errors share one envelope: {"error":{"code":"…","message":"…","request_id":"…"}}. Retrying with the same message.id and body is always safe.

Receiving replies

When the agent or an operator replies, we POST to your webhook URL:

POST https://bridge.example.com/alltimer
content-type: application/json
webhook-id: turn_8fK2…
webhook-timestamp: 1793350000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
  "id": "turn_8fK2…",
  "type": "message.created",
  "version": 1,
  "occurredAt": "2026-11-05T10:00:07.120Z",
  "origin": "agent",
  "subject": { "type": "conversation", "id": "cnv_…" },
  "data": {
    "channelConnectionId": "chn_…",
    "conversation": { "id": "thread-123", "alltimerId": "cnv_…" },
    "recipient": { "id": "user-42" },
    "sender": { "type": "agent", "id": "agt_…", "name": "Ava" },
    "message": { "id": "itm_…", "text": "Yes, we ship to Canada.", "author": "agent" }
  }
}
  • message.author and sender.type are agent or operator; origin is agent or user.
  • recipient.id is the sender.id of the last customer message in the thread.
  • One reply is one webhook, whatever its length. Split on your side if your platform caps length.
  • The test event is {"type":"test", "data":{"channelConnectionId":"…","webhookUrl":"…"}, …}. Answer it 2xx and do nothing else.

Answer 2xx once your bridge has taken responsibility for the message.

Verify every webhook

Webhooks follow Standard Webhooks. The signed content is {webhook-id}.{webhook-timestamp}.{raw body}, HMAC-SHA256 with the base64-decoded part of your secret after whsec_. webhook-signature holds one or more space-separated v1,<base64> values; accept the request if any matches, and reject a timestamp more than 5 minutes from your clock. Use the raw request bytes.

import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.ALLTIMER_SIGNING_SECRET); // "whsec_…"
const event = wh.verify(rawBody, headers); // throws if invalid or stale

Deduplicate on webhook-id — required

A retry, and a manual replay from the dashboard, carry the same webhook-id as the original. If your endpoint got the first attempt but its answer was lost, the retry is a duplicate. Your bridge must remember the webhook-ids it has handled and answer 2xx to a repeat without sending the message again.

Retries

The first attempt happens within about ten seconds. Then:

Your answerWhat we do
2xxAccepted by your bridge.
400, 401, 403, 404, 410, 422Final. The reply is marked not delivered, the inbox shows a notice, and the channel reads Bridge failing.
Any other status, a timeout (10 s), a connection or DNS failureRetried after about 30 s, 1, 2, 4, 8, 16 and 32 minutes — 8 attempts — then final.

Every attempt is in the Bridge tab's deliveries log (status, response code, attempts), where a failed delivery can be replayed once you have fixed your bridge.

Accepted is not delivered

Your 2xx only proves your bridge has the reply. Whether your customer got it is something only your bridge knows.

StateInbox shows
Queued, or your bridge answered 2xxSent to your bridge
Retrying, or outcome unknownDelivery status unknown
Status callback delivered or readDelivered
Status callback failed, or a final failureNot delivered, with a notice

Report delivery

POST to the reply's status URL with the same key, using data.message.id from the webhook:

POST /v1/custom-channels/{connectionId}/messages/{messageId}/status
x-api-key: <ingest key>
content-type: application/json

{ "status": "delivered", "occurredAt": "2026-11-05T10:00:09Z" }
  • status is delivered, read or failed; for failed, add "error": {"code": "…", "message": "…"}.
  • Transitions only move forward: the answer is 200 {"applied":true} when the reply moved, 200 {"applied":false} for a repeat or a backwards move. Repeating a callback is harmless.
  • 404 message_not_found means the id is not a reply on this channel. A callback that races our own bookkeeping can see this for a moment; retry it.

Rotating credentials

  • Ingest key. Rotate ingest key issues a new key and the old one stops working immediately — put the new key in your bridge right away.
  • Signing secret. Rotate signing secret issues a new whsec_…. For the next 24 hours every webhook carries two signatures, one per secret; Standard Webhooks libraries accept either.

Webhook URL rules

The URL is checked when you save it and before every delivery: HTTPS only, no username, password or #fragment, and every address it resolves to must be public. We connect to the exact address we checked, follow 307/308 redirects only to other public hosts, and read at most a short excerpt of your answer.

Try it

The repository ships a dependency-free example bridge (tools/examples/custom-channel-bridge): it posts what you type as customer messages, verifies and deduplicates webhooks, prints replies, reports delivered, and can simulate a failing bridge so you can watch retries.

  • API and webhooks — how the public API is authenticated.
  • Channels — every channel Alltimer supports.
  • Inbox — operator takeover works the same on a custom channel.

On this page