# FirstCall Webhook API

Webhooks deliver bot lifecycle events to your server. You can configure up to **3 webhook URLs on the free plan or 10 on paid plans** per account — each event fans out in parallel to every enabled webhook, each signed with its own per-row secret.

## Setup

1. Go to **Dashboard > Settings** at [app.firstcall.dev](https://app.firstcall.dev)
2. Click **+ Add webhook**, paste your HTTPS URL, optionally name it
3. Click **Save All** to commit
4. Copy that row's **Webhook Secret** for signature verification (each webhook has its own)
5. Toggle the row's **Enabled** switch off any time to pause delivery (applies instantly)
6. Click **Delete** to remove a webhook (applies instantly, with confirmation)

### Per-bot override

You can override the account-level fan-out for a single bot by setting `webhook_url` at `POST /v1/bots`. That bot's events will go ONLY to your per-bot URL (not to the account-level webhooks), signed with the **per-bot override secret** shown at the bottom of Settings. The override URL replaces — does not add to — the account fan-out for that bot.

## Events

### `bot.created`

Sent immediately when a bot is created via the API.

```json
{
  "event": "bot.created",
  "event_id": "evt-a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "bot_id": "bot-abc123def456",
  "meet_url": "https://meet.google.com/abc-def-ghi",
  "bot_name": "Meeting Assistant",
  "mode": "audio-ws",
  "transcription": true,
  "timestamp": "2026-03-21T14:30:01.123Z"
}
```

### `bot.status_changed`

Sent each time the bot transitions to a new state.

```json
{
  "event": "bot.status_changed",
  "event_id": "evt-b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "bot_id": "bot-abc123def456",
  "status": "ready",
  "reason": null,
  "meet_url": "https://meet.google.com/abc-def-ghi",
  "bot_name": "Meeting Assistant",
  "timestamp": "2026-03-21T14:31:15.456Z"
}
```

### `bot.ended`

Sent when the bot is fully disposed, includes duration and cost.

```json
{
  "event": "bot.ended",
  "event_id": "evt-c3d4e5f6-a7b8-9012-cdef-123456789012",
  "bot_id": "bot-abc123def456",
  "status": "ended",
  "reason": "left",
  "meet_url": "https://meet.google.com/abc-def-ghi",
  "bot_name": "Meeting Assistant",
  "duration_minutes": 45,
  "stt_minutes": 45,
  "cost": {
    "bot_compute_microcents": 2624985,
    "stt_microcents": 900000,
    "total_microcents": 3524985
  },
  "timestamp": "2026-03-21T15:15:22.789Z"
}
```

## Bot Status Flow

```
bot.created
     │
     ▼
bot.status_changed  status: "starting"
     │
     ▼
bot.status_changed  status: "joining"
     │
     ▼
bot.status_changed  status: "waiting_room"     (if applicable)
     │
     ▼
bot.status_changed  status: "initializing"
     │
     ▼
bot.status_changed  status: "ready"            ← bot is in the meeting
     │
     ▼
bot.status_changed  status: "ended"            ← meeting over
     │
     ▼
bot.ended           (with duration + cost)
     │
     ▼
bot.transcript_ready (if transcription was enabled)
```

### `bot.transcript_ready`

Sent after `bot.ended` when transcription was enabled and the transcript has been saved. Use `download_url` to fetch the full transcript.

```json
{
  "event": "bot.transcript_ready",
  "event_id": "evt-d4e5f6a7-b8c9-0123-defg-456789012345",
  "bot_id": "bot-abc123def456",
  "meet_url": "https://meet.google.com/abc-def-ghi",
  "bot_name": "Meeting Assistant",
  "entry_count": 142,
  "duration_minutes": 45,
  "download_url": "https://api.firstcall.dev/v1/bots/bot-abc123def456/transcript",
  "formats": ["json", "text"],
  "retention_hours": 24,
  "expires_at": "2026-03-28T14:30:05.000Z",
  "timestamp": "2026-03-27T14:30:05.000Z"
}
```

To download as plain text, append `?format=text` to the download URL.

### `bot.recording_ready`

Sent after `bot.ended` when audio recording was enabled and the WAV file has been uploaded to storage.

```json
{
  "event": "bot.recording_ready",
  "event_id": "evt-e5f6a7b8-c9d0-1234-efgh-567890123456",
  "bot_id": "bot-abc123def456",
  "meet_url": "https://meet.google.com/abc-def-ghi",
  "bot_name": "Meeting Assistant",
  "duration_seconds": 2700,
  "size_bytes": 86400044,
  "format": "wav",
  "download_url": "https://api.firstcall.dev/v1/bots/bot-abc123def456/recording",
  "retention_hours": 24,
  "expires_at": "2026-03-28T14:30:05.000Z",
  "timestamp": "2026-03-27T14:30:05.000Z"
}
```

## Status Reference

| Status | Description |
|--------|-------------|
| `starting` | Container is launching, browser starting |
| `joining` | Attempting to join the meeting |
| `waiting_room` | In the meeting's waiting room |
| `initializing` | In the call, setting up media capture |
| `ready` | Fully connected, capturing audio/video |
| `error` | Something went wrong |
| `ended` | Bot has left or meeting ended |

## End Reasons

| Reason | Description |
|--------|-------------|
| `left` | Developer called DELETE /v1/bots/:id (intentional leave) |
| `ended` | The meeting ended on its own |
| `rejected` | Host rejected the bot from joining |
| `blocked` | Bot was blocked or kicked from the meeting |
| `error` | Internal error (browser crash, network issue) |
| `timeout_silence` | No audio detected for extended period |
| `timeout_alone` | Bot was alone in the meeting too long |
| `timeout_waiting_room` | Stuck in waiting room too long |

## Signature Verification

Every webhook includes an `X-FirstCall-Signature` header containing an HMAC-SHA256 signature of the request body.

**Always verify the signature** to ensure the webhook came from FirstCall.

### Signature format

```
X-FirstCall-Signature: sha256=<64 lowercase hex chars>
```

The signature is `HMAC-SHA256(secret, raw_body)` returned in hex with a `sha256=` prefix (GitHub-style).

> **The HMAC key is the entire secret string, including the `whsec-` prefix.** Don't strip it. Don't base64-decode. Use the value exactly as it appears in the dashboard, e.g. `whsec-EXAMPLE-paste-yours-from-the-dashboard`. (This differs from some other providers like Stripe, where `whsec_` is a label and the key is the decoded portion after it.)

Other headers you'll receive on every webhook:

| Header | Purpose |
|---|---|
| `X-FirstCall-Event` | Event name, e.g. `bot.created`, `bot.status_changed`, `bot.ended` |
| `X-FirstCall-Event-Id` | Unique `evt-<uuid>` — use for idempotency / dedup |
| `X-FirstCall-Signature` | `sha256=<hex>` of raw body, signed with your webhook's secret |
| `Content-Type` | `application/json` |

### ⚠ Use the RAW request body, not a re-serialized version

Sign-and-verify HMAC works only when both sides hash **the same exact bytes**. FirstCall signs the raw body bytes we send over the wire (`Content-Type: application/json`, the verbatim JSON we serialized). Your verifier must compute HMAC over **the raw request body bytes**, not over a re-serialized version of the parsed JSON.

This matters because:
- `JSON.stringify(req.body)` may re-emit the JSON with **different key order, whitespace, or number formatting** than what FirstCall sent. Even if semantically identical, the byte string is different and HMAC-SHA256 will mismatch.
- Default Express's `express.json()` middleware **discards** the raw bytes after parsing — `req.body` is the parsed object, not the bytes. You must explicitly capture the raw buffer.

### Node.js (Express, capturing raw body)

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

const app = express();
const WEBHOOK_SECRET = process.env.FIRSTCALL_WEBHOOK_SECRET;

function verifyAndParse(rawBody, signatureHeader) {
  if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return null;
  const expected = signatureHeader.slice('sha256='.length);

  const digest = crypto.createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)            // raw buffer — DO NOT re-serialize req.body
    .digest('hex');

  // Use timingSafeEqual to prevent timing-based secret leaks
  if (expected.length !== digest.length) return null;
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(digest))) return null;

  return JSON.parse(rawBody.toString('utf8'));
}

