Cally360
Developer Documentation

Read the data your widget collects — from any language.

Every field your widget captures is available through a REST API and pushed to you in real time via signed webhooks. Optional storage-confirmation callbacks prove the event landed in your database. Works with PHP, Node, Python, Go, Ruby — anything that can make an HTTP request.

Introduction

Cally360 widgets capture structured visitor data — name, email, phone, and whatever custom fields you configured when you created the key. This data lives in the Cally360 CRM, and it's also available to your own backend through two channels:

REST API (pull)

Your server calls our endpoints on demand. Good for initial backfill, nightly reconciliation, and any time you need to fetch historical data.

Webhooks (push)

We POST signed JSON to a URL you register the moment something happens. Real-time, retried on failure, with an optional storage-confirmation callback.

What you can read

  • Every lead your widget produced, with custom field values
  • Immutable per-submission history for audit and booking trails
  • Call history to your Intercom ID
  • Follow-ups and scheduled events

Authentication

Every request is authenticated with a secret key tied to one widget. Generate it from your Cally360 dashboard under Dashboard → Website Embed API → Developer Access. The plaintext is shown once; copy it to your server immediately.

Format

Authorization: Bearer cally_live_sk_<40 hex chars>

Scoping

A secret key can only read data produced by its paired widget. If your key is bound to Intercom ID 540748306, you will never see leads or calls from any other Intercom ID — even ones owned by the same Cally360 account. This is by design: leaking one key never exposes another widget's data.

Do not embed this key in a browser

The secret key is for server-to-server calls only. If you put it in JavaScript on a public page, anyone who opens DevTools has your data. The public widget key (cally_live_..., without _sk_) is the one that goes in the widget HTML — it can only initiate calls, never read data.

Quickstart

Set CALLY_SECRET as an environment variable on your server, then run one of the snippets below. Each prints your latest five leads.

<?php
$secret = getenv('CALLY_SECRET');

$ch = curl_init('https://cally360.com/api/developer_api?action=leads&per_page=5');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $secret],
]);
$body = curl_exec($ch);
curl_close($ch);

$data = json_decode($body, true);
foreach ($data['data']['leads'] as $lead) {
    echo $lead['full_name'] . ' — ' . $lead['email'] . PHP_EOL;
    print_r($lead['widget_fields']);
}

Expected output — one line per lead, plus the custom fields the visitor filled in:

Jane Doe — jane@example.com
{ "check_in": "2026-11-01", "check_out": "2026-11-07", "passport": "A1234567" }
John Smith — john@example.com
{ "check_in": "2026-11-03", "check_out": "2026-11-05", "passport": "B7654321" }

Dynamic schemas

You defined your widget's custom fields in the Cally360 dashboard. Whatever keys you chose there are the keys that appear in the API, unchanged. If your widget captures check_in, check_out, and passport, then every lead object contains exactly those keys:

"widget_fields": {
  "check_in":  "2026-11-01",
  "check_out": "2026-11-07",
  "passport":  "A1234567"
}

No transformation, no renaming, no field whitelist. Your schema is yours. To discover the schema at runtime, call ?action=me — the response includes a widget_schema array describing every field.

widget_fields

Captured from the visitor. Immutable per submission. Latest values also appear on the lead object for convenience.

crm_fields

Your team's internal Customize Fields, editable in the Cally360 CRM drawer. These change over the life of a contact. They are never collected from the visitor.

Both namespaces are returned side-by-side. Even if a widget field and a CRM field share the same name, they never collide.

REST endpoints

Base URL: https://cally360.com/api/developer_api. All endpoints accept ?action= as a query parameter, and POST endpoints accept the same via a JSON body.

GET ?action=me

Key information

Returns metadata about the secret key: app name, bound Intercom ID, expiration, rate limit, and the widget custom field schema. Useful as a connectivity check and to discover which fields your widget collects.

Sample response

