Skip to content

Get a message

Retrieve the full content, delivery status and media reference of any message sent or received in your account, using its id as the identifier.

Use GET /v1/messages/{id} for the main number. For extra numbers, use GET /v1/numbers/{ref}/messages/{id}, where {ref} is the number’s current alias or its id (num_…, the message’s number_id). Prefer the id: an alias read from an older message may have been renamed since.

Required scope: messages:read

Get a message

get-message.sh
curl -X GET \
https://api.turbonotify.com/v1/messages/msg_01993c5a1b24d3e9f01a2b3c4d5e6f70 \
-H "Authorization: Bearer YOUR_KEY"

Response: text message (outbound)

HTTP status: 200
{
  "id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70",
  "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
  "number_alias": "main",
  "number_id": "num_0f8e2c4a6b1d4e7f9a3c5d7e9b1a3c5e",
  "from": {
    "name": "Turbo Notify",
    "number": "5511973117056",
    "alias": "main"
  },
  "to": {
    "name": "João",
    "number": "5511988887777"
  },
  "preview": "Hello, your order is confirmed!",
  "content_expired": false,
  "text": "Hello, your order is confirmed!",
  "content_type": "text",
  "direction": "outbound",
  "channel": "whatsapp",
  "origin": "tn",
  "at": "2026-06-01T13:00:05Z",
  "status": {
    "state": "delivered",
    "at": "2026-06-01T13:01:00Z"
  }
}

Response: image message (inbound)

HTTP status: 200
{
  "id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f71",
  "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
  "number_alias": "main",
  "number_id": "num_0f8e2c4a6b1d4e7f9a3c5d7e9b1a3c5e",
  "from": {
    "name": "João",
    "number": "5511988887777"
  },
  "to": {
    "name": "Turbo Notify",
    "number": "5511973117056",
    "alias": "main"
  },
  "preview": "Product photo",
  "content_expired": false,
  "image": {
    "caption": "Product photo",
    "kind": "available",
    "url": "https://your-bucket.r2.cloudflarestorage.com/organizations/.../image.jpeg?X-Amz-...",
    "expires_at": "2026-06-01T14:00:00Z",
    "mime_type": "image/jpeg",
    "size_bytes": 123456
  },
  "content_type": "image",
  "direction": "inbound",
  "channel": "whatsapp",
  "origin": "native",
  "at": "2026-06-01T13:00:00Z",
  "status": {
    "state": "read",
    "at": "2026-06-01T13:05:00Z"
  }
}

Response fields

from and to are objects whose format depends on the message direction, the same one used in webhooks. Your number in the conversation is identified by number_id (and by number_alias, the alias it had at the time); its details are available at GET /v1/numbers/{ref}, with the number_id.

FieldTypeDescription
idstringPublic identifier of the message (msg_ + 32 hex)
organization_idstringPublic identifier of your organization (org_{hex32}). Always present.
number_aliasstringThe alias the Turbo Notify number had at the time of the message: "main" or the alias of an extra number, the same value as in the event. If the number is renamed later, older messages keep the old alias. To read the number’s details, call GET /v1/numbers/{ref} with the number_id.
number_idstringIdentifier of the number (num_…), right after number_alias. It never changes, even if the alias is renamed.
fromobjectSender: the format depends on the direction
toobjectRecipient: the format depends on the direction
previewstringOpening excerpt of the content. It expires with the content, at your plan’s retention period (it becomes ""), see About preview
content_expiredbooleantrue once the content passed your plan’s retention period and was deleted. The message stays available for 12 months, see Expired content
specific fieldobject|stringFull content, named after the content_type (text, image, …). See the table below.
content_typestringtext, image, audio, video, document, sticker, location, calendar, contact, interactive. Polls and unrecognized types are stored as text. The preview keeps whatever text is available.
directionstringoutbound (sent) or inbound (received)
channelstringAlways "whatsapp"
originstring"tn" (sent through the Turbo Notify API) or "native" (typed on the phone). Always present.
atstringISO 8601 in UTC with a Z suffix and second precision (e.g. 2026-06-01T13:00:05Z): the stable occurrence time of the message on WhatsApp (outbound: the actual send time; inbound: the time it was received). It does not change as the message moves sent → delivered → read. For the current delivery or read time, use status.at.
statusobject{state, at}: current delivery state and the matching status.at. Unlike the root at, status.at moves forward with each transition (to delivered, then read).
mentionsarrayOptional: present only when the message mentions someone (inbound or outbound). List of mentioned contacts, items {contact_id?, name?, number} (same format as the inbound from), in the order of the tokens in the text, with no duplicates. A @<digits> in the text is a mention only if the number appears here. It follows the same retention window as the rest of the content. See Mentions (@mentions).

