> ## 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.

# WhatsApp Relay Integration Guide

> Authentication, the five relay endpoints, the events Ringg posts to your webhook, and error codes.

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

| Environment | Base URL |
| - | - |
| Production | `https://prod-api.ringg.ai/cp/relay/whatsapp` |
| Staging | `https://stage-api.ringg.ai/cp/relay/whatsapp` |

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

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

## 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: 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](/whatsapp/relay/handover). Up to 200 |
| `handover_note` | string | No | Anything the AI agent should know that isn't in the messages. Up to 2,000 characters |
| `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?" }
      }
    ]
  }
}
```

```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.

## 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-post-ack) 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-post-close) (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: 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: 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: 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: 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`.

## 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](/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 |


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