{
  "success": true,
  "message": "Key is valid.",
  "data": {
    "key_id": 19,
    "app_name": "Airport view suites",
    "bound_peer_id": "540748306",
    "owner_user_id": 3,
    "status": "active",
    "expires_at": "2026-10-26 14:38:14",
    "widget_schema": [
      { "key": "check_in",  "label": "Check In Date",  "type": "date", "required": false, "placeholder": "" },
      { "key": "check_out", "label": "Check Out Date", "type": "date", "required": false, "placeholder": "" },
      { "key": "passport",  "label": "Passport No.",   "type": "text", "required": true,  "placeholder": "A1234567" }
    ],
    "rate_limit": 60,
    "capabilities": [
      "leads:read",
      "submissions:read",
      "calls:read",
      "events:read",
      "webhooks:write",
      "webhooks:ack"
    ]
  }
}
GET ?action=leads&per_page=50&page=1

List leads

Paginated list of every contact that came through your widget. Each row includes the widget custom field values captured from the visitor, plus CRM-side fields edited by your team.

Parameters

per_page Default 50, max 100.
page Default 1.
since ISO 8601 datetime. Only return rows created at or after this time.
status Filter by pipeline status: new, contacted, qualified, proposal, won, lost.
q Free-text search on name, email, or phone.

Sample response

{
  "success": true,
  "message": "Leads loaded.",
  "data": {
    "leads": [
      {
        "contact_uuid": "crm_...",
        "full_name": "Jane Doe",
        "email": "jane@example.com",
        "phone": "+91...",
        "purpose": "Booking",
        "status": "new",
        "priority": "normal",
        "widget_key_id": 19,
        "widget_fields": {
          "check_in": "2026-11-01",
          "check_out": "2026-11-07",
          "passport": "A1234567"
        },
        "crm_fields": {
          "lead_score": "8"
        },
        "created_at": "2026-10-09 12:34:56",
        "updated_at": "2026-10-09 12:34:56"
      }
    ]
  },
  "meta": { "page": 1, "per_page": 50, "total": 1, "total_pages": 1 }
}
GET ?action=lead&uuid=crm_...

Get one lead

Full detail for a single contact including notes. Returns 404 if the contact is not in your scope.

Parameters

uuid The contact_uuid returned by the list endpoint.

Sample response

{
  "success": true,
  "message": "Lead loaded.",
  "data": {
    "lead": {
      "contact_uuid": "crm_...",
      "full_name": "Jane Doe",
      "email": "jane@example.com",
      "phone": "+91...",
      "widget_fields": { "check_in": "2026-11-01", "passport": "A1234567" },
      "crm_fields": {},
      "notes": "Referred by existing tenant",
      "created_at": "2026-10-09 12:34:56",
      "updated_at": "2026-10-09 12:34:56"
    }
  }
}
GET ?action=lead_submissions&uuid=crm_...

Submission history for one lead

Every time this visitor submitted the widget — including repeat submissions — as an immutable, timestamped list. Use this for booking history, audit trails, and reconstructing what the visitor entered at each interaction.

Parameters

uuid The contact_uuid.

Sample response

{
  "success": true,
  "message": "Submissions loaded.",
  "data": {
    "submissions": [
      {
        "submission_uuid": "sub_...",
        "widget_key_id": 19,
        "widget_fields": { "check_in": "2026-11-01", "passport": "A1234567" },
        "source_ip": "203.0.113.42",
        "user_agent": "Mozilla/5.0 ...",
        "created_at": "2026-10-09 12:34:56"
      }
    ]
  },
  "meta": { "count": 1 }
}
GET ?action=submissions&since=2026-10-01

Raw submission feed

Every widget submission across every lead, in reverse chronological order. Use this for initial backfill, nightly reconciliation, or when you need one record per interaction rather than one record per contact.

Parameters

per_page Default 200, max 500.
page Default 1.
since ISO 8601 datetime.
until ISO 8601 datetime.

Sample response

{
  "success": true,
  "message": "Submissions loaded.",
  "data": {
    "submissions": [
      {
        "submission_uuid": "sub_...",
        "contact_uuid": "crm_...",
        "full_name": "Jane Doe",
        "widget_key_id": 19,
        "widget_fields": { "check_in": "2026-11-01" },
        "created_at": "2026-10-09 12:34:56"
      }
    ]
  },
  "meta": { "page": 1, "per_page": 200, "total": 1, "total_pages": 1 }
}
GET ?action=calls&per_page=50

Call history

Incoming calls to your Intercom ID, with duration, status, and guest information. Filterable by date.

