Home/Help Center/Export message history

Integrations

Export Your WhatsApp Message History

Pull every conversation and message, including imported WhatsApp history, into your own warehouse with the REST API, then keep it in sync incrementally.

By Chirag Darji · Updated 1 Sept 2026 · 8 min read

On this page
  1. Before you start
  2. Steps
  3. What you will see
  4. Settings and options
  5. Troubleshooting
  6. Why not just use webhooks?
API keys in VGraple CRM showing a read-only key alongside a full-access one, with the endpoint reference below

Every message VGraple CRM holds, on WhatsApp, Messenger, Instagram and the web chat widget, is readable through the REST API, including the one-time WhatsApp history that coexistence imports from your business phone. This guide covers the two jobs that come up in practice: a one-off backfill of everything into your own database, and a scheduled sync that keeps it current afterward.

Export message history: first 5 of 7 steps

  1. 1Confirm the key works
  2. 2Pull the conversation list first
  3. 3Walk the messages oldest-first
  4. 4Follow the cursor to the end
  5. 5Download the attachments you need
The steps on this page, in order.

Before you start

  • Create a Read-only API key in Settings > API Keys. Access level is chosen when the key is created and cannot be changed later, so pick read-only now rather than reusing a key that can also send messages.
  • Know which direction you are walking. A backfill reads oldest-first with order=asc; a sync reads a recent window. Mixing the two in one job is the usual source of gaps.
  • Have somewhere to write to that can hold a message id as a primary key. Every row returned carries a stable id, and most also carry a wa_message_id, so the export is safely re-runnable.

Steps

  1. Confirm the key works. This also tells you which organisation it belongs to, which matters if you manage more than one.
curl https://crm.vgraple.co.in/api/v1/me \
  -H "Authorization: Bearer vgk_your_key_here"
  1. Pull the conversation list first. Threads are the dimension your messages hang off, and there are far fewer of them, so exporting them first gives you somewhere to attach every message as it arrives.
curl "https://crm.vgraple.co.in/api/v1/conversations?order=asc&limit=100" \
  -H "Authorization: Bearer vgk_your_key_here"

Each row carries id, channel, status, priority, label, unread_count, last_message_at, first_response_at, waiting_since, closed_at, csat_score, created_at, updated_at and the contact it belongs to. Keep paginating while next_cursor is not null.

  1. Walk the messages oldest-first. This is the backfill itself. order=asc starts at the oldest message the workspace holds, imported history included.
curl "https://crm.vgraple.co.in/api/v1/messages?order=asc&limit=100" \
  -H "Authorization: Bearer vgk_your_key_here"
{
  "messages": [
    {
      "id": "msg_01H8X...",
      "wa_message_id": "wamid.HBgM...",
      "body": "Do you deliver to Rotterdam?",
      "type": "text",
      "direction": "inbound",
      "status": "delivered",
      "template_name": null,
      "error": null,
      "is_forwarded": false,
      "media": null,
      "reply_to": null,
      "wa_timestamp": "2026-03-02T09:14:00.000Z",
      "created_at": "2026-03-02T09:14:00.000Z",
      "updated_at": "2026-08-31T11:02:13.771Z",
      "channel": "whatsapp",
      "contact": { "id": "con_01H8W...", "name": "Asha Patel", "phone": "919876543210", "wa_id": "919876543210" },
      "conversation_id": "conv_01H8V..."
    }
  ],
  "next_cursor": "msg_01H8X..."
}
  1. Follow the cursor to the end. Pass the previous response next_cursor back as ?cursor=, keeping every other parameter identical, until next_cursor comes back null.
curl "https://crm.vgraple.co.in/api/v1/messages?order=asc&limit=100&cursor=msg_01H8X..." \
  -H "Authorization: Bearer vgk_your_key_here"

Ordering is by creation time with the message id as a tiebreaker, so a page boundary that lands in the middle of a bulk import or a broadcast, where thousands of rows share a timestamp, still returns every row exactly once.

  1. Download the attachments you need. A message with a file returns a media object rather than null.
"media": {
  "mime": "image/webp",
  "caption": "delivery-address.webp",
  "url": "https://crm.vgraple.co.in/api/v1/media/msg_01H8X...",
  "stored": true
}

Fetch it with the same key. The response streams the file with its real content type.

curl -L "https://crm.vgraple.co.in/api/v1/media/msg_01H8X..." \
  -H "Authorization: Bearer vgk_your_key_here" -o delivery-address.webp

Prioritise anything with "stored": false. That means the file is still held on Meta's servers rather than ours, and Meta expires it roughly 30 days after the message.

  1. Record a watermark, then switch to incremental. Once the backfill finishes, store the created_at of the last message you wrote. From then on, ask only for what came after it.
curl "https://crm.vgraple.co.in/api/v1/messages?order=asc&since=2026-09-01T00:00:00Z&limit=100" \
  -H "Authorization: Bearer vgk_your_key_here"

since and until are both inclusive, so re-using the last row's timestamp as the next since re-fetches that row rather than skipping it. Upsert on id and it costs you nothing.

  1. Re-pull a trailing window for delivery updates. Message text and timestamps never change, but delivery state does: an outbound send moves through sent, delivered and read, and can pick up an error afterwards. A nightly job that re-reads the last few days and upserts on id keeps those columns honest.
