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

# Webhooks

> Stop polling. Get notified when analyses complete, drafts finish, and personas get assigned.

Webhooks are HTTPS callbacks Parlay fires to your endpoint whenever something interesting happens. Production integrations should use webhooks instead of polling.

## How it works

1. You **register a webhook** with a URL and the events you care about. Parlay returns a `signing_secret` (shown **once** — store it securely).
2. When a matching event fires server-side, Parlay POSTs the event JSON to your URL with a signed timestamp header.
3. Your endpoint verifies the signature, processes the event, and returns 2xx within 30 seconds.
4. If your endpoint is unreachable or returns 5xx, Parlay retries with exponential backoff. Retry schedule: \~1 min, 5 min, 25 min, 2 hr, 8 hr (5 attempts total).

## Events

| Event                      | Fires when                                | Payload `data` shape                                                                    |
| -------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------- |
| `analysis.completed`       | An analysis finishes successfully         | Full analysis row (same shape as `GET /v1/analyses/:id`)                                |
| `analysis.failed`          | An analysis fails to complete             | Full analysis row with `status: "failed"` and populated `error`                         |
| `persona.assigned`         | A `persona_assign` job completes          | `{ org_id, rep_id, job_id, result }`                                                    |
| `methodology.assigned`     | A `methodology_assign` job completes      | `{ org_id, rep_id, job_id, result }`                                                    |
| `profile.synthesized`      | A `synthesis` job completes               | `{ org_id, rep_id, job_id, result }`                                                    |
| `playbook.draft.completed` | A playbook draft finishes generation      | `{ draft_id, source_count, gemini_input_tokens, gemini_output_tokens, cost_usd_cents }` |
| `playbook.published`       | A draft is promoted to an active playbook | `{ playbook_id, draft_id, version }`                                                    |
| `insights.generated`       | An org insights snapshot completes        | `{ org_id, snapshot_id }`                                                               |
| `webhook.test`             | You called the test endpoint              | `{ message, webhook_id, fired_at }`                                                     |

## Register a webhook

```bash theme={null}
curl -X POST https://api.goparlay.io/v1/webhooks \
  -H "Authorization: Bearer pk_sandbox_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "url": "https://your-app.com/parlay-webhook",
    "name": "production",
    "enabled_events": [
      "analysis.completed",
      "analysis.failed",
      "playbook.draft.completed"
    ]
  }'
```

Response (the **only time** you see the signing secret):

```json theme={null}
{
  "id": "wh_2k4xJzaB...",
  "url": "https://your-app.com/parlay-webhook",
  "enabled_events": ["analysis.completed", "analysis.failed", "playbook.draft.completed"],
  "signing_secret": "whsec_...long-string...",
  "created_at": "2026-04-24T18:53:17.867123+00:00"
}
```

**Store the `signing_secret` immediately** — there's no API to retrieve it again. If you lose it, call `rotate_webhook_secret` to generate a new one.

## Receiving and verifying

Each webhook arrives as a `POST` with these headers:

| Header               | Value                                                  |
| -------------------- | ------------------------------------------------------ |
| `Content-Type`       | `application/json`                                     |
| `User-Agent`         | `parlay-webhook/1`                                     |
| `X-Parlay-Event`     | The event name (e.g. `analysis.completed`)             |
| `X-Parlay-Delivery`  | UUID of this delivery attempt                          |
| `X-Parlay-Signature` | `t=<unix_timestamp>,v1=<hex_hmac>` (Stripe convention) |

Body (`analysis.completed` example — `data` is the full analysis row):

```json theme={null}
{
  "id": "evt_2k4xJzaB...",
  "event": "analysis.completed",
  "livemode": false,
  "api_version": "2026-04-24",
  "created_at": "2026-04-24T18:53:17.867123+00:00",
  "data": {
    "id": "ec1851ba-4037-41bf-9ad5-d64391c11ab4",
    "status": "completed",
    "duration_seconds": 312,
    "transcript": { /* utterances + word_count */ },
    "analysis": {
      "prospect_name": "Casey Morgan",
      "recording_title": "Closed Casey",
      "ai_summary": "Strong opening, clean discovery, ...",
      "double_down": "The pacing on the value prop was perfect ...",
      "questions_asked": 11,
      "filler_word_count": 3,
      "words_per_minute": 142,
      "overall_score": 95,
      "clarity_score": 94,
      "influence_score": 96,
      "objection_score": 92,
      "discovery_score": 95,
      "delivery_score": 97,
      "close_score": 96,
      "feedback_v5": { "clarity": { "positive": "...", "negative": "..." }, /* + 5 more pillars */ },
      "action_plan_v5": { "double_down_implementation": "...", "general_communication_improvement": "...", "quoted_principle": "..." }
    },
    "created_at": "2026-04-24T18:53:00.123Z",
    "completed_at": "2026-04-24T18:53:17.456Z"
  }
}
```

