Read messages and media
Read conversations, check message status and download attachments through REST.
Reviewed
Use messages:read to inspect conversations and messages belonging to the key's WhatsApp number. Use media:read to download a permitted attachment. These reads work in either inbound mode without changing the responder. Retained history may remain readable after disconnection; deleted numbers, contacts, media or retained records can still become unavailable.
List conversations
curl --fail-with-body 'https://api.waaru.app/v1/conversations?limit=10' \
--header "Authorization: Bearer $WAARU_API_KEY" \
--header 'Accept: application/json'The response contains items and nextCursor. Each item contains id, contactId, phoneNumber, contactName, lastMessageAt, humanOwned, and lastMessage (a message object or null). Results are ordered by latest message, newest first.
humanOwned: true means the conversation is assigned, taken over, or paused for a person. Your server must not attempt to bypass that state. An authorized teammate can use Release to API in Inbox when appropriate.
Paginate without offsets
Both list endpoints accept limit from 1 to 100 and default to 50. Their cursor parameter names differ:
| Endpoint | Send the returned nextCursor as |
|---|---|
GET /v1/conversations | after |
GET /v1/conversations/:conversationId/messages | before |
A null cursor means there is no next page. Treat each cursor as opaque, preserve it exactly, and URL-encode it. Do not construct one from an ID, reuse it for a different list, or send unsupported page or offset parameters. Supported filters are listed below.
For the next page of conversations, set WAARU_CURSOR to the returned cursor:
curl --get --fail-with-body 'https://api.waaru.app/v1/conversations' \
--header "Authorization: Bearer $WAARU_API_KEY" \
--data-urlencode 'limit=10' \
--data-urlencode "after=$WAARU_CURSOR"Read message history
Replace conversation_example with a returned conversation ID:
curl --fail-with-body 'https://api.waaru.app/v1/conversations/conversation_example/messages?limit=20' \
--header "Authorization: Bearer $WAARU_API_KEY" \
--header 'Accept: application/json'This returns items and nextCursor, newest message first. Pass nextCursor as before to read older messages. Store message IDs so your client can reconcile results without rendering duplicates.
Read one message or its delivery status
curl --fail-with-body 'https://api.waaru.app/v1/messages/message_example' \
--header "Authorization: Bearer $WAARU_API_KEY" \
--header 'Accept: application/json'Example response:
{
"message": {
"id": "message_example",
"conversationId": "conversation_example",
"contactId": "contact_example",
"direction": "outbound",
"status": "delivered",
"type": "text",
"text": { "body": "Thanks. Your enquiry reached the team." },
"occurredAt": "2026-09-06T09:00:00.000Z",
"attribution": {
"source": "api",
"actorKind": "unknown",
"actorId": null,
"campaignId": null,
"relatedMessageId": null
}
}
}The same message shape appears in history and lastMessage. Inbound messages use direction: "inbound" and status: "received". Outbound statuses are queued, sent, delivered, read, and failed; failures may include failureCode. Content fields depend on type. Do not assume every inbound message contains text.body, or that all provider-specific interactive fields are returned.
Interpret interactive replies and non-text content
An inbound reply-button selection can include this content within the message object:
{
"type": "interactive",
"interactive": {
"type": "INTERACTIVE_RESPONSE",
"mode": "BUTTON",
"optionId": "track_order",
"label": "Track order",
"value": "track_order"
}
}Response modes are BUTTON, LIST and TEMPLATE_QUICK_REPLY. Use the returned selection identifier and mode to match the choice your application offered. Do not parse the visible label as though it were a free-form customer command.
Read payloads do not always mirror send payloads:
- Outbound API media normally preserves its submitted
image,video,audioordocumentobject withlinkand supported caption/filename fields. - Downloadable inbound media uses the separate
mediafields described below to show attachment availability. - Inbound contact cards use entries with
nameas a string andphonesandemailsas string arrays. API-sent contact cards preserve the submitted structured contact-card fields. - Unsupported content can use
type: "unsupported". Keep a readable fallback instead of assumingtext.bodyexists.
Download an attachment
An available attachment includes media.availability: "ready", its media.id, and a downloadPath such as /v1/media/media_example. Other availability values include pending, failed, and unavailable. Wait or handle the unavailable state instead of guessing a download URL.
curl --fail-with-body 'https://api.waaru.app/v1/media/media_example' \
--header "Authorization: Bearer $WAARU_API_KEY" \
--output attachment.binA successful API-key request streams bytes with Content-Type, Content-Disposition, and Cache-Control: private, no-store. It does not return a JSON URL or redirect to storage. Read the content headers when choosing how to save or display the file. Permission, number scope, and media availability are checked again when downloading.
Limit concurrent downloads. The current limit is eight active streams per key and number. A 429 with media_concurrency_exceeded means too many downloads are active. Release existing streams before retrying. The external API has no media upload endpoint; outbound media uses a public HTTPS link.
Next: Receive signed events or handle errors and limits.
Filter and read incrementally
For incremental reads, conversation lists also accept contactId, q (contact name or phone, at most 128 characters), and updatedSince (inclusive last-message time in UTC ISO 8601). Search does not inspect message bodies.
Message lists accept direction=inbound|outbound, status=received|queued|sent|delivered|read|failed, and since. The default order remains newest first with before. Supplying after, or since without before, switches to oldest first; pass nextCursor as after to continue. Do not combine before and after. Keep filters unchanged during pagination.
These filters follow message occurrence time. They do not replay later status changes to older messages; use the revision-based changes feed below, signed callbacks or direct message reads for delivery updates. Provider uncertainty remains queued until settled.
Endpoint details
Every request uses Authorization: Bearer $WAARU_API_KEY and a connected number in Developer API mode. The scope below is required. IDs and payloads are illustrative; replace them with your own permitted resources. See authentication and common errors, size limits and retries.
GET /v1/messages/changes
Required scope: messages:read
Requires messages:read. Bootstrap without after and save resumeCursor before retained history reconciliation. Then follow nextCursor, including empty pages, and persist resumeCursor for the next polling cycle. Ordered by immutable workspace revision with a fixed cycle watermark; delivery status comes from that observation, not current message state. Outbound creation status is null. Cursor lease is seven days (410 cursor_expired); invalid or wrong-number cursor is 400 invalid_cursor. history=retained_only: deletions have no tombstones and the lease is not a history-retention promise. No send or reply authority.
| Parameter | Location | Required | Constraints |
|---|---|---|---|
limit | query | No | {"type":"integer","minimum":1,"maximum":100,"default":50} |
after | query | No | {"type":"string","minLength":1,"maxLength":2048} |
Request
curl --fail-with-body --request GET 'https://api.waaru.app/v1/messages/changes' \
--header "Authorization: Bearer $WAARU_API_KEY"Send no request body.
Response: HTTP 200
{
"items": [],
"nextCursor": null,
"resumeCursor": "opaque_cursor_returned_by_server",
"history": "retained_only"
}| Field | Required | Type and constraints |
|---|---|---|
items | Yes | {"maxItems":100,"type":"array"} Nested fields are in the complete schema below. |
nextCursor | Yes | {"type":["string","null"]} |
resumeCursor | Yes | {"type":"string"} |
history | Yes | {"type":"string","const":"retained_only"} |
Response body: complete JSON schema
Required fields appear in required. Omit optional fields unless needed. Null is accepted only where the schema includes it. Arrays describe their elements in items; variants appear in anyOf or oneOf.
{
"type": "object",
"properties": {
"items": {
"maxItems": 100,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"revision": {
"type": "string",
"pattern": "^[1-9]\\d{0,19}$"
},
"messageId": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"type": {
"type": "string",
"enum": [
"message.created",
"message.status_changed"
]
},
"status": {
"anyOf": [
{
"type": "string",
"enum": [
"received",
"queued",
"sent",
"delivered",
"read",
"failed"
]
},
{
"type": "null"
}
]
},
"occurredAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
"id",
"revision",
"messageId",
"type",
"status",
"occurredAt"
],
"additionalProperties": false
}
},
"nextCursor": {
"type": [
"string",
"null"
]
},
"resumeCursor": {
"type": "string"
},
"history": {
"type": "string",
"const": "retained_only"
}
},
"required": [
"items",
"nextCursor",
"resumeCursor",
"history"
],
"additionalProperties": false
}Failures use a stable code and safe message. They never expose internal exception messages, traces or provider diagnostics. Check the error reference before retrying. An inaccessible resource can return 404 even if its identifier exists elsewhere.
GET /v1/messages/{messageId}
Required scope: messages:read
Requires messages:read.
| Parameter | Location | Required | Constraints |
|---|---|---|---|
messageId | path | Yes | {"type":"string","minLength":1,"maxLength":191} |
Request
curl --fail-with-body --request GET 'https://api.waaru.app/v1/messages/messageId_example' \
--header "Authorization: Bearer $WAARU_API_KEY"Send no request body.
Response: HTTP 200
{
"message": {
"id": "message_example",
"conversationId": "conversation_example",
"contactId": "contact_example",
"direction": "outbound",
"status": "delivered",
"type": "text",
"text": {
"body": "Your request reached the team."
},
"occurredAt": "2026-10-01T09:00:00.000Z",
"attribution": {
"source": "api",
"actorKind": "system",
"actorId": null,
"campaignId": null,
"relatedMessageId": null
}
}
}| Field | Required | Type and constraints |
|---|---|---|
message | Yes | {"type":"object","required":["id","conversationId","contactId","direction","status","type","occurredAt","attribution"]} Nested fields are in the complete schema below. |
Response body: complete JSON schema
Required fields appear in required. Omit optional fields unless needed. Null is accepted only where the schema includes it. Arrays describe their elements in items; variants appear in anyOf or oneOf.
{
"type": "object",
"properties": {
"message": {
"type": "object",
"required": [
"id",
"conversationId",
"contactId",
"direction",
"status",
"type",
"occurredAt",
"attribution"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 191
},
"conversationId": {
"type": "string",
"minLength": 1,
"maxLength": 191
},
"contactId": {
"type": "string",
"minLength": 1,
"maxLength": 191
},
"direction": {
"enum": [
"inbound",
"outbound"
]
},
"status": {
"enum": [
"received",
"queued",
"sent",
"delivered",
"read",
"failed"
]
},
"type": {
"type": "string"
},
"occurredAt": {
"type": "string",
"format": "date-time"
},
"attribution": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": [
"meta_webhook",
"agent",
"api",
"logic_flow",
"automation",
"ai_agent",
"broadcast",
"coexistence_sync",
"system",
"unknown"
]
},
"actorKind": {
"type": "string",
"enum": [
"contact",
"user",
"logic_flow",
"automation",
"ai_agent",
"broadcast",
"coexistence",
"system",
"unknown"
]
},
"actorId": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
},
{
"type": "null"
}
]
},
"campaignId": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
},
{
"type": "null"
}
]
},
"relatedMessageId": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
},
{
"type": "null"
}
]
}
},
"required": [
"source",
"actorKind",
"actorId",
"campaignId",
"relatedMessageId"
],
"additionalProperties": false
},
"text": {
"type": "object"
},
"media": {
"type": "object"
},
"template": {
"type": "object"
},
"interactive": {
"type": "object"
},
"location": {
"type": "object"
},
"contacts": {
"type": "array",
"items": {
"type": "object"
}
},
"failureCode": {
"type": "string"
}
}
}
},
"required": [
"message"
],
"additionalProperties": false
}Failures use a stable code and safe message. They never expose internal exception messages, traces or provider diagnostics. Check the error reference before retrying. An inaccessible resource can return 404 even if its identifier exists elsewhere.
GET /v1/conversations
Required scope: messages:read
Requires messages:read.
| Parameter | Location | Required | Constraints |
|---|---|---|---|
limit | query | No | {"type":"integer","minimum":1,"maximum":100,"default":50} |
after | query | No | {"type":"string","maxLength":512} Use nextCursor from the previous response; keep filters unchanged. |
contactId | query | No | {"type":"string","minLength":1,"maxLength":191} |
q | query | No | {"type":"string","minLength":1,"maxLength":128} Search contact names and phone numbers, not message bodies. |
updatedSince | query | No | {"type":"string","format":"date-time"} Inclusive last-message time, not a general change-data stream. |
Request
curl --fail-with-body --request GET 'https://api.waaru.app/v1/conversations' \
--header "Authorization: Bearer $WAARU_API_KEY"Send no request body.
Response: HTTP 200
{
"items": [
{
"id": "conversation_example",
"contactId": "contact_example",
"phoneNumber": "+919876543210",
"contactName": "Asha",
"lastMessageAt": "2026-10-01T09:00:00.000Z",
"humanOwned": false,
"lastMessage": {
"id": "message_example",
"conversationId": "conversation_example",
"contactId": "contact_example",
"direction": "outbound",
"status": "delivered",
"type": "text",
"text": {
"body": "Your request reached the team."
},
"occurredAt": "2026-10-01T09:00:00.000Z",
"attribution": {
"source": "api",
"actorKind": "system",
"actorId": null,
"campaignId": null,
"relatedMessageId": null
}
}
}
],
"nextCursor": null
}| Field | Required | Type and constraints |
|---|---|---|
items | Yes | {"type":"array","maxItems":100} Nested fields are in the complete schema below. |
nextCursor | Yes | {"type":["string","null"]} |
Response body: complete JSON schema
Required fields appear in required. Omit optional fields unless needed. Null is accepted only where the schema includes it. Arrays describe their elements in items; variants appear in anyOf or oneOf.
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 191
},
"contactId": {
"type": "string",
"minLength": 1,
"maxLength": 191
},
"phoneNumber": {
"type": "string"
},
"contactName": {
"type": "string"
},
"lastMessageAt": {
"type": "string",
"format": "date-time"
},
"humanOwned": {
"type": "boolean"
},
"lastMessage": {
"anyOf": [
{
"type": "object",
"required": [
"id",
"conversationId",
"contactId",
"direction",
"status",
"type",
"occurredAt",
"attribution"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 191
},
"conversationId": {
"type": "string",
"minLength": 1,
"maxLength": 191
},
"contactId": {
"type": "string",
"minLength": 1,
"maxLength": 191
},
"direction": {
"enum": [
"inbound",
"outbound"
]
},
"status": {
"enum": [
"received",
"queued",
"sent",
"delivered",
"read",
"failed"
]
},
"type": {
"type": "string"
},
"occurredAt": {
"type": "string",
"format": "date-time"
},
"attribution": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": [
"meta_webhook",
"agent",
"api",
"logic_flow",
"automation",
"ai_agent",
"broadcast",
"coexistence_sync",
"system",
"unknown"
]
},
"actorKind": {
"type": "string",
"enum": [
"contact",
"user",
"logic_flow",
"automation",
"ai_agent",
"broadcast",
"coexistence",
"system",
"unknown"
]
},
"actorId": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
},
{
"type": "null"
}
]
},
"campaignId": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
},
{
"type": "null"
}
]
},
"relatedMessageId": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
},
{
"type": "null"
}
]
}
},
"required": [
"source",
"actorKind",
"actorId",
"campaignId",
"relatedMessageId"
],
"additionalProperties": false
},
"text": {
"type": "object"
},
"media": {
"type": "object"
},
"template": {
"type": "object"
},
"interactive": {
"type": "object"
},
"location": {
"type": "object"
},
"contacts": {
"type": "array",
"items": {
"type": "object"
}
},
"failureCode": {
"type": "string"
}
}
},
{
"type": "null"
}
]
}
},
"required": [
"id",
"contactId",
"phoneNumber",
"contactName",
"lastMessageAt",
"humanOwned",
"lastMessage"
],
"additionalProperties": false
},
"maxItems": 100
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"items",
"nextCursor"
],
"additionalProperties": false
}Failures use a stable code and safe message. They never expose internal exception messages, traces or provider diagnostics. Check the error reference before retrying. An inaccessible resource can return 404 even if its identifier exists elsewhere.
GET /v1/conversations/{conversationId}/messages
Required scope: messages:read
Requires messages:read. Default newest first using before. since or after reads oldest first, using after for subsequent pages. before and after are mutually exclusive. since filters occurredAt; status changes to older messages require callbacks or direct reads.
| Parameter | Location | Required | Constraints |
|---|---|---|---|
conversationId | path | Yes | {"type":"string","minLength":1,"maxLength":191} |
limit | query | No | {"type":"integer","minimum":1,"maximum":100,"default":50} |
after | query | No | {"type":"string","maxLength":512} Use nextCursor from the previous response; keep filters unchanged. |
before | query | No | {"type":"string","maxLength":512} |
since | query | No | {"type":"string","format":"date-time"} |
direction | query | No | {"enum":["inbound","outbound"]} |
status | query | No | {"enum":["received","queued","sent","delivered","read","failed"]} |
Request
curl --fail-with-body --request GET 'https://api.waaru.app/v1/conversations/conversationId_example/messages' \
--header "Authorization: Bearer $WAARU_API_KEY"Send no request body.
Response: HTTP 200
{
"items": [
{
"id": "message_example",
"conversationId": "conversation_example",
"contactId": "contact_example",
"direction": "outbound",
"status": "delivered",
"type": "text",
"text": {
"body": "Your request reached the team."
},
"occurredAt": "2026-10-01T09:00:00.000Z",
"attribution": {
"source": "api",
"actorKind": "system",
"actorId": null,
"campaignId": null,
"relatedMessageId": null
}
}
],
"nextCursor": null
}| Field | Required | Type and constraints |
|---|---|---|
items | Yes | {"type":"array","maxItems":100} Nested fields are in the complete schema below. |
nextCursor | Yes | {"type":["string","null"]} |
Response body: complete JSON schema
Required fields appear in required. Omit optional fields unless needed. Null is accepted only where the schema includes it. Arrays describe their elements in items; variants appear in anyOf or oneOf.
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"conversationId",
"contactId",
"direction",
"status",
"type",
"occurredAt",
"attribution"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 191
},
"conversationId": {
"type": "string",
"minLength": 1,
"maxLength": 191
},
"contactId": {
"type": "string",
"minLength": 1,
"maxLength": 191
},
"direction": {
"enum": [
"inbound",
"outbound"
]
},
"status": {
"enum": [
"received",
"queued",
"sent",
"delivered",
"read",
"failed"
]
},
"type": {
"type": "string"
},
"occurredAt": {
"type": "string",
"format": "date-time"
},
"attribution": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": [
"meta_webhook",
"agent",
"api",
"logic_flow",
"automation",
"ai_agent",
"broadcast",
"coexistence_sync",
"system",
"unknown"
]
},
"actorKind": {
"type": "string",
"enum": [
"contact",
"user",
"logic_flow",
"automation",
"ai_agent",
"broadcast",
"coexistence",
"system",
"unknown"
]
},
"actorId": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
},
{
"type": "null"
}
]
},
"campaignId": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
},
{
"type": "null"
}
]
},
"relatedMessageId": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
},
{
"type": "null"
}
]
}
},
"required": [
"source",
"actorKind",
"actorId",
"campaignId",
"relatedMessageId"
],
"additionalProperties": false
},
"text": {
"type": "object"
},
"media": {
"type": "object"
},
"template": {
"type": "object"
},
"interactive": {
"type": "object"
},
"location": {
"type": "object"
},
"contacts": {
"type": "array",
"items": {
"type": "object"
}
},
"failureCode": {
"type": "string"
}
}
},
"maxItems": 100
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"items",
"nextCursor"
],
"additionalProperties": false
}Failures use a stable code and safe message. They never expose internal exception messages, traces or provider diagnostics. Check the error reference before retrying. An inaccessible resource can return 404 even if its identifier exists elsewhere.
GET /v1/media/{id}
Required scope: media:read
Requires media:read.
| Parameter | Location | Required | Constraints |
|---|---|---|---|
id | path | Yes | {"type":"string","minLength":1,"maxLength":191} |
Request
curl --fail-with-body --request GET 'https://api.waaru.app/v1/media/id_example' \
--header "Authorization: Bearer $WAARU_API_KEY" \
--output attachment.binSend no request body.
Response: HTTP 200
Binary bytes with the attachment Content-Type, Content-Disposition and private, no-store caching. No JSON success body or storage redirect. Stream the response to a file and close or cancel it when finished.
Failures use a stable code and safe message. They never expose internal exception messages, traces or provider diagnostics. Check the error reference before retrying. An inaccessible resource can return 404 even if its identifier exists elsewhere.
Interpret safe message attribution
Every projected message includes attribution: source, actorKind, nullable actorId, campaignId and relatedMessageId. Sources are meta_webhook, agent, api, logic_flow, automation, ai_agent, broadcast, coexistence_sync, system or unknown. Actor kinds are contact, user, logic_flow, automation, ai_agent, broadcast, coexistence, system or unknown. IDs are opaque and returned only when retained ownership evidence authorizes that relation. Sparse or unrecognized evidence stays unknown or null; never infer an actor from a label or guess a campaign from content. REST, MCP reads and message callbacks use the same projection. Attribution carries no reply authority and exposes no raw source keys or provider IDs.
Observe message and status changes
Use the revision-based message changes feed for later delivery observations, including changes to older messages. Save a bootstrap cursor before scanning history and follow empty pages during catch-up.
WhatsApp message formats
Build Waaru Developer API payloads for text, templates, linked media, interactive buttons and lists, locations, and one contact card with the supported field limits.
Message changes
Poll retained canonical message revisions with safe bootstrap, bounded catch-up cursors and explicit recovery after a gap.