Register an HTTPS endpoint and Chirply POSTs each platform event to it as it happens — new contacts, inbound messages, won deals, paid invoices. Every delivery is HMAC-signed so your server can prove it came from us, and every event is also retained in a pull stream you can reconcile against, so nothing is ever lost to a missed POST.
One call registers an endpoint for one or more events. It needs a key with the write scope, the URL must be HTTPS with a fully-qualified public hostname and no embedded credentials, and you can name up to 30 events per call (up to 200 active subscriptions per workspace).
curl -X POST https://app.chirply.io/api/v1/webhooks \
-H "Authorization: Bearer chp_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/chirply",
"events": ["contact_created", "message_received", "invoice_paid"]
}'HTTP/1.1 201 Created
{
"data": {
"subscriptions": [
{ "id": "…", "event": "contact_created", "url": "https://hooks.example.com/chirply", "active": true, "created_at": "…" },
{ "id": "…", "event": "message_received", "url": "https://hooks.example.com/chirply", "active": true, "created_at": "…" },
{ "id": "…", "event": "invoice_paid", "url": "https://hooks.example.com/chirply", "active": true, "created_at": "…" }
],
"secret": "whsec_…", ← shown ONCE, store it now
"signature": "X-Chirply-Signature: t=<unix>,v1=HMAC-SHA256(secret, `${t}.${rawBody}`) hex",
"deliveries_url": "/api/v1/webhooks/{id}/deliveries"
}
}One registration creates one subscription row per event, and all of them share the one whsec_… signing secret in that response. The secret is returned exactly once — we store only an encrypted form and there is no way to read it back, so store it when you see it. Registering an event that is already subscribed at the same URL is a 409, not a second delivery.
Manage subscriptions the same way: GET /api/v1/webhooks lists them (plus every subscribable event name), GET /api/v1/webhooks/:id fetches one, and DELETE /api/v1/webhooks/:id unsubscribes — deliveries stop immediately.
Prefer clicking? The same subscriptions are managed in the app under Developers → Webhooks → Add webhook, and by any AI agent through the webhooks.subscribe, webhooks.list and webhooks.unsubscribe operations — one registry behind all three surfaces.
Webhook events are the automation trigger names — the same vocabulary the visual workflow builder fires on, delivered from the same dispatch seam. This table is rendered from the live taxonomy, so it is exactly what the API will accept.
| Event | Fires when |
|---|---|
| message | |
| gmail_message_received | New incoming mail in a connected Gmail account. Historical imports, drafts, sent mail, spam and trash never start this trigger. |
| facebook_message_received | A person enters a connected Facebook Page conversation by message, button, referral link, or ad. |
| facebook_messenger_event_received | A connected Messenger thread reports account linking, feedback, a game play, opt-in, customer information, or an in-thread lead form submission. |
| facebook_comment_received | A person comments on a connected Facebook Page post or an eligible ad post delivered through Meta's Page feed webhook. |
| instagram_message_received | A person enters a connected Instagram conversation by DM, story interaction, button, referral link, or ad. |
| instagram_comment_received | A person comments on a post from a connected Instagram Professional account. |
| whatsapp_message_received | A customer messages a connected WhatsApp Business number. |
| message_received | The contact replies by SMS, email, Facebook Messenger, Instagram DM, or WhatsApp. STOP/unsubscribe replies are excluded. |
| call_completed | A call finishes. |
| call_answered | A contact answers an inbound or outbound call. |
| call_missed | A call is busy, unanswered, or fails to connect. Inbound misses fire even when the caller is brand new — a contact is created so the workflow has someone to enroll — and whether or not the line's own missed-call text back sent. Tokens: {{caller}}, {{number}}. |
| call_queue_added | A contact is put on a call queue — by hand, by an automation, or through the API. |
| call_queue_called | A rep finishes a call with someone on a call queue and records the outcome. |
| call_dispositioned | Someone says how a call went — from the post-call prompt, the power dialer, the call log, or the API. Fires again if the outcome is later corrected, so the automation acts on the answer that is true now. |
| call_analyzed | The AI finishes reading a call — a few minutes after it ends — and reports how it went. Runs only on lines with call analysis switched on. Tokens: {{summary}}, {{sentiment}}, {{topicsText}}, {{objectionsText}}, {{nextStep}}. |
| voice_campaign_result | An outbound IVR or call-blast recipient reaches a final answer result. |
| message_sent | Your team or an automation sends the contact an SMS or email. |
| conversation_status_changed | A conversation is opened, closed, or snoozed. |
| conversation_owner_changed | A conversation is assigned or reassigned to a team member. |
| contact | |
| google_sheets_row_new | A watched worksheet changed after its silent initial baseline. Set up a watch in Settings → Integrations → Google Sheets, then select its ID. Rows use an immutable unique key; changes between polls may coalesce. |
| google_sheets_row_updated | A watched worksheet changed after its silent initial baseline. Set up a watch in Settings → Integrations → Google Sheets, then select its ID. Rows use an immutable unique key; changes between polls may coalesce. |
| custom_record_created | A custom-object record was created. Runs with its linked contact when present. Event context includes recordId, objectKey, values, previousValues and changedFields. Existing records are not replayed. |
| custom_record_updated | A custom-object record was updated. Runs with its linked contact when present. Event context includes recordId, objectKey, values, previousValues and changedFields. Existing records are not replayed. |
| custom_record_deleted | A custom-object record was deleted. Runs with its linked contact when present. Event context includes recordId, objectKey, values, previousValues and changedFields. Existing records are not replayed. |
| contact_created | A new contact is added — by hand, file upload, tracking script, API, or signup. |
| birthday | On, before, or after each contact's birthday. |
| tag_added | A tag is applied to a contact. |
| tag_removed | A tag is removed from a contact. |
| list_membership_added | A contact is added to a static list. |
| list_membership_removed | A contact is removed from a static list. |
| contact_lifecycle_changed | A contact becomes a lead, active contact, customer, or churned customer. |
| contact_owner_changed | A contact is assigned or reassigned to a team member. |
| task_created | A follow-up task is created for the contact. |
| task_completed | A task linked to the contact is marked done. |
| note_added | A note is added to the contact's timeline. |
| form_submitted | A contact submits any native form or a form on a funnel page. |
| facebook_lead_received | Automatically saves or matches a contact before this automation starts when a connected Facebook or Instagram instant form is submitted. |
| member_access_granted | A contact receives access to a members product. |
| member_access_revoked | A members product is taken away from a contact. |
| pipeline | |
| deal_stage_changed | A deal moves stages. |
| deal_created | A new opportunity is opened for a contact. |
| deal_won | A contact's deal is marked won. |
| deal_lost | A contact's deal is marked lost or abandoned. |
| deal_reopened | A closed deal is moved back to open. |
| deal_at_risk | The AI's deal check-up finds a deal newly in trouble — its health drops to At risk or Stalled. Fires once per downturn, not again on every re-check of a deal that is already in trouble. Tokens: {{dealTitle}}, {{dealValue}}, {{riskReasons}}, {{winProbability}}. |
| projects | |
| project_task_completed | A top-level project task is marked complete, from the board, the task form, the API or another workflow. Runs without a contact. |
| project_subtask_completed | A subtask is marked complete; {{parentTaskTitle}} names its task. Runs without a contact. |
| project_task_moved | An existing project task or subtask moves to another board column. Runs without a contact. |
| commerce | |
| purchase_made | A funnel or store order is paid. To filter by product or collection, or read every line, use Order paid. |
| upsell_accepted | A buyer adds a one-click upsell or downsell. |
| order_paid | A store or funnel order is paid. Every line comes with its SKU, variant, quantity and amounts, plus the buyer's email. Tokens: {{order.number}}, {{order.total_amount}}, {{order.lines.0.name}}, {{buyerEmail}}. |
| order_refunded | A full or partial refund is recorded on a store or funnel order — from the order screen, the API or Stripe. Tokens: {{refund.amount}}, {{refund.reason}}, {{order.refunded_amount}}. |
| order_cancelled | A store or funnel order is cancelled from the order screen, the API, or by its payment being cancelled in Stripe. Tokens: {{order.cancel_reason}}. |
| shopify_checkout_abandoned | A Shopify checkout is left incomplete. Narrow by store, value, product, vendor, or product type. |
| shopify_order_created | A new Shopify order is placed. Tokens include order, customer, product, value, discount, and attribution data. |
| shopify_first_order | A customer places their first order in this Shopify store. |
| shopify_repeat_order | An existing customer places another Shopify order. |
| shopify_order_paid | A Shopify order becomes paid. |
| shopify_order_fulfilled | A Shopify order is fulfilled and ready for post-purchase follow-up. |
| shopify_order_cancelled | A Shopify order is cancelled. This can also be used as a stop trigger. |
| shopify_refund_created | A full or partial Shopify refund is created. This can stop or redirect post-purchase sequences. |
| website | |
| website_visit | A known contact returns to your tracked website (once per visit, not per page). |
| conversion | A lead or sale is reported from your website — via tracker.convert() or the Sale/Lead pixel — optionally above a value. |
| website_identified | An anonymous website visitor becomes a contact. |
| scheduling | |
| appointment_booked | Someone books a time on one of your calendars. |
| appointment_canceled | A booked appointment is canceled by the invitee or your team. |
| appointment_rescheduled | An invitee moves their appointment to a new time. |
| appointment_no_show | An appointment is marked as a no-show. |
| payments | |
| stripe_payment_succeeded | A payment on your connected Stripe account succeeds. Tokens: {{amount}}, {{currency}}, {{description}}. |
| stripe_payment_failed | A payment on your connected Stripe account fails (e.g. a declined card). Tokens: {{amount}}, {{currency}}. |
| stripe_refund_issued | A charge on your connected Stripe account is refunded. Tokens: {{amount_refunded}}, {{currency}}. |
| stripe_subscription_created | Someone starts a subscription on your connected Stripe account. Tokens: {{plan}}, {{amount}}, {{interval}}. |
| stripe_subscription_canceled | A subscription on your connected Stripe account ends (churn). Tokens: {{plan}}, {{status}}. |
| stripe_dispute_created | A customer disputes a charge (chargeback) on your connected Stripe account. Tokens: {{amount}}, {{currency}}. |
| invoice_paid | A contact pays one of your invoices or checkout orders. |
| invoice_payment_failed | A payment attempt on one of your invoices fails. |
| email_opened | A contact opens one of your campaign emails. |
| email_clicked | A contact clicks a link in one of your campaign emails. Token: {{url}}. |
| email_bounced | An email hard-bounces (a permanent failure). The address is suppressed. |
| email_complained | A contact marks one of your emails as spam. The address is suppressed. |
| email_unsubscribed | A contact unsubscribes from your emails — via the footer link, the preference centre, or a spam-free opt-out. |
| community | |
| community_post_created | A member (or your team) publishes a new post in one of your community groups. |
| community_comment_created | A member (or your team) comments on a community post. |
| course_enrolled | A contact is enrolled in one of your courses — by themselves, your team, or an automation. |
| lesson_completed | A contact finishes a lesson in one of your courses. |
| course_completed | A contact finishes every lesson in a course. Fires once per person per course. |
| member_level_up | A community member earns enough points to reach a new level. |
| other | |
| app_event | An installed app fired one of its own events (via events.emit). Narrow it to a specific app and event key. |
| gohighlevel_event | A signed event arrived from HighLevel. Choose any documented Marketplace webhook event, or leave it open to every GHL event. |
| klaviyo_event | A signed live event arrived from the connected Klaviyo account. Leave the topic blank to match every topic the account exposes. |
Every payload shares one envelope — { event, org_id, contact_id, context, at } — with the event-specific fields inside context. For real payload samples, ask for your own workspace’s most recent deliveries of an event: GET /api/v1/webhooks/samples?event=contact_created returns up to three, byte-for-byte as they were sent. An event that has never fired in your workspace returns an empty list rather than an invented shape.
Order events — order_paid, order_refunded and order_cancelled — fire for store and funnel orders and carry the whole order: every line with its product, SKU, variant name and options, quantity, unit and line amounts and discount, plus totals and the buyer’s email. Amounts are in the currency’s smallest unit. Dedupe on context.event_id. To see one before a real sale, use Send test on the webhook in Developers (or webhooks.send_test): it delivers this example, marked "test": true.
{
"event": "order_paid",
"org_id": "00000000-0000-4000-8000-0000000000aa",
"contact_id": "00000000-0000-4000-8000-0000000000cc",
"context": {
"event_id": "order_paid:00000000-0000-4000-8000-000000000001",
"version": 1,
"order": {
"id": "00000000-0000-4000-8000-000000000001",
"number": 1042,
"status": "paid",
"source": "storefront",
"store_id": "00000000-0000-4000-8000-0000000000bb",
"funnel_id": null,
"currency": "gbp",
"subtotal_amount": 9000,
"discount_amount": 900,
"shipping_amount": 0,
"tax_amount": 0,
"total_amount": 8100,
"refunded_amount": 0,
"discount_code": "EARLYBIRD",
"buyer": {
"email": "sam@example.com",
"name": "Sam Taylor",
"phone": null,
"contact_id": "00000000-0000-4000-8000-0000000000cc"
},
"shipping_address": {},
"billing_address": {},
"customer_note": null,
"fulfillment_status": "unfulfilled",
"payment": {
"mode": "test",
"stripe_payment_intent_id": "pi_test_example",
"stripe_subscription_id": null
},
"terms": {
"accepted_at": "2026-10-03T12:00:00.000Z",
"url": "https://example.com/terms",
"label": "I agree to the terms and conditions",
"version": "2026-10-01"
},
"tax": {
"tax_amount": 0
},
"lines": [
{
"id": "00000000-0000-4000-8000-000000000011",
"product_id": "00000000-0000-4000-8000-000000000021",
"product_name": "Festival pass",
"name": "Festival pass — Weekend",
"sku": "FEST-WKND",
"variant": {
"id": "00000000-0000-4000-8000-000000000031",
"name": "Weekend",
"sku": "FEST-WKND",
"options": {
"pass": "Weekend"
}
},
"quantity": 3,
"unit_amount": 3000,
"subtotal_amount": 9000,
"discount_amount": 900,
"total_amount": 8100,
"refunded_quantity": 0,
"refunded_amount": 0,
"kind": "main",
"fulfillment_status": "unfulfilled",
"tax": null
}
],
"item_count": 3,
"created_at": "2026-10-03T12:00:00.000Z",
"paid_at": "2026-10-03T12:00:00.000Z",
"cancelled_at": null,
"cancel_reason": null
},
"refund": null,
"cancellation": null,
"orderId": "00000000-0000-4000-8000-000000000001",
"orderNumber": 1042,
"orderSource": "storefront",
"storeId": "00000000-0000-4000-8000-0000000000bb",
"funnelId": null,
"buyerEmail": "sam@example.com",
"buyerName": "Sam Taylor",
"amount": 8100,
"currency": "gbp",
"productIds": [
"00000000-0000-4000-8000-000000000021"
],
"skus": [
"FEST-WKND"
],
"productNames": [
"Festival pass"
],
"collectionIds": [
"00000000-0000-4000-8000-000000000041"
],
"collectionSlugs": [
"summer-events"
],
"collectionNames": [
"Summer events"
],
"refundAmount": null
},
"at": "2026-10-03T12:00:01.000Z"
}Each occurrence is one HTTPS POST per subscription, JSON body, with three Chirply headers:
POST https://hooks.example.com/chirply
Content-Type: application/json
User-Agent: Chirply-Webhooks/1
X-Chirply-Event: contact_created
X-Chirply-Delivery: 018f2c6e-… ← unique per delivery; dedupe on this
X-Chirply-Signature: t=1758120000,v1=5f8a…
{
"event": "contact_created",
"org_id": "…",
"contact_id": "…", ← null when the event has no contact
"context": { … }, ← event-specific fields
"at": "2026-09-17T14:00:00.000Z"
}X-Chirply-Event — the event name, so you can route before parsing.X-Chirply-Delivery — a unique id per delivery. Retries of the same delivery reuse it, so deduplicate on this id to make your handler idempotent.X-Chirply-Signature — t=<unix>,v1=<hex>, verified below.Every delivery also carries the same three values under neutral names — X-Webhook-Event, X-Webhook-Delivery and X-Webhook-Signature — which is what an agency's white-labelled Developers screen documents. Read whichever pair you prefer; the values are identical.
Any 2xx response within 10 seconds counts as delivered. Redirects are not followed. Respond fast and process after acknowledging — a handler that does its work before responding will time out on a slow day and be retried, so it must be idempotent anyway.
The signature is HMAC-SHA256(secret, `${t}.${rawBody}`), hex encoded, where t is the unix timestamp from the header and rawBody is the request body exactly as received — verify the raw bytes before any JSON parsing, because re-serialized JSON will not match. Compare with a constant-time function, and reject timestamps older than your tolerance (5 minutes is a good default) to blunt replay of a captured request. Each retry is signed fresh at send time, so a legitimate retry always carries a current t.
import crypto from "node:crypto";
export function verifySignature({ secret, header, rawBody, toleranceSeconds = 300 }) {
// header is the X-Chirply-Signature value: "t=<unix>,v1=<hex>"
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=", 2)));
const { t, v1 } = parts;
if (!t || !v1) return false;
// Reject stale timestamps so a captured delivery can't be replayed later.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: verify against the RAW body — parse JSON only after it passes.
app.post("/chirply", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifySignature({
secret: process.env.WEBHOOK_SECRET,
header: req.get("X-Chirply-Signature") ?? "",
rawBody: req.body.toString("utf8"),
});
if (!ok) return res.status(400).end();
res.status(200).end(); // acknowledge fast …
const event = JSON.parse(req.body); // … then do the work
});import hashlib, hmac, time
def verify_signature(secret: str, header: str, raw_body: bytes,
tolerance_seconds: int = 300) -> bool:
"""header is the X-Chirply-Signature value: 't=<unix>,v1=<hex>'."""
parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
try:
if abs(time.time() - int(t)) > tolerance_seconds:
return False
except ValueError:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask: request.get_data() is the raw body — verify BEFORE request.json.
@app.post("/chirply")
def chirply_hook():
if not verify_signature(WEBHOOK_SECRET,
request.headers.get("X-Chirply-Signature", ""),
request.get_data()):
return "", 400
return "", 200A delivery that fails — non-2xx, timeout, or connection error — is retried automatically: up to 6 attempts, each becoming eligible again 5 minutes after the last and picked up by the next pass of the delivery worker. After the sixth failure the delivery is marked dead and not retried further.
Watch it happen at GET /api/v1/webhooks/:id/deliveries — each delivery’s status (pending → delivered, or dead once retries run out), attempt count, and the last error, newest first. It answers “why is my endpoint silent” without a support ticket. The same log is on the Developers screen in the app.
Every event your subscriptions matched is also retained with its full payload in a cursor-paged stream, whether or not your endpoint answered. Walk it oldest-first with webhooks.read_events and you can recover anything you missed during an outage — or skip hosting an endpoint entirely and poll on your own schedule, which is also how AI agents (which can’t receive a POST) consume events over MCP:
curl -X POST https://app.chirply.io/api/v1/actions/webhooks.read_events \
-H "Authorization: Bearer chp_live_your_key" \
-H "Content-Type: application/json" \
-d '{"after": "<next_cursor from your last call>", "limit": 100}'Pass each response’s next_cursor as after on the next call to continue without re-reading or skipping. A subscription is still required — it is what declares which events to retain.
Secrets are minted per registration and never readable back, so rotation is a re-registration:
/chirply-v2) — same host, new secret, both delivering.DELETE the old subscription. Deliveries to the old path stop immediately.Doing it in that order means there is never a moment without a verifying endpoint. Deduplicate on X-Chirply-Delivery and the overlap window is harmless.
cloudflared tunnel --url http://localhost:3000 or ngrok http 3000 — and subscribe the tunnel URL.# Craft a signed delivery against your own verifier:
BODY='{"event":"contact_created","org_id":"test","contact_id":null,"context":{},"at":"2026-09-17T14:00:00.000Z"}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "whsec_your_secret" -hex | sed 's/^.* //')
curl -X POST http://localhost:3000/chirply \
-H "Content-Type: application/json" \
-H "X-Chirply-Event: contact_created" \
-H "X-Chirply-Delivery: local-test-1" \
-H "X-Chirply-Signature: t=$T,v1=$SIG" \
-d "$BODY"contact_created — then read the attempt in the delivery log and the payload in webhooks.read_events.GET /api/v1/webhooks/samples?event=<name> to see the real payload shape your workspace produces for an event before you write the handler.