<Tip>
  The `data` object on `analysis.completed` is **byte-identical** to the response of `GET /v1/analyses/:id`. If you already have a decoder for that endpoint, the same decoder works on the webhook.
</Tip>

### Signature verification (TypeScript)

```ts theme={null}
import crypto from "node:crypto";

function verifyParlaySignature(
  rawBody: string,
  header: string,
  signingSecret: string,
  toleranceSeconds = 300
): boolean {
  // header = "t=1745518397,v1=abc123..."
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=") as [string, string])
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return false;

  // Reject stale events (replay protection)
  const ageSeconds = Math.abs(Date.now() / 1000 - t);
  if (ageSeconds > toleranceSeconds) return false;

  const signed = `${t}.${rawBody}`;
  const expected = crypto.createHmac("sha256", signingSecret).update(signed).digest("hex");

  return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v1, "hex"));
}
```

### Signature verification (Python)

```python theme={null}
import hmac, hashlib, time

def verify_parlay_signature(raw_body: bytes, header: str, signing_secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", 0))
    v1 = parts.get("v1", "")
    if not t or not v1:
        return False
    if abs(time.time() - t) > tolerance:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(signing_secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

**IMPORTANT:** verify against the **raw, unparsed** request body. JSON-stringify and re-parse will produce a different signature.

## Test your endpoint

Once registered, you can fire a test event:

```bash theme={null}
curl -X POST https://api.goparlay.io/v1/webhooks/<webhook_id>/test \
  -H "Authorization: Bearer pk_sandbox_YOUR_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

Parlay sends a `webhook.test` event to your URL. Use this in your local dev (with [ngrok](https://ngrok.com) or a [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/)) to confirm signature verification works before going live.

## Best practices

<AccordionGroup>
  <Accordion title="Respond fast, process async">
    Return 2xx within a few seconds. If processing takes longer than that, push the event onto a queue and process it in a background worker. Webhooks that take 30+ seconds will time out and be retried.
  </Accordion>

  <Accordion title="Know your runtime's execution limits">
    Edge and serverless runtimes have short execution windows. If your handler runs heavy work synchronously (audio extraction, AI calls, large DB writes), it can time out before the response is sent.

    | Runtime                            | Typical limit            |
    | ---------------------------------- | ------------------------ |
    | Vercel Functions (Node)            | 10s Hobby, 60s Pro       |
    | Vercel Edge Functions              | 25s streaming, 30s total |
    | Cloudflare Workers                 | 30s CPU                  |
    | Supabase Edge Functions            | 150s                     |
    | AWS Lambda default                 | 3s (raise to 15 min)     |
    | Cloud Run / Render / Fly / Railway | Long-running OK          |

    The safe pattern in every runtime: acknowledge the webhook in the handler, push heavy work to a queue or worker. For audio slicing in particular, the `ffmpeg_extract` recipe shipped on all-day-session segments needs the `ffmpeg` binary, which most edge runtimes don't provide. See [Backend requirements](/guides/all-day-sessions#backend-requirements) on the all-day guide for the full breakdown.
  </Accordion>

  <Accordion title="Make your handler idempotent">
    The same event may be delivered more than once (network retries, your endpoint timing out). Use `event.id` as a dedupe key in your DB.
  </Accordion>

  <Accordion title="Verify the signature on every request">
    Anyone can POST to your URL. The signature header is what proves it came from Parlay. Reject any request without a valid signature.
  </Accordion>

  <Accordion title="Handle event types you don't care about gracefully">
    If you only listen for `analysis.completed`, but Parlay adds `analysis.transcribed` later, your endpoint will receive both. Switch on `event` and ignore unknown types — don't 4xx, that triggers retries.
  </Accordion>

  <Accordion title="Use multiple webhooks for environment isolation">
    Register one webhook for staging (`https://staging.your-app.com/...`) and another for production. Both get the same events — your code routes by `livemode`.
  </Accordion>
</AccordionGroup>

## Rotating the signing secret

If the secret is ever compromised:

```bash theme={null}
curl -X POST https://api.goparlay.io/v1/webhooks/<webhook_id>/rotate \
  -H "Authorization: Bearer pk_sandbox_YOUR_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

Returns a new `signing_secret` (only shown once). The old secret stops working immediately. Update your endpoint's secret first, then rotate, to avoid a brief verification gap.

## Pausing without losing config

```bash theme={null}
curl -X PATCH https://api.goparlay.io/v1/webhooks/<webhook_id> \
  -H "Authorization: Bearer pk_sandbox_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "paused": true }'
```

Paused webhooks queue events server-side for up to 24 hours, then drop them. Resume with `paused: false`.
