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
- Register a webhook URL with PGI when your partner account is provisioned (contact PGI to set this up)
- When an event occurs, PGI sends an HTTP POST to your URL with the event payload
- The request includes an
X-PGI-Signatureheader for verification - Your server should return
200 OKwithin 10 seconds to acknowledge receipt
Event types
| Event | Triggered when |
|---|---|
| document.extracted | PGI's extraction pipeline finishes processing an uploaded document. Contains extracted answers and a document summary. |
| application.quoted | An application has been approved by underwriters and a quote is available. This is how you receive quotes in the async flow. |
| application.declined | An application has been declined by underwriters. |
| policy.bound | A policy binding is confirmed and the policy is active. |
| claim.submitted | A new claim is filed against a policy. |
| claim.status_changed | A claim transitions between workflow statuses (e.g. submitted to under_review). |
| claim.closed | A claim is fully resolved and closed. |
| support.message_received | A PGI underwriter replies to a support ticket. |
Webhook payload
Every webhook delivery has the same envelope structure:
{
"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"
}
}
| Field | Type | Description |
|---|---|---|
| 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:
| Header | Description |
|---|---|
| Content-Type | application/json |
| X-PGI-Signature | HMAC-SHA256 signature in the format v1=<hex>. Signed with your webhook_secret. |
| X-PGI-Timestamp | Unix timestamp (seconds) when the event was dispatched. Use to reject replays older than 5 minutes. |
| X-PGI-Event | The 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:
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
{
"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"
}
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
{
"event": "application.declined",
"application_id": "D4E5F6A1B2C3789012345678EF",
"status": "declined",
"business_name": "Maple Leaf Technologies Inc."
}
claim.status_changed
{
"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
{
"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
- Always verify signatures. Never process a webhook without checking the HMAC signature. This prevents spoofed requests.
- Return 200 quickly. Do your processing asynchronously. If your handler takes more than 10 seconds, the delivery is considered failed.
- Use the webhook_id for idempotency. Store processed webhook IDs and skip duplicates. This protects against edge cases where a delivery might be sent more than once.
- Build fallback polling. Since Phase 1 does not include automatic retries, poll the relevant GET endpoints periodically to catch any events you may have missed.
- Keep your webhook secret safe. Rotate it if compromised by contacting PGI support.