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 successfully{ analysis_id, org_id, rep_id, scores, status }
analysis.failedAn analysis fails to complete{ analysis_id, org_id, rep_id, 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{ note: "test", timestamp }

Register a webhook

curl -X POST https://parlay-api-dev-o7nogixtqq-uc.a.run.app/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:
{
  "id": "evt_2k4xJzaB...",
  "event": "analysis.completed",
  "livemode": false,
  "created_at": "2026-04-24T18:53:17.867123+00:00",
  "data": {
    "analysis_id": "f2782fc7-5355-48bf-a314-d6bf90bc4907",
    "org_id": "demo-acme",
    "rep_id": "alex-smith",
    "status": "completed",
    "scores": { "clarity": 94, "influence": 95, "objection": 92 }
  }
}

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://parlay-api-dev-o7nogixtqq-uc.a.run.app/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.
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://parlay-api-dev-o7nogixtqq-uc.a.run.app/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://parlay-api-dev-o7nogixtqq-uc.a.run.app/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.