On this page

Every webhook delivery from VGraple CRM shares one envelope shape (an id, a type, a created timestamp and a data object) wrapped around 15 possible event types, from a message arriving to a broadcast finishing. This reference documents each event's trigger condition and its exact payload fields, so you can parse a delivery without guessing at field names.
Before you start
- This page assumes you already have an endpoint receiving deliveries. See configuring outbound webhooks if you have not created one yet.
- Every payload should be verified with the
X-Signature-256header before you trust it; see verifying webhook signatures. - 11 of these 15 events are selectable directly in Settings > Webhooks; the four broadcast lifecycle events not yet in that checkbox list (
broadcast.started,broadcast.paused,broadcast.failed,broadcast.reply) can still be subscribed to viaPOST /api/v1/hookswith an API key, described below.
The envelope every event shares
Every delivery, regardless of type, is a JSON object with this shape:

{
"id": "evt_9f2a7c1b4e8d0a3f",
"type": "message.sent",
"created": 1798329600,
"data": { }
}
id is unique per delivery attempt (retries of the same underlying event reuse a related but distinct id). type matches the event name exactly, so you can route on it with a simple switch statement. created is a Unix timestamp in seconds. data holds the event-specific object described below.
Message events
message.received fires when a customer's inbound message is saved to a conversation. message.sent fires when an agent, an automation rule or an API call sends an outbound message; broadcast recipients do not fire individual message.sent events, subscribe to broadcast.completed for campaign-level confirmation instead.
{
"id": "evt_9f2a7c1b4e8d0a3f",
"type": "message.sent",
"created": 1798329600,
"data": {
"message": {
"id": "msg_01H...",
"wa_message_id": "wamid.HBgL...",
"body": "Your appointment is confirmed for Saturday at 3 PM.",
"type": "text",
"direction": "outbound",
"status": "sent",
"channel": "whatsapp",
"template_name": null,
"error": null,
"is_forwarded": false,
"media": null,
"reply_to": null,
"wa_timestamp": "2026-08-26T09:00:00.000Z",
"created_at": "2026-08-26T09:00:00.000Z",
"updated_at": "2026-08-26T09:00:01.114Z"
},
"contact": { "id": "con_01H...", "name": "Priya Sharma", "phone": "+919876543210" },
"conversation_id": "conv_01H..."
}
}
message.received uses the identical message shape with "direction": "inbound".
This is the same message object GET /api/v1/messages returns, so whatever parses one parses the other. Four of its fields are worth calling out:
| Field | What it carries |
|---|---|
status | Delivery state: sent, delivered, read, failed. Webhooks fire once, at send or receipt, so a later status change is not re-delivered; poll the API if you need the final state. |
error | null unless the platform rejected the message, otherwise { "code", "message" } with the provider's own code. |
media | null, or { "mime", "caption", "url", "stored" }. The url needs your API key as a Bearer token, it is not public. "stored": false means the file still lives on Meta's servers and expires roughly 30 days after the message. |
reply_to | null, or the quoted message in both id spaces: { "message_id", "wa_message_id" }. |
channel tells you which surface the message came from: whatsapp, messenger, instagram or web_chat. wa_timestamp is the messaging platform's own clock and created_at is ours; they differ by the delivery delay on live traffic.
Contact events
contact.created fires from every real contact-creation path: an inbound WhatsApp or Meta DM, a manual add, a CSV import, a lead auto-create, a Calendly booking, a comment DM, a template-send auto-create, or the public API. It deliberately does not fire for synthetic web-chat placeholder contacts that have not yet identified themselves. contact.opted_out fires when a contact opts out of messaging.
{
"id": "evt_3c8f1a90bd42e771",
"type": "contact.created",
"created": 1798329600,
"data": {
"contact": {
"id": "con_01H...",
"wa_id": "919876543210",
"name": "Priya Sharma",
"phone": "+919876543210",
"email": "[email protected]",
"source": "import",
"source_channel": "whatsapp",
"opted_in": true,
"created_at": "2026-08-26T09:00:00.000Z"
}
}
}
Lead events
lead.created fires from all six lead sources: Meta lead ads, WhatsApp forms, VGraple web forms, web chat, comments, and the API. lead.stage_changed fires when a lead moves to a different pipeline stage, whether by dragging it on the Kanban board or editing it in the detail panel; bulk housekeeping moves (like a fallback move after a stage is deleted) intentionally do not fire it.
{
"id": "evt_7d21fa30ce998c11",
"type": "lead.created",
"created": 1798329600,
"data": {
"lead": {
"id": "lead_01H...",
"name": "Asha Patel",
"phone": "+919876543210",
"email": "[email protected]",
"source": "meta_lead_ad",
"stage_id": "stage_01H...",
"stage_name": "New",
"pipeline_id": "pipe_01H...",
"contact_id": "con_01H...",
"form_name": "Monsoon Offer Form",
"campaign_name": "Monsoon Sale 2026",
"created_at": "2026-08-26T09:00:00.000Z"
}
}
}
Broadcast events
Broadcast events are campaign-level, not per-recipient, except broadcast.reply, which fires once per recipient who responds. A campaign reaching 10,000 contacts still fires exactly one broadcast.completed event, so your endpoint never absorbs a burst sized to the audience.
| Event | Fires when |
|---|---|
broadcast.started | A broadcast campaign begins sending |
broadcast.paused | A broadcast auto-parks (template pause, portfolio pacing, red quality rating, quota reached, or a high early failure rate) |
broadcast.completed | A broadcast finishes, including crash-recovery completion after a deploy |
broadcast.failed | A broadcast ends in a failed state |
broadcast.reply | A recipient replies to a broadcast |
{
"id": "evt_a1f9c02de5b73401",
"type": "broadcast.completed",
"created": 1798329600,
"data": {
"broadcast": {
"id": "bc_01H...",
"name": "Monsoon Flash Sale",
"status": "completed",
"total_recipients": 3200,
"sent": 3180,
"delivered": 3050,
"failed": 20
}
}
}
Conversation events
conversation.assigned fires on a manual assignment or an SLA-driven auto-assign. conversation.resolved fires when an agent marks a conversation resolved (CSAT survey mechanics do not re-fire it). conversation.reopened fires on an agent reopening a resolved conversation, or a customer sending a new message that reopens one (a CSAT rating tap does not count).
{
"id": "evt_5b0e21ac8f34d902",
"type": "conversation.assigned",
"created": 1798329600,
"data": {
"conversation_id": "conv_01H...",
"contact": { "id": "con_01H...", "name": "Priya Sharma" },
"assigned_to": { "id": "usr_01H...", "name": "Rohan" }
}
}
Form events
form.submitted fires when a VGraple web form is submitted, the same form-builder feature covered in building web forms.
{
"id": "evt_e40b19c3a276f508",
"type": "form.submitted",
"created": 1798329600,
"data": {
"form_id": "form_01H...",
"form_name": "Contact Us",
"lead_id": "lead_01H...",
"is_new_lead": true,
"name": "Asha Patel",
"email": "[email protected]",
"phone": "+919876543210",
"page_url": "https://example.com/contact",
"submitted_at": "2026-08-26T09:00:00.000Z"
}
}
What you will see
A well-formed delivery always parses as the envelope above; data's inner shape depends only on type, and shares fields consistently across events for the same underlying resource (every message-related event uses the same message object, for instance). There is no versioning field in the envelope today, so treat any new field VGraple CRM adds to an existing payload as additive rather than a breaking change.
Settings and options
| Field | Applies to | Notes |
|---|---|---|
id | Every event | Unique per delivery attempt, prefixed evt_ |
type | Every event | Matches one of the 15 catalog names exactly |
created | Every event | Unix timestamp, seconds |
data | Every event | Event-specific object, documented above per event family |
source (Settings > Webhooks list, not in the payload) | Endpoint metadata | "zapier" if created by a Zap turning on, otherwise unset |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| A field you expected is missing from a payload | The field is only present when applicable (for example template_name is null on a non-template send) | Handle null and missing optional fields defensively rather than assuming every field is always populated |
You subscribed to broadcast.reply but see no deliveries | No recipient has replied yet, or the reply landed outside the tracked broadcast attribution window | Confirm the broadcast actually has replies on its detail page in the UI before assuming the webhook is broken |
You want broadcast.started but it is not in the Settings checkbox list | The Settings UI form currently exposes 11 of the 15 catalog events | Use POST /api/v1/hooks with your API key and "events": ["broadcast.started"] in the body; see the REST API quickstart |
| Two deliveries for what looks like the same event | Webhooks are delivered at-least-once; a retry after a transient failure can occasionally overlap with a fresh delivery | Deduplicate on id in your own system if exact-once processing matters to you |
contact.created never fires for your web chat widget visitors | This is by design, not a bug | Wait for the visitor to identify themselves (name, phone or email); a placeholder web-chat contact never fires the event |
Once you can parse a payload, confirm it is authentic with signature verification, and see configuring webhooks to manage which endpoints receive which events.