Skip to main content

Set up the Custom Webhook integration

The Custom Webhook integration sends charles events to an HTTPS endpoint you control, in real time. Use it to keep your own database, CRM or data warehouse in sync with what happens in charles contact changes, subscription changes, and message engagement.

Before you start

You will need:

  • An HTTPS endpoint that is reachable from the public internet. Plain HTTP, private, or internal addresses are rejected.

  • A fast response. Your endpoint must reply within 10 seconds with any 2xx status. Acknowledge the request first, then process it afterwards.

  • Authentication details (optional). HTTP Basic credentials or a custom header, if you want to verify that requests come from charles.

Not ready to build an endpoint yet? You can create a temporary one in seconds with a service like Napkin.io or webhook.site, point charles at it, and watch the events arrive. These are fine for testing, but use your own endpoint before going live.

One thing to know up front: when you save the integration, charles immediately sends a verification request to your URL — a POST with an empty body. Your endpoint must answer it with a 2xx status, or the setup fails. If your endpoint validates the request body, make sure it allows an empty one on this first call.

Step 1 — Install the integration

  1. Go to Settings → Integrations.

  2. Find Custom Webhook and click Connect.

  3. Fill in the connection details:

    • URL — your HTTPS endpoint, for example https://api.example.com/charles/events

    • AuthenticationNo authentication or HTTP Basic

    • Username and Password — only when you pick HTTP Basic

    • Headers — optional. Add up to 20 custom headers, sent on every request. A shared secret header is a good way to confirm a call came from charles.

  4. Click Save.

The URL and authentication cannot be changed later. They are verified when the integration is installed, so charles locks them afterwards. To use a different endpoint or different credentials, delete this webhook and install a new one. Headers and event selection stay editable.

Step 2 — Choose which events you receive

On the integration's page, the Events card controls what charles sends. Nothing is sent for an event type you have not switched on.

  • Contact events — new contact, custom properties, tags, email, address, and so on, plus GDPR deletions

  • Subscription events — subscribed to WhatsApp, unsubscribed from WhatsApp

  • Engagement events — WhatsApp message delivered, read, clicked

  • Message events — every message exchanged between a contact and your business: inbound and outbound.

  • Order events — a new order is placed by a contact charles already knows.

Switch on what you need and click Save.

Step 3 — Send a test event

Click Test in the top right of the integration page and pick an event type. charles immediately sends one sample event of that type to your saved URL — with the same headers, authentication, and path as a real event.

You will see whether your endpoint accepted it, how long it took, and the exact payload that was sent, which you can copy.

A few useful details:

  • Test events carry "test": true at the top level. Real events leave the field out entirely, so you can drop, divert, or process test events as you prefer.

  • You can test an event type before switching it on, and while the integration is disabled.

  • A failed test never disables your integration — it is only a diagnostic.

  • Wait a few seconds between tests; there is a short rate limit per integration.

What charles sends

Every event is a single POST with a JSON body. All events share the same four top-level fields:

{   
"id": "9f1c2f7a-7d24-4c2e-9a4a-2c3f5f6d1b90",
"timestamp": "2026-08-04T09:12:33.412Z",
"type": "contact",
"payload": { "action": "contact.updated", "…": "…" }
}
  • id — unique per event and stable across retries. Use it to deduplicate.

  • timestamp — when the event happened in charles, not when it was delivered.

  • typecontact, optin, or engagement. Switch on this to pick a handler.

  • payload — the event itself, always containing an action. Switch on action inside your handler.

New fields are added over time, so ignore anything you do not recognise rather than rejecting the request.


Contact events

type: "contact", action: "contact.updated". Sent whenever a contact changes. The payload is a full snapshot of the contact after the change, not a list of what changed.