Fields by content_type

Besides the common fields above, each content_type exposes a specific field with the full content:

content_typeSpecific fieldDescription
texttextFull message text
imageimageObject with caption? and the media fields (kind, url?, expires_at?, …) directly
videovideoObject with caption? and the media fields
audioaudioObject with the media fields and transcription? (BYO Speech-to-Text, if set up)
documentdocumentObject with filename?, caption? and the media fields
stickerstickerObject with the media fields
locationlocationObject with latitude, longitude, name?, address?, url?, is_live?, accuracy_m?, comment?, thumbnail_b64?. is_live appears only when true (live location). It never comes as false; treat a missing key as “not live”. accuracy_m (GPS accuracy, in meters) and comment (a text note attached by the sender) appear only on inbound locations, when WhatsApp sends them. They cannot be sent through the API.
calendarcalendarObject with name, description?, start_at?, end_at?, join_link?, location?, has_reminder?, reminder_offset_sec?, extra_guests_allowed?. An inbound calendar can also carry is_canceled?/is_scheduled_call? (the organizer canceled the event / it is a scheduled call: flagged by WhatsApp itself; is_scheduled_call is never sendable through the API). On an inbound calendar, has_reminder/extra_guests_allowed/is_canceled/is_scheduled_call appear only when true: treat a missing key as “no”. On an outbound calendar sent through the API, the content is returned exactly as it was sent: "has_reminder": false, "extra_guests_allowed": false and explicitly null keys (description, end_at, join_link, location, reminder_offset_sec) may appear (is_scheduled_call never appears on outbound calendars; is_canceled appears as true after you cancel the event through the API). Treat false/null/missing key as equivalent.
contactcontactStructured vCard: the same fields accepted when sending (Send a contact): display_name, phones[], emails?, organization?, title?, address?
interactiveinteractiveObject with interactive_type (always "cta_url"), body, header? ({type: "text", text}), footer?, url_button: {display_text, url}

For replied messages (quotes):

FieldTypeDescription
repliedobject{message_id: string, preview: string}: id and excerpt of the quoted message

For reacted messages:

FieldTypeDescription
textstringThe reaction emoji (e.g. "👍") or "" for a removal

Example: text message

GET /v1/numbers/{ref}/messages/{id}: text
{
"id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0f8e2c4a6b1d4e7f9a3c5d7e9b1a3c5e",
"from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"to": { "name": "João", "number": "5511988887777" },
"preview": "Hello, your order is confirmed!",
"content_expired": false,
"text": "Hello, your order is confirmed!",
"content_type": "text",
"direction": "outbound",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-06-01T13:00:05Z",
"status": { "state": "delivered", "at": "2026-06-01T13:01:00Z" }
}

Example: image message (inbound, BYO Storage active)

