> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nixflex.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive call data in your own systems

Set a webhook URL on a phone number and Nixflex will POST a clean, flat JSON payload to your server about 60 seconds after every call on that number ends. Use this to sync transcripts to your database, trigger downstream workflows, or pipe events into Zapier / Make. Webhooks are configured **per phone number**, so each number can send to its own endpoint.

## Setting a webhook

Set the webhook URL on one of your numbers:

```bash theme={null}
curl -X PUT https://api.nixflex.com/v1/integrations/webhook/number/+447700900123 \
  -H "Authorization: Bearer KEY_ID:KEY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://api.yourapp.com/nixflex/calls"}'
```

`url` is required and must be `https://`. Every call on that number — inbound, outbound, or batch — will POST to it.

**Read** the current URL (returned in full, since it is your own endpoint), or **DELETE** to remove it:

```bash theme={null}
curl https://api.nixflex.com/v1/integrations/webhook/number/+447700900123 -H "Authorization: Bearer KEY_ID:KEY_SECRET"
# -> { "phone_number": "+447700900123", "webhook_url": "https://api.yourapp.com/nixflex/calls", "is_set": true }

curl -X DELETE https://api.nixflex.com/v1/integrations/webhook/number/+447700900123 -H "Authorization: Bearer KEY_ID:KEY_SECRET"
```

## Second webhook URL (optional)

Each number can have a second webhook URL in addition to the primary one. Both URLs receive the same payload, for both events (`call.started` and `call.completed`). Use it to send call data to a second endpoint — for example, your own system alongside a primary destination.

The second URL is **API-only** (there is no dashboard field for it). It mirrors the primary webhook routes, using `webhook2` in the path:

```bash theme={null}
# Set the second URL
curl -X PUT https://api.nixflex.com/v1/integrations/webhook2/number/+447700900123 \
  -H "Authorization: Bearer KEY_ID:KEY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://api.yourapp.com/nixflex/calls-2"}'

# Read it
curl https://api.nixflex.com/v1/integrations/webhook2/number/+447700900123 -H "Authorization: Bearer KEY_ID:KEY_SECRET"
# -> { "phone_number": "+447700900123", "webhook_url_2": "https://...", "is_set": true }

# Remove it
curl -X DELETE https://api.nixflex.com/v1/integrations/webhook2/number/+447700900123 -H "Authorization: Bearer KEY_ID:KEY_SECRET"
```

`url` is required and must be `https://`. The primary webhook (set via `/webhook/number/`) is unaffected — the two URLs are independent. A number with both set delivers to both; with only one set, only that one fires.

## Payload structure

The body is a **flat JSON object** — every field is at the top level (no nested `call` wrapper). Only a curated, whitelisted set of fields is sent; internal data (your system prompt, API key id, database ids) is never included.

```json theme={null}
{
  "event": "call.completed",
  "call_id": "CA1b5c0bfad4b56635b773938f16db2db7",
  "agent_id": "agent_f2b06c0e0bd877e9",
  "direction": "inbound",
  "from": "+447386172392",
  "to": "+447446466847",
  "status": "completed",
  "ended_reason": "silence_timeout",
  "started_at": "2026-05-28T02:59:26.296Z",
  "ended_at": "2026-05-28T03:00:36.410Z",
  "duration_seconds": 70,
  "transcript": "Agent: Hello! How can I help?\nCaller: Hi, my name is Amir...",
  "summary": "Amir called inquiring about the business and requested a callback.",
  "sentiment": "neutral",
  "successful": true,
  "extracted": {
    "caller_name": "Amir",
    "wants_callback": true
  },
  "bookings": [
    {
      "action": "created",
      "booking_uid": "scf821a4pcuq3oglf8rfh0tc8k",
      "start": "2026-05-29T09:00:00.000Z",
      "attendee_name": "Amir",
      "attendee_email": "amir@example.com",
      "service": "appointment",
      "timezone": "Europe/London"
    }
  ],
  "recording_url": "https://storage.nixflex.com/recordings/CA1b5c....mp3",
  "recording_duration_seconds": 64,
  "voicemail_detected": false,
  "campaign_id": null,
  "avg_latency_ms": 901
}
```

### Key fields