{   
"id": "3b7f0a1e-6c55-4f0b-b3a1-9d0e1f2a3b4c",
"timestamp": "2026-08-04T09:12:33.412Z",
"type": "contact",
"payload": { "action": "contact.updated",
"person_id": "00000000-0000-0000-0000-000000000001",
"first_name": "Alex",
"last_name": "Beispiel",
"emails": ["[email protected]", "[email protected]"], "phone_numbers": ["+490000000000"],
"date_of_birth": "1990-01-01",
"tags": ["vip", "newsletter"],
"addresses": [
{
"lines": ["Musterstrasse 1"],
"locality": "Berlin",
"region": null,
"postalCode": "10115",
"country": "DE",
"type": "billing",
"company": null,
"firstName": "Alex",
"lastName": "Beispiel",
"phone": null,
"comment": null
}
],
"custom_properties": [
{ "name": "Favourite category", "value": "skincare" },
{ "name": "Loyalty points", "value": 240 },
{ "name": "Interests", "value": ["serum", "cleanser"] }
],
"channels": [{ "name": "whatsapp", "id": "+490000000000" }],
"opt_in": {
"name": "Newsletter",
"status": "granted",
"type": "single_opt_in",
"channel": "whatsapp",
"timestamp": "2026-01-01T00:00:00.000Z"
}
}
}
  • person_id is the contact's id in charles. It does not change — use it as your join key.

  • emails and phone_numbers list every stored value, newest first, so the first entry is the current one.

  • custom_properties values keep their own type: text is a string, a checkbox is a boolean, a number is a number, a multiselect is an array, and currency or range properties are objects such as { "amount": 49.9, "currency": "EUR" }. Do not assume they are strings.

  • channels holds the contact's identity per channel — name is the channel, id is the identifier on that channel.

  • Every field can be empty or null, since contacts are rarely complete.


Subscription events

type: "optin", action: "opt_in.updated". Sent when a contact subscribes, unsubscribes, or is denied. Use this to drive your own consent state.

{   "id": "8c2d4e6f-1a3b-4c5d-8e9f-0a1b2c3d4e5f",   
"timestamp": "2026-08-04T09:15:02.004Z",
"type": "optin",
"payload": {
"action": "opt_in.updated",
"person_id": "00000000-0000-0000-0000-000000000001",
"name": "Newsletter",
"status": "withdrawn",
"type": "single_opt_in",
"channel": "whatsapp",
"timestamp": "2026-08-04T09:15:01.980Z"
}
}
  • status is what you branch on: granted, withdrawn, or denied. The action is the same for all three.

  • type is single_opt_in or double_opt_in.

  • channel is the channel the consent applies to. Consent is per channel — someone who unsubscribes from WhatsApp may still be subscribed elsewhere, so key your records on person_id together with channel.

  • The timestamp inside the payload is when the opt-in itself changed, which can differ slightly from the event timestamp.


Engagement events

type: "engagement", with an action of message.delivered, message.read, or message.clicked. Sent when one of your outbound messages reaches or is opened by a contact.

{   "id": "b1a2c3d4-e5f6-4718-9a0b-1c2d3e4f5a6b",   
"timestamp": "2026-08-04T09:20:41.318Z",
"type": "engagement",
"payload": {
"action": "message.clicked",
"person_id": "00000000-0000-0000-0000-000000000001",
"resources": [
{ "type": "message", "payload": { "id": "5f8c…", "pricing": "paid" } },
{ "type": "campaign", "payload": { "id": "0c3a…", "name": "Summer sale" } },
{ "type": "template", "payload": { "id": "77b1…", "name": "summer_sale_v2" } },
{ "type": "cta_button", "payload": { "label": "Shop now", "url": "https://example.com/sale" } }
]
}
}

The resources array describes what the event is about. Find entries by their type — the order is not guaranteed:

  • message — always present. { id, pricing }, where pricing is paid or free

  • campaign, flow, or agent — where the message came from. Exactly one of the three

  • template — the message template used, when there was one

  • cta_button{ label, url }, present when a link button was clicked

  • quick_reply_button{ label }, present when a quick-reply button was tapped

Both button taps report message.clicked; the resource tells you which kind it was.


Message events

The Messages topic sends your systems an event for every message exchanged between a contact and your business: both inbound and outbound.


Every event carries a payload.action field that tells you which way the message travelled:

  • message.received — a message from the contact to your business.

  • message.sent — a message your business sent, from any source (broadcast, flow, AI agent, inbox agent, or API).