Parameters

per_page Default 50, max 200.
page Default 1.
since ISO 8601 datetime.

Sample response

{
  "success": true,
  "message": "Calls loaded.",
  "data": {
    "calls": [
      {
        "call_uuid": "call_...",
        "caller_name": "Jane Doe",
        "caller_peer_id": "gst_abc",
        "receiver_peer_id": "540748306",
        "call_type": "audio",
        "call_status": "missed",
        "duration_seconds": 0,
        "is_guest_call": true,
        "started_at": "2026-10-09 12:34:56",
        "ended_at": "2026-10-09 12:34:58",
        "created_at": "2026-10-09 12:34:56"
      }
    ]
  }
}
GET ?action=events&status=pending

Follow-up events

Scheduled follow-ups, appointments, and reminders attached to your leads. Filter by status to find pending or overdue items.

Parameters

per_page Default 50, max 200.
page Default 1.
status pending, completed, cancelled, snoozed.

Sample response

{
  "success": true,
  "message": "Events loaded.",
  "data": {
    "events": [
      {
        "event_uuid": "evt_...",
        "contact_uuid": "crm_...",
        "full_name": "Jane Doe",
        "title": "Confirm booking",
        "event_type": "followup",
        "scheduled_at": "2026-11-01 10:00:00",
        "status": "pending",
        "auto_created": false,
        "created_at": "2026-10-09 12:34:56"
      }
    ]
  }
}
GET ?action=webhook_endpoints

List webhook endpoints

Every URL you have registered to receive webhook events for this key. Includes the subscription list, ack mode, and a 24-hour delivery-health summary.

Sample response

{
  "success": true,
  "message": "Endpoints loaded.",
  "data": {
    "endpoints": [
      {
        "endpoint_uuid": "ep_...",
        "url": "https://your-app.com/hooks/cally360",
        "description": "Production receiver",
        "events": ["call.initiated", "call.missed", "call.ended"],
        "status": "active",
        "ack_mode": "soft",
        "consecutive_failures": 0,
        "last_success_at": "2026-10-09 12:00:00",
        "last_failure_at": null,
        "secret_prefix": "a1b2c3d4e5f6",
        "created_at": "2026-10-01 09:00:00",
        "delivery_health": {
          "delivered_24h": 47,
          "confirmed_24h": 45,
          "unconfirmed_24h": 2,
          "failed_24h": 0,
          "last_delivery_at": "2026-10-09 12:34:56"
        }
      }
    ]
  }
}
POST ?action=webhook_endpoints&op=create

Register a webhook endpoint

Register a URL to receive events. The signing secret is returned ONCE in the response and is never shown again. Store it on your server immediately.

Parameters

op Must be "create".
url HTTPS URL. http:// will be rejected.
description Optional human-readable label.
events Array of event names. At least one required.
ack_mode Optional. One of: none, soft, strong. Default soft.

Sample response

{
  "success": true,
  "message": "Endpoint registered. Store the signing secret — it will not be shown again.",
  "data": {
    "endpoint_uuid": "ep_...",
    "url": "https://your-app.com/hooks/cally360",
    "events": ["call.initiated", "call.missed", "call.ended"],
    "ack_mode": "soft",
    "signing_secret": "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0",
    "secret_prefix": "9f8e7d6c5b4a"
  }
}
POST ?action=webhook_endpoints&op=update_ack_mode

Change the ack mode on an endpoint

Switch between none, soft, and strong confirmation without re-registering the URL. The signing secret stays the same. Use this when you upgrade your receiver from fire-and-forget to provable storage.

Parameters

op Must be "update_ack_mode".
endpoint_uuid The endpoint to update.
ack_mode One of: none, soft, strong.

Sample response

{
  "success": true,
  "message": "ack_mode updated.",
  "data": { "ack_mode": "strong" }
}
POST ?action=webhook_endpoints_test

Send a test event

Enqueues a synthetic event that the dispatch worker will deliver to the specified endpoint on its next tick (within 30 seconds). Use this to verify signature verification end-to-end before going live.

Parameters

endpoint_uuid The endpoint to send the test to.

Sample response

