Skip to main content
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

EventFires whenPayload data shape
analysis.completedAn analysis finishes successfullyFull analysis row (same shape as GET /v1/analyses/:id)
analysis.failedAn analysis fails to completeFull analysis row with status: "failed" and populated error
persona.assignedA persona_assign job completes{ org_id, rep_id, job_id, result }
methodology.assignedA methodology_assign job completes{ org_id, rep_id, job_id, result }
profile.synthesizedA synthesis job completes{ org_id, rep_id, job_id, result }
playbook.draft.completedA playbook draft finishes generation{ draft_id, source_count, gemini_input_tokens, gemini_output_tokens, cost_usd_cents }
playbook.publishedA draft is promoted to an active playbook{ playbook_id, draft_id, version }
insights.generatedAn org insights snapshot completes{ org_id, snapshot_id }
webhook.testYou called the test endpoint{ message, webhook_id, fired_at }

Register a webhook

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):
{
  "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:
HeaderValue
Content-Typeapplication/json
User-Agentparlay-webhook/1
X-Parlay-EventThe event name (e.g. analysis.completed)
X-Parlay-DeliveryUUID of this delivery attempt
X-Parlay-Signaturet=<unix_timestamp>,v1=<hex_hmac> (Stripe convention)
Body (analysis.completed example — data is the full analysis row):
{
  "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"
  }
}
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.

Signature verification (TypeScript)

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)

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:
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 or a Cloudflare Tunnel) to confirm signature verification works before going live.

Best practices

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.
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.
RuntimeTypical limit
Vercel Functions (Node)10s Hobby, 60s Pro
Vercel Edge Functions25s streaming, 30s total
Cloudflare Workers30s CPU
Supabase Edge Functions150s
AWS Lambda default3s (raise to 15 min)
Cloud Run / Render / Fly / RailwayLong-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 on the all-day guide for the full breakdown.
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.
Anyone can POST to your URL. The signature header is what proves it came from Parlay. Reject any request without a valid signature.
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.
Register one webhook for staging (https://staging.your-app.com/...) and another for production. Both get the same events — your code routes by livemode.

Rotating the signing secret

If the secret is ever compromised:
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

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.