// Use express.raw, not express.json — we need the raw buffer for HMAC.
app.post(
  '/webhooks/firstcall',
  express.raw({ type: 'application/json', limit: '1mb' }),
  (req, res) => {
    const event = verifyAndParse(req.body, req.headers['x-firstcall-signature']);
    if (!event) return res.status(401).send('Invalid signature');

    // Idempotency: dedupe by event_id (also in X-FirstCall-Event-Id header)
    // const eventId = req.headers['x-firstcall-event-id'];
    // if (await alreadyProcessed(eventId)) return res.status(200).send('OK');

    switch (event.event) {
      case 'bot.created':
        console.log(`Bot ${event.bot_id} created`);
        break;
      case 'bot.status_changed':
        console.log(`Bot ${event.bot_id} → ${event.status} (${event.reason})`);
        break;
      case 'bot.ended':
        console.log(`Bot ${event.bot_id} ended, duration: ${event.duration_minutes} min`);
        break;
    }

    res.status(200).send('OK');
  }
);
```

### Python (Flask)

`request.data` in Flask returns the raw request body as bytes — exactly what HMAC needs. No special middleware required.

```python
import hmac
import hashlib
import json
from flask import Flask, request

app = Flask(__name__)
WEBHOOK_SECRET = "your-webhook-secret"