Identifying the message and the contact

Each event includes a set of fields to identify who was involved and which message it was:

Field

What it is

message_id

charles's own ID for the message.

external_message_id

The WhatsApp message ID (wamid…) — the same ID you see in Meta. Use it to match events against your own WhatsApp data. May be null in rare cases.

person_id

The charles contact. May be null if the message couldn't be matched to a known contact.

from / to

The two phone numbers, in international format. On message.received, from is the contact; on message.sent, from is your business number.

channel

The channel the message travelled on, e.g. whatsapp.

timestamp

When the message happened. Note: the timestamp at the top of the envelope is when charles created the event, which can be slightly later.

What kind of message it is

The content.type field tells you the message format. It's one of:

text, image, video, audio, document, sticker, location, file, flow_response, or mixed (when one message carries more than one kind of attachment).

It can also be null for a message that carries neither text nor an attachment.


Where an outbound message came from

For message.sent events, the context object hints at what produced the message:

Field

What it is

broadcast_execution_id

The broadcast send this message belongs to.

recurring_trigger_id

The recurring trigger that started it, if any.

flow_id

The flow that sent it.

ai_agent_id

The AI agent that sent it.

button_id

Set when the contact tapped a button.

campaign_id

Legacy campaign ID. Prefer broadcast_execution_id for new integrations.

Treat context as a hint, not a guarantee. Every one of these fields can be null, and some outbound messages arrive with all of them null.

Example event

{
"id": "5cbc447287650e5eb07cf9954e117cea",
"timestamp": "2026-08-12T14:39:46.000Z",
"type": "message",
"payload": {
"action": "message.received",
"message_id": "3c2cb684-bc6a-411f-93a2-bcb2a066d9f4",
"external_message_id": "wamid.HBgMNDM2NzY5MDIxNDMz...",
"person_id": "33d88cfa-8fe5-4bd8-871f-6df2d040ce0f",
"channel": "whatsapp",
"from": "+436769021433",
"to": "+443300274178",
"timestamp": "2026-08-12T14:39:44.000Z",
"content": {
"type": "text",
"body": "Do you ship to Austria?"
},
"context": {
"broadcast_execution_id": null,
"recurring_trigger_id": null,
"campaign_id": null,
"flow_id": null,
"ai_agent_id": null,
"button_id": null
}
}
}


Important things to know

"Sent" is not "delivered"

Messages and Engagement are separate topics that you subscribe to independently.

  • Messages tells you a message exists. message.sent fires when charles has handed the message to WhatsApp.

  • Engagement tells you what happened to it afterwards — message.delivered, message.read, message.clicked.

A message.sent event is not a guarantee of delivery. If you need delivery confirmation, subscribe to Engagement as well.


Messages sent outside charles are not included

Only messages charles itself handled are exported. If a teammate replies from the Meta Business Suite inbox, or another tool sends on the same WhatsApp number, you will not receive a message.sent event for it. This is deliberate.


Media and locations carry no content

Attachments are described, not delivered. For a message with media, content.attachments lists each attachment's type and mime_type — and nothing else. charles never sends a link to the file. The media stays in charles; to retrieve it, use your own WhatsApp Business API access.

"content": {
"type": "image",
"body": null,
"attachments": [{ "type": "image", "mime_type": "image/jpeg" }]
}

Location messages carry no coordinates. A shared location arrives as {"type": "location", "mime_type": null} with no latitude or longitude. This is deliberate — if you need the coordinates, read them from your own WhatsApp Business API access.


Working with WhatsApp Flow responses

When a contact submits a WhatsApp Flow, you receive a message.received event shaped like this:

"content": {
"type": "flow_response",
"flow_name": "order_form",
"reply_to_external_message_id": "wamid.HBgMNDM2NzY5MDIxNDMz..."
}

Two things to keep in mind:

  • The values the contact typed are not included. charles deliberately omits them.

  • flow_name is often not useful. WhatsApp frequently reports it as the literal string "flow" rather than the flow's real name.

How to identify which flow a response belongs to

