Webhooks

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.

Subscribing

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.

Event catalog (90)

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.

EventFires when
message
gmail_message_receivedNew incoming mail in a connected Gmail account. Historical imports, drafts, sent mail, spam and trash never start this trigger.
facebook_message_receivedA person enters a connected Facebook Page conversation by message, button, referral link, or ad.
facebook_messenger_event_receivedA connected Messenger thread reports account linking, feedback, a game play, opt-in, customer information, or an in-thread lead form submission.
facebook_comment_receivedA person comments on a connected Facebook Page post or an eligible ad post delivered through Meta's Page feed webhook.
instagram_message_receivedA person enters a connected Instagram conversation by DM, story interaction, button, referral link, or ad.
instagram_comment_receivedA person comments on a post from a connected Instagram Professional account.
whatsapp_message_receivedA customer messages a connected WhatsApp Business number.
message_receivedThe contact replies by SMS, email, Facebook Messenger, Instagram DM, or WhatsApp. STOP/unsubscribe replies are excluded.
call_completedA call finishes.
call_answeredA contact answers an inbound or outbound call.
call_missedA 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_addedA contact is put on a call queue — by hand, by an automation, or through the API.
call_queue_calledA rep finishes a call with someone on a call queue and records the outcome.
call_dispositionedSomeone 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_analyzedThe 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_resultAn outbound IVR or call-blast recipient reaches a final answer result.
message_sentYour team or an automation sends the contact an SMS or email.
conversation_status_changedA conversation is opened, closed, or snoozed.
conversation_owner_changedA conversation is assigned or reassigned to a team member.
contact
google_sheets_row_newA 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_updatedA 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_createdA 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_updatedA 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_deletedA 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_createdA new contact is added — by hand, file upload, tracking script, API, or signup.
birthdayOn, before, or after each contact's birthday.
tag_addedA tag is applied to a contact.
tag_removedA tag is removed from a contact.
list_membership_addedA contact is added to a static list.
list_membership_removedA contact is removed from a static list.
contact_lifecycle_changedA contact becomes a lead, active contact, customer, or churned customer.
contact_owner_changedA contact is assigned or reassigned to a team member.
task_createdA follow-up task is created for the contact.
task_completedA task linked to the contact is marked done.
note_addedA note is added to the contact's timeline.
form_submittedA contact submits any native form or a form on a funnel page.
facebook_lead_receivedAutomatically saves or matches a contact before this automation starts when a connected Facebook or Instagram instant form is submitted.
member_access_grantedA contact receives access to a members product.
member_access_revokedA members product is taken away from a contact.
pipeline
deal_stage_changedA deal moves stages.
deal_createdA new opportunity is opened for a contact.
deal_wonA contact's deal is marked won.
deal_lostA contact's deal is marked lost or abandoned.
deal_reopenedA closed deal is moved back to open.
deal_at_riskThe 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_completedA top-level project task is marked complete, from the board, the task form, the API or another workflow. Runs without a contact.
project_subtask_completedA subtask is marked complete; {{parentTaskTitle}} names its task. Runs without a contact.
project_task_movedAn existing project task or subtask moves to another board column. Runs without a contact.
commerce
purchase_madeA funnel or store order is paid. To filter by product or collection, or read every line, use Order paid.
upsell_acceptedA buyer adds a one-click upsell or downsell.
order_paidA 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_refundedA 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_cancelledA 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_abandonedA Shopify checkout is left incomplete. Narrow by store, value, product, vendor, or product type.
shopify_order_createdA new Shopify order is placed. Tokens include order, customer, product, value, discount, and attribution data.
shopify_first_orderA customer places their first order in this Shopify store.
shopify_repeat_orderAn existing customer places another Shopify order.
shopify_order_paidA Shopify order becomes paid.
shopify_order_fulfilledA Shopify order is fulfilled and ready for post-purchase follow-up.
shopify_order_cancelledA Shopify order is cancelled. This can also be used as a stop trigger.
shopify_refund_createdA full or partial Shopify refund is created. This can stop or redirect post-purchase sequences.
website
website_visitA known contact returns to your tracked website (once per visit, not per page).
conversionA lead or sale is reported from your website — via tracker.convert() or the Sale/Lead pixel — optionally above a value.
website_identifiedAn anonymous website visitor becomes a contact.
scheduling
appointment_bookedSomeone books a time on one of your calendars.
appointment_canceledA booked appointment is canceled by the invitee or your team.
appointment_rescheduledAn invitee moves their appointment to a new time.
appointment_no_showAn appointment is marked as a no-show.
payments
stripe_payment_succeededA payment on your connected Stripe account succeeds. Tokens: {{amount}}, {{currency}}, {{description}}.
stripe_payment_failedA payment on your connected Stripe account fails (e.g. a declined card). Tokens: {{amount}}, {{currency}}.
stripe_refund_issuedA charge on your connected Stripe account is refunded. Tokens: {{amount_refunded}}, {{currency}}.
stripe_subscription_createdSomeone starts a subscription on your connected Stripe account. Tokens: {{plan}}, {{amount}}, {{interval}}.
stripe_subscription_canceledA subscription on your connected Stripe account ends (churn). Tokens: {{plan}}, {{status}}.
stripe_dispute_createdA customer disputes a charge (chargeback) on your connected Stripe account. Tokens: {{amount}}, {{currency}}.
invoice_paidA contact pays one of your invoices or checkout orders.
invoice_payment_failedA payment attempt on one of your invoices fails.
email
email_openedA contact opens one of your campaign emails.
email_clickedA contact clicks a link in one of your campaign emails. Token: {{url}}.
email_bouncedAn email hard-bounces (a permanent failure). The address is suppressed.
email_complainedA contact marks one of your emails as spam. The address is suppressed.
email_unsubscribedA contact unsubscribes from your emails — via the footer link, the preference centre, or a spam-free opt-out.
community
community_post_createdA member (or your team) publishes a new post in one of your community groups.
community_comment_createdA member (or your team) comments on a community post.
course_enrolledA contact is enrolled in one of your courses — by themselves, your team, or an automation.
lesson_completedA contact finishes a lesson in one of your courses.
course_completedA contact finishes every lesson in a course. Fires once per person per course.
member_level_upA community member earns enough points to reach a new level.
other
app_eventAn installed app fired one of its own events (via events.emit). Narrow it to a specific app and event key.
gohighlevel_eventA signed event arrived from HighLevel. Choose any documented Marketplace webhook event, or leave it open to every GHL event.
klaviyo_eventA 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"
}

Delivery

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.

Verifying signatures

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.

Node

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
});

Python

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 "", 200

Retries & the delivery log

A 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.

Reconciliation — the pull stream

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.

Rotating a signing secret

Secrets are minted per registration and never readable back, so rotation is a re-registration:

  1. Register a new subscription for the same events at a fresh path on your server (for example /chirply-v2) — same host, new secret, both delivering.
  2. Deploy the new secret to the new path and confirm deliveries verify there.
  3. 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.

Testing locally

  • Webhook URLs must be public HTTPS, so put a tunnel in front of your dev server — cloudflared tunnel --url http://localhost:3000 or ngrok http 3000 — and subscribe the tunnel URL.
  • Exercise your verifier before anything fires by signing a request yourself, the same way we do:
# 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"
  • Trigger a real event cheaply — creating a contact fires contact_created — then read the attempt in the delivery log and the payload in webhooks.read_events.
  • Use GET /api/v1/webhooks/samples?event=<name> to see the real payload shape your workspace produces for an event before you write the handler.