# RelayKin Gateway: Integration Guide

This guide is for a developer or AI agent connecting an application to WhatsApp, Telegram or SMS through the RelayKin Gateway. One API key and secret can reach WhatsApp, Telegram and SMS accounts through the same endpoints and webhooks (see [Telegram accounts](#telegram-accounts) and [SMS accounts](#sms-accounts) for the differences). With it your application can:

- **Send** WhatsApp and Telegram messages: text, image, video, audio, **voice notes**, documents, stickers and locations.
- **Receive** incoming messages and status events, pushed to your webhook (signed) or fetched by polling.
- **Download** media that contacts send: images, voice notes, documents and so on.
- **Voice:** send text that arrives as a voice note, and get incoming voice notes as text (when enabled on the portal).
- **AI:** generate text with `POST /v1/ai/respond` on the ChatGPT account connected to the portal (when connected).

The machine-readable contract is in [`openapi.yaml`](openapi.yaml). This file explains how to use it.

---

## 1. What you need

The portal admin creates a key under **Portal → Integrations → New API key** and gives you:

| Value | Example | Use |
|---|---|---|
| Base URL | `https://relaykin.signalkin.ai/v1` | Prefix for every API call |
| API key | `kwa_3f9c1a...` | `X-API-Key` header (public identifier) |
| API secret | `kws_Jx8...` | `X-API-Secret` header (**keep secret**, shown only once) |
| Webhook signing secret | `whsec_9Qa...` | Verifies webhooks the gateway sends you |

Store them as environment variables, never in source code:

```sh
RELAYKIN_BASE_URL=https://relaykin.signalkin.ai/v1
RELAYKIN_API_KEY=kwa_...
RELAYKIN_API_SECRET=kws_...
RELAYKIN_WEBHOOK_SECRET=whsec_...
```

The admin also chooses which accounts (WhatsApp, Telegram or SMS) the key may use, an optional webhook URL, an optional event filter, and an optional IP allowlist.

> **Production base URL:** `https://relaykin.signalkin.ai/v1` (valid TLS certificate, served through Cloudflare).
> The previous address, `https://kinwa.signalkin.ai/v1`, still works for existing integrations. Move to the new one when convenient.
> When the admin sets an IP allowlist on your key, list the IP your server uses to call out, **IPv6 included** if it
> has one: the gateway sees the address Cloudflare received your request from.

---

## 2. Authentication

Send both headers on **every** request, over HTTPS only:

```
X-API-Key: kwa_...
X-API-Secret: kws_...
```

- A missing or wrong key or secret returns `401 unauthorized`.
- A request from an IP that isn't on the key's allowlist returns `403 ip_not_allowed`.
- Using an account the key isn't allowed to use returns `403 account_not_allowed`.
- If the secret leaks, the admin can rotate it with **New secret**; the old secret stops working immediately.

**Rate limit:** 20 requests per second per key, with bursts up to 40. Beyond that the gateway returns `429 rate_limited` with a `Retry-After` header.

---

## 3. Quick start

```sh
# 1. Which accounts can I use? Each has "channel": "whatsapp", "telegram" or "sms".
curl -s "$RELAYKIN_BASE_URL/accounts" \
  -H "X-API-Key: $RELAYKIN_API_KEY" -H "X-API-Secret: $RELAYKIN_API_SECRET"
# → {"data":[{"id":1,"name":"Sales MY","phone":"60123456789","status":"connected","connected":true,"last_seen":"...","channel":"whatsapp"}]}

# 2. Send a text from account 1
curl -s "$RELAYKIN_BASE_URL/messages" \
  -H "X-API-Key: $RELAYKIN_API_KEY" -H "X-API-Secret: $RELAYKIN_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"account_id":1,"to":"60198765432","type":"text","text":"Hello from our app 👋"}'
# → {"data":{"message_id":"3EB0C1...","status":"...","account_id":1,"to":"60198765432","type":"text"}}
```

Only send from accounts with `"connected": true`. Otherwise the call returns `409 account_not_connected`.

---

## 4. Sending messages: `POST /v1/messages`

All message types share one endpoint. The JSON body:

| Field | Type | Required | Notes |
|---|---|---|---|
| `account_id` | integer | yes | The WhatsApp account (sender), from `GET /v1/accounts` |
| `to` | string | yes | International number with country code (`60198765432`; `+`, spaces and dashes are allowed), **or** a JID: `...@s.whatsapp.net`, a group `...@g.us`, or `...@lid`. To reply, pass the incoming event's `data.chat_id`. |
| `type` | string | yes | `text`, `image`, `video`, `audio`, `voice`, `document`, `sticker`, or `location` |
| `text` | string | for `text`; optional for `voice` | Up to 65,536 bytes. For `voice` without media: the text read aloud (text-to-voice), up to 4,096 characters |
| `media_url` | string | media types* | Public `http(s)` URL; the account's server downloads it |
| `media_base64` | string | media types* | Base64 file content; a `data:...;base64,` prefix is allowed |
| `filename` | string | no | Filename for uploads (shown for documents) |
| `caption` | string | no | For `image`, `video`, `document` |
| `reply_to` | string | no | Message ID to quote (reply to) |
| `view_once` | boolean | no | For `image` and `video` |
| `latitude`, `longitude` | number | for `location` | Decimal degrees |
| `tts_voice` | string | no | Text-to-voice only: voice name (provider voices such as `alloy`, `nova`, `shimmer`, `echo`, `onyx`). Default is set by the admin |
| `tts_instructions` | string | no | Text-to-voice only: how to speak, e.g. `"Warm, friendly, Malaysian English accent"` |

\* Media types need **exactly one** of `media_url` or `media_base64` (except `voice` sent with `text`; see below). The request body can be up to 32 MB, which is about 24 MB of file data after base64 encoding. WhatsApp's own size limits still apply.

### Examples

```jsonc
// Text, quoting a received message
{"account_id":1,"to":"60198765432","type":"text","text":"Thanks, noted!","reply_to":"3EB0ABC123"}

// Image by URL with caption
{"account_id":1,"to":"60198765432","type":"image","media_url":"https://cdn.example.com/promo.jpg","caption":"Our new menu"}

// Document upload
{"account_id":1,"to":"60198765432","type":"document","media_base64":"JVBERi0xLjcK...","filename":"invoice-1042.pdf","caption":"Invoice #1042"}

// Location
{"account_id":1,"to":"60198765432","type":"location","latitude":3.1390,"longitude":101.6869}

// Group message
{"account_id":1,"to":"120363025555555555@g.us","type":"text","text":"Hello team"}
```

### Voice notes (`type: "voice"`)

`voice` sends a real WhatsApp **voice note**: it plays inline with a waveform and shows the blue mic when played.

- Send any common audio format, such as MP3, WAV, M4A/AAC or OGG, as `media_url` or `media_base64`.
- The gateway converts it automatically to the format WhatsApp requires (OGG/Opus, mono, 48 kHz).
- This works well for text-to-speech output: generate the audio, base64 it, and send it.

```jsonc
{"account_id":1,"to":"60198765432","type":"voice","media_base64":"<base64 of tts.mp3>","filename":"reply.mp3"}
```

`audio` sends the file as a regular audio attachment instead, shown as a music file rather than a voice note.

#### Text-to-voice

Send `type: "voice"` with `text` and **no** media, and the gateway generates the speech for you and sends it as a voice note:

```jsonc
{"account_id":1,"to":"60198765432","type":"voice","text":"Hi Ali, your order has shipped and arrives tomorrow.","tts_voice":"nova"}
```

- Up to 4,096 characters per voice note.
- Takes a few seconds longer than a normal send (the audio is generated first).
- `400 tts_disabled` means text-to-voice isn't enabled on this portal; generate the audio yourself and send `media_base64`.
- `502 tts_failed` means the speech provider failed; retry with backoff like a `502`.

### Response

```json
{"data":{"message_id":"3EB0C127D7BACC83D6A1","status":"...","account_id":1,"to":"60198765432","type":"voice"}}
```

Keep `message_id`: delivery and read receipts (`message.ack` events) and replies refer to it.

---

## 5. Receiving messages

There are two ways to receive events, and you can use both:

| | Webhook (push) | Polling (pull) |
|---|---|---|
| Setup | Admin sets a **webhook URL** on the key | Nothing extra |
| Latency | About 1 second | Your poll interval |
| Reliability | Retried for ~23 hours if your endpoint is down | Events kept for **7 days** |
| Best for | Production | Development, firewalled apps, backfill after an outage |

### 5.1 Event envelope (same for webhook and polling)

```json
{
  "id": 1842,
  "event": "message",
  "account_id": 1,
  "account": {"id":1,"name":"Sales MY","phone":"60123456789","status":"connected","connected":true,"last_seen":"2026-11-05T09:40:00Z"},
  "created_at": "2026-11-05T09:41:12.512Z",
  "data": {
    "id": "3EB0C127D7BACC83D6A1",
    "chat_id": "60198765432@s.whatsapp.net",
    "from": "60198765432@s.whatsapp.net",
    "from_name": "Ali",
    "sender_display_name": "Ali",
    "timestamp": "2026-11-05T09:41:12Z",
    "is_from_me": false,
    "body": "Hi, is the shop open today?"
  }
}
```

- `id` is unique and increasing. Use it to **deduplicate**, because a webhook can arrive more than once, and as the polling cursor.
- `event` is the event type (see 5.6).
- `data` is the WhatsApp event, passed through as-is.

### 5.2 Media in incoming messages

When a contact sends media, `data` contains the media field (`image`, `video`, `audio`, `document`, `sticker` or `video_note`) plus two fields added by the gateway:

```json
"data": {
  "id": "3EB0...", "chat_id": "60198765432@s.whatsapp.net", "from": "60198765432@s.whatsapp.net",
  "audio": "statics/media/1752404905-b939.ogg",
  "media_info": {"mime_type": "audio/ogg; codecs=opus", "seconds": 7, "ptt": true, "file_length": 12034},
  "media_type": "audio",
  "media_url": "https://relaykin.signalkin.ai/v1/media/1/1752404905-b939.ogg"
}
```

- `media_info.ptt: true` means it's a **voice note**.
- Download the file with `GET media_url`, **with the same `X-API-Key` and `X-API-Secret` headers**. The response is the raw file with its `Content-Type`.
- Ignore the raw `statics/...` path; it's internal to the account's server.
- Download promptly. Media files are **deleted from the account's server after 30 days** (the admin can change this); after that `GET media_url` returns `404 media_not_found`. Save your own copy of anything you need to keep.
- Captions appear in `data.body`.

### 5.3 Voice note transcription

When enabled on the portal, every incoming voice note is transcribed automatically. You receive the normal `message` event first (with `media_info.ptt: true`), and a few seconds later a **separate** event with the text:

```json
{
  "id": 1843,
  "event": "message.transcribed",
  "account_id": 1,
  "data": {
    "message_id": "3EB0C127D7BACC83D6A1",
    "chat_id": "60198765432@s.whatsapp.net",
    "from": "60198765432@s.whatsapp.net",
    "from_name": "Ali",
    "timestamp": "2026-11-05T09:41:12Z",
    "media_url": "https://relaykin.signalkin.ai/v1/media/1/1752404905-b939.ogg",
    "seconds": 7,
    "text": "Hi, is the shop open today?"
  }
}
```

- `data.message_id` is the `data.id` of the original `message` event; use it to match the two.
- If transcription fails you get `message.transcription_failed` with the same fields plus `error` instead of `text`. Fall back to downloading the audio, or ask the contact to type.
- Not transcribed: audio files that are not voice notes, voice notes the account sent itself (`is_from_me`), and voice notes longer than the admin's limit (default 10 minutes). Those produce no transcription event.
- **Simple bot pattern:** for `message` events with `media_info.ptt: true`, wait for the matching `message.transcribed` and treat its `text` like a typed message.

### 5.4 Webhooks

The gateway sends a `POST` to your webhook URL with the envelope as the JSON body and these headers:

```
Content-Type: application/json
X-KinWA-Event: message
X-KinWA-Event-Id: 1842
X-KinWA-Signature: t=1793870472,v1=5f2c9a...e1
```

The `X-KinWA-…` header names come from the product's previous name, Kin-WA. They stay unchanged so existing integrations keep verifying webhooks.

**Your endpoint must:**
1. **Verify the signature** before trusting the body (see below).
2. Return any **2xx within 15 seconds**. Do slow work, such as AI replies, asynchronously after acknowledging.
3. **Deduplicate** on `X-KinWA-Event-Id` (or the envelope `id`).

**Retries:** after a non-2xx response or a timeout, the gateway retries after 30s, 2m, 10m, 30m, 1h, 3h, 6h and 12h, then gives up. You can still fetch the event by polling for 7 days.

#### Signature verification

`v1` is the hex HMAC-SHA256 of `"<t>.<raw request body>"`, using the webhook signing secret. Compute it over the **raw bytes** of the body, before any JSON parsing. Reject requests whose `t` is more than 5 minutes old, to block replays.

**Node.js (Express):**
```js
import crypto from "node:crypto";
import express from "express";

const app = express();
app.post("/whatsapp/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-KinWA-Signature") || "";
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  const t = Number(parts.t);
  const expected = crypto.createHmac("sha256", process.env.RELAYKIN_WEBHOOK_SECRET)
    .update(`${t}.`).update(req.body).digest("hex");
  const ok = parts.v1 && parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)) &&
    Math.abs(Date.now() / 1000 - t) < 300;
  if (!ok) return res.sendStatus(401);

  const evt = JSON.parse(req.body);
  res.sendStatus(200);              // acknowledge fast
  handleEvent(evt).catch(console.error); // then process (dedupe on evt.id)
});
```

**Python (FastAPI):**
```python
import hmac, hashlib, json, os, time
from fastapi import FastAPI, Request, Response, BackgroundTasks

app = FastAPI()
SECRET = os.environ["RELAYKIN_WEBHOOK_SECRET"].encode()

@app.post("/whatsapp/webhook")
async def webhook(request: Request, background: BackgroundTasks):
    raw = await request.body()
    parts = dict(p.split("=", 1) for p in request.headers.get("X-KinWA-Signature", "").split(",") if "=" in p)
    t = int(parts.get("t", "0"))
    expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(parts.get("v1", ""), expected) or abs(time.time() - t) > 300:
        return Response(status_code=401)
    background.add_task(handle_event, json.loads(raw))  # dedupe on event["id"]
    return Response(status_code=200)
```

To test, the admin can press **Test webhook** in the portal, or you can call `POST /v1/webhook/test`. Either one sends an event with `"event":"test"` and `"id":0`.

#### Changing the webhook later

You don't need a webhook on day one. Start with polling, and set, change or remove the webhook at any time with your own key:

```sh
curl -s -X PUT "$RELAYKIN_BASE_URL/webhook" \
  -H "X-API-Key: $RELAYKIN_API_KEY" -H "X-API-Secret: $RELAYKIN_API_SECRET" -H "Content-Type: application/json" \
  -d '{"url":"https://your-app.example.com/whatsapp/webhook"}'
# → {"data":{"url":"https://your-app.example.com/whatsapp/webhook","polling_only":false}}
```

- `GET /v1/webhook` shows the current URL.
- The URL must be `https` and resolve to a public address, otherwise you get `400 invalid_webhook`. Redirects aren't followed.
- `{"url":""}` removes the webhook. Undelivered events are cancelled but stay available through polling.
- Changing the URL sends undelivered events to the new one straight away. Events from before a webhook existed are not sent; fetch those by polling.
- The signing secret stays the same. Then call `POST /v1/webhook/test` to confirm it works.

### 5.5 Polling: `GET /v1/events`

```sh
curl -s "$RELAYKIN_BASE_URL/events?after=1842&limit=100" \
  -H "X-API-Key: $RELAYKIN_API_KEY" -H "X-API-Secret: $RELAYKIN_API_SECRET"
# → {"data":[ <envelope>, ... ], "next_after": 1907}
```

- `after`: return events with `id` greater than this. Start at `0`, then use `next_after` from the previous response, and persist it.
- `limit`: 1–500 (default 100). `account_id` (optional) filters to one account.
- Poll every 2–10 seconds. When `data` is empty, keep the same cursor.

### 5.6 Event types

Most events come from WhatsApp; the rest are added by the gateway. The key's event filter can limit which ones you receive. New event types may be added, so ignore any you don't recognize.

| Event | Meaning |
|---|---|
| `message` | Incoming (or own, `is_from_me: true`) message of any type |
| `message.ack` | Delivery/read receipt for a sent message |
| `message.reaction` | Emoji reaction added or removed |
| `message.edited` / `message.revoked` / `message.deleted` | Message edited, deleted for everyone, or deleted for me |
| `chat_presence` | Contact is typing or recording |
| `group.participants` / `group.info` / `group.joined` | Group membership and name changes |
| `message.transcribed` / `message.transcription_failed` | Text of an incoming voice note (see 5.3); added by the gateway |
| `message.tagged` | AI labels for a message: `tags.intent`, `tags.language`, `tags.urgency`. Sent separately, a few seconds after the `message` event (see 6) |
| `reply.suggested` | The AI drafted a reply for approval: `draft_id`, `draft`, `chat_id`, `contact` |
| `conversation.handoff` / `conversation.resumed` | A chat moved to a person, with `reason`, or back to the AI |
| `message.queue_sent` / `message.queue_failed` | A held message went out (`queue_id`, `message_id`) or was given up on (`queue_id`, `code`, `error`) |
| `conversation.closed` / `conversation.rated` | Staff closed a chat (`rating_requested`), and the customer's 1 to 5 `rating` |
| `contact.consent` | Marketing consent was recorded: `status` (`granted` or `withdrawn`) and `source` |
| `contact.opted_out` / `contact.opted_in` | A contact opted out or back in; see section 7 for what happens to sends |
| `call.offer` | Someone called the account. `data.auto_rejected: true` means it was declined automatically (see section 8) |
| `device.logged_out` / `device.temporary_ban` / `device.reachout_timelock` | Account problems; stop sending and alert a human |

The full field reference for `data` is in the RelayKin webhook payload documentation (`docs/webhook-payload.md` in this repository).

**Avoid reply loops:** ignore `message` events where `data.is_from_me` is `true`. These include messages your own app sent.

---

## 6. Other operations

| Method & path | Body | Purpose |
|---|---|---|
| `GET /v1/accounts/{account_id}` | | One account's status |
| `GET /v1/accounts/{account_id}/chats/{chat}/messages?limit=50&offset=0&search=` | | Stored chat history (`chat` = phone or JID) |
| `POST /v1/accounts/{account_id}/messages/{message_id}/read` | `{"chat":"60198765432"}` | Mark a received message as read (blue ticks) |
| `POST /v1/accounts/{account_id}/messages/{message_id}/reaction` | `{"chat":"60198765432","emoji":"👍"}` | React; `""` removes the reaction |
| `POST /v1/accounts/{account_id}/typing` | `{"chat":"60198765432","state":"typing"}` | Show "typing…"; `"paused"` stops it |
| `GET /v1/media/{account_id}/{file}` | | Download incoming media (use `data.media_url`) |
| `POST /v1/webhook/test` | | Send a test event to your webhook |
| `GET /v1/webhook` | | Show your webhook URL |
| `PUT /v1/webhook` | `{"url":"https://..."}` | Set, change or remove (`""`) your webhook (see 5.4) |
| `POST /v1/ai/respond` | `{"input":"...","instructions":"...","model":"..."}` | Generate text (see below) |

### Text AI: `POST /v1/ai/respond`

Runs one text model call on the ChatGPT account the admin connected in the portal. `input` is required (up to 32,000 characters); `instructions` (the system prompt) and `model` are optional.

```sh
curl -s "$RELAYKIN_BASE_URL/ai/respond" \
  -H "X-API-Key: $RELAYKIN_API_KEY" -H "X-API-Secret: $RELAYKIN_API_SECRET" -H "Content-Type: application/json" \
  -d '{"instructions":"Reply as a friendly shop assistant, in one or two sentences.","input":"Are you open today?"}'
# → {"data":{"text":"Yes, we are open until 9pm!","model":"...","input_tokens":31,"output_tokens":12}}
```

- Text only: speech goes through `type: "voice"` and `message.transcribed`.
- This does not send anything to WhatsApp. To reply to a contact, pass `text` to `POST /v1/messages`.
- Each call is independent: send any conversation history you need inside `input`.
- Allow up to 90 seconds. There is no fallback provider, so handle the errors in section 7.

---

### Chat summary: `POST /v1/accounts/{account_id}/chats/{chat}/summary`

Summarizes the latest messages with the connected ChatGPT account. The body is optional: `{"limit": 100}` (1 to 200).

```json
{"data":{"summary":"- Wants a refund for order 1042\n- Refund agreed\n- Waiting on bank details","messages":37}}
```

### Chat history extras

`GET /v1/accounts/{account_id}/chats/{chat}/messages` now also returns `transcript` on transcribed voice notes and `tags` on tagged messages.

### Group details: `GET /v1/accounts/{account_id}/groups/{group}`

Returns a group's details, including its name. `{group}` is the group JID, with or without `@g.us`. The `message` event doesn't include the group name, so call this when you need it, and cache the result.

### Handing a chat to a person: `PUT /v1/accounts/{account_id}/chats/{chat}/handoff`

Stops the AI in a chat, or gives the chat back. Only relevant when the account has AI replies on.

```json
{"status":"human","reason":"VIP customer"}
```

`status` is `human` or `ai`.

### AI drafts

When an account is set to suggest replies, `GET /v1/drafts` lists open drafts for the accounts your key may use.

- `POST /v1/drafts/{draft_id}/send` sends one. An optional `{"text":"..."}` replaces the draft's text.
- `POST /v1/drafts/{draft_id}/dismiss` discards one.

A draft that was already handled returns `409 draft_closed`.

### Holding messages over a limit

Add `"queue": true` to `POST /v1/messages`, or ask an admin to turn it on for the account, and a message over a pace limit is held instead of refused. You get `202` instead of `200`:

```json
{"data":{"queue_id":41,"status":"queued","reason":"send_rate_limited","account_id":3,"to":"60123456789","type":"text"}}
```

- The portal sends it when the limit allows, and you receive `message.queue_sent` or `message.queue_failed`.
- A held message gives up after 72 hours, and always respects opt-outs.
- On Telegram, `telegram_limited` is held too, and sent once the limit ends. A send that Telegram itself refuses for flooding goes back in the queue for 15 minutes without using up an attempt. `account_banned` is never queued.
- `bulk_message_blocked` and `opted_out` are never queued. Neither is `media_base64` or text-to-voice, so use `media_url`.
- `GET /v1/queue/{queue_id}` shows its status. `DELETE /v1/queue/{queue_id}` cancels one still waiting.

### Contact labels and notes

`GET /v1/accounts/{account_id}/contacts/{contact}` returns a contact's record. `PUT` the same path with `{"labels":["VIP"],"notes":"Prefers mornings"}` to replace it. These are the same labels and notes staff see in the Inbox.

### Marketing consent

`GET /v1/accounts/{account_id}/contacts/{contact}/consent` returns `granted` (the latest decision) and the full `history`.

`POST` the same path with `{"status":"granted","source":"checkout form","note":"ticked the box"}` to record a decision. `source` is required. The history is never edited, so a later `withdrawn` entry replaces an earlier grant. A contact who sends STOP is recorded as withdrawn automatically.

Campaigns can be set to message only contacts with consent; it is checked as each message goes out.

### Opt-outs

Contacts who send only `STOP`, `BERHENTI` or `UNSUBSCRIBE` are opted out automatically. They opt back in by sending `START`, `MULA` or `SUBSCRIBE`.

- `GET /v1/accounts/{account_id}/opt-outs` lists them.
- `PUT /v1/accounts/{account_id}/opt-outs/{contact}` adds one.
- `DELETE /v1/accounts/{account_id}/opt-outs/{contact}` removes one. Only do this if the contact asked.

## 7. Errors

Every error has the same shape:

```json
{"error":{"code":"account_not_connected","message":"account WhatsApp session is awaiting_pair; it must be connected"}}
```

| HTTP | `code` | What to do |
|---|---|---|
| 400 | `invalid_body`, `invalid_request`, `invalid_account`, `invalid_chat`, `invalid_file`, `tts_disabled`, `invalid_webhook` | Fix the request; don't retry as-is |
| 401 | `unauthorized` | Check the key and secret |
| 403 | `ip_not_allowed`, `account_not_allowed` | Ask the admin to adjust the key |
| 403 | `opted_out` | The contact opted out. Don't retry, and remove them from your campaigns |
| 403 | `account_banned` | Telegram has banned this account. Don't retry; alert a human |
| 404 | `account_not_found`, `media_not_found`, `draft_not_found`, `queue_not_found` | |
| 409 | `account_not_ready`, `account_not_connected` | The account is being set up or is offline; retry later and alert a human if it persists |
| 409 | `draft_closed`, `queue_closed` | Already sent, dismissed or cancelled |
| 409 | `chat_assigned` | Staff assigned this chat to someone else; only an admin or API key can send its draft |
| 429 | `rate_limited` | Wait for `Retry-After` seconds |
| 429 | `send_rate_limited`, `daily_limit_reached`, `new_contact_limit_reached` | The account's sending guard is protecting the number. Wait for `Retry-After` seconds, and slow down |
| 429 | `telegram_limited` | Telegram is limiting this account. Wait for `Retry-After` seconds; sending through a limit only makes it longer |
| 429 | `bulk_message_blocked` | The same text already went to too many contacts this hour. Personalize it; retrying the same text won't help |
| 400 | `ai_model_unsupported` | The connected account refuses that model; omit `model` |
| 503 | `ai_not_connected`, `ai_reconnect_required`, `ai_no_models` | The admin must connect or reconnect the ChatGPT account; alert a human, don't retry in a loop |
| 504 | `ai_timeout` | The model was too slow; retry once or shorten the input |
| 502 | `whatsapp_error`, `account_unreachable`, `tts_failed`, `ai_failed` | WhatsApp, Telegram or the account's server failed; retry with backoff (for example 2s, 5s, 15s), at most 3 times |

`whatsapp_error` is the code for any error the messaging network or the account's server returns, Telegram included; the name is kept so existing integrations keep working. It carries that error's HTTP status: `404` when a phone number has no reachable Telegram account, `429` when Telegram is rate-limiting (wait and slow down), `502` for failures.

**Retrying sends:** sending isn't idempotent. A retry after a timeout can produce a duplicate message, so only retry on `429` (except `bulk_message_blocked`), `502` or connection errors before a response arrives. Never retry on `400`, `401`, `403` or `409`.

---

## 8. Capabilities and limits

| Feature | Supported |
|---|---|
| Send/receive text, image, video, audio, document, sticker, location | ✅ |
| Send voice notes (any audio format, auto-converted) | ✅ `type: "voice"` |
| Text-to-voice: send text that arrives as a voice note | ✅ `type: "voice"` + `text` (when enabled) |
| Receive voice notes and download them | ✅ `media_info.ptt: true` + `media_url` |
| Voice note transcription (voice-to-text) | ✅ `message.transcribed` event (when enabled) |
| Replies (quote), reactions, read receipts, typing indicator | ✅ |
| Group messages | ✅ `to` = `...@g.us` |
| Generate text with AI | ✅ `POST /v1/ai/respond` (when a ChatGPT account is connected) |
| Learn that an incoming call is ringing | ✅ `call.offer` event |
| Decline calls automatically and reply with a message | ✅ Portal setting (on by default) |
| **Place a WhatsApp voice/video call** | ❌ Not supported |
| **Answer a call, or play audio into a call** | ❌ Not supported |

**Why calls don't work:** WhatsApp calling uses a separate real-time voice protocol. The library this system uses (whatsmeow, the version this repository pins) only receives call notifications and can reject calls; it can't start, answer or stream audio into calls. The practical substitute is to **send a voice note**. For example, generate speech with TTS and send it with `type: "voice"`. By default the portal **declines incoming calls automatically** and sends the caller a short text (at most once every 10 minutes per caller) asking them to message instead; you still receive `call.offer` with `auto_rejected: true`, and can follow up with your own voice note.

**Responsible use:** these are regular WhatsApp accounts (unofficial API). Mass unsolicited messages, identical bulk texts and sudden volume spikes get numbers banned. Message people who expect to hear from you, vary the content, and pace your sends. For high-volume marketing, use the official WhatsApp Business Platform.

---

### Telegram accounts

An account with `"channel": "telegram"` in `GET /v1/accounts` uses the same endpoints, webhooks, signatures, sending limits, opt-outs and queue as a WhatsApp account. Pick the account by `account_id` as usual. The differences:

**Recipients and IDs**

- **Chats** are `123@tg.user` (a person), `456@tg.group` or `c789@tg.group` (groups and supergroups). Keep these as opaque strings and reply to `data.chat_id`. The digits in `123@tg.user` are a Telegram ID, not a phone number.
- **Sending to a phone number** works in `to`. The portal looks up the person's Telegram chat and sends there. It returns `404 whatsapp_error` if the number has no Telegram account or the person's privacy settings hide it. Looking a number up can add it to the account's Telegram contacts; reading a chat never does.
- A WhatsApp address (`...@s.whatsapp.net`, `...@g.us`, `...@lid`) sent to a Telegram account is refused with `400 invalid_request`.
- **Message IDs** in supergroups look like `c789_55`. Treat every message ID as opaque.

**What works the same**

| Feature | Telegram |
|---|---|
| Send text, image, video, audio, document, sticker, location | ✅ |
| Voice notes (`type: "voice"`, any audio format, converted to OGG/Opus) | ✅ |
| Replies, reactions, read receipts, typing indicator | ✅ |
| Receive messages and download media (`media_url`) | ✅ |
| Events `message`, `message.ack`, `message.edited`, `message.deleted`, `device.logged_out` | ✅ |
| Contact labels, notes, consent, opt-outs, handoff | ✅ |
| Holding messages over a limit (`"queue": true`) | ✅, Telegram limits included |
| Campaigns | ❌ always refused for Telegram accounts |
| View-once media | ❌ `400` |
| Calls | ❌ |
| AI endpoints, AI drafts, transcription, text-to-voice | Only when the portal's owner has allowed AI for Telegram; otherwise they return no results |

**Limits and bans**

Telegram limits accounts that message many new people or send too fast, and bans accounts it considers spam. The portal checks each Telegram account's status regularly and acts on it before sending:

- **Limited:** sends are refused with `429 telegram_limited` and a `Retry-After` header, set to when the limit ends (15 minutes if Telegram doesn't say). With `"queue": true` (or the account's hold setting on), the message is held instead and sent once the limit ends.
- **Banned:** sends are refused with `403 account_banned`. Don't retry; alert a human.
- **Telegram refusing a send directly:** you get `429 whatsapp_error` with Telegram's wait time in the message. Back off and don't retry at once.

New Telegram accounts get the same warm-up limit on new contacts as WhatsApp numbers. Message people who expect to hear from you.

### SMS accounts

An account with `"channel": "sms"` sends and receives SMS through an Android phone with that number's SIM card. It uses the same endpoints, webhooks, signatures, sending limits, opt-outs and queue. The differences:

- **Chats** are `60123456789@sms`. Reply to `data.chat_id` as usual. Messages from a named sender such as a bank arrive as `MAYBANK@sms`; they can't be replied to (`400 invalid_request`).
- **Sending:** put an international number in `to` (`60123456789`, `+60 12-345 6789` or `60123456789@sms`). WhatsApp and Telegram addresses are refused with `400 invalid_request`.
- **Text only:** `type` must be `text`, at most 1,600 characters (long texts go out as several SMS parts, each charged by the mobile network). Any other type returns `400 invalid_request` "SMS accounts can only send text". Reactions, read receipts and typing are ignored.
- **Delivery:** `message.ack` arrives with `receipt_type` `sent` when the phone sent the SMS, and `delivered` when the recipient's phone got it (if the network reports delivery). A failed SMS arrives as `message.failed` with a `reason`.
- **Incoming:** a `message` event with `chat_id` and `from` as `<number>@sms`, `from_name` as `+<number>`, and the text in `body`. No media.
- **Not available:** campaigns (mobile networks block SIM cards that send in bulk), media, groups and calls.
- **Phone offline:** if the phone is off or offline, the message waits on the server and goes out when the phone checks in again. The send answers at once, and a `message.ack` follows once the phone has sent it. If the phone was unpaired, sends return `409 account_not_connected`.
- **Errors from the phone's service** use `whatsapp_error` with the HTTP status, like Telegram's.

## 9. Checklist for the integrating agent

1. Load the four `RELAYKIN_*` values from the environment.
2. Call `GET /v1/accounts`; pick an account with `connected: true`. Make the account ID configurable.
3. Implement `sendMessage({to, type, ...})` with `POST /v1/messages`. Handle the errors in section 7, and retry only on `429` and `502`.
4. Implement the webhook endpoint: raw body, signature check, 2xx within 15s, dedupe on the envelope `id`, async processing.
5. Ignore `is_from_me: true`. Reply to `data.chat_id`.
6. Download incoming media through `data.media_url` with the API headers, and store your own copy (server copies are deleted after 30 days).
   For voice notes, use the `message.transcribed` event's `text` when transcription is enabled.
7. Optionally poll `GET /v1/events` from a persisted cursor to backfill after downtime.
8. Treat `device.logged_out`, `device.temporary_ban`, `account_banned` and a non-connected `account` status as alerts for a human.
9. For Telegram accounts, handle `429 telegram_limited` by waiting for `Retry-After` (or send with `"queue": true`), and treat chat and message IDs as opaque strings.
10. Test with the portal's **Test webhook** button, then with a real message to the account.