To reliably match a response to its flow, follow the reply_to_external_message_id. It's the external_message_id of the outbound message that offered the flow — and that message's own message.sent event carries context.flow_id. So the process is:

  1. Store external_message_id and context.flow_id from every message.sent event you receive.

  2. When a flow_response arrives, look up its reply_to_external_message_id in that store.

  3. The matching record's context.flow_id is the flow the contact responded to.

This means you need to be subscribed to Messages before the flow is offered in order to resolve a response to it later.


Order events

type: "order", with an action of order.created. Sent when a new order is placed by a contact charles already knows.

{
"id": "547f39d06012ffb6d6831b88ee923d3c",
"timestamp": "2026-08-17T12:31:01.437Z",
"type": "order",
"payload": {
"action": "order.created",
"order_id": "00000000-0000-0000-0000-000000000003",
"external_order_id": "5555555555",
"order_number": "#1234",
"order_custom_id": "1234",
"person_id": "00000000-0000-0000-0000-000000000001",
"email": "[email protected]",
"phone": "+490000000000",
"external_person_id": "4444444444",
"vendor": "shopify",
"storefront_id": "00000000-0000-0000-0000-000000000004",
"currency": "EUR",
"amount_total_gross": "129.90",
"amount_total_net": "109.16",
"amount_total_tax": "20.74",
"amount_total_shipping_gross": "4.90",
"discount_amount_total": "10.00",
"status": null,
"financial_status": "paid",
"fulfillment_status": null,
"timestamp": "2026-08-17T12:31:01.223Z"
}
}

Identifying the order and the contact

