---
title: "Webhooks Guide"
description: "Set up HTTPS endpoints to receive real-time call, prospect, and action events from Symbo"
url: https://help.symbo.ai/en/articles/10404562-webhooks-guide
collection: "Integrations"
updated: 2026-10-28
language: en
---

# Webhooks Guide

Set up HTTPS endpoints to receive real-time call, prospect, and action events from Symbo

This article covers everything you need to set up, secure, and consume Symbo webhooks. For signature validation specifics, see:

[

Validating Symbo Webhooks

Symbo uses HMAC Validation to authenticate webhook requests made to your application. This ensures that the request originates from Symbo and has not been tampered with.

](https://help.symbo.ai/en/articles/10404504-validating-symbo-webhooks.md)

## **How webhooks work**

1.  You configure one or more **endpoints** in Symbo (an HTTPS URL we’ll send requests to).
    
2.  For each endpoint, you subscribe to one or more **events** (e.g. `call:completed`).
    
3.  When a subscribed event happens in Symbo, we fire an HTTP `POST` to your endpoint with a JSON body and a signed `X-Hmac-Signature` header.
    
4.  Your server verifies the signature, processes the payload, and responds with a `2xx` status to acknowledge receipt.
    

If your server returns a non-2xx status (or doesn’t respond), we’ll log the failure and retry.

## **Setting up an endpoint**

### Step 1: Open the Webhooks page

Open **Organization settings** and go to **Integrations → Webhooks**, or head straight to [https://app.symbo.ai/org-settings?type=webhooks](https://app.symbo.ai/org-settings?type=webhooks).

![](https://staticfiles.gleap.io/ghelparticle/VbgSVit9uEAgqy4Oy6lIHQCCv9NWCJcjSS2OzMMOG4iPH7n5qDBdrfaPlSensbnEjkJOm4xf8cV.png)

![](https://staticfiles.gleap.io/ghelparticle/k8B4bVspwUAKc8FwA9DY9CkEDCE5qzVCs4ZyvrJtIrW1TXRutUgDrX1CWjh4j3yp6ArRNICp3R8.png)

### Step 2: Add a webhook

Click **Add webhook** in the top-right corner of the page.

![](https://staticfiles.gleap.io/ghelparticle/qZu4l6pdaFhNzK9BPDM3V7CNJJpJKYTgTmSIJe5nNBXn5OGboDZKb9dPM2j1qN1BqWSXqw1pysP.png)

### Step 3: Choose the webhook type

Leave **Webhook Type** set to **Custom**. (Spinify is a purpose-built type for the Spinify integration — it asks for a Spinify API secret instead of a URL.)

### Step 4: Fill in the endpoint details

-   **Name** — a friendly label (e.g. “Production CRM sync”).
    
-   **Events** — pick one or more from the list below.
    
-   **URL** — must be `https://` and publicly reachable.
    

### Step 5: Save and copy your secret

Save. Symbo generates a **webhook secret** for the endpoint. Hover over the endpoint’s row to reveal (eye icon) or copy it — you’ll need it to validate incoming requests.

You can have multiple endpoints, each subscribing to different events. This is useful if you want to fan out call events to one service and prospect events to another.

## **Available events**

<table data-id="22dc7300-c1e7-4466-91e9-51d8e1051825" style="min-width: 100px;"><colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1"><p data-id="cb2ac5aa-ee30-4dcc-acf6-55b1872d5852">Event</p></td><td colspan="1" rowspan="1"><p data-id="4117a8cb-14b0-432f-bf5e-e080f9b4bef5">Object</p></td><td colspan="1" rowspan="1"><p data-id="aa95d750-3df8-4e5b-b95b-c352d5fbaaa2">Method</p></td><td colspan="1" rowspan="1"><p data-id="ed7a1d2b-5bf5-4f19-80f1-cee51fa69a7f">Triggered When</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="577338ea-8614-488a-82e0-1a58f47834d2">call:created</p></td><td colspan="1" rowspan="1"><p data-id="794b0563-588a-41fb-a78a-c7569fa9eb0d">Call</p></td><td colspan="1" rowspan="1"><p data-id="0dbf6694-a4ff-47b8-b140-5d58cd4666fe">POST</p></td><td colspan="1" rowspan="1"><p data-id="8c1ba2a4-8734-4ec3-a485-1770077473d3">A call is initiated (inbound or outbound)</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="83581fa0-60ed-476f-8c5b-5ede82b6610e">call:completed</p></td><td colspan="1" rowspan="1"><p data-id="44bd20e7-a8fe-44ac-bc7d-23c03edba736">Call</p></td><td colspan="1" rowspan="1"><p data-id="f4c01b56-1d0f-4011-aaf4-00d47299bb49">POST</p></td><td colspan="1" rowspan="1"><p data-id="e8900699-ba63-45e1-b18b-3ad71f1acb47">A call ends — answered or not</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="476910e0-1a56-439c-a81b-6a8f23a26b8f">call:updated</p></td><td colspan="1" rowspan="1"><p data-id="22fabc6f-a33b-4187-a9b3-280931a332e7">Call</p></td><td colspan="1" rowspan="1"><p data-id="d2c36b6d-50b3-458c-86b1-971caf226895">POST</p></td><td colspan="1" rowspan="1"><p data-id="8d149515-c433-48f9-8077-b0b4200173d5">A call’s note, disposition, or coaching note changes; also fires on cancel/fail</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="845853b8-cb09-4625-ac8b-8695b1ae648e">call:recording-completed</p></td><td colspan="1" rowspan="1"><p data-id="e2bba6ee-6277-46ee-8961-5d808ec89e77">Call</p></td><td colspan="1" rowspan="1"><p data-id="c15d848e-a746-456a-89e5-d02aad59bc5b">POST</p></td><td colspan="1" rowspan="1"><p data-id="053a7364-921b-461b-932b-2972f4259226">Recording and transcription are processed and downloadable</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="57c800e1-c133-47f2-8da8-beea40240250">call:contactlookup</p></td><td colspan="1" rowspan="1"><p data-id="9225faf3-f595-485d-abaf-3c9dc9c213b5">Call</p></td><td colspan="1" rowspan="1"><p data-id="3fd1a402-c21b-4c13-a12b-744d0d5be547">GET</p></td><td colspan="1" rowspan="1"><p data-id="f778b5ce-6f9e-4f92-97b8-24d613a0a23f">A call dials a number not yet linked to a prospect — see special section below</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="a72e3fb2-ccde-4c27-91df-f481b0b68793">prospect:created</p></td><td colspan="1" rowspan="1"><p data-id="03e9e6f2-5c3f-4ab3-b3db-3fc13eaf25a6">Prospect</p></td><td colspan="1" rowspan="1"><p data-id="bd6584a1-7198-44e4-8018-e333c25e9de0">POST</p></td><td colspan="1" rowspan="1"><p data-id="cd5f925f-b0b9-451b-8db2-ad0e263189d3">A new prospect is added to Symbo</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="aebdd7f5-8708-4fb1-8916-726ad74f9e48">prospect:updated</p></td><td colspan="1" rowspan="1"><p data-id="588469bc-2e0f-4156-8590-1409a6d95638">Prospect</p></td><td colspan="1" rowspan="1"><p data-id="2852093c-c5ee-4837-b188-7c9c1487933a">POST</p></td><td colspan="1" rowspan="1"><p data-id="ae2617d1-7f02-4a8e-ac77-f6271b25d47d">A prospect record is modified</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="11ca9a8b-5e12-402c-b5e5-865c557850c5">action:completed</p></td><td colspan="1" rowspan="1"><p data-id="20113482-2abb-4b5a-acd6-3c5bfd448725">Action</p></td><td colspan="1" rowspan="1"><p data-id="3321ccce-ef3f-489d-9a38-9ab8edb7b9d9">POST</p></td><td colspan="1" rowspan="1"><p data-id="8f7bb071-d873-4898-9b26-832714c23c9a">A task, call, or email step is marked complete</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="5aadec43-9b5b-4347-a154-7cb3ea4b32e4">action:updated</p></td><td colspan="1" rowspan="1"><p data-id="316f1ab1-c62a-4c2c-b4fd-8868682ff7e1">Action</p></td><td colspan="1" rowspan="1"><p data-id="cc8f3fa0-b91b-4259-9f31-9097b64b7512">POST</p></td><td colspan="1" rowspan="1"><p data-id="0742144b-2d48-436a-bf8f-5f7d7ae56dbf">An action record is modified</p></td></tr></tbody></table>

## **Request format**

All `POST` webhooks share the same envelope:

```http
POST /your-webhook-path HTTP/1.1
Host: your-app.com
Content-Type: application/json
X-Hmac-Signature: 7c4b3e9f1a2d8c6b5f0e9d8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c

{
  "id": "evt_01HXYZ...",
  "object": "Call",
  "description": "call:completed",
  "user_id": "usr_01HXYZ...",
  "created_at": "2026-05-04T15:30:00.000Z",
  "data": {
    /* event-specific payload */
  }
}

```

The envelope fields:

<table data-id="7272b5de-6c56-4447-9896-d8a5284bbcb4" style="min-width: 50px;"><colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1"><p data-id="f5d0de25-24db-4305-9e8f-f461e790c3b7"><strong>Field</strong></p></td><td colspan="1" rowspan="1"><p data-id="b57f1601-70eb-417e-b0ad-ebb8540412b9"><strong>Description</strong></p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="a729a313-8926-4205-bad7-d8c7ab641656">id</p></td><td colspan="1" rowspan="1"><p data-id="9ea7ade1-d202-42b0-8c73-7657a51f6258">Unique event ID — useful for deduplication and idempotency</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="ef3a7318-3f14-4e29-93db-997ffd7e8482">object</p></td><td colspan="1" rowspan="1"><p data-id="98ad323b-57af-415c-b548-d7f0a96141b6">The resource type (Call, Prospect, or Action)</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="31523fbd-cd2e-412d-8331-2722d0d12e01">description</p></td><td colspan="1" rowspan="1"><p data-id="510e82f6-372a-4a43-ba7c-11829c46a9be">The event name (e.g. call:completed)</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="2a6cde39-0c50-4bd5-be4c-c1e29c718098">user_id</p></td><td colspan="1" rowspan="1"><p data-id="ca550558-0b42-46f7-bdc7-7619f74e1c99">The Symbo user the event is associated with</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="e0ac1163-658a-4bcf-8b85-20685f59613c">created_at</p></td><td colspan="1" rowspan="1"><p data-id="4561922d-2b3b-46df-8e93-587a38694a92">ISO 8601 timestamp of when the event was created</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="833df217-4372-4a38-9e5a-390a8a4b3674">data</p></td><td colspan="1" rowspan="1"><p data-id="d9af3c8b-20fa-435e-91f2-c4c83284e935">The event-specific payload (see examples below)</p></td></tr></tbody></table>

## **Validating the signature**

Every `POST` webhook includes an `X-Hmac-Signature` header so you can verify the request came from Symbo and wasn’t tampered with.

The signature is computed as:

```javascript
HMAC-SHA256(JSON.stringify(payload.data), your_webhook_secret)

```

> ⚠ **_Important:_** _The signature is over the_ **_inner_** `data` **_object only_**_, not the full envelope. This is the most common cause of validation failures._

Node.js example:

```javascript
const crypto = require('crypto')

function verifySignature(req) {
  const provided = req.headers['x-hmac-signature']
  const expected = crypto
    .createHmac('sha256', process.env.SYMBO_WEBHOOK_SECRET)
    .update(JSON.stringify(req.body.data))
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(provided, 'hex'),
    Buffer.from(expected, 'hex')
  )
}

```

Python example:

```python
import hmac, hashlib, json, os

def verify_signature(request):
    provided = request.headers.get('X-Hmac-Signature')
    expected = hmac.new(
        key=os.environ['SYMBO_WEBHOOK_SECRET'].encode(),
        msg=json.dumps(request.json['data']).encode(),
        digestmod=hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(provided, expected)

```

If signatures don’t match, log the rejection and return a `401`. Most validation issues come down to:

-   Hashing the full envelope instead of just `data`
    
-   Re-serializing `data` with different key ordering or whitespace (use the parsed object directly, or be careful about how your framework formats JSON)
    
-   Mixing up secret values between environments
    

## **Example payloads**

### `call:completed`

```json
{
  "id": "evt_01HXYZ8L4N3P5Q6R7S8T9U0V1W",
  "object": "Call",
  "description": "call:completed",
  "user_id": "usr_01HXYZ7K3M2N4P5Q6R7S8T9U0V",
  "created_at": "2026-05-04T15:34:12.000Z",
  "data": {
    "id": "call_01HXYZABC123",
    "direction": "outbound",
    "from": "+15551234567",
    "to": "+15559876543",
    "state": "completed",
    "dialed_at": "2026-05-04T15:30:00.000Z",
    "answered_at": "2026-05-04T15:30:08.000Z",
    "completed_at": "2026-05-04T15:34:12.000Z",
    "duration_seconds": 244,
    "outcome": "connected",
    "call_disposition": "Meeting Booked",
    "call_disposition_group": "Positive",
    "recording_url": null,
    "transcription": null,
    "rep_overall_sentiment": "positive",
    "prospect_overall_sentiment": "positive",
    "total_talk_time_millis": 232000,
    "rep_talk_time_millis": 102000,
    "prospect_talk_time_millis": 130000,
    "is_logged_to_crm": true,
    "crm_activity_external_id": "0035g00000XYZ",
    "note": "Great convo, scheduled demo for Friday.",
    "prospect": { "id": "psp_01HXYZ...", "full_name": "Jane Doe", "title": "VP Sales" },
    "user": { "id": "usr_01HXYZ...", "first_name": "Sam", "last_name": "Rep" }
  }
}

```

### `call:recording-completed`

```json
{
  "id": "evt_01HXYZAN6Q5R7S8T9U0V1W2X3Y",
  "object": "Call",
  "description": "call:recording-completed",
  "user_id": "usr_01HXYZ...",
  "created_at": "2026-05-04T15:35:30.000Z",
  "data": {
    "id": "call_01HXYZABC123",
    "duration_seconds": 244,
    "recording_url": "https://recordings.symbo.ai/call_01HXYZABC123.mp3",
    "transcription": "Hi Jane, this is Sam from Iconic...",
    "transcription_completed_at": "2026-05-04T15:35:30.000Z",
    "transcription_url": "https://recordings.symbo.ai/call_01HXYZABC123.json",
    "transcription_json": [
      { "speaker": "rep", "start_ms": 0, "end_ms": 4200, "text": "Hi Jane, this is Sam from Iconic." },
      { "speaker": "prospect", "start_ms": 4500, "end_ms": 6800, "text": "Hi Sam, what's this about?" }
    ],
    "prospect": { "id": "psp_01HXYZ...", "full_name": "Jane Doe" }
  }
}

```

### `prospect:created`

```json
{
  "id": "evt_01HXYZBP7R6S8T9U0V1W2X3Y4Z",
  "object": "Prospect",
  "description": "prospect:created",
  "user_id": "usr_01HXYZ...",
  "created_at": "2026-05-04T14:00:00.000Z",
  "data": {
    "id": "psp_01HXYZ...",
    "first_name": "Jane",
    "last_name": "Doe",
    "full_name": "Jane Doe",
    "title": "VP Sales",
    "linkedin_url": "https://linkedin.com/in/janedoe",
    "website_url_1": "https://acme.com",
    "time_zone": "America/Los_Angeles",
    "prospect_status": "active",
    "auto_schedule": true,
    "tier": "Tier 1",
    "segment": "Enterprise"
  }
}

```

Custom prospect fields are flattened into `data` alongside the standard fields. The exact set depends on your org’s prospect schema.

### `action:completed`

```json
{
  "id": "evt_01HXYZDR9T8U0V1W2X3Y4Z5A6B",
  "object": "Action",
  "description": "action:completed",
  "user_id": "usr_01HXYZ...",
  "created_at": "2026-05-04T15:34:12.000Z",
  "data": {
    "id": "act_01HXYZ...",
    "action_type": "call",
    "state": "completed",
    "completed": true,
    "completed_at": "2026-05-04T15:34:12.000Z",
    "due_at": "2026-05-04T15:00:00.000Z",
    "scheduled_at": "2026-05-04T15:30:00.000Z",
    "subject": "Discovery call with Jane Doe",
    "body_html": "<p>Discuss Q3 expansion plans.</p>",
    "note": "Meeting booked for Friday.",
    "drive_step_id": "dst_01HXYZ...",
    "priority": 2,
    "is_pinned": false,
    "is_private": false
  }
}

```

## **Special case:** `call:contactlookup`

Unlike every other webhook, `call:contactlookup` is a `GET` request — and it expects _you_ to respond with contact data, which Symbo will then attach to the call.

It fires when a call dials a number that isn’t already linked to a prospect in Symbo, giving you a chance to look up the number in your own CRM and provide the matching contact on the fly.

**Request we send:**

```http
GET /your-lookup-path?number=%2B15559876543 HTTP/1.1
Host: your-endpoint.com

```

There is no body and no `X-Hmac-Signature` on this request.

**Response your server must return** (status `200`):

```json
{
  "first_name": "Jane",
  "last_name": "Doe",
  "external_id": "crm-contact-12345",
  "work_phone": "+15559876543",
  "mobile_phone": "+15559876544",
  "home_phone": null,
  "other_phone": null,
  "work_email": "jane@acme.com",
  "personal_email": null,
  "account": {
    "external_id": "crm-account-678",
    "name": "Acme Corp"
  }
}

```

Required fields:

-   `first_name`
    
-   `external_id`
    
-   If `account` is included: both `account.external_id` and `account.name`
    

If any required field is missing, or if your server returns a non-`200` status, the response is silently dropped and no prospect is created.

## **Retries and delivery guarantees**

-   We consider a delivery successful if your endpoint returns any `2xx` status.
    
-   Any other status (or a network failure) increments the `retry_count` for the event.
    
-   We deliver **at least once** — your code should be idempotent. Use the envelope `id` as a dedupe key.
    
-   Webhook deliveries time out after a short window. Process the payload asynchronously and return `200` quickly; don’t do heavy work in the request handler.
    

## **Best practices**

-   **Always validate the signature.** Don’t trust the source IP or any other header.
    
-   **Use HTTPS.** We will not deliver to plain `http://` URLs.
    
-   **Respond fast.** Aim for under 5 seconds. Push slow work to a queue.
    
-   **Be idempotent.** Use the `id` field on the envelope to dedupe — the same event can arrive more than once.
    
-   **Log failures.** Recording the body and computed-vs-provided signature on rejection makes debugging a lot easier.
    
-   **Use separate endpoints per environment.** Don’t share a webhook secret between staging and production.
    

## **Troubleshooting**

**I’m not receiving any webhooks.**

-   Confirm the endpoint URL is publicly reachable over HTTPS.
    
-   Confirm at least one event is selected on the endpoint.
    
-   Trigger a matching action in Symbo (e.g. complete a call), then check the delivery history.
    

**Signatures aren’t matching.**

-   You’re almost certainly hashing the wrong thing. Hash `payload.data`, not the full envelope.
    
-   Make sure you’re using the secret from the _correct_ endpoint.
    
-   Avoid re-serializing the body if your framework changed key order or whitespace — hash the raw JSON-stringified `data` object as it was received.
    

**My endpoint is being hammered with retries.**

-   Your server is probably returning a non-`2xx` status. Check the delivery history for the response code we’re seeing.
    

**Still stuck?** Message us or email [team@symbo.ai](mailto:team@symbo.ai) with an example event ID from the delivery history and we’ll dig in.