GET /v1/numbers/{ref}/messages/{id}: image (available)
{
"id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f71",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0f8e2c4a6b1d4e7f9a3c5d7e9b1a3c5e",
"from": { "name": "João", "number": "5511988887777" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "Product photo",
"content_expired": false,
"image": {
"caption": "Product photo",
"kind": "available",
"url": "https://<bucket>.s3.amazonaws.com/...?X-Amz-Signature=...",
"expires_at": "2026-06-01T14:00:00Z",
"mime_type": "image/jpeg",
"size_bytes": 184320
},
"content_type": "image",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T13:00:00Z",
"status": { "state": "read", "at": "2026-06-01T13:05:00Z" }
}

Example: image message (without BYO Storage)

GET /v1/numbers/{ref}/messages/{id}: image (unavailable)
{
"id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f72",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0f8e2c4a6b1d4e7f9a3c5d7e9b1a3c5e",
"from": { "name": "João", "number": "5511988887777" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "[Image]",
"content_expired": false,
"image": {
"kind": "unavailable",
"reason": "no_storage",
"mime_type": "image/jpeg",
"size_bytes": 184320,
"setup_hint": {
"setup_url": "https://dashboard.turbonotify.com/storage",
"docs_url": "https://docs.turbonotify.com/general/byo-storage-setup/",
"message": "Configure BYO Object Storage to receive media."
}
},
"content_type": "image",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T13:00:00Z",
"status": { "state": "delivered", "at": "2026-06-01T13:00:00Z" }
}

Example: location message

GET /v1/numbers/{ref}/messages/{id}: location
{
"id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f73",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0f8e2c4a6b1d4e7f9a3c5d7e9b1a3c5e",
"from": { "name": "João", "number": "5511988887777" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "Av. Paulista, Avenida Paulista, 1578, Bela Vista,",
"content_expired": false,
"location": {
"latitude": -23.561414,
"longitude": -46.655881,
"name": "Av. Paulista",
"address": "Avenida Paulista, 1578, Bela Vista, São Paulo - SP",
"url": "https://maps.google.com/?q=-23.561414,-46.655881",
"thumbnail_b64": "..."
},
"content_type": "location",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T13:00:00Z",
"status": { "state": "delivered", "at": "2026-06-01T13:00:00Z" }
}

Example: calendar message

GET /v1/numbers/{ref}/messages/{id}: calendar
{
"id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f74",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0f8e2c4a6b1d4e7f9a3c5d7e9b1a3c5e",
"from": { "name": "João", "number": "5511988887777" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "Sprint Planning — Mon, 1 Jun 2026 17:00",
"content_expired": false,
"calendar": {
"name": "Sprint Planning",
"description": "Sprint 24 planning.",
"start_at": "2026-06-01T14:00:00-03:00",
"end_at": "2026-06-01T15:00:00-03:00",
"join_link": "https://meet.example.com/sprint-planning",
"has_reminder": true,
"reminder_offset_sec": 600
},
"content_type": "calendar",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T13:00:00Z",
"status": { "state": "delivered", "at": "2026-06-01T13:00:00Z" }
}

Example: interactive message (cta_url)

GET /v1/numbers/{ref}/messages/{id}: interactive
{
"id": "msg_d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0f8e2c4a6b1d4e7f9a3c5d7e9b1a3c5e",
"from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"to": { "name": "João", "number": "5511988887777" },
"preview": "Click to learn more",
"content_expired": false,
"interactive": {
"interactive_type": "cta_url",
"body": "Click to learn more",
"footer": "Turbo Notify",
"url_button": {
"display_text": "Open",
"url": "https://turbonotify.com"
}
},
"content_type": "interactive",
"direction": "outbound",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-06-01T13:00:05Z",
"status": { "state": "delivered", "at": "2026-06-01T13:01:00Z" }
}

The url_button opens the link directly on the recipient’s side; header is optional ({type: "text", text}; text only, for now).

Example: message with mentions

The text carries the normalized @<number> tokens; the mentions structure holds the matching contacts, already resolved:

GET /v1/numbers/{ref}/messages/{id}: text with mentions
{
"id": "msg_c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0f8e2c4a6b1d4e7f9a3c5d7e9b1a3c5e",
"from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"to": { "group_id": "grp_e442553a56b14e2695f6acfd7d651b1a", "name": "Sales Team" },
"preview": "Good morning @5511977776666, can you check today's order?",
"content_expired": false,
"text": "Good morning @5511977776666, can you check today's order?",
"mentions": [
{
"contact_id": "ct_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"name": "Maria Santos",
"number": "5511977776666"
}
],
"content_type": "text",
"direction": "outbound",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-07-03T13:00:05Z",
"status": { "state": "delivered", "at": "2026-07-03T13:01:00Z" }
}

The field appears on inbound and outbound messages and stays after the content expires: it holds no text, only the mentioned contacts (see Message retention).

Example: reply (quoted message)

GET /v1/numbers/{ref}/messages/{id}: replied
{
"id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0f8e2c4a6b1d4e7f9a3c5d7e9b1a3c5e",
"from": { "name": "João", "number": "5511988887777" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "Yes, that's the order!",
"content_expired": false,
"text": "Yes, that's the order!",
"replied": {
"message_id": "msg_99887766554433221100ffeeddccbbaa",
"preview": "Order #1234 was approved."
},
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T13:00:00Z",
"status": { "state": "delivered", "at": "2026-06-01T13:00:00Z" }
}

Example: reaction

GET /v1/numbers/{ref}/messages/{id}: reacted
{
"id": "msg_b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0f8e2c4a6b1d4e7f9a3c5d7e9b1a3c5e",
"from": { "name": "João", "number": "5511988887777" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "👍",
"content_expired": false,
"text": "👍",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T13:00:00Z",
"status": { "state": "delivered", "at": "2026-06-01T13:00:00Z" }
}

Media fields

The media fields appear directly in the specific object (image, video, audio, document, sticker), with no media sub-object. The kind field tells you which of the formats below the media has:

kind: "available"

Media available for download. Requires Object Storage set up (opens in a new tab) .

FieldTypeDescription
kindstring"available"
urlstringPresigned download URL
expires_atstring | nullWhen access to the media expires. Get the message again to obtain a new URL after it expires, within your plan’s content retention period.
mime_typestring | nullMIME type of the file (e.g. image/jpeg, application/pdf)
size_bytesinteger | nullFile size in bytes

kind: "pending"

The media is still being copied to your bucket. Wait a few seconds and query again.

FieldTypeDescription
kindstring"pending"
mime_typestring | nullDetected MIME type
size_bytesinteger | nullFile size in bytes

kind: "unavailable"

The media is not available. The reason field gives the cause.

FieldTypeDescription
kindstring"unavailable"
reasonstringno_storage: no Object Storage set up; storage_circuit_open: your Object Storage had been failing repeatedly when the media arrived, and sending media to it was temporarily paused; upload_failed: the storage was healthy, but this item could not be saved; deleted: the message was deleted/revoked after the media had already been stored; expired: the content passed your plan’s retention period and was deleted (see Expired content). The reason is recorded when the media is received, deleted or expires and does not change afterwards.
mime_typestring | nullDetected MIME type
size_bytesinteger | nullFile size in bytes
setup_hintobject | nullPresent when reason is no_storage or storage_circuit_open. Contains setup_url, docs_url and message.

audio.transcription

When BYO Speech-to-Text is turned on for your organization, inbound audio messages get a transcription field inside the audio object, also identified by kind (omitted when transcription was not attempted, for example on outbound audio or when transcription is not set up). Same format as data.transcription in the message.received webhooks/polling:

FieldTypeDescription
kindstring"available": transcription completed, or "unavailable": not available.
textstring(available only) Transcribed text.
languagestring | null(available only) BCP-47 code of the detected/configured language, when available.
reasonstring(unavailable only) One of these 6 values: not_configured, provider_error, file_too_large (audio over 24 MB), timeout, provider_not_supported (the configured transcription provider is not supported right now) or internal_error.
hintstring | null(unavailable only) Optional guidance, written for whoever reads the event (with a link to the dashboard’s Transcription screen when there is something to adjust there).

A transcription failure never blocks the message flow. To turn transcription on, see Audio transcription.

Message status

The status field holds the current delivery state:

FieldTypeDescription
statestringCurrent state: pending (accepted with 202, not yet sent to WhatsApp), sent, delivered, read, failed (outbound); delivered, read (inbound)
atstringISO 8601: timestamp of the current delivery state; it moves forward with each transition (sent → delivered → read). Not to be confused with the root-level at, which is the stable occurrence time of the message.
errorobjectStructured error for the failure: present only when state: "failed"; absent (key omitted) in every other state. Same format as data.error in the *.failed webhooks.
error.codestringStable code: decide how to handle the failure from this value.
error.messagestringReadable description of the failure, for logs and debugging. It may include a WhatsApp diagnostic in English; personal identifiers (numbers, JIDs) never appear in it. The stable contract is the code, not this text.
error.hintstringActionable guidance, derived from the code. Absent (key omitted, never null) when there is no guidance for the code.

About preview

The preview field follows exactly the same content retention period as your organization’s plan, see Message retention. It is not kept any longer than the rest of the content. While the content is within that period, preview holds an excerpt of the content, never splitting an emoji or accented character:

  • For text: the first ~50 characters of the text.
  • For location: humanized text without brackets (e.g. Av. Paulista, Sao Paulo, SP).
  • For calendar: a summary without brackets, with the weekday and month abbreviated in English and the time converted to UTC (e.g. Sprint Planning — Mon, 1 Jun 2026 17:00).
  • For media: the caption (when available) or the type label in English ([Image], [Video], [Audio], [Document], [Sticker]). Document: caption → file name → [Document]. Audio with a transcription is the exception: when a transcription is available, preview holds the transcribed text (truncated at 50 characters). [Audio] only appears when there is neither a transcription nor a caption.
  • For interactive: the message’s body text, or [Interactive] when the body is empty.
  • For reactions: the emoji, or "" for a removal.
  • Mentions: each @<number> token present in mentions counts as exactly 1 character toward the ~50 limit, and the excerpt never cuts a token in half; a literal @<digits> outside mentions counts character by character. The value is identical to data.preview in the webhooks/polling.

Once the content passes your plan’s retention period, preview becomes "" and content_expired turns true: there is no way to get the excerpt back after that point. The same period deletes data.preview (along with the rest of the content) from the stored copy of a webhook delivery that has already completed and from the events of GET /v1/events: lookup by id, webhooks and polling follow one single period. The exception is a webhook delivery that has not completed yet, which keeps the content until it is delivered or the retries end (at most 72 hours).

Expired content

Only content expires. A message past your plan’s content retention period (1 hour by default, up to 72 hours depending on the plan) stays available for 12 months, and status, receipts, reactions, reply, react, edit and delete keep working. In the response:

  • content_expired is true.
  • preview and text are "".
  • Media and structured blocks (image, video, audio, document, sticker, location, calendar, contact, interactive) hold {"kind": "unavailable", "reason": "expired"}, the same shape as a deleted message, with another reason.
  • replied.preview is "" when the quoted message expired; replied.message_id is still there.
  • mentions stays.

A deleted message keeps the deleted shape (reason: "deleted") and content_expired: false.

About kind

To receive inbound media with kind: "available", set up the Object Storage integration (opens in a new tab) in the dashboard. Without it, Turbo Notify has nowhere to store the media and returns kind: "unavailable" with reason: "no_storage".

When Object Storage is set up, Turbo Notify copies each received media file to your bucket and returns a presigned URL with a configurable validity. Access to the media expires at expires_at. You can get a new URL by querying the message again while the content is within your plan’s retention period. After that, the media block comes as {"kind": "unavailable", "reason": "expired"} and the file stays in your bucket (Expired content).

Possible errors

StatusError codeDescription
400invalid_message_idThe identifier does not match the msg_ + 32 hex format.
401not_authenticatedThe API key is invalid or missing.
403insufficient_scopeThe API key does not have the messages:read scope (the response includes required_scope).
404not_foundMessage not found, belongs to another organization, or belongs to a number other than the one in the URL (on the shortcut route without {ref}, the main number): the same response as for a message that never existed.
404number_not_found(only on the route with {ref}) The alias or id in the URL does not match any registered number.
400invalid_number_id(only on the route with {ref}) The value in the URL starts with num_ but is not a valid number id.
429rate_limit_exceededRequest limit exceeded.
500internal_errorUnexpected internal server error.