def verify_signature(raw_body: bytes, signature_header: str) -> bool:
    if not signature_header or not signature_header.startswith("sha256="):
        return False
    expected = signature_header[len("sha256="):]
    digest = hmac.new(
        WEBHOOK_SECRET.encode(),
        raw_body,                # raw bytes — request.data, NOT request.json re-serialized
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, digest)

@app.route("/webhooks/firstcall", methods=["POST"])
def webhook():
    signature = request.headers.get("X-FirstCall-Signature", "")
    if not verify_signature(request.data, signature):
        return "Invalid signature", 401

    event = json.loads(request.data)

    if event["event"] == "bot.created":
        print(f"Bot {event['bot_id']} created")
    elif event["event"] == "bot.status_changed":
        print(f"Bot {event['bot_id']} → {event['status']}")
    elif event["event"] == "bot.ended":
        print(f"Bot {event['bot_id']} ended, {event['duration_minutes']} min")

    return "OK", 200
```

### Verifying with multiple webhooks configured

If you've configured multiple webhook URLs in your FirstCall dashboard, **each webhook has its own per-row signing secret**. Your receiver should know which secret pairs with which URL — typically by setting up one route per URL, each with its corresponding secret in env. Don't try to "guess" by attempting all secrets — that defeats the security model.

### Common verification pitfalls

| Symptom | Cause | Fix |
|---|---|---|
| Signature always fails | Re-serializing the body before hashing | Use raw bytes (`req.body` after `express.raw()`, or `request.data` in Flask) |
| Works in dev, fails in prod | Body parser middleware running before your raw handler | Apply `express.raw()` only to the webhook route, not globally |
| Intermittent failures | Reverse proxy stripping/modifying the body | Check that the proxy preserves the body bytes byte-for-byte. Some WAFs minify JSON. |
| Failures after rotating a secret | Old secret still cached on receiver side | When you click **Regenerate** in the dashboard, the new secret applies to events sent AFTER that moment. Update your receiver's env immediately, or transition by adding a new webhook URL with the new secret and disabling the old row once cut over. |

## Retry Policy

If your server returns a **5xx error** or doesn't respond within **10 seconds**, FirstCall retries the webhook:

| Attempt | Delay |
|---------|-------|
| 1st retry | 1 second |
| 2nd retry | 2 seconds |
| 3rd retry | 4 seconds |

After 3 failed retries, the webhook is dropped. **4xx errors are not retried** (they indicate a client-side issue).

## Idempotency

Each webhook includes a unique `event_id`. Use this to deduplicate events if you receive the same webhook twice (e.g., due to network retries).

```javascript
const processedEvents = new Set();

app.post('/webhooks/firstcall', (req, res) => {
  const { event_id } = req.body;

  if (processedEvents.has(event_id)) {
    return res.status(200).send('Already processed');
  }

  processedEvents.add(event_id);
  // Process the event...
  res.status(200).send('OK');
});
```

## Testing Webhooks

During development, use a tool like [ngrok](https://ngrok.com) to expose your local server:

```bash
ngrok http 3000
# Copy the https URL and set it as your webhook URL in the dashboard
```

## Cost Fields

The `bot.ended` event includes cost in **microcents** (1 dollar = 1,000,000 microcents):

| Field | Description | Example |
|-------|-------------|---------|
| `bot_compute_microcents` | Bot container compute cost | 2624985 ($0.26) |
| `stt_microcents` | Transcription cost (0 if not enabled) | 900000 ($0.09) |
| `total_microcents` | Total cost | 3524985 ($0.35) |

To convert to dollars: `total_microcents / 1000000`
