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

# Webhooks

> Real-time notifications for interview status changes sent to your configured endpoints.

Offboard.tech sends webhook notifications when interviews are completed, saved, or abandoned. Configure your webhook URL in workspace settings to receive real-time updates.

## Configuration

Set your webhook URL in workspace settings:

```typescript theme={null}
// Via dashboard settings
workspace.config.webhookUrl = 'https://yourapp.com/api/webhooks/offboard'

// Or via API
await fetch('/api/workspaces/{id}', {
  method: 'PATCH',
  body: JSON.stringify({
    config: { webhookUrl: 'https://yourapp.com/api/webhooks/offboard' }
  })
})
```

## Events

### `interview.completed`

Customer completed the interview without accepting a save offer:

```json theme={null}
{
  "event": "interview.completed",
  "workspace_id": "ws_abc123",
  "workspace_name": "Acme Inc",
  "interview_id": "int_xyz789",
  "status": "completed",
  "sentiment": "neutral",
  "summary": "Customer is leaving because the service is too expensive compared to competitors.",
  "metadata": {
    "plan": "pro",
    "value": "99",
    "customer_id": "cust_456"
  },
  "created_at": "2024-01-15T10:30:00Z"
}
```

### `interview.saved`

Customer accepted a save offer:

```json theme={null}
{
  "event": "interview.saved",
  "workspace_id": "ws_abc123",
  "workspace_name": "Acme Inc",
  "interview_id": "int_xyz789",
  "status": "saved",
  "sentiment": "positive",
  "summary": "Customer considered leaving due to price but accepted 20% discount offer.",
  "metadata": {
    "plan": "pro",
    "save_offer": "20% off for 3 months"
  },
  "created_at": "2024-01-15T10:30:00Z"
}
```

### `interview.abandoned`

Customer left mid-interview (sent via weekly reports, not real-time):

```json theme={null}
{
  "event": "interview.abandoned",
  "workspace_id": "ws_abc123",
  "workspace_name": "Acme Inc",
  "interview_id": "int_xyz789",
  "status": "abandoned",
  "sentiment": null,
  "summary": null,
  "metadata": {
    "plan": "pro"
  },
  "created_at": "2024-01-15T10:30:00Z"
}
```

## Payload Schema

```typescript theme={null}
interface WebhookPayload {
  event: 'interview.completed' | 'interview.saved' | 'interview.abandoned'
  workspace_id: string
  workspace_name: string
  interview_id: string
  status: 'completed' | 'saved' | 'abandoned'
  sentiment: 'positive' | 'neutral' | 'negative' | null
  summary: string | null  // PII-scrubbed summary
  metadata: {
    // URL params from magic link + any additional data
    [key: string]: unknown
  }
  created_at: string  // ISO 8601 timestamp
}
```

### Sentiment Values

| Value      | Meaning                                                             |
| ---------- | ------------------------------------------------------------------- |
| `positive` | Customer was satisfied; accepted a save offer or left on good terms |
| `neutral`  | Circumstantial reasons; no strong positive or negative feeling      |
| `negative` | Bad experience; expressed frustration or complaints                 |

## Handling Webhooks

### Basic Handler

```typescript theme={null}
// POST /api/webhooks/offboard
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const payload = await request.json()

  switch (payload.event) {
    case 'interview.completed':
      await handleCompleted(payload)
      break
    case 'interview.saved':
      await handleSaved(payload)
      break
    case 'interview.abandoned':
      await handleAbandoned(payload)
      break
  }

  return NextResponse.json({ received: true })
}

async function handleCompleted(payload: WebhookPayload) {
  // Process completed interview
  const customerId = payload.metadata.customer_id as string

  // Update CRM
  await updateCRM(customerId, {
    churn_reason: payload.summary,
    sentiment: payload.sentiment,
    churned_at: new Date(),
  })

  // Cancel subscription
  await cancelSubscription(customerId)
}

async function handleSaved(payload: WebhookPayload) {
  // Customer accepted save offer
  const customerId = payload.metadata.customer_id as string
  const offer = payload.metadata.save_offer as string

  // Apply retention offer
  await applyDiscount(customerId, {
    offer: offer,
    duration: '3 months',
  })

  // Notify sales team
  await notifySales({ customerId, offer, summary: payload.summary })
}

async function handleAbandoned(payload: WebhookPayload) {
  // Customer didn't complete interview
  // Could trigger follow-up email sequence
  await scheduleFollowUp(payload.metadata.customer_id as string)
}
```