curl "https://crm.vgraple.co.in/api/v1/messages?since=2026-08-25T00:00:00Z&direction=outbound" \
  -H "Authorization: Bearer vgk_your_key_here"

Compare updated_at against what you already stored to decide whether a row is worth rewriting.

What you will see

A full export gives you two tables. Conversations are the threads, one row per customer per channel, carrying the service metrics the inbox shows: when the thread opened, when a human first replied, how long it has been waiting, whether it was resolved, and any CSAT score. Messages are the contents, each pointing back at its conversation_id and carrying both wa_timestamp (WhatsApp's own clock) and created_at (ours). For imported history the two are identical; for live traffic they differ by the webhook delivery delay, usually under a second.

Replies come back as a reply_to object with both id spaces, message_id for our id and wa_message_id for WhatsApp's, so you can rebuild the quote graph whichever identifier your warehouse keyed on.

Settings and options

ParameterWhat it doesDefault
orderasc for a forward backfill, desc for newest-firstdesc
limitRows per page, maximum 10050
cursorThe previous response next_cursor; omit on the first pageNone
since / untilInclusive window on creation time, ISO 8601Unbounded
directioninbound or outboundBoth
typeMessage type, for example text, image, document, templateAll types
statusDelivery state, for example sent, delivered, read, failedAll states
conversation_idOne thread onlyAll threads
contact_idEvery thread belonging to one contactAll contacts
Rate limitRequests per hour per key, rolling window600

Troubleshooting

SymptomLikely causeFix
The export returns far fewer messages than the inbox showsThe walk stopped early because next_cursor was dropped between pages, or a since/until window was left on the requestRepeat the walk passing every parameter unchanged on each page, changing only cursor
Imported WhatsApp history is missing entirelyThe coexistence history import did not run or was declined on the business phoneCheck the channel status first; see chat history not importing
403 "This API key is read-only"A write call went out on a read-only keyExpected, and the point of a read-only key. If the integration genuinely needs to write, create a separate full-access key
429 "Rate limit exceeded"More than 600 requests in the last rolling hour on this keyPace the export, roughly one request every six seconds sustains it indefinitely, or give the sync its own key
A media URL returns 410The attachment was still on Meta's servers and the original expired before it was re-hostedNothing to recover for that file; export attachments with "stored": false first in future runs
Duplicate rows appear on each sync runThe job inserts rather than upserts, and since is inclusiveUpsert on the message id, which is stable for the life of the record
A message body is empty for a location or contact cardThose message types carry no text; the structured payload renders in the inbox insteadUse type to identify them and treat an empty body as expected for those types

Why not just use webhooks?

Outbound webhooks tell you about things as they happen, which is the right tool for reacting: notify a system, update a record, trigger a workflow. They are not a history, and the coexistence import deliberately does not fire them, because a single import can write months of conversations in minutes and would flood your endpoint with events about the past. Use webhooks for what happens next and this API for what already happened. A warehouse pipeline usually wants both: the API for the initial backfill and a nightly correction pass, webhooks for the live stream in between.

Frequently asked questions

Does this include the WhatsApp history imported by coexistence?
Yes. The one-time import that coexistence brings in, up to 180 days of chats from the business phone, is written into the same message table the live inbox uses, with each row stamped with the original WhatsApp timestamp rather than the import time. It comes back from GET /api/v1/messages like any other message and paginates in true chronological order.
Why does the imported history never fire my outbound webhooks?
A history import can write tens of thousands of messages in a few minutes, and firing a webhook for each one would hammer your endpoint with events for conversations that happened months ago. The import is deliberately silent, which is exactly why the export endpoint exists. Webhooks are for what happens next, the API is for what already happened.
Do I need a full-access API key for an export?
No, and you should not use one. Create the key with Read-only access in Settings > API Keys. It is accepted on every endpoint in this guide and rejected with a 403 on anything that sends a message or changes a record, so a leaked warehouse credential cannot message your customers.
How many requests does a full export take?
One request per 100 messages, plus one per 100 conversations. A workspace with 20,000 messages is about 200 requests, comfortably inside the 600 requests per hour per key limit. Larger histories should be paced, or split across two keys, since the limit is per key on a rolling hour.
Will a message I already exported ever change?
Yes, in one direction: delivery status. An outbound message moves through sent, delivered and read, and can later carry an error code if Meta rejects it. Text, timestamps and media never change once written. Re-pull a trailing window on updated_at rather than assuming a row is final.
Can I get the images, PDFs and voice notes too?
Yes. Any message with an attachment returns a media object whose url is an authenticated download link. Fetch it with the same key. Attachments that have not been re-hosted to permanent storage yet come back with stored set to false, which means the original expires about 30 days after the message, so download those first.
Is there a CSV export instead?
Not for messages. Contacts and leads export to CSV from their own pages in the app, but conversation history is only available through the API, because a flat file cannot carry the reply threading, media links and per-message delivery state that make the history useful once it is in your database.

Run your WhatsApp on VGraple CRM

Free forever plan, official Meta WhatsApp Business API, set up in 15 minutes. No card needed.