The payload gives you several IDs so you can match the order and the contact against your own systems:

  • order_id — the order's ID in charles. Always present.

  • external_order_id — the order's ID in your shop system. This is usually what you'll match on.

  • order_number and order_custom_id — the order number as your shop formats it (for example #1234) and the same number unformatted. Which one is filled depends on your platform.

  • person_id — the contact in charles. Always present, and the same ID used by contact and subscription events.

  • external_person_id — the customer's ID in your shop system. It's only meaningful alongside vendor and storefront_id, and it can be null.

Contact details

email and phone hold the contact's current details as charles has them — not necessarily what was typed at checkout. If a contact has several, you receive the most recently updated one. Either field can be null. To get the full list, subscribe to contact events and match on person_id.

Amounts and currency

Amounts are sent as strings with two decimal places (for example "129.90") so no precision is lost in transit. Any amount can be null when your shop doesn't send that figure. currency applies to every amount in the payload.

Order status

To check the state of an order, use financial_status (for example paid, pending, refunded) and fulfillment_status (for example fulfilled, partially_fulfilled). The separate status field is often null and shouldn't be relied on.

Timestamps

As with other event types, there are two timestamps: the one at the top of the envelope is when charles created the event, and the one inside payload is when the order was placed.

Which orders you receive

  • Only orders that charles could link to one of your contacts. An order from a shopper charles doesn't recognise produces no event.

  • On Shopify, orders from shoppers who haven't given consent aren't stored by charles at all, so no event is sent for them. This is intentional, and it means your order events will be a subset of the orders in your shop. Other platforms don't filter this way.

  • Only new orders. Later changes to an order — payment, fulfilment, cancellation, or refund — don't trigger an event.

  • Historical imports and syncs are silent. Connecting a shop, or re-syncing one, doesn't replay past orders to your endpoint.

What's deliberately not included

The payload describes the order, not its contents: there are no line items, no shipping or billing address, and no link to the order status page. Your shop system is the source of truth for those, and the order IDs above let you look them up. If your use case needs any of this, let us know — we'd rather add it knowingly than by default.


Contact deletion events

type: "contact", action: "contact.deleted". Sent when a contact is erased from charles under a GDPR request — either by someone erasing that contact from their profile in charles, or by your support contact erasing a whole list of them.

It arrives on the same Contact events subscription as contact.updated, so if you already receive contact events you will start receiving these too. Branch on payload.action to tell them apart.

{
"id": "04aa14976b99c1216201586482be9547",
"timestamp": "2026-08-19T09:06:12.958Z",
"type": "contact",
"payload": {
"action": "contact.deleted",
"person_id": "00000000-0000-0000-0000-000000000001"
}
}

  • person_id is the only field, and it is the same id you received in earlier contact.updated events. Match on it to find your own copy of the contact and delete it.

  • No contact details are included — no name, email, phone number, address, tag, custom property or opt-in. The contact asked to be forgotten, so the event that reports it carries nothing that could identify them.

  • timestamp is when the erasure was requested, not when the delivery was attempted.

  • One event per contact. Erasing a list of 500 contacts sends 500 events, not one event listing 500 ids.

Important things to know

  • The erasure is final. There is no matching "restored" event, and the contact will never appear in a later contact.updated event. Treat this as your signal to delete, not to mark inactive — the request behind it is a legal one.

  • Each erasure is announced once. If the same deletion is run again, nothing is sent, because there is no longer a contact to erase.

  • Only GDPR erasures are sent. Contacts that charles removes for internal housekeeping do not produce this event, and neither does a contact simply unsubscribing — that is a opt_in.updated event with a status of withdrawn.

  • You may receive this for a contact you never saw. A contact only produces contact.updated events once they have a subscription in charles; an erasure is reported for every contact regardless. Ignore an id you do not recognise.


Responding to events

Reply with any 2xx status to acknowledge. The response body is ignored. Because the timeout is 10 seconds, acknowledge first and do your processing afterwards.

Your response

What charles does

2xx

The event is delivered. Nothing further happens

408, 429, or any 5xx

charles retries — up to 3 attempts, waiting longer between each, starting at 30 seconds. The same id comes back

No response, timeout, connection or TLS error

Also retried, on the same schedule

Any other 4xx

Not retried. The event is dropped — a 4xx means you understood the request and rejected it

401 or 403

Not retried, and the integration is paused, because the credentials no longer work. Fix them and re-enable it in charles

A few consequences worth designing for:

  • Make your handler idempotent. Delivery is at-least-once, so the same id can arrive more than once.

  • Do not rely on the order events arrive in. Compare the timestamp before overwriting your own data.

  • If you want an event redelivered, answer with a 5xx, not a 4xx.

Managing the integration

  • Change headers or event selection — edit them on the integration page and click Save.

  • Pause deliveries — open the menu and choose Disable. A disabled webhook receives nothing, and nothing is replayed when you switch it back on.

  • Change URL or credentials — delete the integration and install it again with the new details.

  • Remove it menu, then Delete.

Troubleshooting

What you see

What it usually means

"charles couldn't reach this URL" when saving

The endpoint is not publicly reachable, is not HTTPS, or did not answer the verification request within 10 seconds. Remember that request has an empty body

Setup fails but your endpoint looks fine

Check that it returns 2xx for a POST with an empty body, and that no firewall or bot protection blocks the call

The integration shows as paused

Your endpoint answered 401 or 403. Check the credentials, then re-enable the integration

No events arriving

Check that the event type is switched on in the Events card, that the integration is enabled, and send a test event to confirm the connection

Some events missing

Events are only sent for types you subscribed to. Note that engagement events cover WhatsApp message delivered, read, and clicked only

The same event twice

Expected under retries — deduplicate on the event id

Frequently asked questions

  • How do I verify a request really came from charles?
    Use HTTP Basic authentication, a secret custom header, or both. charles sends them on every delivery, including test events.

  • Can I receive events at more than one endpoint?
    No. You can install one Custom Webhook per workspace, and it sends to a single URL. If you need several destinations, receive the events at one endpoint and fan them out on your side.

  • Are events batched?
    No — one event per request.

  • Can I replay past events?
    No. Events are sent as they happen; a disabled webhook misses everything sent while it was off.

  • Is there a rate limit on what I receive?
    Deliveries follow your contacts' activity, so volume tracks your traffic. Only the manual Test button is rate limited.

  • Will I be told when a contact is deleted?
    Yes, if you receive Contact events: a GDPR erasure sends contact.deleted with the contact's person_id and nothing else. Deletions that charles performs for internal housekeeping are not included.

Did this answer your question?