### Verifying Signatures

Every outbound webhook is signed when a `webhookSecret` is stored on the
workspace (see below). Requests carry the Stripe-style header:

```
x-offboard-signature: t=1705314600,v1=5257a869e7e8e1a7e2d2e2b8c1f...
```

Verify it before trusting the payload:

```typescript theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto'

const [t, v1] = Object.fromEntries(
  request.headers.get('x-offboard-signature')!.split(',').map(kv => kv.split('=', 2))
)
// 1. Reject replays: |now - t| must be ≤ 300 seconds
// 2. Recompute the MAC over the exact raw body
const expected = createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex')
// 3. Constant-time compare
const ok = timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
```

Store the secret (write-only, AES-256-GCM at rest — never returned by any
endpoint). Easiest path: **Dashboard → Settings → Webhook Configuration →
"Generate signing secret"** — shown once, rotate anytime. Or via API
(`openssl rand -hex 32` works too — any high-entropy string):

```bash theme={null}
curl -X POST https://offboard.tech/api/workspaces/{id}/secrets \
  -H 'Content-Type: application/json' \
  -d '{"generateWebhookSecret": true}'
# → {"webhookSecret":"<hex>"} — response is the only place it's ever shown
```

Webhooks sent before a `webhookSecret` is stored are unsigned.

**SSRF protection:** Offboard only sends webhooks to `https://` URLs and blocks private IP ranges (localhost, 10.x, 192.168.x, 127.x, 169.254.x). Webhook URLs pointing to internal infrastructure will be silently rejected.

Recommended practices for your webhook handler:

1. Return 2xx quickly and process asynchronously
2. Log all webhook payloads for debugging
3. Use idempotency keys (`interview_id`) to handle any duplicate deliveries
4. Validate the `event` field before processing

### Delivery & Retries

Webhooks are delivered through a durable outbox. When an interview is finalized, the status change and the webhook event are committed in the same database transaction — a crash between the two cannot lose the event.

| Behavior | Detail                                                              |
| -------- | ------------------------------------------------------------------- |
| Delivery | At-least-once — retries with exponential backoff (up to 8 attempts) |
| Dispatch | Immediate attempt on finalize, plus a 10-minute scheduled sweep     |
| Dedup    | `metadata.event_id` is a stable idempotency key — dedupe on it      |
| Timeout  | 10 seconds per attempt                                              |

**Handle duplicates:** retries and the scheduled sweep can produce a second delivery after a crash. Key your handler on `metadata.event_id` (or `interview_id` + `event`) and return 2xx for repeats.

## Testing

### Test Webhook Endpoint

Use the test endpoint to verify your webhook handler:

```bash theme={null}
curl -X POST https://offboard.tech/api/workspaces/{id}/test-webhook \
  -H "Content-Type: application/json" \
  -d '{
    "webhookUrl": "https://yourapp.com/api/webhooks/offboard"
  }'
```

### Local Testing

Use ngrok or similar to test webhooks locally:

```bash theme={null}
# Start ngrok
ngrok http 3000

# Use ngrok URL as webhook URL
https://abc123.ngrok.io/api/webhooks/offboard
```

### Sample Payloads

```typescript theme={null}
// interview.completed
{
  "event": "interview.completed",
  "workspace_id": "test_ws",
  "workspace_name": "Test Workspace",
  "interview_id": "00000000-0000-0000-0000-000000000000",
  "status": "completed",
  "sentiment": "neutral",
  "summary": "This is a test webhook payload.",
  "metadata": { "test": true },
  "created_at": "2024-01-15T10:30:00Z"
}
```

## Rate Limiting

Webhooks are sent per interview completion. Bulk events (weekly reports) are sent as single webhook with batched data.

## Best Practices

1. **Return 2xx quickly** - Process asynchronously, don't block webhook
2. **Verify signatures** - Ensure request authenticity
3. **Idempotency** - Handle duplicate webhooks gracefully
4. **Error logging** - Log webhook failures for debugging
5. **Test in staging** - Verify handler before production