{
  "success": true,
  "message": "Test event enqueued. It will be delivered within 30 seconds.",
  "data": { "enqueued": 1 }
}
POST ?action=webhook_endpoints&op=delete

Delete a webhook endpoint

Permanently removes an endpoint and all its delivery history. Cannot be undone.

Parameters

op Must be "delete".
endpoint_uuid The endpoint to delete.

Sample response

{
  "success": true,
  "message": "Endpoint deleted."
}
POST ?action=webhook_ack

Send a storage-confirmation callback

Called by your receiver AFTER you commit the event to your own database. Authenticated with the endpoint's signing secret via X-Cally-Signature (not a Bearer token). Records storage_confirmation = "strong" against the original delivery.

Parameters

delivery_id The X-Cally-Delivery value from the webhook request.
stored true or false.
row_id Optional. Your own primary key for the row you inserted.
error Optional. Reason the insert failed, when stored is false.

Sample response

{
  "success": true,
  "message": "ACK recorded.",
  "data": {
    "delivery_id": "5f2a...",
    "stored": true,
    "row_id": "4321",
    "confirmed_at": "2026-10-09T12:34:57+00:00"
  }
}

Webhooks

Register an HTTPS URL from the API, subscribe to the events you care about, and we will POST a signed JSON envelope to you the moment an event fires. Deliveries are retried with exponential backoff on failure, and every attempt is logged so you can replay if needed.

Request envelope

POST /your-webhook-path HTTP/1.1
Host: your-app.com
Content-Type: application/json
X-Cally-Event: call.initiated
X-Cally-Delivery: 5f2a...
X-Cally-Signature: t=1730000000,v1=8a1b...,window=300
X-Cally-Attempt: 1

{
  "event": "call.initiated",
  "delivery_id": "5f2a...",
  "created_at": "2026-10-09T12:34:56+00:00",
  "data": {
    "call_uuid": "call_...",
    "contact_uuid": "crm_...",
    "owner_user_id": 3,
    "caller_name": "Jane Doe",
    "caller_phone": "+91...",
    "caller_email": "jane@example.com",
    "caller_peer_id": "gst_abc",
    "receiver_peer_id": "540748306",
    "call_type": "audio",
    "is_guest_call": true,
    "created_at": "2026-10-09T12:34:56+00:00"
  }
}

Delivery behavior

  • Timeout is 15 seconds per attempt.
  • 2xx = delivered. No further attempts.
  • 4xx (except 429) = permanent failure, no retry.
  • 5xx, 429, timeout = retry per the schedule below.
  • After 7 failed attempts, the delivery is marked failed.
  • After 50 consecutive endpoint failures, the endpoint is auto-disabled and you get an email.
Attempt Delay after previous attempt
1Immediate (within 30 s of the event firing)
210 s
31 m
45 m
530 m
62 h
76 h
824 h — final attempt

Event catalog

Contacts

contact.created Recommended

Fires when a new CRM contact is created — from a widget submission, an intercom call, or a manual CRM entry. The payload includes the full contact record plus, when applicable, the widget key that produced it and the custom field values from the first submission.

Payload fields

contact_uuid owner_user_id peer_id full_name email phone purpose status priority source source_key widget_fields crm_fields created_at
contact.updated Recommended

Fires when an existing contact is edited. This includes status changes, assignment changes, notes edits, and custom-field edits made by staff. The payload always contains the full current state of the contact.

Payload fields

contact_uuid owner_user_id peer_id full_name email phone status priority assigned_to_user_id widget_fields crm_fields changed_fields updated_at
contact.deleted

Fires when a contact is permanently deleted. The payload contains only the contact UUID and owner ID — no personal data — so integrators can remove the corresponding record from their own database without leaking the deleted data back over the wire.

Payload fields

contact_uuid owner_user_id deleted_at

Submissions

submission.created Recommended

Fires on every widget submission, including repeat submissions from the same visitor. This is the raw event stream — it does not debounce and does not aggregate. Use this if you need one record per interaction rather than one record per contact.

Payload fields

submission_uuid contact_uuid owner_user_id peer_id widget_key_id widget_fields source_ip user_agent created_at

Calls

call.initiated

Fires when a call session is authorized and begins ringing. Useful for real-time dashboards. Note that not all initiated calls will be answered — subscribe to call.answered and call.missed to know the outcome.

