# Guide to CallRail Webhooks: Features and Best Practices

CallRail webhooks push call tracking data into your own systems as calls and leads come in: a call starts ringing, it finishes and its recording and transcript are attached, someone tags it afterwards, a text message arrives, a form is submitted on your site. If you use CallRail to attribute calls to marketing campaigns, webhooks are how that data reaches your CRM, screen-pop tool or reporting pipeline without polling the API.

This guide covers how CallRail webhooks are configured, how to verify the `Signature` header, how to tell one event from another when the payload doesn't say, and how to build a receiver for a sender that never retries.

## What are CallRail webhooks?

CallRail webhooks belong to a company. Each company has one Webhooks integration, and its configuration holds a separate list of URLs for each event: pre-call, call routing complete, post-call, call modified, the two outbound call events, text message received and sent, and form submission. When an event fires, CallRail sends a JSON `POST` to every URL in that event's list.

The body is the resource itself. A call event sends a call object in the same format as the call API, a text event sends the text message, and a form event sends the form submission. No field or header names the event. The only thing that tells a pre-call webhook from a post-call webhook is the URL it was sent to.

Every request carries a `Signature` header: a base64 HMAC-SHA1 of the request body, keyed with a secret token CallRail generates for the company.

## CallRail webhook features

| Feature | Details |
| --- | --- |
| Configuration | The Webhooks integration in the CallRail app, or `POST /v3/a/{account_id}/integrations.json` |
| Subscription model | One URL list per event, in the integration's `config` object |
| Event identification | None on the wire. Each event's URL tells you which event it is |
| Signature header | `Signature` |
| Scheme | HMAC-SHA1, base64 digest |
| Signed content | The request body |
| Secret | One token per company, on the Webhooks configuration page (`signing_key` in the API) |
| Replay window | None defined. CallRail suggests comparing the body's timestamp with the current time |
| Success response | Any `2xx` |
| Retries | None. CallRail does not resend webhooks |
| Failure handling | Repeated failures can disable the integration automatically |

CallRail publishes no source IP list for webhooks.

## Common events

| Event | Config key | Body | Fires when |
| --- | --- | --- | --- |
| Pre-Call | `pre_call_webhook` | Call object | An inbound call is received, before it is connected |
| Call Routing Complete | `answered_call_webhook` | Call object | An inbound call has been routed to its destination |
| Post-Call | `post_call_webhook` | Call object | An inbound call has ended and its recording, transcription and summary are attached |
| Call Modified | `updated_call_webhook` | Call object plus `changes` | A call changes after it has ended, for example a tag, note or spam flag |
| Outbound Post-Call | `post_outbound_call_webhook` | Call object, `call_type` is `outbound` | An outbound call has ended |
| Outbound Call Modified | `updated_outbound_call_webhook` | Call object plus `changes` | An outbound call changes after it has ended |
| Text Message Received | `sms_received_webhook` | Text message | A tracking number receives a text |
| Text Message Sent | `sms_sent_webhook` | Text message plus `agent` | A tracking number sends a text |
| Form Submission | `form_captured_webhook` | Form submission | A tracked form is submitted (Form Tracking must be active) |

CallRail also documents a Convert Assist Complete webhook, sent after an interaction finishes processing Convert Assist AI answers, with `person_resource_id`, `answers` and `interaction_count`. The configuration table has no key for it, so CallRail does not say how to subscribe to it on its own.

The call events form a sequence, and their timing matters. Pre-call and call routing complete fire in real time with partial data. Post-call is not real time: it waits for the recording, transcription and call summary, up to 20 minutes after hangup, and then fires even if some of that data is still missing. CallRail recommends subscribing to Call Modified too, so that a late recording or transcript still reaches you. Call Modified carries the updated fields in place plus a `changes` array naming them, such as `["note"]` or `["tags"]`.

Here is CallRail's Text Message Received example:

```json
{
  "id": 879204976,
  "resource_id": "SCI0c14896tw1b64fb970ab62aaf4fbefd9",
  "source_number": "770-555-5555",
  "destination_number": "770-123-4567",
  "content": "CallRail is the best! ",
  "message_type": "sms",
  "media_urls": [],
  "timestamp": "2017-10-23T15:57:18.826-04:00",
  "lead_status": null,
  "conversation_id": "crpLH",
  "company_resource_id": "COM834847e9f4bb4f9h8ll29eb173fe658e",
  "person_resource_id": "PERb122rc301ec45f5yy021d4jj180c1a79"
}

```

Call bodies are larger. They follow the call API's fields and also include legacy fields such as `callernum`, `trackingnum` and `datetime` alongside the current ones. CallRail asks new integrations to rely on the documented API fields and to parse the JSON body, ignoring the few legacy attributes that also arrive in the query string.

## Setting up CallRail webhooks

Because nothing in the payload names the event, give every event its own URL. Either use separate paths, or add a query parameter you control, such as `?event=post_call`, and read it in your handler.

1. In CallRail, open the Webhooks integration for the company and enter a URL for each event you need.
2. Copy the secret token shown on the Webhooks configuration page and store it server-side, for example as `CALLRAIL_SIGNING_KEY`.
3. Make a test call or submit a test form and check that your endpoint returns a `2xx`.

To create the integration through the API:

```bash
curl -X POST "https://api.callrail.com/v3/a/{account_id}/integrations.json" \
  -H "Authorization: Token token={api_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "Webhooks",
    "company_id": "COM8154748ae6bd4e278a7cddd38a662f4f",
    "config": {
      "post_call_webhook": ["https://example.com/webhooks/callrail?event=post_call"],
      "updated_call_webhook": ["https://example.com/webhooks/callrail?event=updated_call"],
      "form_captured_webhook": ["https://example.com/webhooks/callrail?event=form_captured"]
    }
  }'

```

