> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ringg.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> To search or read these docs, connect the Ringg docs MCP server at https://docs.ringg.ai/mcp (no key needed; tools search_ringg_ai and query_docs_filesystem_ringg_ai) instead of browsing pages one by one. Setup for each client: https://docs.ringg.ai/resources/mcp-server/docs-mcp-server/mcp-for-ai-assistants.md
> The dashboard labels agents "Assistants"; these docs say "agent". Both mean the same object, identified by agent_id.
> API base URL: https://prod-api.ringg.ai/ca/api/v0. Send the workspace API key in the X-API-KEY header, from server-side code only.
> Placing a call dials a real phone number and spends credits. Confirm the number with the user before calling.
> To act on a Ringg workspace (place calls, edit agents, read transcripts), use the separate Ringg workspace MCP server at https://mcp.ringg.ai/mcp: https://docs.ringg.ai/resources/mcp-server/workspace-mcp-server/overview.md

# WhatsApp Relay integration guide

> Integrate your WhatsApp service with Ringg AI Relay: authentication, the five relay endpoints, the events Ringg posts to your webhook, and the error codes.

export const RelatedTopics = ({items}) => <div className="not-prose mt-12">
    <div className="text-sm font-medium text-gray-500 dark:text-zinc-400 mb-3">Related topics</div>
    <div className="flex flex-col gap-2.5">
      {items.map(([title, href]) => <a key={href} href={href} className="flex items-center gap-2.5 text-sm text-gray-700 hover:text-gray-950 dark:text-zinc-300 dark:hover:text-white">
          <Icon icon="file-text" size={16} color="currentColor" />
          {title}
        </a>)}
    </div>
  </div>;

All relay endpoints are `POST`, take and return JSON, and live under one base URL:

```text theme={null}
https://prod-api.ringg.ai/cp/relay/whatsapp
```

Ringg shares the staging base URL with you during onboarding.

