Docs/API/Webhooks

Webhook Events

Receive real-time HTTP POST notifications when resources change state. Webhooks are signed with HMAC-SHA256 so you can verify they came from PGI.

How webhooks work

  1. Register a webhook URL with PGI when your partner account is provisioned (contact PGI to set this up)
  2. When an event occurs, PGI sends an HTTP POST to your URL with the event payload
  3. The request includes an X-PGI-Signature header for verification
  4. Your server should return 200 OK within 10 seconds to acknowledge receipt
Important Webhooks are delivered once. If your server is down or returns a non-2xx response, the event is not retried in Phase 1. Retry with exponential backoff is planned for a future release. Build your integration to handle occasional missed events by polling the relevant GET endpoints.

Event types

EventTriggered when
document.extractedPGI's extraction pipeline finishes processing an uploaded document. Contains extracted answers and a document summary.
application.quotedAn application has been approved by underwriters and a quote is available. This is how you receive quotes in the async flow.
application.declinedAn application has been declined by underwriters.
policy.boundA policy binding is confirmed and the policy is active.
claim.submittedA new claim is filed against a policy.
claim.status_changedA claim transitions between workflow statuses (e.g. submitted to under_review).
claim.closedA claim is fully resolved and closed.
support.message_receivedA PGI underwriter replies to a support ticket.

Webhook payload

Every webhook delivery has the same envelope structure:

Webhook Payload
{
  "event": "policy.bound",
  "webhook_id": "wh_2026040312345",
  "created_at": "2026-04-05T09:14:22Z",
  "data": {
    "policy_number": "PGI-2026-CA-00142",
    "status": "bound",
    "named_insured": "Maple Leaf Technologies Inc.",
    "coverage_amount": 250000,
    "premium": 6250.00,
    "effective_date": "2026-04-15",
    "expiry_date": "2027-04-15"
  }
}
FieldTypeDescription
event string The event type (e.g. policy.bound).
webhook_id string Unique identifier for this delivery. Use for idempotency.
created_at string ISO 8601 timestamp of when the event occurred.
data object Event-specific payload. Shape varies by event type.

HTTP headers

PGI sends these headers with every webhook delivery:

HeaderDescription
Content-Typeapplication/json
X-PGI-SignatureHMAC-SHA256 signature in the format v1=<hex>. Signed with your webhook_secret.
X-PGI-TimestampUnix timestamp (seconds) when the event was dispatched. Use to reject replays older than 5 minutes.
X-PGI-EventThe event type (same as the event field in the payload).

Verifying webhook signatures

Always verify the X-PGI-Signature header before processing a webhook. This confirms the request came from PGI, the payload has not been tampered with, and the delivery is not a replay.

The signature is computed as:

Algorithm
sig_payload = f"{timestamp}.{raw_body_string}"
hmac_hex = HMAC-SHA256(webhook_secret, sig_payload)
X-PGI-Signature: v1={hmac_hex}

Your webhook_secret is generated automatically when your partner account is provisioned and sent to you out-of-band. Keep it in a secret manager. The v1= prefix is fixed and allows PGI to introduce new signing algorithms in the future without breaking existing integrations.

Replay protection

Check that X-PGI-Timestamp is within 5 minutes of your server's current time. Reject any delivery where the timestamp is older than 300 seconds. This prevents an attacker who captures a valid signed request from replaying it later.

Verification examples

import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret_here"
TOLERANCE_SECONDS = 300  # 5 minutes

def verify_pgi_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
    # Reject stale deliveries
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False

    # Recompute signature
    sig_payload = f"{timestamp}.{raw_body.decode('utf-8')}"
    expected_hex = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        sig_payload.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    expected = f"v1={expected_hex}"

    return hmac.compare_digest(signature, expected)

