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
Go to Settings → Integrations.
Find Custom Webhook and click Connect.
Fill in the connection details:
URL — your HTTPS endpoint, for example
https://api.example.com/charles/eventsAuthentication — No 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.
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
Subscription events — subscribed to WhatsApp, unsubscribed from WhatsApp
Engagement events — WhatsApp message delivered, read, clicked
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": trueat 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.type—contact,optin, orengagement. Switch on this to pick a handler.payload— the event itself, always containing anaction. Switch onactioninside 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_idis the contact's id in charles. It does not change — use it as your join key.emailsandphone_numberslist every stored value, newest first, so the first entry is the current one.custom_propertiesvalues 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.channelsholds the contact's identity per channel —nameis the channel,idis 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"
}
}statusis what you branch on:granted,withdrawn, ordenied. The action is the same for all three.typeissingle_opt_inordouble_opt_in.channelis the channel the consent applies to. Consent is per channel — someone who unsubscribes from WhatsApp may still be subscribed elsewhere, so key your records onperson_idtogether withchannel.The
timestampinside 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 }, wherepricingispaidorfreecampaign,flow, oragent— where the message came from. Exactly one of the threetemplate— the message template used, when there was onecta_button—{ label, url }, present when a link button was clickedquick_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.
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 |
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 |
Two consequences worth designing for:
Make your handler idempotent. Delivery is at-least-once, so the same
idcan arrive more than once.Do not rely on the order events arrive in. Compare the
timestampbefore 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 |
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.