Payload fields

call_uuid contact_uuid owner_user_id caller_name caller_peer_id receiver_peer_id call_type is_guest_call created_at
call.answered Recommended

Fires when the receiver picks up the call. Does not include duration — use call.ended for that.

Payload fields

call_uuid contact_uuid owner_user_id caller_name caller_peer_id receiver_peer_id call_type answered_at
call.missed Recommended

Fires when a call is not answered within the ring timeout, or is explicitly rejected by the receiver, or ends before any participant joins. This is the most commonly subscribed call event — most integrations care about missed calls far more than answered ones.

Payload fields

call_uuid contact_uuid owner_user_id caller_name caller_phone caller_email caller_peer_id receiver_peer_id call_type purpose missed_at
call.ended Recommended

Fires when a call terminates normally. Includes the total duration in seconds and, if available, a recording URL.

Payload fields

call_uuid contact_uuid owner_user_id caller_name caller_peer_id receiver_peer_id call_type duration_seconds recording_url ended_at

Events

event.created Recommended

Fires when a follow-up, appointment, or reminder is scheduled on a contact — whether manually by staff or automatically by the CRM Auto-Pilot rules engine.

Payload fields

event_uuid contact_uuid owner_user_id title event_type scheduled_at description auto_created assigned_to_user_id created_at
event.completed

Fires when a follow-up is marked done by staff.

Payload fields

event_uuid contact_uuid owner_user_id title completed_at
event.cancelled

Fires when a follow-up is cancelled before completion.

Payload fields

event_uuid contact_uuid owner_user_id title cancelled_at

Delivery lifecycle

Understanding how a webhook moves through the system helps you reason about latency, retries, and how to debug. Here is the exact path from the moment an event fires to the moment your server has the data.

  1. 1

    Event fires on Cally360

    A visitor clicks Call, or a lead is created, or a call ends. The backend handler calls developer_webhook_emit() with the event name and the payload.

  2. 2

    Enqueue, don't dispatch

    developer_webhook_emit() does not make an HTTP call. It writes one row per subscribed endpoint into webhook_deliveries with status = 'pending' and returns immediately. This keeps the triggering request fast.

  3. 3

    Cron worker picks up the row

    A dispatch worker runs every 30 seconds. It claims a batch of pending rows using SELECT … FOR UPDATE SKIP LOCKED, guaranteeing no two workers deliver the same row twice.

  4. 4

    Sign, POST, and record

    For each claimed row, the worker computes the HMAC signature, POSTs the payload to your URL with the X-Cally-Signature header, and records the response status, body, and duration back into webhook_deliveries.

  5. 5

    Your receiver verifies and stores

    Your server reads the raw body, verifies the signature against the endpoint's signing secret, deduplicates on delivery_id, and inserts the event into its own database.

  6. 6

    Optional: storage confirmation

    Your receiver returns a 2xx with { "stored": true }, or POSTs a signed callback to ?action=webhook_ack. Cally360 upgrades the delivery to a confirmed-storage state you can see in the dashboard.

Expected latency

Average 15 seconds, worst case 30 seconds, from the moment the event fires to the moment your server receives the POST. The 30-second ceiling is the cron interval. If your use case requires sub-second delivery, contact support about a dedicated delivery queue.

Storage confirmation

A 2xx response from your server tells us the request arrived. It does not tell us your code actually wrote the event to your database. To close that gap, every endpoint has an ack mode that controls how strongly you confirm the write.

none

Fire-and-forget. We record the HTTP status but make no claim about storage. Suitable for read-only mirrors and analytics pipelines.

soft default

Your receiver returns { "stored": true } in the 2xx body. We record the storage claim. Lighter than a separate callback.

strong

Your receiver POSTs a signed callback to ?action=webhook_ack after committing. Provable, cryptographically authenticated, and immune to a buggy receiver that lies.

Soft ACK body — what your receiver returns

// On success
HTTP/1.1 200 OK
Content-Type: application/json

{ "stored": true, "row_id": 4321 }

// Or, when the insert fails:
HTTP/1.1 200 OK
Content-Type: application/json

{ "stored": false, "error": "unique constraint on call_uuid" }

