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

# Webhooks

> Learn how to receive and verify signed events for everything that happens after your API call returns.

Most of the integration is request/response. Webhooks cover everything that happens on Prescience's side after your call returns: underwriting finalizing a large-group quote, a company activating to `prod`, an enrollment landing.

Signatures follow the [standard-webhooks](https://www.standardwebhooks.com) scheme (the same one used by Svix and a growing list of providers), so you can verify with an off-the-shelf library or \~10 lines of code.

## Setup

Register your HTTPS endpoint. The signing secret comes back **once**; store it like an API key:

```bash theme={null}
curl -X PUT https://www.getprescience.com/api/partner/v1/webhook \
  -H "Authorization: Bearer $PRESCIENCE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://api.example-hr.com/webhooks/prescience" }'
```

```json Response (200) theme={null}
{
  "url": "https://api.example-hr.com/webhooks/prescience",
  "secret": "whsec_4f8a1c2b9d3e6f7a8b9c0d1e2f3a4b5c"
}
```

One endpoint per mode (your test key registers the test endpoint; your live key, the live one). Calling `PUT /webhook` again replaces the URL and issues a fresh secret; that's also the rotation mechanism.

Then prove the loop works end to end:

```bash theme={null}
curl -X POST https://www.getprescience.com/api/partner/v1/webhook/test \
  -H "Authorization: Bearer $PRESCIENCE_API_KEY"
```

This dispatches a signed `ping` event to your endpoint. If your handler verifies it and returns 2xx, you're done with setup.

## Delivery format

Every event is a `POST` with a JSON body and three signature headers:

```http theme={null}
POST /webhooks/prescience HTTP/1.1
Content-Type: application/json
webhook-id: evt_9b21c7e4f60a
webhook-timestamp: 1781255673
webhook-signature: v1,HBEvFBV11h9jUBzFayGaYNRxDDEgPNGT2bzaRQdfnwo=
```

```json theme={null}
{"id":"evt_9b21c7e4f60a","type":"enrollment.created","createdAt":"2026-06-12T09:14:33Z","mode":"test","data":{"enrollmentId":"enr_3f7a92c81b40","groupId":"grp_8c2f41d09a3e","quoteId":"qt_5b9e2c7f10ad","startDate":"2026-09-01","companyDomain":"acme.com","onboardingMode":"hosted"}}
```

The body is delivered compact (no whitespace). The signature above is the genuine HMAC of this exact example under the setup secret `whsec_4f8a1c2b9d3e6f7a8b9c0d1e2f3a4b5c`. Wire up your verifier against it as a unit test before touching production traffic.

## Event catalog

| Event                 | Fires when                                                                                                                                | `data`                                                                               |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `ping`                | You call `POST /webhook/test`.                                                                                                            | `{}`                                                                                 |
| `quote.finalized`     | An `in_review` quote (above the rate card's in-review threshold, default 200 employees) is finalized by underwriting and becomes `ready`. | `quoteId`, `groupId`, `status`                                                       |
| `enrollment.created`  | An enrollment is created for one of your groups.                                                                                          | `enrollmentId`, `groupId`, `quoteId`, `startDate`, `companyDomain`, `onboardingMode` |
| `group.state_changed` | The provisioned company changes state, most importantly `sandbox → prod` (the group is live).                                             | `groupId`, `companyDomain`, `previousState`, `state`                                 |
| `census.processed`    | A census write finishes processing.                                                                                                       | `groupId`, `received`, `accepted`, `memberCount`, `errorCount`                       |

Full payload schemas are in the [API reference](/api-reference/introduction) under Webhooks.

## Verifying signatures

The signature is an HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`:

1. **Key**: strip the `whsec_` prefix from your secret and base64-decode the remainder; the resulting bytes are the HMAC key.
2. **Signed content**: concatenate the `webhook-id` header, the `webhook-timestamp` header, and the **raw request body**, joined by `.`. Verify against the exact bytes you received, never a re-serialized parse.
3. **Compare**: base64-encode the HMAC and compare (constant-time) against each space-separated `v1,<base64>` candidate in `webhook-signature`. Any match passes.

Reject anything that fails, and reject timestamps older than \~5 minutes to blunt replay.

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  export function verifyWebhook(
    rawBody: string, // exact bytes; read before any JSON parsing
    headers: { id: string; timestamp: string; signature: string },
    secret: string, // whsec_...
  ): boolean {
    if (!headers.id || !headers.timestamp || !headers.signature) return false;

    // Reject stale timestamps (replay protection)
    const skew = Math.abs(Date.now() / 1000 - Number(headers.timestamp));
    if (!Number.isFinite(skew) || skew > 300) return false;

    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const signedContent = `${headers.id}.${headers.timestamp}.${rawBody}`;
    const expected = Buffer.from(
      createHmac("sha256", key).update(signedContent).digest("base64"),
    );

    // Header may carry several space-separated "v1,<base64>" candidates
    return headers.signature.split(" ").some((part) => {
      const [version, value] = part.trim().split(",", 2);
      if (version !== "v1" || !value) return false;
      const candidate = Buffer.from(value);
      return candidate.length === expected.length && timingSafeEqual(candidate, expected);
    });
  }

  // Express example: note express.raw(), not express.json()
  app.post("/webhooks/prescience", express.raw({ type: "application/json" }), (req, res) => {
    const ok = verifyWebhook(
      req.body.toString("utf8"),
      {
        id: req.header("webhook-id") ?? "",
        timestamp: req.header("webhook-timestamp") ?? "",
        signature: req.header("webhook-signature") ?? "",
      },
      process.env.PRESCIENCE_WEBHOOK_SECRET!,
    );
    if (!ok) return res.status(401).send("invalid signature");

    const event = JSON.parse(req.body.toString("utf8"));
    // ...route on event.type, dedupe on event.id...
    res.status(200).send("ok");
  });
  ```

  ```python Python theme={null}
  import base64
  import hashlib
  import hmac
  import time


  def verify_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
      """raw_body must be the exact request bytes; verify before parsing JSON."""
      event_id = headers.get("webhook-id", "")
      timestamp = headers.get("webhook-timestamp", "")
      signature = headers.get("webhook-signature", "")
      if not (event_id and timestamp and signature):
          return False

      # Reject stale timestamps (replay protection)
      try:
          if abs(time.time() - int(timestamp)) > 300:
              return False
      except ValueError:
          return False

      key = base64.b64decode(secret.removeprefix("whsec_"))
      signed_content = f"{event_id}.{timestamp}.".encode() + raw_body
      expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest()).decode()

      # Header may carry several space-separated "v1,<base64>" candidates
      for part in signature.split(" "):
          version, _, value = part.strip().partition(",")
          if version == "v1" and value and hmac.compare_digest(value, expected):
              return True
      return False


  # Flask example
  from flask import Flask, request

  app = Flask(__name__)

  @app.post("/webhooks/prescience")
  def prescience_webhook():
      if not verify_webhook(request.get_data(), request.headers, WEBHOOK_SECRET):
          return "invalid signature", 401

      event = request.get_json()
      # ...route on event["type"], dedupe on event["id"]...
      return "ok", 200
  ```
</CodeGroup>

<Tip>
  The official [standard-webhooks libraries](https://github.com/standard-webhooks/standard-webhooks) (`npm install standard-webhooks`, `pip install standardwebhooks`) implement this exact verification: pass them the raw body, the headers, and your `whsec_` secret.
</Tip>

## Retries and reliability

Delivery is best-effort with **3 retries on exponential backoff (1s, 5s, 25s)** after the initial attempt. A delivery counts as successful on any 2xx response. Exhausted deliveries are recorded on our side and surfaced to your partner engineer, but they are not queued durably in v1.

Build your handler accordingly:

* **Return 2xx fast** (under 10 seconds). Enqueue work; don't do it inline.
* **Dedupe on `event.id`.** Retries reuse the same `id`, and you should treat deliveries as at-least-once.
* **Don't treat webhooks as the source of truth.** They're a prompt, not a ledger: on anything critical (or after downtime), reconcile by polling `GET /groups`, `GET /quotes/{id}`, and `GET /enrollments/{id}`. Every webhook-announced fact is readable from the API.
