Webhooks
Webhooks let your own systems hear about activity in CurrentClient the moment it happens — a new message landing in an inbox, a call wrapping up, a voicemail coming in — instead of you having to poll the API and ask.
Setting up an endpoint
- Log into CurrentClient and open Settings.
- Click Developers in the left sidebar, then Webhooks.
- Click Add endpoint.
- Give it a Name (just for you, so you can tell it apart from any others) and the Endpoint URL we should
POSTto. - Pick which events you want to receive — see the full list below.
- Save it. Your endpoint gets its own signing secret the moment it's created — copy it somewhere safe (you can also view it again any time with Reveal on the endpoint's card). We use it to sign every delivery so you can confirm it really came from us (more on that below).
This screen only manages WEBHOOK-type endpoints —
it's separate from the Zapier integration, which handles its own
subscriptions.
Events you can subscribe to
Every delivery is a single POST request with a JSON body,
a Content-Type: application/json header, and an
X-CC-Event-Type header carrying the same event type as
the body — handy if you want to route on the header without
parsing JSON first. This is the current catalog, with an example
payload for each.
Every event listed below carries a matching top-level
"event_type" field in the body (in addition to the
X-CC-Event-Type header) — it's shown first in each
example because that's genuinely where it sits on the wire.
message.received
Fires when an inbound message lands on one of your routed inboxes.
{
"event_type": "message.received",
"id": "MSG-4f9c2a10",
"phone_number": "+15551234567",
"sender_name": "Jane Doe",
"message": "Thanks, see you then!",
"timestamp": "2026-09-08T14:32:10+00:00",
"media_urls": []
}
media_urls is an empty list unless the message included attachments, in which case it's a list of URLs.
message.sent
Fires when an outbound message you sent is confirmed sent. Subscribe to this alongside message.received to see the full conversation, not just the inbound half.
{
"event_type": "message.sent",
"id": "MSG-9c31af02",
"phone_number": "+15551234567",
"contact_name": "Jane Doe",
"message": "See you at 3pm",
"timestamp": "2026-09-08T14:32:10+00:00",
"media_urls": []
}
media_urls is an empty list unless the message included attachments, in which case it's a list of URLs.
call.missed
Fires when an inbound call goes unanswered.
{
"event_type": "call.missed",
"id": "CALL-7a12e903",
"phone_number": "+15551234567",
"caller_name": "Jane Doe",
"timestamp": "2026-09-08T14:32:10+00:00",
"duration": "0"
}
call.completed
Fires when any call — inbound or outbound, answered or not — finishes.
{
"event_type": "call.completed",
"id": "CALL-7a12e903",
"phone_number": "+15551234567",
"caller_name": "Jane Doe",
"direction": "inbound",
"status": "completed",
"timestamp": "2026-09-08T14:32:10+00:00",
"duration": "125",
"note": "",
"summary": ""
}
note and summary are empty strings when nothing's been added yet.
voicemail.received
Fires when a caller leaves a voicemail.
{
"event_type": "voicemail.received",
"id": "CALL-7a12e903",
"phone_number": "+15551234567",
"caller_name": "Jane Doe",
"duration": "42",
"audio_url": "https://media.currentclient.com/recordings/abc123.mp3",
"transcription": "Hey, it's Jane, calling to confirm...",
"timestamp": "2026-09-08T14:32:10+00:00"
}
callrecording.created
Fires when a call recording finishes processing and is ready to fetch.
{
"event_type": "callrecording.created",
"id": "RE1234567890abcdef1234567890abcdef",
"call_id": "CALL-7a12e903",
"phone_number": "+15551234567",
"caller_name": "Jane Doe",
"recording_url": "https://media.currentclient.com/recordings/abc123.mp3",
"duration": "125",
"timestamp": "2026-09-08T14:32:10+00:00",
"transcription": "Full call transcript goes here..."
}
calltranscript.completed
Fires when a call's full transcript finishes processing (distinct from callrecording.created, which fires when the audio itself is ready).
{
"event_type": "calltranscript.completed",
"id": "CALL-7a12e903",
"phone_number": "+15551234567",
"caller_name": "Jane Doe",
"direction": "inbound",
"duration": "125",
"transcript": "Full call transcript goes here...",
"timestamp": "2026-09-08T14:32:10+00:00"
}
callsummary.completed
Fires when the AI-generated summary of a call finishes processing.
{
"event_type": "callsummary.completed",
"id": "CALL-7a12e903",
"phone_number": "+15551234567",
"caller_name": "Jane Doe",
"direction": "inbound",
"duration": "125",
"summary": "Client confirmed the Tuesday meeting and asked about rescheduling next month's review.",
"timestamp": "2026-09-08T14:32:10+00:00"
}
survey.submitted
Fires when a contact submits one of your survey/form campaigns.
{
"event_type": "survey.submitted",
"id": "PLOP-9f21ac04",
"contact_id": "CONTACT-a83f1290",
"contact_name": "Jane Doe",
"campaign_id": "CAMPAIGN-1120",
"campaign_title": "New Client Intake",
"campaign_url": "https://app.currentclient.com/app/surveys/CAMPAIGN-1120",
"responses": [
{ "id": "COMP-1", "label": "What's the best way to reach you?", "response": "Text message" },
{ "id": "COMP-2", "label": "Referred by", "response": "" }
],
"timestamp": "2026-09-05 20:14:03"
}
responses is a list, one entry per form field, in the
order the campaign defines them; response is
"" for a field the contact left blank.
Note: unlike every other event on this page,
timestamp here is passed through as recorded rather than
normalized to ISO 8601 — don't assume the format matches the
other events' timestamps when parsing this one.
message.undelivered
Fires when an outbound message you sent fails to deliver.
{
"event_type": "message.undelivered",
"id": "MSG-77af0012",
"phone_number": "+15551234567",
"contact_name": "Jane Doe",
"timestamp": "2026-09-08T14:32:10+00:00"
}
reminder.due
Fires when a scheduled conversation reminder comes due.
{
"event_type": "reminder.due",
"id": "CONV-3391af02",
"phone_number": "+15551234567",
"contact_name": "Jane Doe",
"timestamp": "2026-09-08T14:32:10+00:00"
}
widgetsubmission.created
Fires when a visitor submits your embedded website widget.
{
"event_type": "widgetsubmission.created",
"id": "TWS-88ab210f",
"widget_id": "TW-4471",
"name": "Jane Doe",
"phone_number": "+15551234567",
"message": "Hi, I'd like to schedule a call.",
"source_domain": "janedoefirm.com",
"inbox_number": "+15559876543",
"timestamp": "2026-09-08T14:32:10+00:00"
}
Except where noted above (survey.submitted), timestamps
are ISO 8601 in UTC (+00:00 offset). Every field is
always present, even if it's an empty string.
Verifying a delivery came from us
Every request we send carries two headers:
X-CC-Signature | a hex-encoded HMAC-SHA256 signature |
|---|---|
X-CC-Timestamp | the Unix timestamp (seconds) the signature was generated at |
To verify a delivery, recompute the signature yourself and compare it to the one we sent — never trust a payload whose signature doesn't match, and never skip this check just because the request came in over HTTPS.
The recipe:
- Reject the request if either
X-CC-SignatureorX-CC-Timestampis missing or empty — treat an absent header exactly like a wrong one, never as "unsigned, so skip the check". - Read the raw request body exactly as it arrived — don't parse it into JSON and re-serialize it first. Even a harmless-looking change (key order, whitespace) produces a different signature than the one we computed, and the check will fail even though the payload is genuine.
- Build the string
"{timestamp}.{raw_body}"(the timestamp fromX-CC-Timestamp, a literal period, then the raw body). - Compute HMAC-SHA256 of that string, keyed with your endpoint's signing secret, and hex-encode the digest.
- Compare it to
X-CC-Signatureusing a constant-time comparison — a plain==leaks timing information an attacker can use to guess the signature byte by byte. - We also recommend rejecting anything where
X-CC-Timestampis more than a few minutes old (we use 5 minutes on our own inbound webhooks) — that protects you against someone replaying an old, legitimately-signed request.
Worked example (Python):
import hashlib
import hmac
import time
WEBHOOK_SIGNING_SECRET = "your-endpoint-secret-here"
TOLERANCE_SECONDS = 300 # 5 minutes
def verify_currentclient_signature(
raw_body: bytes, timestamp_header: str, signature_header: str
) -> bool:
"""Verify an X-CC-Signature / X-CC-Timestamp pair against raw_body.
raw_body must be the exact, unparsed request body - read it before
any JSON deserialization happens.
"""
try:
timestamp = int(timestamp_header)
except (TypeError, ValueError):
return False
if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
return False
signed_payload = f"{timestamp}.".encode("utf-8") + raw_body
expected_signature = hmac.new(
WEBHOOK_SIGNING_SECRET.encode("utf-8"), signed_payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, signature_header)
# Example use in a Flask-style view:
#
# raw_body = request.get_data() # bytes, unparsed
# ok = verify_currentclient_signature(
# raw_body,
# request.headers.get("X-CC-Timestamp", ""),
# request.headers.get("X-CC-Signature", ""),
# )
# if not ok:
# abort(401)
Retries, delivery logs, and what happens when your endpoint goes quiet
If your endpoint returns anything other than a 2xx status, or doesn't respond at all, we'll retry with exponential backoff: 30 seconds, 1 minute, 2 minutes, 4 minutes, 8 minutes, 16 minutes, 32 minutes, and a final retry about an hour after that — 8 retries in all, after which that event's schedule is exhausted.
If three separate events in a row each burn through their entire retry schedule with no successful delivery, we automatically disable the endpoint — that way a broken integration doesn't just fail silently and pile up. When that happens:
- We'll email you explaining what happened.
- You'll get an entry in your Notification Center with the same details.
Every attempt — success or failure — lands in that endpoint's delivery log, along with the event, timestamp, and the response we got back, so you can see exactly what went wrong before you need to hit Re-enable.
Rotating your signing secret
If your secret ever leaks, or you just want to cycle it as good hygiene, you can rotate it from the same endpoint card. Rotating takes effect immediately — the moment you do it, deliveries start signing with the new secret, so update your verification code with the new value right away. Anything you haven't updated will start failing its signature check until you do.
Common questions
Are deliveries signed, and do I have to verify them?
Yes — every delivery from an endpoint that has a signing secret
(every endpoint created in Settings > Developers > Webhooks does)
carries an X-CC-Signature header. Verifying it is
optional on our side (we don't reject you for not checking) but
strongly recommended.
Where do I get the signing secret?
Each endpoint has its own secret, generated by us (a 64-character hex string) — you can't choose it. Find it under Settings > Developers > Webhooks > your endpoint > Reveal. It's also returned when you create the endpoint or rotate its secret.
Quick reference: header, algorithm, and what's signed
| Signature header | X-CC-Signature |
|---|---|
| Algorithm | HMAC-SHA256, hex-encoded (no sha256= prefix) |
| Key | The endpoint's signing secret, used as its UTF-8 string (not hex-decoded) |
| Signed value | "{timestamp}.{raw_body}" — the timestamp, a literal period, then the exact raw request body |
| Timestamp header | X-CC-Timestamp — Unix time in whole seconds (e.g. 1789000000) |
Does creating or re-enabling an endpoint change the secret?
Creating an endpoint generates its secret, once. Editing an endpoint, changing its subscribed events, or clicking Re-enable never changes it. Only an explicit Rotate secret does — and that takes effect immediately.
Does reconnecting an OAuth integration affect my webhook?
No. A webhook endpoint is its own record with its own secret, separate from any OAuth-connected integration. Reconnecting or re-authorizing an OAuth integration doesn't create, delete, or rotate a webhook endpoint or its signing secret.