We record storage_confirmed, storage_row_id, and storage_confirmation = 'soft' on the delivery row. Visible in your dashboard's delivery-health panel.

Strong ACK — what your receiver calls after committing

<?php
// Called AFTER you commit the event to your own DB.
// Fire-and-forget — never block your receiver on the ACK.

function send_ack(
    string $deliveryId,
    bool   $stored,
    ?string $rowId = null,
    ?string $error = null
): void {
    $secret  = getenv('CALLY_SIGNING_SECRET');
    $baseUrl = 'https://cally360.com/api/developer_api';

    $body = json_encode([
        'action'      => 'webhook_ack',
        'delivery_id' => $deliveryId,
        'stored'      => $stored,
        'row_id'      => $rowId,
        'error'       => $error,
    ], JSON_UNESCAPED_SLASHES);

    $t  = time();
    $v1 = hash_hmac('sha256', $t . '.' . $body, $secret);

    $ch = curl_init($baseUrl);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS     => 1500,          // never block the receiver
        CURLOPT_CONNECTTIMEOUT => 2,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'X-Cally-Signature: t=' . $t . ',v1=' . $v1,
        ],
    ]);
    @curl_exec($ch);
    curl_close($ch);
}

What gets recorded

  • • soft ACK sets storage_confirmation = 'soft' and stores storage_row_id if you provided it.
  • • strong ACK upgrades an existing soft record to storage_confirmation = 'strong'.
  • • The dashboard's delivery-health panel shows Delivered / Confirmed / Failed counts for the last 24 hours per endpoint.
  • • If you lose the signing secret, rotate it from the dashboard and update your env var. Deliveries continue without interruption.

Idempotency

Webhook delivery is at-least-once. Rarely, the same event will arrive twice — usually because the network dropped the 2xx acknowledgement after your server had already processed the payload. This is unavoidable over an unreliable network, and it's the same guarantee Stripe, GitHub, and every other major webhook provider makes.

The fix is a deduplication check keyed on X-Cally-Delivery. The value is stable across every retry of the same event, and it's also present inside the payload as delivery_id.

Deduplicate on delivery_id

A one-table, one-query pattern. Wrap the dedupe check and the business insert in a single transaction so a mid-flight crash cannot leave the two out of sync.

// Required table:
//
// CREATE TABLE processed_webhooks (
//   delivery_id  VARCHAR(64) NOT NULL PRIMARY KEY,
//   received_at  DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP
// );

$deliveryId = $payload['delivery_id'] ?? '';

$pdo->beginTransaction();

$dup = $pdo->prepare(
    "SELECT 1 FROM processed_webhooks WHERE delivery_id = ? LIMIT 1"
);
$dup->execute([$deliveryId]);

if ($dup->fetchColumn()) {
    // Already processed. Roll back and acknowledge as a success so
    // Cally360 stops retrying this delivery.
    $pdo->rollBack();
    http_response_code(200);
    echo json_encode(['stored' => true, 'row_id' => null]);
    exit;
}

$pdo->prepare("INSERT INTO processed_webhooks (delivery_id) VALUES (?)")
    ->execute([$deliveryId]);

// Your actual business insert here.
$pdo->prepare("INSERT INTO client_calls (call_uuid, caller_name) VALUES (?, ?)")
    ->execute([$payload['data']['call_uuid'] ?? '', $payload['data']['caller_name'] ?? '']);

$rowId = (int)$pdo->lastInsertId();

$pdo->commit();

http_response_code(200);
echo json_encode(['stored' => true, 'row_id' => $rowId]);

Signature verification

Every webhook carries an X-Cally-Signature header in the format t=<unix>,v1=<hex>,window=<seconds>. Verify it before you trust the payload. The algorithm is HMAC-SHA256 over {timestamp}.{raw_body} using the endpoint's signing secret.

The window field is advisory — it tells your verifier how many seconds of tolerance the sender recommends. Always cap your accepted window at 600 seconds regardless of the header value.

Use the raw request body. Do not parse and re-encode the JSON — whitespace and key order matter. Read the raw bytes from the incoming request, pass them to the verifier, and only parse to JSON after verification succeeds.