| Field                                | Type          | Notes                                                                                                                                                 |
| ------------------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event`                              | string        | `call.started` (call connected) or `call.completed` (call finished). The dashboard Test button sends `webhook.test`. See [Event types](#event-types). |
| `call_id`                            | string        | Twilio call SID. Use this as your idempotency key.                                                                                                    |
| `agent_id`                           | string        | The agent that handled the call.                                                                                                                      |
| `direction`                          | string        | `inbound` or `outbound`.                                                                                                                              |
| `from` / `to`                        | string        | Caller's number / number dialled.                                                                                                                     |
| `status`                             | string        | Final call status, e.g. `completed`.                                                                                                                  |
| `ended_reason`                       | string        | Why the call ended, e.g. `caller_hangup`, `silence_timeout`.                                                                                          |
| `started_at` / `ended_at`            | string        | ISO 8601 timestamps (UTC).                                                                                                                            |
| `duration_seconds`                   | number        | Full connection time in seconds (ring/connect to hang-up). Used for billing.                                                                          |
| `transcript`                         | string        | Plain text, newline-separated. Each line prefixed `Agent:` or `Caller:`.                                                                              |
| `summary`, `sentiment`, `successful` | —             | From post-call analysis. See [Post-call analysis](/advanced/post-call-analysis).                                                                      |
| `extracted`                          | object / null | Your custom extraction fields. `null` if none configured.                                                                                             |
| `bookings`                           | array         | Appointments created, rescheduled, or cancelled during the call. Empty array if none. See [Appointment bookings](#appointment-bookings).              |
| `recording_url`                      | string        | Public MP3 link. Populated by the 60s delay (see below).                                                                                              |
| `recording_duration_seconds`         | number        | Length of the recorded conversation in seconds. Use this to show end-users the real talk time.                                                        |
| `voicemail_detected`                 | boolean       | Outbound only — whether the call reached voicemail.                                                                                                   |
| `campaign_id`                        | string / null | Set if the call was part of a batch campaign.                                                                                                         |
| `avg_latency_ms`                     | number        | Average agent response latency for the call, in milliseconds.                                                                                         |

<Warning>
  The payload contains caller data — the caller's phone number (`from`), the full `transcript`, and any `extracted` fields can include personal information. Store and process it in line with your own privacy policy and applicable law.
</Warning>

## Appointment bookings

If the agent books, reschedules, or cancels an appointment during a call (via a connected calendar), each action is captured as a structured object in the `bookings` array. This is the reliable way to know an appointment changed â€” read it directly instead of parsing the transcript or summary.

The array is **per call**: every booking action taken on that one call is listed, in order. A call with no booking activity sends `bookings: []`.

```json theme={null}
"bookings": [
  {
    "action": "created",
    "booking_uid": "scf821a4pcuq3oglf8rfh0tc8k",
    "start": "2026-05-29T09:00:00.000Z",
    "attendee_name": "Amir",
    "attendee_email": "amir@example.com",
    "service": "appointment",
    "timezone": "Europe/London"
  }
]
```

| Field            | Type   | Notes                                                                                                                                |
| ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `action`         | string | `created`, `rescheduled`, or `cancelled`.                                                                                            |
| `booking_uid`    | string | The calendar's own reference for the appointment. Stable across reschedule/cancel, so you can match actions to the same appointment. |
| `start`          | string | ISO 8601 start time of the appointment. Present for `created` and `rescheduled`; omitted for `cancelled`.                            |
| `attendee_name`  | string | The caller's name as booked. Present when captured (typically on `created`).                                                         |
| `attendee_email` | string | The email the booking is under.                                                                                                      |
| `service`        | string | The appointment type.                                                                                                                |
| `timezone`       | string | IANA timezone the appointment is scheduled in.                                                                                       |

<Tip>
  Because each action carries the `booking_uid`, you can mirror these straight into your own appointments table: insert on `created`, update on `rescheduled`, mark cancelled on `cancelled`. Drive your own confirmations and reminders from there.
</Tip>

<Note>
  Bookings are also stored on the call record, so if a webhook delivery is missed you can still fetch them later via the call's API record â€” they are never lost.
</Note>

## Event types

The `event` field tells you which event the payload represents:

* **`call.started`** — fires when a call connects, about 2 seconds after it begins. Use it for live tracking (a call just started on one of your numbers). Only the early fields are populated: `event`, `call_id`, `agent_id`, `direction`, `from`, `to`, `status`, and `started_at`. The post-call fields (`transcript`, `summary`, `sentiment`, `successful`, `extracted`, `recording_url`, `duration_seconds`, `ended_at`, `avg_latency_ms`) are `null`, and `bookings` is an empty array, because the call has not happened yet.
* **`call.completed`** — fires about 60 seconds after a call ends, with the full payload (transcript, summary, recording, bookings, and analysis).
* **`webhook.test`** — sent by the dashboard "Test" button so you can verify reachability. Not a real call.

Both `call.started` and `call.completed` are delivered to the same webhook URL. Switch on the `event` field to tell them apart. `call.started` uses the same delivery, retry, and logging as `call.completed`.

## Signing and verification

Every webhook request includes a signature header so you can verify it came from Nixflex:

```
X-Nixflex-Signature: t=1779500000,v1=abc123...
```

The signature is HMAC-SHA256 of `<timestamp>.<raw_body>` using the **API key secret** (`key_secret`) tied to the call's API key as the signing secret.

Your `key_secret` is the part of your API key **after the colon**. API keys are formatted `nxf_<id>:nxfs_<secret>`, so the signing secret is the `nxfs_...` half only (not the full key, and not the `nxf_` id).

<Note>
  If signature generation fails on our side, the webhook is still delivered **without** the `X-Nixflex-Signature` header (delivery is never blocked). Treat a missing signature header as unsigned and skip verification for that request rather than erroring.
</Note>

<Warning>
  Always verify webhook signatures in production. Without verification, anyone who knows your URL could spam your endpoint with fake data.
</Warning>

### Verifying in Node.js

```javascript theme={null}
const crypto = require('crypto');