The response includes `signing_key`, the same token as on the configuration page. A company can have only one integration of each type, so to change the URLs, update the existing integration rather than creating another.

## Securing CallRail webhooks

Compute an HMAC-SHA1 of the raw request body with the signing key as a plain string, base64-encode it, and compare it to the `Signature` header in constant time.

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

function verifyCallRailSignature(rawBody, signatureHeader, signingKey) {
  if (!signatureHeader || !signingKey) return false;
  const expected = crypto.createHmac('sha1', signingKey).update(rawBody).digest('base64');
  const received = Buffer.from(signatureHeader, 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/callrail', express.raw({ type: '*/*' }), (req, res) => {
  const signingKey = process.env.CALLRAIL_SIGNING_KEY;
  if (!signingKey) return res.sendStatus(500); // fail closed

  if (!verifyCallRailSignature(req.body, req.get('signature'), signingKey)) {
    return res.sendStatus(401);
  }

  const event_name = req.query.event; // the parameter you put on each URL
  const payload = JSON.parse(req.body.toString('utf8'));
  // Persist { event_name, payload }, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os

def verify_callrail_signature(raw_body: bytes, signature_header, signing_key: str) -> bool:
    if not signature_header or not signing_key:
        return False
    digest = hmac.new(signing_key.encode("utf-8"), raw_body, hashlib.sha1).digest()
    expected = base64.b64encode(digest)
    return hmac.compare_digest(expected, signature_header.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_callrail_signature(raw_body, request.headers.get("signature"),
#                           os.environ["CALLRAIL_SIGNING_KEY"])

```

CallRail publishes a test vector. With the key `072e77e426f92738a72fe23c4d1953b4` and the sample call body in the Security section of CallRail's API docs (compact JSON, no trailing newline), your code should produce `UZAHbUdfm3GqL7qzilGozGzWV64=`. Copy the body exactly; a single added space or newline changes the result.

The signature covers only the body. CallRail notes that each request includes a timestamp you can compare with the current time to reject replays. If you do this, keep it optional and generous: on call events, `timestamp` is the call's start time, and a post-call webhook can arrive 20 minutes after hangup.

> **Make CallRail webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `Signature` header, stores every call, text and form event the moment it arrives, and lets you retry or replay deliveries that CallRail itself will never resend.

## CallRail webhook limitations and pain points

### A failed delivery is gone

**The Problem:** If your endpoint is down or returns an error, that call or form submission never reaches you.

**Why It Happens:** CallRail does not resend webhooks. A non-`2xx` response or a connection failure loses the event, and CallRail warns that "missing calls might be your first indication of a problem". Repeated failures can also disable the integration automatically.

**Workarounds:**

* Keep the endpoint simple: verify, store, return `2xx`, and process later.
* Reconcile periodically against the CallRail API for the calls, texts and forms you expected.
* Monitor the integration's state so you notice if CallRail disables it.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and keeps it, then retries against your service until it succeeds. Your downtime becomes a delay instead of lost leads, and fewer failed responses reach CallRail.

### No event type in the payload

**The Problem:** Your handler receives a call object and can't tell whether it is a pre-call, post-call or call modified webhook.

**Why It Happens:** CallRail sends the resource without an envelope. The event is decided by which URL list you put the endpoint in, and nothing in the request repeats it.

**Workarounds:**

* Register a distinct path or query parameter per event.
* If you must share one URL, infer from the shape: a `changes` array means call modified, `call_type: "outbound"` means an outbound event, `message_type` means a text, and `form_data` means a form. This cannot separate pre-call from post-call reliably.

**How Hookdeck Can Help:** Hookdeck filters can match on the request's path and query as well as its body, so a per-event parameter on each CallRail URL is enough to route each event to the right destination.

### Post-call data arrives late or incomplete

**The Problem:** Recordings, transcripts or summaries are missing from the post-call payload, or arrive long after the call.

**Why It Happens:** Post-call waits for recording, transcription and summary processing for up to 20 minutes, then fires with whatever is ready.

**Workarounds:**

* Subscribe to Call Modified and update the stored call when `changes` lists `recording`, `transcription_text` or `call_summary`.
* Use pre-call or call routing complete for anything that must happen during the call.

**How Hookdeck Can Help:** Hookdeck's filters and routing can send post-call and call modified events to the same handler and hold them for replay if that handler fails.

## Best practices

### Verify against the raw body

The signature is computed over the exact bytes CallRail sent. Read the raw body, verify, then parse. Test your code against CallRail's published vector before going live.

### Give each event its own URL

Use separate paths or a query parameter so that every request identifies its own event. It makes routing, logging and replay much simpler.

### Treat the call as a record that changes

Store calls keyed on `id` and update them in place as later events arrive. A call can produce pre-call, routing complete, post-call and several call modified webhooks.

### Deduplicate deliveries

There is no delivery ID. Key text messages on `id` or `resource_id`, forms on `id`, and calls on `id` plus the event name, so a repeated delivery of the same event updates rather than duplicates. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Because CallRail never retries, a slow or failing handler costs data. Return `2xx` as soon as the event is stored. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Ignore the query string fields

Legacy attributes also arrive as query parameters. CallRail only adds and updates fields in the JSON body, so read the body and ignore the query string, apart from any parameter you added yourself.

## Conclusion

CallRail signs each webhook with a base64 HMAC-SHA1 of the body in the `Signature` header, keyed with the company's signing token. The payload never names the event, so give each event its own URL, and because CallRail never resends a failed webhook, acknowledge quickly and keep the endpoint available.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every `Signature`, and keeps each call, text and form event until your service has processed it, adding the retries CallRail doesn't provide.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle CallRail webhooks reliably in minutes.