<?php
$secret = getenv('CALLY_SIGNING_SECRET');
$rawBody = file_get_contents('php://input');
$header  = $_SERVER['HTTP_X_CALLY_SIGNATURE'] ?? '';

// Parse "t=...,v1=...,window=..."
$t = $v1 = $window = null;
foreach (explode(',', $header) as $piece) {
    $piece = trim($piece);
    if (str_starts_with($piece, 't='))      $t      = substr($piece, 2);
    if (str_starts_with($piece, 'v1='))     $v1     = substr($piece, 3);
    if (str_starts_with($piece, 'window=')) $window = substr($piece, 7);
}

if (!$t || !$v1 || !ctype_digit($t)) {
    http_response_code(400); exit('bad signature header');
}

// Advisory window; never accept more than 600s.
$tolerance = 300;
if ($window !== null && ctype_digit($window)) {
    $tolerance = min((int)$window, 600);
}

if (abs(time() - (int)$t) > $tolerance) {
    http_response_code(400); exit('timestamp out of window');
}

$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
if (!hash_equals($expected, strtolower($v1))) {
    http_response_code(401); exit('invalid signature');
}

// Signature valid — safe to parse and process.
$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
http_response_code(200); echo 'ok';

Client samples

Production-ready clients and webhook verifiers for the most common languages. Every recipe follows the same shape: load the secret from an environment variable, verify the raw body before trusting it, and return 2xx fast.

Fetch the latest leads

Drop-in client. Prints name, email, and custom widget fields.

<?php
declare(strict_types=1);

final class Cally360Client
{
    public function __construct(
        private string $secret,
        private string $baseUrl = 'https://cally360.com/api/developer_api'
    ) {
        if (!str_starts_with($secret, 'cally_live_sk_')) {
            throw new InvalidArgumentException('Secret must start with cally_live_sk_');
        }
    }

    public function get(string $action, array $params = []): array
    {
        $params['action'] = $action;
        $ch = curl_init($this->baseUrl . '?' . http_build_query($params));
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $this->secret],
            CURLOPT_TIMEOUT        => 15,
            CURLOPT_SSL_VERIFYPEER => true,
        ]);
        $body = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        $data = json_decode((string)$body, true, 512, JSON_THROW_ON_ERROR);
        if ($code >= 400 || ($data['success'] ?? false) !== true) {
            throw new RuntimeException("Cally360 [{$data['error_code']}]: {$data['message']}");
        }
        return $data;
    }

    public function leads(int $perPage = 5, int $page = 1): array
    {
        return $this->get('leads', ['per_page' => $perPage, 'page' => $page]);
    }
}

$client = new Cally360Client(getenv('CALLY_SECRET'));
foreach ($client->leads()->['data']['leads'] as $lead) {
    echo "{$lead['full_name']} <{$lead['email']}>\n";
    print_r($lead['widget_fields']);
}

Verify an incoming webhook

Constant-time HMAC-SHA256 verification with a 5-minute replay window. Always pass the raw body — never re-encoded JSON.

<?php
$secret   = getenv('CALLY_SIGNING_SECRET');
$rawBody  = file_get_contents('php://input');
$header   = $_SERVER['HTTP_X_CALLY_SIGNATURE'] ?? '';

$t = $v1 = null;
foreach (explode(',', $header) as $piece) {
    $piece = trim($piece);
    if (str_starts_with($piece, 't='))  $t  = substr($piece, 2);
    if (str_starts_with($piece, 'v1=')) $v1 = substr($piece, 3);
}
if (!$t || !$v1 || !ctype_digit($t) || abs(time() - (int)$t) > 300) {
    http_response_code(400); exit('bad header');
}

$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
if (!hash_equals($expected, strtolower($v1))) {
    http_response_code(401); exit('invalid signature');
}

$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);

// ... process event, insert to your DB ...

http_response_code(200);
echo json_encode(['stored' => true, 'row_id' => 4321]);

Rules that apply to every recipe

  • • Secrets come from environment variables — never hardcode them.
  • • Add .env to .gitignore before the first commit.
  • • Always pass the raw request body to the verifier — never re-encode JSON.
  • • Use constant-time comparison for HMACs.
  • • Enforce a 5-minute replay window; cap at 600 s if the header suggests more.
  • • Deduplicate on X-Cally-Delivery to tolerate retries.
  • • Return 2xx within 15 seconds — enqueue slow work.