@app.route("/webhooks/pgi", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-PGI-Signature", "")
    timestamp = request.headers.get("X-PGI-Timestamp", "")

    if not verify_pgi_signature(request.data, signature, timestamp):
        abort(401, "Invalid or expired signature")

    payload = request.get_json()
    event = payload["event"]

    if event == "document.extracted":
        print(f"Document extracted: {payload['document_id']} for app {payload['application_id']}")
    elif event == "application.quoted":
        print(f"Quote ready: {payload['quote_id']} for app {payload['application_id']}")
    elif event == "policy.bound":
        print(f"Policy bound: {payload['policy_number']}")
    elif event == "claim.submitted":
        print(f"Claim submitted: {payload['claim_id']}")
    elif event == "support.message_received":
        print(f"New message on ticket: {payload['ticket_id']}")

    return "", 200
const crypto = require("crypto");
const express = require("express");

const app = express();
const WEBHOOK_SECRET = "your_webhook_secret_here";
const TOLERANCE_SECONDS = 300; // 5 minutes

function verifyPgiSignature(rawBody, signature, timestamp) {
  // Reject stale deliveries
  const ts = parseInt(timestamp, 10);
  if (isNaN(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) {
    return false;
  }

  // Recompute signature
  const sigPayload = `${timestamp}.${rawBody.toString("utf8")}`;
  const expectedHex = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(sigPayload)
    .digest("hex");
  const expected = `v1=${expectedHex}`;

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

app.post("/webhooks/pgi", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-pgi-signature"] || "";
  const timestamp = req.headers["x-pgi-timestamp"] || "";

  if (!verifyPgiSignature(req.body, signature, timestamp)) {
    return res.status(401).send("Invalid or expired signature");
  }

  const payload = JSON.parse(req.body);
  const { event } = payload;

  switch (event) {
    case "document.extracted":
      console.log(`Document extracted: ${payload.document_id} for app ${payload.application_id}`);
      break;
    case "application.quoted":
      console.log(`Quote ready: ${payload.quote_id} for app ${payload.application_id}`);
      break;
    case "policy.bound":
      console.log(`Policy bound: ${payload.policy_number}`);
      break;
    case "claim.submitted":
      console.log(`Claim submitted: ${payload.claim_id}`);
      break;
    case "support.message_received":
      console.log(`New message on ticket: ${payload.ticket_id}`);
      break;
  }

  res.sendStatus(200);
});

Example payloads by event

application.quoted

Payload
{
  "event": "application.quoted",
  "application_id": "D4E5F6A1B2C3789012345678EF",
  "status": "approved",
  "business_name": "Maple Leaf Technologies Inc.",
  "quote_id": "E5F6A1B2C3D4789012345678AA",
  "annual_premium": "6250.00",
  "rate": "2.500",
  "coverage_amount": "250000.00",
  "valid_until": "2026-05-03T15:05:44Z"
}
Next step Use the quote_id from this webhook to bind the policy. You can also retrieve the quote at any time via GET /api/v2/quotes/{quote_id}/.

application.declined

Payload
{
  "event": "application.declined",
  "application_id": "D4E5F6A1B2C3789012345678EF",
  "status": "declined",
  "business_name": "Maple Leaf Technologies Inc."
}

claim.status_changed

Payload
{
  "event": "claim.status_changed",
  "webhook_id": "wh_2026040454321",
  "created_at": "2026-04-04T10:15:00Z",
  "data": {
    "claim_id": "A2B3C4D5E6F7890123456789CC",
    "policy_number": "PGI-2026-CA-00142",
    "previous_status": "submitted",
    "new_status": "under_review",
    "event_type": "payment_default"
  }
}

support.message_received

Payload
{
  "event": "support.message_received",
  "webhook_id": "wh_2026040411111",
  "created_at": "2026-04-04T09:15:00Z",
  "data": {
    "ticket_id": "B3C4D5E6F7A1890123456789DD",
    "message": {
      "sender": "pgi_underwriter",
      "content": "Yes, covenant breach is a covered event type under this policy.",
      "created_at": "2026-04-04T09:15:00Z"
    }
  }
}

Best practices