On this page

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
- 1Confirm the key works
- 2Pull the conversation list first
- 3Walk the messages oldest-first
- 4Follow the cursor to the end
- 5Download the attachments you need
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 awa_message_id, so the export is safely re-runnable.
Steps
- 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"
- 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.
- Walk the messages oldest-first. This is the backfill itself.
order=ascstarts 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..."
}
- Follow the cursor to the end. Pass the previous response
next_cursorback as?cursor=, keeping every other parameter identical, untilnext_cursorcomes backnull.
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.
- Download the attachments you need. A message with a file returns a
mediaobject rather thannull.
"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.
- Record a watermark, then switch to incremental. Once the backfill finishes, store the
created_atof 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.
- Re-pull a trailing window for delivery updates. Message text and timestamps never change, but delivery state does: an outbound send moves through
sent,deliveredandread, and can pick up anerrorafterwards. A nightly job that re-reads the last few days and upserts onidkeeps 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
| Parameter | What it does | Default |
|---|---|---|
order | asc for a forward backfill, desc for newest-first | desc |
limit | Rows per page, maximum 100 | 50 |
cursor | The previous response next_cursor; omit on the first page | None |
since / until | Inclusive window on creation time, ISO 8601 | Unbounded |
direction | inbound or outbound | Both |
type | Message type, for example text, image, document, template | All types |
status | Delivery state, for example sent, delivered, read, failed | All states |
conversation_id | One thread only | All threads |
contact_id | Every thread belonging to one contact | All contacts |
| Rate limit | Requests per hour per key, rolling window | 600 |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The export returns far fewer messages than the inbox shows | The walk stopped early because next_cursor was dropped between pages, or a since/until window was left on the request | Repeat the walk passing every parameter unchanged on each page, changing only cursor |
| Imported WhatsApp history is missing entirely | The coexistence history import did not run or was declined on the business phone | Check 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 key | Expected, 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 key | Pace the export, roughly one request every six seconds sustains it indefinitely, or give the sync its own key |
| A media URL returns 410 | The attachment was still on Meta's servers and the original expired before it was re-hosted | Nothing to recover for that file; export attachments with "stored": false first in future runs |
| Duplicate rows appear on each sync run | The job inserts rather than upserts, and since is inclusive | Upsert on the message id, which is stable for the life of the record |
| A message body is empty for a location or contact card | Those message types carry no text; the structured payload renders in the inbox instead | Use 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.