| Endpoint | You call it to |
| - | - |
| [`/inbound`](#forward-a-customer-message) | Forward a customer message, optionally with a handover |
| [`/ack`](#report-the-result) | Report whether a reply reached Meta |
| [`/status`](#forward-delivery-statuses) | Pass on Meta's delivery statuses |
| [`/outbound`](#start-a-conversation) | Start a conversation with an approved template |
| [`/close`](#close-a-conversation) | End a conversation |

Ringg calls **your** webhook with [events](#events-ringg-sends-to-your-webhook).

## Before you start

<Columns cols={2}>
  <Card title="A registered number" icon="message-circle" href="/documentation/deploy/whatsapp/relay-overview#details-to-share-for-setup" horizontal>
    Ringg registers your `phone_number_id` from the details you share.
  </Card>

  <Card title="Credentials from Ringg" icon="key" href="/documentation/deploy/whatsapp/relay-overview#credentials-and-endpoints" horizontal>
    Your workspace API key and the number's signing secret, which sign every request.
  </Card>
</Columns>

## Authentication

Every request carries three headers:

| Header | Value |
| - | - |
| `X-API-Key` | Your Ringg workspace API key |
| `X-Relay-Timestamp` | The current Unix time in **seconds**, for example `1791279012` |
| `X-Relay-Signature` | `sha256=` followed by the lowercase hex HMAC-SHA256 of `{timestamp}.{raw body}`, keyed with the number's signing secret |

The signature covers the exact bytes you send. Serialize the body once, sign those bytes, and send those same bytes. Re-serializing after signing changes spacing or key order and breaks the signature. Requests more than 300 seconds old or ahead are rejected.

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac
  import json
  import time

  import requests

  BASE_URL = "https://prod-api.ringg.ai/cp/relay/whatsapp"
  API_KEY = "<your Ringg workspace API key>"
  SIGNING_SECRET = "<signing secret for this number>"


  def relay_post(path: str, body: dict) -> requests.Response:
      raw = json.dumps(body, separators=(",", ":")).encode("utf-8")
      timestamp = str(int(time.time()))
      digest = hmac.new(
          SIGNING_SECRET.encode("utf-8"), timestamp.encode("utf-8") + b"." + raw, hashlib.sha256
      ).hexdigest()
      return requests.post(
          f"{BASE_URL}/{path}",
          data=raw,  # the exact bytes that were signed
          headers={
              "Content-Type": "application/json",
              "X-API-Key": API_KEY,
              "X-Relay-Timestamp": timestamp,
              "X-Relay-Signature": f"sha256={digest}",
          },
          timeout=15,
      )
  ```

  ```javascript Node.js theme={null}
  import crypto from "node:crypto";

  const BASE_URL = "https://prod-api.ringg.ai/cp/relay/whatsapp";
  const API_KEY = "<your Ringg workspace API key>";
  const SIGNING_SECRET = "<signing secret for this number>";

  export async function relayPost(path, body) {
    const raw = JSON.stringify(body);
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const digest = crypto.createHmac("sha256", SIGNING_SECRET).update(`${timestamp}.${raw}`).digest("hex");
    return fetch(`${BASE_URL}/${path}`, {
      method: "POST",
      body: raw, // the exact string that was signed
      headers: {
        "Content-Type": "application/json",
        "X-API-Key": API_KEY,
        "X-Relay-Timestamp": timestamp,
        "X-Relay-Signature": `sha256=${digest}`,
      },
    });
  }
  ```
</CodeGroup>

The API key proves which workspace is calling; the signature proves the body came from you, unchanged. Both must be valid, and the API key must belong to the workspace that owns the number.

## Forward a customer message

**Endpoint:** `POST /inbound`

Send Meta's `value` object (from `entry[].changes[].value` in Meta's webhook) unchanged, one message per request.

| Field | Type | Required | Description |
| - | - | - | - |
| `phone_number_id` | string | Yes | Your number's Meta id |
| `value` | object | Yes | Meta's `value` object, with `messages[0].id` set to Meta's message id (`wamid…`) |
| `received_at` | string | No | When you received it from Meta, RFC 3339 UTC |
| `prior_messages` | array | No | The conversation so far, when [handing over from a human agent](/documentation/deploy/whatsapp/relay-integration-guide#handover-from-a-human-agent). Up to 200 |
| `handover_note` | string | No | Anything the AI agent should know that isn't in the messages. Up to 2,000 characters |
| `custom_args` | object | No | The agent's [custom variables](#custom-variables) for the chat this message starts. Up to 100 keys |
| `media` | array | No | Accepted for compatibility, not read. The relay answers text only |

```json Request theme={null}
{
  "phone_number_id": "123456789012345",
  "received_at": "2026-10-06T09:30:12Z",
  "value": {
    "messaging_product": "whatsapp",
    "metadata": { "display_phone_number": "12025550100", "phone_number_id": "123456789012345" },
    "contacts": [{ "wa_id": "12025550123", "profile": { "name": "Rahul" } }],
    "messages": [
      {
        "from": "12025550123",
        "id": "wamid.DUMMY_CUSTOMER_MESSAGE_ID",
        "timestamp": "1791279012",
        "type": "text",
        "text": { "body": "Which plan covers my parents?" }
      }
    ]
  },
  "custom_args": { "policy_id": "POL-12345", "plan_name": "Family Floater" }
}
```

```json 202 Accepted theme={null}
{ "status": "accepted" }
```

`202` means the message is stored and will be processed; the reply follows on your webhook. Sending the same `messages[0].id` again is harmless: it is processed once. If you get a `5xx` or no response, retry the same request.

### Custom variables

Use `custom_args` to give the AI agent facts about the customer that it can't learn from the chat, such as a policy number or plan name. The agent's prompt refers to them by name, so use the variable names agreed with Ringg.

* **Set when a chat starts.** Ringg reads `custom_args` only from the message that starts a chat. Later messages in the same chat can't change them.
* **Send them with every message anyway.** When a chat closes or times out, the customer's next message starts a new chat, which reads `custom_args` again. If you send them every time, a new chat always has them.
* **Missing and extra keys.** A variable defined on the agent that you don't send is stored as `null`, and the agent works without it. Keys the agent doesn't define are stored too.
* **Where they show up.** Every event for the chat carries them in `custom_args`, and the dashboard lists them with the conversation. Ringg adds `mobile_number`, the customer's number, when you don't send it, and `source`.

## Events Ringg sends to your webhook

Ringg `POST`s each event to your webhook URL with these headers:

| Header | Value |
| - | - |
| `Authorization` | `Bearer <your webhook bearer token>` |
| `Content-Type` | `application/json; charset=utf-8` |
| `X-Event-Id` | The event's id, also in the body |
| `X-Event-Type` | `message.outbound`, `message.typing` or `conversation.closed` |
| `X-Delivery-Sequence` | The reply's sequence, on `message.outbound` only |
| `X-Channel` | `whatsapp_relay` |
| `X-Trace-Id` | Ringg's trace id, when available. Quote it when asking for help |

Answer `2xx` once you have stored or queued the event, within 10 seconds. How Ringg reacts to anything else:

| Your webhook answers | Ringg |
| - | - |
| `2xx` | Treats the event as delivered and moves on to the chat's next reply |
| `408`, `425`, `429`, `500`, `502`, `503`, `504`, a timeout, or a network error | Retries, up to 8 attempts, backing off from 1 s to 60 s. `Retry-After` is honoured, up to 300 s |
| Any other `4xx` | Stops, and records the reply as not delivered |

### message.outbound

A reply to send to the customer. `delivery.message` is the exact body for Meta's `POST /{phone_number_id}/messages`.

```json theme={null}
{
  "event_id": "00000000-0000-4000-8000-000000000011",
  "event_type": "message.outbound",
  "occurred_at": "2026-10-06T09:30:16Z",
  "channel": "whatsapp_relay",
  "chat_id": "00000000-0000-4000-8000-000000000001",
  "delivery": {
    "phone_number_id": "123456789012345",
    "to_number": "12025550123",
    "sequence": 3,
    "expires_at": "2026-10-06T09:31:16Z",
    "message": {
      "messaging_product": "whatsapp",
      "to": "12025550123",
      "type": "text",
      "text": { "body": "For parents aged 62 and 58, these plans fit best…" }
    }
  },
  "custom_args": {},
  "trace_id": "00000000-0000-4000-8000-000000000101"
}
```

Then:

* **Order:** send replies to Meta in `sequence` order per chat. Sequences start at 1 and increase by 1.
* **Expiry:** don't send a reply after its `expires_at`; ack it as `skipped` instead.
* **Ack:** always [ack](#report-the-result) every `message.outbound`.

### message.typing

Sent as soon as Ringg starts working on a customer's message. `delivery.message` marks the message as read and shows a typing indicator. Send it to Meta if you want the customer to see it. It has no `sequence` and no ack.

```json theme={null}
{
  "event_id": "00000000-0000-4000-8000-000000000012",
  "event_type": "message.typing",
  "occurred_at": "2026-10-06T09:30:13Z",
  "channel": "whatsapp_relay",
  "chat_id": "00000000-0000-4000-8000-000000000001",
  "delivery": {
    "phone_number_id": "123456789012345",
    "to_number": "",
    "message": {
      "messaging_product": "whatsapp",
      "status": "read",
      "message_id": "wamid.DUMMY_CUSTOMER_MESSAGE_ID",
      "typing_indicator": { "type": "text" }
    }
  },
  "custom_args": {}
}
```

### conversation.closed

The chat has ended, either because the AI agent closed it (`reason: "completed"`) or because you called [`/close`](#close-a-conversation) (your `reason`). There is nothing to send to Meta. The customer's next message starts a new chat.

```json theme={null}
{
  "event_id": "00000000-0000-4000-8000-000000000013",
  "event_type": "conversation.closed",
  "occurred_at": "2026-10-06T09:41:02Z",
  "channel": "whatsapp_relay",
  "chat_id": "00000000-0000-4000-8000-000000000001",
  "reason": "completed",
  "delivery": { "phone_number_id": "123456789012345", "to_number": "12025550123", "message": {} },
  "custom_args": {}
}
```

## Report the result

**Endpoint:** `POST /ack`

Call once per `message.outbound`, after you tried to send it.

| Field | Type | Required | Description |
| - | - | - | - |
| `phone_number_id` | string | Yes | |
| `chat_id` | string | Yes | From the event |
| `event_id` | string | Yes | From the event |
| `sequence` | integer | No | From the event |
| `status` | string | Yes | `sent`, `failed`, or `skipped` (expired, not sent) |
| `message_id` | string | With `sent` | Meta's message id for the reply |
| `sent_at` | string | No | RFC 3339 UTC |
| `error` | object | With `failed` | Meta's error, for example `{ "code": 131047, "message": "Re-engagement message" }` |

```json Request theme={null}
{
  "phone_number_id": "123456789012345",
  "chat_id": "00000000-0000-4000-8000-000000000001",
  "event_id": "00000000-0000-4000-8000-000000000011",
  "sequence": 3,
  "status": "sent",
  "message_id": "wamid.DUMMY_REPLY_MESSAGE_ID",
  "sent_at": "2026-10-06T09:30:17Z"
}
```

```json 200 OK theme={null}
{ "status": "recorded", "chat_closed": false }
```

* **`unknown_event`:** Ringg doesn't recognise the `event_id`, or it doesn't belong to that `chat_id`. Don't retry the ack.
* **`chat_closed: true`:** Meta refused the reply in a way that ends the conversation (`131047`, `131026` or `131051`). Ringg has closed the chat.

## Forward delivery statuses

**Endpoint:** `POST /status`

Pass on the `statuses` array from Meta's status webhooks, as received. The body has exactly two fields: don't wrap it in Meta's `entry` or `value`.

```json Request theme={null}
{
  "phone_number_id": "123456789012345",
  "statuses": [
    {
      "id": "wamid.DUMMY_REPLY_MESSAGE_ID",
      "status": "delivered",
      "timestamp": "1791279020",
      "recipient_id": "12025550123"
    }
  ]
}
```

```json 200 OK theme={null}
{ "status": "recorded", "received": 1 }
```

## Start a conversation

**Endpoint:** `POST /outbound`

Opens a chat with a customer and returns an approved template for you to send. When the customer replies, forward it to `/inbound` as usual and the AI agent continues.

Send an `X-Idempotency-Key` header to make retries safe: the same key within 24 hours returns the first response instead of opening a second chat.

| Field | Type | Required | Description |
| - | - | - | - |
| `phone_number_id` | string | Yes | |
| `to_number` | string | Yes | The customer's number, with country code |
| `template` | object | Yes | `name`, `language.code` (default `en_US`), and Meta `components` |
| `custom_args` | object | No | Variables for the agent. Echoed back on this chat's events |

```json Request theme={null}
{
  "phone_number_id": "123456789012345",
  "to_number": "12025550123",
  "template": {
    "name": "renewal_reminder",
    "language": { "code": "en" },
    "components": [{ "type": "body", "parameters": [{ "type": "text", "text": "Rahul" }] }]
  },
  "custom_args": { "policy_id": "POL-12345" }
}
```

```json 200 OK theme={null}
{
  "status": "ready_to_send",
  "chat_id": "00000000-0000-4000-8000-000000000002",
  "event_id": "00000000-0000-4000-8000-000000000014",
  "delivery": {
    "sequence": 1,
    "phone_number_id": "123456789012345",
    "to_number": "12025550123",
    "expires_at": "2026-10-06T09:31:16Z",
    "message": {
      "messaging_product": "whatsapp",
      "to": "12025550123",
      "type": "template",
      "template": { "name": "renewal_reminder", "language": { "code": "en" }, "components": [ … ] }
    }
  }
}
```

Send `delivery.message` to Meta, then ack it with this `event_id`, exactly as for a `message.outbound` event.

| Status | Body `detail` | Meaning |
| - | - | - |
| `409` | `active_session_exists` | The customer already has an open chat on this number. The body includes its `chat_id` |
| `422` | `unknown_template` | The template isn't in the approved list Ringg holds for the number |

## Close a conversation

**Endpoint:** `POST /close`

Ends a chat, for example when your human agent resolves it. Optionally sends one last message first.

| Field | Type | Required | Description |
| - | - | - | - |
| `phone_number_id` | string | Yes | |
| `chat_id` | string | Yes | |
| `closing_message` | string | No | Sent to the customer as a final `message.outbound` |
| `reason` | string | No | Recorded and echoed on `conversation.closed`. Default `closed_by_client` |

```json Request theme={null}
{
  "phone_number_id": "123456789012345",
  "chat_id": "00000000-0000-4000-8000-000000000001",
  "closing_message": "Thanks for chatting with us. Message us any time.",
  "reason": "resolved"
}
```

```json 200 OK theme={null}
{ "status": "closing", "chat_id": "00000000-0000-4000-8000-000000000001" }
```

`closing` means a final message is on its way to your webhook; without `closing_message` the status is `closed`. Either way, you then receive `conversation.closed`.

## Handover from a human agent

When your own team has been talking to the customer, hand the conversation to the AI agent with the history so it can pick up where they left off.

### Hand a conversation to the AI agent

When a human agent hands over, forward the customer's latest message as usual, and add the messages Ringg has not seen in `prior_messages`.

```json theme={null}
{
  "phone_number_id": "123456789012345",
  "value": {
    "messaging_product": "whatsapp",
    "metadata": { "display_phone_number": "12025550100", "phone_number_id": "123456789012345" },
    "contacts": [{ "wa_id": "12025550123", "profile": { "name": "Rahul" } }],
    "messages": [
      {
        "from": "12025550123",
        "id": "wamid.DUMMY_LATEST",
        "timestamp": "1791279012",
        "type": "text",
        "text": { "body": "What would the premium be for 10 lakh cover?" }
      }
    ]
  },
  "prior_messages": [
    { "role": "customer", "text": "Hello, need a policy for my parents", "timestamp": "2026-10-06T09:10:02Z", "message_id": "wamid.DUMMY_C1" },
    { "role": "agent", "text": "Sure! What are your parents' ages?", "timestamp": "2026-10-06T09:11:40Z", "message_id": "wamid.DUMMY_A1", "agent_name": "Priya" },
    { "role": "customer", "text": "Papa is 62 and mummy is 58. We are in Lucknow", "timestamp": "2026-10-06T09:12:15Z", "message_id": "wamid.DUMMY_C2" },
    { "role": "agent", "text": "Noted. Any existing illness, like diabetes or BP?", "timestamp": "2026-10-06T09:13:01Z", "message_id": "wamid.DUMMY_A2", "agent_name": "Priya" },
    { "role": "customer", "text": "Papa has diabetes, on metformin", "timestamp": "2026-10-06T09:14:30Z", "message_id": "wamid.DUMMY_C3" }
  ],
  "handover_note": "Budget around 40k a year for both. Prefers a call after 6 pm."
}
```

The AI agent answers the latest message with everything above in mind: ages, city, condition, budget.

#### prior\_messages fields

| Field | Required | Description |
| - | - | - |
| `role` | Yes | `customer` or `agent`. `agent` is your human agent. |
| `text` | Yes | The message text, 1 to 4,096 characters |
| `message_id` | Strongly recommended | The WhatsApp message id (`wamid…`) Meta gave the message |
| `timestamp` | Recommended | When it was sent, RFC 3339 UTC. Shown in the dashboard as the message's time |
| `agent_name` | No | Your human agent's name, for `agent` messages. Shown in the dashboard |

#### Rules

* **Order:** oldest first, up to 200 messages.
* **Latest message:** don't include the customer message you are forwarding. It goes in `value.messages`.
* **Text only:** send the text of each message. For a photo or voice note your agent handled, a short description such as `[customer sent a photo of the policy]` helps.
* **`message_id`:** always include Meta's message id. It lets Ringg recognise messages it already has, including the AI agent's own replies, which you may store with your human agents' messages. Without ids, Ringg can only match on text.

### Send everything, or only what's new

Either works:

* **Only what's new.** Send the messages from since the AI agent last saw the chat. This is the smallest payload.
* **A recent window every time.** Send, say, the last 20 messages on every handover, including the AI agent's own replies. Ringg skips the messages it already holds and stores the AI agent's replies as its own, not as your human agent's.

Either way, Ringg records each message once. It marks a handover only when one of your human agents wrote something new, or you sent a `handover_note`.

### Take a conversation back

There is no API call to take a chat back. Your human agent simply starts answering, and you **stop forwarding** that chat's customer messages to Ringg.

When you hand over again, include the messages exchanged while your human agent had the chat: their replies and the customer's messages. The AI agent then knows what was said in between.

<Note>
  A handover always travels with a customer message, and the AI agent answers it. Hand over with the customer's latest unanswered message. If the customer hasn't written since your human agent's last reply, keep the history and send it with their next message.
</Note>

### End the conversation

When the conversation is done, call [`/close`](/documentation/deploy/whatsapp/relay-integration-guide#close-a-conversation), optionally with a closing message. The customer's next message starts a new chat.

### What you see in the dashboard

In **Logs → WhatsApp**, a conversation that was handed over shows:

* **Handed over to AI agent** where the AI agent took over.
* **Handed over to human agent** where your human agent's messages resume.
* **Human agent · Priya** or **AI agent** above each message on the agent's side.
* Each handed-over message at the time it was actually sent.

## Errors

Authentication and validation errors share one shape. Quote the `trace_id` when you contact Ringg.

```json theme={null}
{
  "detail": {
    "error": {
      "code": "invalid_signature",
      "message": "X-Relay-Signature did not verify",
      "trace_id": "00000000-0000-4000-8000-000000000102"
    }
  }
}
```

| Status | `code` | Meaning | Retry? |
| - | - | - | - |
| `400` | `malformed_json` | The body isn't a JSON object | No: fix the body |
| `400` | `phone_number_id_required` | `phone_number_id` is missing | No |
| `401` | `missing_api_key` | No `X-API-Key` header | No |
| `401` | `invalid_api_key` | The API key isn't recognised | No |
| `403` | `workspace_mismatch` | The API key belongs to a workspace that doesn't own this number | No |
| `403` | `invalid_signature` | Signature or timestamp didn't verify, or the timestamp is more than 300 s off | No: see [Troubleshooting](/documentation/deploy/whatsapp/relay-troubleshooting#403-invalid_signature) |
| `404` | `unknown_phone_number_id` | The number isn't registered for relay | No |
| `404` | `chat_not_found` | No such chat on this number (`/close`) | No |
| `422` | `invalid_body` | A field is missing, has the wrong type, or isn't part of the request | No: fix the body |
| `422` | `missing_message_id` | `value.messages[0].id` is missing (`/inbound`) | No |
| `503` | `unable_to_accept` | The message couldn't be stored | Yes |
| `503` | `auth_unavailable`, `config_unavailable`, `relay_unavailable` | A temporary problem on Ringg's side | Yes, with backoff |

## Next steps

<Columns cols={2}>
  <Card title="Hand over from a human agent" icon="users" href="/documentation/deploy/whatsapp/relay-integration-guide#handover-from-a-human-agent">
    Pass a conversation to the AI agent with its history, and take it back.
  </Card>

  <Card title="Read chats in Logs" icon="list" href="/documentation/monitor/logs#whatsapp-chats">
    Every relay conversation with its transcript and analysis.
  </Card>
</Columns>

<RelatedTopics items={[["WhatsApp agents", "/documentation/build-agents/agents/whatsapp-agents/overview"], ["Connect WhatsApp", "/documentation/inventory/tools-integrations/connect-whatsapp"], ["Create a WhatsApp agent", "/documentation/build-agents/agents/whatsapp-agents/create-an-agent"], ["WhatsApp tools", "/documentation/build-agents/customize-agent-behaviour/tools/whatsapp-tools"], ["WhatsApp chat events", "/documentation/build-agents/customize-agent-behaviour/webhooks/whatsapp-chat-events"], ["Start a conversation", "/documentation/deploy/whatsapp/start-a-conversation"], ["WhatsApp Relay", "/documentation/deploy/whatsapp/relay-overview"], ["WhatsApp Relay troubleshooting", "/documentation/deploy/whatsapp/relay-troubleshooting"], ["WhatsApp chats in Logs", "/documentation/monitor/logs#whatsapp-chats"]]} />


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.