Skip to main content
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 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:
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" }'
Response (200)
{
  "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:
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:
POST /webhooks/prescience HTTP/1.1
Content-Type: application/json
webhook-id: evt_9b21c7e4f60a
webhook-timestamp: 1781255673
webhook-signature: v1,HBEvFBV11h9jUBzFayGaYNRxDDEgPNGT2bzaRQdfnwo=
{"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

EventFires whendata
pingYou call POST /webhook/test.{}
quote.finalizedAn 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.createdAn enrollment is created for one of your groups.enrollmentId, groupId, quoteId, startDate, companyDomain, onboardingMode
group.state_changedThe provisioned company changes state, most importantly sandbox → prod (the group is live).groupId, companyDomain, previousState, state
census.processedA census write finishes processing.groupId, received, accepted, memberCount, errorCount
Full payload schemas are in the API reference 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.
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");
});
The official standard-webhooks libraries (npm install standard-webhooks, pip install standardwebhooks) implement this exact verification: pass them the raw body, the headers, and your whsec_ secret.

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.