Testing

You have two paths. The dashboard modal is easiest for a first run. The cURL flow is useful for CI and for scripting your onboarding.

Path A — use the dashboard

  1. Log in to dashboard.
  2. Open Website Embed API.
  3. Click Webhooks next to your key.
  4. Paste your URL, choose events, click Register Webhook.
  5. Copy the signing secret into your env var.
  6. Click Send test on the endpoint you just added.
  7. Watch the delivery-health panel for the confirmed count to tick up.

Path B — use the API

1. Get a throwaway URL

Go to webhook.site and copy the unique URL. It logs every request to your browser.

2. Register the endpoint

curl -sS -X POST \
  -H "Authorization: Bearer $CALLY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
        "action": "webhook_endpoints",
        "op": "create",
        "url": "https://webhook.site/your-unique-id",
        "description": "Test receiver",
        "ack_mode": "soft",
        "events": ["call.initiated", "call.missed", "call.ended"]
      }' \
  "https://cally360.com/api/developer_api"

Copy the signing_secret from the response. Store it — it will not be shown again.

3. Fire a test event

curl -sS -X POST \
  -H "Authorization: Bearer $CALLY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
        "action": "webhook_endpoints_test",
        "endpoint_uuid": "ep_..."
      }' \
  "https://cally360.com/api/developer_api"

The dispatch worker picks up the enqueued event on its next tick — within 30 seconds. Watch webhook.site for the incoming request and check the X-Cally-Signature header.

4. Clean up

curl -sS -X POST \
  -H "Authorization: Bearer $CALLY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
        "action": "webhook_endpoints",
        "op": "delete",
        "endpoint_uuid": "ep_..."
      }' \
  "https://cally360.com/api/developer_api"

Errors & limits

Error responses

Failures return an HTTP status plus an error_code string. The message is human-readable and safe to log.

{
  "success": false,
  "message": "Invalid secret key.",
  "error_code": "INVALID_KEY",
  "meta": { "request_id": "req_...", "duration_ms": 12, "timestamp": 1730000000 }
}
Status Error code Meaning
400MISSING_ACTIONNo action parameter.
400UNKNOWN_ACTIONUnknown endpoint.
400BAD_JSONThe body is not valid JSON.
401NO_AUTHMissing Authorization header.
401INVALID_KEYSecret is wrong or revoked.
401NO_SIGNATUREMissing X-Cally-Signature on a webhook_ack call.
401BAD_SIGNATURESignature mismatch on a webhook_ack call.
403KEY_INACTIVE / KEY_EXPIREDKey not active or past expiry.
403IP_NOT_ALLOWEDCaller IP not in allowlist.
404NOT_FOUNDResource missing or out of scope.
404UNKNOWN_DELIVERYdelivery_id in a webhook_ack does not exist.
405METHOD_NOT_ALLOWEDWrong HTTP verb.
422MISSING_UUID / INVALID_URL / NO_EVENTSValidation failed.
422INVALID_ACK_MODEack_mode must be none, soft, or strong.
429RATE_LIMITMore than 60 req/min on this key.
500DB_ERROR / SERVER_ERRORSomething broke on our side. Retry.
503MIGRATION_PENDINGServer-side migration not yet applied. Contact support.

Rate limits

  • 60 requests per minute per secret key.
  • Exceeded requests receive 429 with a retry_after_seconds hint.
  • The limit is per key, not per IP or per account.
  • Webhook delivery is not rate-limited — the queue drains as fast as the cron worker can process.
  • If you need a higher limit, contact support.

Best practices

  • Store the secret in an environment variable, not in code.
  • Use webhooks for real-time, and a nightly REST pull to reconcile anything you missed.
  • Verify the signature on every webhook before processing. Always use constant-time comparison.
  • Deduplicate on X-Cally-Delivery — retries happen.
  • Return 2xx quickly. If you need to do slow work, enqueue it and return 200 immediately.
  • Use ack_mode = 'strong' if you need provable storage confirmation for compliance.
  • Log the X-Cally-Delivery header — it makes support requests easy to trace.