Webhooks
Receive real-time HTTP callbacks when events happen in your Kayse account — no polling required.
Overview
Webhooks push event data to your server the moment something happens: a call starts, a message is delivered, and more. Each delivery is signed, retried on failure, and logged for inspection.
Use Cases
- Sync call outcomes to your CRM in real-time
- Trigger follow-up workflows when messages are delivered
- Build audit logs of all campaign activity
- Send notifications to Slack, Teams, or other channels
Setting Up Webhooks
From Company Integrations
- Open Company → Integrations
- Select Webhooks to open the dedicated detail screen
- Click New
- Enter your endpoint URL
- Select events to subscribe to (or leave empty for all events)
- Optionally attach an App Key
- Save
From a Campaign
- Open a campaign and go to the Workflows tab
- Scroll to the Webhooks section and click New
- The campaign filter is applied automatically — the webhook only fires for events in that campaign
Configuration Fields
| Field | Required | Description |
|---|---|---|
| Name | Yes | A label for this webhook (minimum 3 characters) |
| Destination URL | Yes | The HTTPS endpoint that receives events. Must resolve to a public IP address (no localhost, private, or link-local IPs). |
| Events | No | Which event types trigger this webhook. Leave empty to receive all event types. |
| Case Type Filter | No | Limit deliveries to events matching specific case types. Leave empty for all. |
| Campaign Filter | No | Limit deliveries to events from specific campaigns. Cannot be combined with message events or the all-events option. |
| App Key | No | Attach an API key — it will be sent as the X-Public-Api-Key header on every delivery for extra verification. |
| Active | — | Enable or disable the webhook. Disabled webhooks stop receiving events (except test events). |
Event Types
Kayse currently fires the following event types:
| Event Type | Trigger |
|---|---|
call_started | A campaign call begins |
call_ended | A campaign call finishes |
call_analyzed | The transcript and summary for a call are ready |
message_sent | An outbound message (SMS, email, etc.) is sent |
message_delivered | The provider confirms a message was delivered |
task_completed | A case task is marked as completed |
form_submitted | A form task is submitted |
webhook_test | You clicked Send Test in the UI |
All Events
Leave the event selection empty when creating a webhook to automatically receive all event types, including any new types added in the future.
Campaign Filter Restriction
Campaign filters can only be used when specific event types are selected, and cannot be combined with message events (message_sent, message_delivered) because messages are not scoped to a single campaign.
Payload Format
Every webhook delivery is an HTTP POST with a JSON body. All payloads share this envelope:
{
"id": "call_started:12345:a1b2c3d4",
"type": "call_started",
"occurred_at": "2025-06-15T14:30:00Z",
"data": {
// event-specific fields — see below
}
}Envelope Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique event identifier. Use this for idempotency — the same id is never delivered twice to the same webhook. |
type | string | One of the event types listed above. |
occurred_at | string | ISO 8601 / RFC 3339 timestamp of when the event happened. |
data | object | Event-specific payload (see sections below). |
Call Events
Sent for call_started, call_ended, and call_analyzed. All three share the same data shape; call_analyzed additionally includes a non-empty summary and may include post_call_analysis.
{
"id": "call_ended:42:e8f3a1b2",
"type": "call_ended",
"occurred_at": "2025-06-15T14:35:00Z",
"data": {
"campaign_call_id": 42,
"client_id": 100,
"case_id": 55,
"campaign_id": 7,
"case_type_id": 3,
"client_name": "Jane Smith",
"client_number": "+15551234567",
"agent_number": "+15559876543",
"direction": "outbound",
"status": "completed",
"call_status": "completed",
"duration_ms": 45000,
"disconnection_reason": "caller_hangup",
"summary": "",
"post_call_analysis": {
"appointment_scheduled": true,
"preferred_language": "English"
}
}
}Call Data Fields
| Field | Type | Description |
|---|---|---|
campaign_call_id | integer | Internal Kayse campaign call ID |
client_id | integer | ID of the client on the call |
case_id | integer or null | Associated case ID, if any |
campaign_id | integer or null | Campaign the call belongs to, if any |
case_type_id | integer or null | Case type ID, if any |
client_name | string | Full name of the client |
client_number | string | Client phone number (E.164) |
agent_number | string | Kayse agent phone number (E.164) |
direction | string | "inbound" or "outbound" |
status | string | Campaign call status (e.g. "completed", "failed", "no_answer") |
call_status | string | Telephony-level status |
duration_ms | integer | Call duration in milliseconds |
disconnection_reason | string | Why the call ended (e.g. "caller_hangup", "agent_hangup", "timeout") |
summary | string | AI-generated call summary. Empty for call_started and call_ended; populated for call_analyzed. |
post_call_analysis | object or absent | Custom post-call analysis fields configured on the campaign agent. Only present for call_analyzed events when custom fields are configured. System fields (e.g. call_summary, converted) are excluded — only user-defined fields appear here. |
Message Events
Sent for message_sent and message_delivered.
{
"id": "message_sent:01J5EXAMPLE:b7c8d9e0",
"type": "message_sent",
"occurred_at": "2025-06-15T14:40:00Z",
"data": {
"message_ulid": "01J5EXAMPLE",
"client_id": 100,
"admin_id": 12,
"case_id": 55,
"case_type_id": 3,
"channel": "sms",
"direction": "outbound",
"delivery_status": "sent",
"text": "Hi Jane, just following up on your case.",
"subject": "",
"client_contact": "+15551234567",
"company_contact": "+15559876543"
}
}Message Data Fields
| Field | Type | Description |
|---|---|---|
message_ulid | string | Unique message identifier (ULID format) |
client_id | integer | ID of the client |
admin_id | integer or null | ID of the admin who sent the message, or null for automated messages |
case_id | integer or null | Associated case ID, if any |
case_type_id | integer or null | Case type ID, if any |
channel | string | Delivery channel: "sms", "email", "whatsapp", etc. |
direction | string | "inbound" or "outbound" |
delivery_status | string | Current delivery status (e.g. "sent", "delivered", "failed") |
text | string | Message body text |
subject | string | Email subject line (empty for SMS/WhatsApp) |
client_contact | string | Client's phone number or email |
company_contact | string | Company phone number or email used |
Task Events
Sent for task_completed and form_submitted. Both events share the same data shape. form_submitted is fired specifically when a form-type task is completed; task_completed is fired for all other case tasks.
{
"id": "task_completed:150:c4d5e6f7",
"type": "task_completed",
"occurred_at": "2025-06-15T15:00:00Z",
"data": {
"case_task_id": 150,
"task_id": 25,
"case_id": 55,
"case_type_id": 3,
"form_id": null,
"task_title": "Upload signed retainer",
"task_body": "Please upload a signed copy of the retainer agreement.",
"status": "completed",
"status_note": "",
"completed_at": "2025-06-15T15:00:00Z"
}
}Task Data Fields
| Field | Type | Description |
|---|---|---|
case_task_id | integer | Internal Kayse case task ID |
task_id | integer | ID of the task template this case task was created from |
case_id | integer | Associated case ID |
case_type_id | integer or null | Case type ID, if any |
form_id | integer or null | Form ID if this is a form-type task, otherwise null |
task_title | string | Title of the task |
task_body | string | Description/body of the task |
status | string | Task status (e.g. "completed") |
status_note | string | Optional status note |
completed_at | string or null | ISO 8601 timestamp of when the task was completed |
Test Event
Sent when you click Send Test in the webhook UI.
{
"id": "test:99:a1b2c3d4",
"type": "webhook_test",
"occurred_at": "2025-06-15T14:45:00Z",
"data": {
"message": "This is a test webhook from Kayse."
}
}| Field | Type | Description |
|---|---|---|
message | string | A static test message |
HTTP Headers
Every webhook delivery includes these headers:
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | Always JSON |
User-Agent | Kayse-Webhooks/1.0 | Identifies the sender |
X-Kayse-Timestamp | 1718458200 | Unix timestamp of when the delivery was sent |
X-Kayse-Event-Id | call_ended:42:e8f3a1b2 | The event id from the payload |
X-Kayse-Signature | a3f2b8c1... | HMAC-SHA256 signature for verification (see below) |
X-Public-Api-Key | pk_live_... | Only present if an App Key is attached to the webhook |
Signature Verification
Every delivery is signed with the webhook's Signing Secret using HMAC-SHA256. Verify the signature to ensure the request genuinely came from Kayse.
How the Signature Is Computed
The signed message is: {timestamp}.{json_body}
HMAC-SHA256(signing_secret, "{X-Kayse-Timestamp}.{raw_request_body}")The result is hex-encoded and sent as X-Kayse-Signature.
Node.js Example
const crypto = require('crypto');
function verifyWebhook(req, signingSecret) {
const timestamp = req.headers['x-kayse-timestamp'];
const signature = req.headers['x-kayse-signature'];
const body = req.rawBody; // must be the raw string, not parsed JSON
const expected = crypto
.createHmac('sha256', signingSecret)
.update(`${timestamp}.${body}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
}Python Example
import hmac
import hashlib
def verify_webhook(timestamp: str, body: bytes, signature: str, signing_secret: str) -> bool:
message = f"{timestamp}.".encode() + body
expected = hmac.new(
signing_secret.encode(),
message,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)Go Example
func verifyWebhook(timestamp string, body []byte, signature, signingSecret string) bool {
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}Finding Your Signing Secret
Open the webhook detail view and click Show next to the Signing Secret field. You can also click Copy to copy it to your clipboard.
Delivery & Retries
Expected Response
Your endpoint should:
- Return a 2xx status code (
200,201,202) - Respond within 15 seconds
- Acknowledge quickly and process asynchronously if needed
A delivery is considered failed if your endpoint returns a non-2xx status, times out, or is unreachable.
Retry Policy
Failed deliveries are retried with exponential backoff over a 24-hour window:
| Attempt | Approximate Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 2 minutes |
| 4 | 4 minutes |
| 5 | 8 minutes |
| 6+ | Continues doubling until the 24-hour window expires |
After 24 hours from the original event, no more retries are attempted and the delivery is marked as failed.
Manual Replay
If a delivery failed, you can replay it from the webhook detail view:
- Open the webhook
- Find the failed delivery in Recent Deliveries
- Click Replay
Replayed deliveries create a new delivery attempt with a fresh event key.
Delivery Logs
Every delivery attempt is logged. Click any row in Recent Deliveries to inspect:
| Field | Description |
|---|---|
| Event | The event type that was sent |
| Result | Delivered or Failed |
| Attempts | How many times this delivery was attempted |
| HTTP Code | The status code your endpoint returned |
| Duration | How long the request took |
| Time | When the attempt was made |
| Request Headers | Headers sent with the delivery (signatures redacted) |
| Request Payload | The full JSON body |
| Response Body | The first 4 KB of your endpoint's response |
| Error | Error message if the delivery failed |
Best Practices
Respond Quickly
Return 200 OK immediately, then process the payload in the background. If your handler takes too long, the delivery will be marked as timed out and retried.
app.post('/webhooks/kayse', (req, res) => {
res.status(200).send('OK');
processEvent(req.body); // async
});Handle Duplicates
Use the id field from the payload for idempotency. In rare cases (network issues, retries), the same event may be delivered more than once.
Use HTTPS
Always use an https:// endpoint. HTTP endpoints are accepted but strongly discouraged in production.
Verify Signatures
Always verify the X-Kayse-Signature header before trusting the payload. This prevents spoofed requests.
Monitor Failures
Check the delivery logs in the webhook detail view regularly. Repeated failures may indicate an endpoint issue.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Not receiving events | Webhook is paused | Toggle Active on |
| Not receiving events | No matching event types selected | Check event subscriptions or leave empty for all |
| Signature mismatch | Using parsed body instead of raw bytes | Verify against the raw request body string |
| Signature mismatch | Wrong signing secret | Copy the secret from the webhook detail view |
| Timeout errors | Handler takes too long | Return 200 immediately, process async |
| SSL errors | Invalid or expired certificate | Ensure your endpoint has a valid SSL certificate |
| "URL cannot target private network addresses" | Endpoint resolves to a private IP | Use a publicly routable address |