// The signing secret is the part of your API key AFTER the colon (the nxfs_ half).
// If your key is nxf_abc:nxfs_xyz, pass "nxfs_xyz" as the secret.
// e.g. const secret = process.env.NIXFLEX_API_KEY.split(':')[1];

function verifyNixflexSignature(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('='))
  );
  const timestamp = parts.t;
  const sig = parts.v1;

  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
    return false;
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(sig),
    Buffer.from(expected)
  );
}
```

<Note>
  Always use the **raw request body bytes** when computing the HMAC. If your framework parses JSON before you see it (Express body-parser does this by default), you'll get a signature mismatch. Use `express.raw()` middleware on the webhook route.
</Note>

## Delivery and retries

The webhook fires roughly **60 seconds after the call ends** — this gives Nixflex time to upload the recording, run post-call analysis, and include everything in one payload. The worker fetches fresh call data at fire time, so `recording_url` and analysis fields are always current.

<Steps>
  <Step title="2xx or 3xx response">
    Delivered. No retry.
  </Step>

  <Step title="4xx (except 408, 429)">
    Treated as a permanent failure — your endpoint has a bug or rejected the request. **No retry.**
  </Step>

  <Step title="5xx, 408, 429, or timeout/network error">
    Retried automatically. Backoff between attempts: **5 min, 5 min, 5 min, 60 min** (5 attempts total over \~76 min).
  </Step>

  <Step title="After 5 failed attempts">
    Given up. Logged in your dashboard so you can investigate.
  </Step>
</Steps>

Request timeout is 10 seconds per attempt — keep your handler fast.

## Viewing delivery history

Go to **Logs → Webhooks** in your dashboard. Every delivery is logged with status, HTTP code, attempt count, last attempt time, the URL, and any error message. The last 5,000 successful and 5,000 failed deliveries are retained.

## Idempotency

The same call may arrive twice if a retry succeeds after the original was actually delivered (rare but possible). Store the `call_id` from each payload and skip if you've already processed it.

## Connecting to Zapier / Make

Both platforms support generic webhook triggers. Point them at the URL Zapier/Make gives you, and every call becomes a trigger. Because the payload is flat, fields map cleanly — you'll see `Summary`, `Transcript`, `Sentiment`, `Duration Seconds`, etc. directly, with no `Call Call ...` prefixes. From there, send data to Google Sheets, HubSpot, Salesforce, GoHighLevel, Slack, Notion, Airtable, email, or anything else those platforms support.

## Testing your webhook

In the dashboard, go to **Integrations → Webhooks**, pick the number, and click **Test**. Nixflex sends a small `webhook.test` payload to that number's URL and shows the HTTP status and response time. This only checks that your endpoint is reachable — it does not send a full call payload.
