Agent Reference
Webhook Integration Guide
Section titled “Webhook Integration Guide”Plainterms can send webhook events to your system when quote lifecycle events happen or visitors open and click quote landing pages. Webhooks are configured in the Plainterms app under Settings -> Webhooks.

Configure A Webhook
Section titled “Configure A Webhook”- Sign in to Plainterms as an administrator.
- Open Settings -> Webhooks.
- Enter your public webhook endpoint URL.
- Select Add Webhook.
- Copy the signing secret when Plainterms shows it. The secret is only shown once, so store it in your receiver as an environment variable.
- Leave the webhook enabled, or use the toggle to disable delivery without deleting the endpoint.
Use HTTPS for production endpoints. Your receiver should accept POST
requests with a JSON body and respond with a 2xx status quickly.
Events
Section titled “Events”Plainterms currently sends these event names:
document.captureddocument.approvedlanding_page.openedlanding_page.clicked
All payloads include an event name and ISO 8601 timestamp.
Quote document events include document_id, organization_id, extracted quote
data when available, a best-effort normalized value, and, when Plainterms can
grant one, a temporary branded PDF download_url with download_url_expires_at.
The default download URL lifetime is seven days, unless the Plainterms runtime
is configured differently. Treat download_url as a reusable bearer link until
it expires; do not put it in logs, tickets, or chat transcripts.
Landing-page open events include organization_id, magic_link_id,
target_type, document_id, bundle_id, landing_page_url, and
user_agent. For bundle links, document_id can be null; for document
links, bundle_id can be null; and landing_page_url or user_agent can be
null when unavailable.
landing_page.clicked is the landing-page interest signal. Plainterms sends it
when a visitor clicks the landing-page action that indicates they are interested
in the quote or selected add-on, not when a policy is bound or paid. The payload
includes the landing-page identifiers, document quote data when available,
add_on_product_selected, selected_add_on_product, and, when Plainterms can
grant one, the same temporary branded PDF download_url fields used by document
events.
Example landing_page.opened payload:
{ "event": "landing_page.opened", "timestamp": "2026-06-19T19:05:00.000Z", "organization_id": "00000000-0000-4000-8000-000000000002", "magic_link_id": "00000000-0000-4000-8000-000000000003", "target_type": "document", "document_id": "00000000-0000-4000-8000-000000000001", "bundle_id": null, "landing_page_url": "https://app.plainterms.io/public/landing/example", "user_agent": "Mozilla/5.0 ..."}Example landing_page.clicked payload:
{ "event": "landing_page.clicked", "timestamp": "2026-06-19T19:06:00.000Z", "organization_id": "00000000-0000-4000-8000-000000000002", "document_id": "00000000-0000-4000-8000-000000000001", "extracted_data": { "carrier": { "name": "Example Carrier" } }, "value": null, "magic_link_id": "00000000-0000-4000-8000-000000000003", "target_type": "document", "bundle_id": null, "landing_page_url": "https://app.plainterms.io/public/landing/example", "user_agent": "Mozilla/5.0 ...", "add_on_product_selected": true, "selected_add_on_product": { "id": "00000000-0000-4000-8000-000000000004", "title": "Vehicle Service Coverage", "description": "Mechanical repair protection", "callouts": ["Factory warranty gap"], "price_schedule": [{ "label": "Monthly protection", "amount": "$49" }], "period_note": "per month", "featured": true }, "download_url": "https://app.plainterms.io/api/external/branded-pdf/example-token", "download_url_expires_at": "2026-06-26T19:06:00.000Z"}For complete TypeScript contracts and a document-event example, see
llms.txt.
Verify Signatures
Section titled “Verify Signatures”Each delivery includes:
Webhook-Signature:sha256=<hex hmac>Webhook-Timestamp: Unix timestamp in secondsWebhook-Id: stable delivery event ID for deduplicationContent-Type:application/json
Compute an HMAC SHA-256 digest over the exact raw request body bytes using the
signing secret shown when the webhook was created. Compare it to the hex value
after sha256=. Do not verify against parsed and reserialized JSON.
Current Plainterms signatures cover the raw request body only. The
Webhook-Timestamp header is delivery metadata; it is not included in the HMAC
input. You can use it as a freshness sanity check, but not as cryptographic
replay protection. Use Webhook-Id idempotency to prevent duplicate side
effects.
import { createHmac, timingSafeEqual } from 'node:crypto';
function headerValue(headers, name) { const value = headers[name] ?? headers[name.toLowerCase()]; return Array.isArray(value) ? value[0] : value;}
export function verifyPlaintermsWebhook(rawBody, headers, secret) { const signature = headerValue(headers, 'webhook-signature') ?? ''; const prefix = 'sha256='; if (!signature.startsWith(prefix)) return false;
const received = Buffer.from(signature.slice(prefix.length), 'hex'); const expected = Buffer.from( createHmac('sha256', secret).update(rawBody).digest('hex'), 'hex', );
return received.length === expected.length && timingSafeEqual(received, expected);}
export function isFreshPlaintermsWebhook(headers, toleranceSeconds = 300) { const timestamp = Number(headerValue(headers, 'webhook-timestamp')); if (!Number.isFinite(timestamp)) return false;
const now = Math.floor(Date.now() / 1000); return Math.abs(now - timestamp) <= toleranceSeconds;}Verify the body signature before trusting the JSON payload. Use Webhook-Id as
an idempotency key so retries do not create duplicate downstream records. If a
duplicate Webhook-Id arrives, avoid repeating side effects, but update your
receipt metadata from the latest attempt; document deliveries can receive a
fresh temporary download_url on a retry.
| Wire value | Meaning |
|---|---|
Webhook-Id header | Stable event delivery ID. Matches delivery-log event_id. |
event_id | Delivery-log copy of the Webhook-Id value. |
webhook_id | Plainterms endpoint configuration UUID. Not the dedupe key. |
event | Event name inside the request payload. |
event_type | Delivery-log copy of the event name. |
Troubleshooting
Section titled “Troubleshooting”Open Settings -> Webhooks to see recent deliveries across endpoints. Select Delivery log on a registered webhook for searchable per-endpoint history. The log can be searched by event ID, status, request payload, response, or error; filtered by status and event; and each row can be opened to view the request and response captured for that delivery. Stored delivery-log copies redact branded PDF bearer tokens, and long response bodies can be truncated.

Common checks:
- No deliveries: confirm the webhook is enabled and the event you expect has actually happened.
- Failed status: open the delivery details and inspect the HTTP status, error, request body, and response body.
- Signature mismatch: verify against the raw request body, not parsed and reserialized JSON.
- Duplicate processing: deduplicate with
Webhook-Id. - Timeout or network errors: make the receiver public, keep response handling
fast, and return a
2xxonly after the event is accepted.
Agent Reference
Section titled “Agent Reference”The following agent-oriented reference is derived from the repository source llms.txt.
This file is for agents helping a user configure or debug Plainterms webhooks.
Plainterms webhook configuration is done in the app UI at Settings -> Webhooks
or /settings/webhooks. The user must be an administrator to add, delete, or
toggle webhooks.
Configuration steps:
- Open Settings -> Webhooks.
- Add a public receiver URL.
- Copy the signing secret immediately when Plainterms shows it. It is shown once after creation. Do not put this secret in docs, tickets, screenshots, or chat transcripts.
- Keep the endpoint enabled, or disable it with the toggle when deliveries should pause.
- Use the Delivery log link beside a webhook for endpoint-specific delivery history.
Receiver requirements:
- Accept POST requests with JSON bodies.
- Use HTTPS in production.
- Return a 2xx status for accepted events. Non-2xx responses are failures.
- Respond quickly; slow receivers can be marked as timed out.
- Deduplicate by
Webhook-Id, which maps to the delivery log’sevent_id. Do not confuse it withwebhook_id, which is the Plainterms endpoint UUID. - When a duplicate
Webhook-Idarrives, avoid repeating side effects but update receipt metadata from the latest attempt. Document delivery retries can carry a fresh temporarydownload_urlanddownload_url_expires_at.
Supported event names:
document.captureddocument.approvedlanding_page.openedlanding_page.clicked
TypeScript delivery contracts:
export type UUID = string;export type ISO8601Timestamp = string;export type UrlString = string;
export type PlaintermsWebhookEventName = | 'document.captured' | 'document.approved' | 'landing_page.opened' | 'landing_page.clicked';
export interface PlaintermsWebhookHeaders { /** * Header names are case-insensitive. Many server frameworks expose them as * lowercase keys. */ 'content-type': string; // Usually application/json. 'webhook-signature': `sha256=${string}`; /** * Unix seconds. Current signatures do not include this header in the HMAC * input, so treat it as metadata or a freshness sanity check, not as signed * replay protection. */ 'webhook-timestamp': `${number}`; /** * Stable delivery event id. This value matches Plainterms delivery-log * `event_id`, not delivery-log `webhook_id`. */ 'webhook-id': `evt_${string}`;}
export interface PlaintermsDocumentWebhookPayload { event: 'document.captured' | 'document.approved'; timestamp: ISO8601Timestamp; document_id: UUID; organization_id: UUID; extracted_data: Record<string, unknown> | null; /** * Best-effort normalized personal-auto value for downstream integrations. * The exact nested shape can evolve; treat it as integration data, not a * stable database schema. */ value: Record<string, unknown> | null; /** * Temporary branded PDF URL. Omitted if Plainterms cannot grant a download, * for example when threshold acknowledgement is required before rendering. It * is a reusable bearer link until expiry; do not log it, paste it into tickets, * or forward it outside the receiving system's trust boundary. */ download_url?: UrlString; /** * ISO timestamp for download_url expiry. Default lifetime is seven days * unless the Plainterms runtime is configured with a different * WEBHOOK_DOWNLOAD_GRANT_TTL_DAYS value. */ download_url_expires_at?: ISO8601Timestamp;}
export interface PlaintermsLandingPageOpenedPayload { event: 'landing_page.opened'; timestamp: ISO8601Timestamp; organization_id: UUID; document_id: UUID | null; magic_link_id: UUID; target_type: 'document' | 'bundle'; bundle_id: UUID | null; landing_page_url: UrlString | null; user_agent: string | null;}
export interface PlaintermsSelectedAddOnProduct { id: UUID; title: string; description: string; callouts: string[]; price_schedule: Array<{ label: string; amount: string }>; period_note: string | null; featured: boolean;}
export interface PlaintermsLandingPageClickedPayload { /** * Sent when a visitor clicks the public landing-page action that indicates * interest in the quote or selected add-on. This is an interest signal, not a * bound-policy or payment event. */ event: 'landing_page.clicked'; timestamp: ISO8601Timestamp; organization_id: UUID; /** * Present for document-backed landing pages. Null for bundle-backed links or * when no document context is available. */ document_id: UUID | null; extracted_data: Record<string, unknown> | null; /** * Best-effort normalized personal-auto value for downstream integrations. * The exact nested shape can evolve; treat it as integration data, not a * stable database schema. */ value: Record<string, unknown> | null; magic_link_id: UUID; target_type: 'document' | 'bundle'; bundle_id: UUID | null; landing_page_url: UrlString | null; user_agent: string | null; add_on_product_selected: boolean; selected_add_on_product: PlaintermsSelectedAddOnProduct | null; /** * Optional enrichment for document-backed click events. If Plainterms cannot * create a branded PDF grant, the click event can still be delivered without * these fields. */ download_url?: UrlString; download_url_expires_at?: ISO8601Timestamp;}
export type PlaintermsWebhookPayload = | PlaintermsDocumentWebhookPayload | PlaintermsLandingPageOpenedPayload | PlaintermsLandingPageClickedPayload;
export interface PlaintermsDeliveryLogEntry { /** * Copy of the Webhook-Id request header. Use this for receiver idempotency. * Legacy rows can be null. */ event_id: `evt_${string}` | null; /** * Plainterms endpoint configuration UUID. This is not the same as Webhook-Id. */ webhook_id: UUID; organization_id: UUID; event_type: PlaintermsWebhookEventName; status_code: number | null; success: boolean; attempt: number; payload: PlaintermsWebhookPayload; response_body: string | null; error_message: string | null; delivered_at: ISO8601Timestamp;}
export interface PlaintermsReceiverAcceptedJson { ok: true; /** * Optional. Plainterms does not require a receipt_id, but returning one makes * support and cross-system tracing easier. */ receipt_id?: string; /** * Optional. Echo the Webhook-Id value if your platform supports it. */ event_id?: `evt_${string}`;}Security headers:
Webhook-Signature:sha256=<hex hmac>Webhook-Timestamp: Unix timestamp in seconds; metadata, not part of the HMACWebhook-Id: stable delivery event ID; same value as delivery-logevent_idContent-Type:application/json
Field-name mapping:
| Value | Meaning |
|---|---|
Webhook-Id header | Stable delivery event id, prefixed with evt_. |
PlaintermsDeliveryLogEntry.event_id | Stored copy of the Webhook-Id header. |
PlaintermsDeliveryLogEntry.webhook_id | Endpoint configuration UUID. Not the dedupe key. |
Payload event | Event name inside the JSON body. |
Delivery log event_type | Stored copy of the payload event name. |
Header Webhook-Timestamp | Unix seconds when the request was constructed. |
Payload timestamp | ISO 8601 event timestamp in the JSON body. |
Signature verification:
Compute HMAC SHA-256 over the exact raw request body using the webhook signing
secret. Compare the hex digest to the value after sha256= in
Webhook-Signature using a constant-time comparison. Do not verify against
parsed and reserialized JSON.
Current Plainterms signatures cover only the raw request body. The
Webhook-Timestamp header is not included in the HMAC input. A receiver can
reject obviously stale timestamps as a defense-in-depth sanity check, but this
is not cryptographic replay protection because an attacker who has a valid body
and signature can change an unsigned timestamp. Always combine signature
verification with Webhook-Id idempotency.
Expected receiver behavior:
export type PlaintermsReceiverSuccessStatus = | 200 | 201 | 202 | 204;
export interface PlaintermsReceiverResponseExpectation { status: PlaintermsReceiverSuccessStatus | number; body?: PlaintermsReceiverAcceptedJson | string | null;}Plainterms treats any HTTP 2xx response as accepted. A response body is not
required. If a body is returned, Plainterms stores response text for delivery-log
debugging. Return JSON such as { "ok": true, "receipt_id": "..." } when the
receiving system can create a durable receipt.
Plainterms treats non-2xx responses, network errors, and timeouts as failed attempts. Do not return a 2xx until the event is durably accepted or queued by the receiving system.
Delivery retry behavior:
export interface PlaintermsWebhookRetryPolicy { /** * Workflow start is best-effort. If Plainterms cannot start the delivery * workflow, the source action is logged but document capture, approval, or * landing-page open is not failed for the end user. */ workflowStartBehavior: 'best-effort'; /** * The delivery step is configured as deliverOneWebhook.maxRetries = 5 in the * current Workflow SDK implementation. */ deliveryStepMaxRetries: 5; deliveryFetchTimeoutMs: 10_000; landingPageOpenedUsesSameDeliveryWorkflow: true; landingPageClickedUsesSameDeliveryWorkflow: true; eventIdStability: 'same Webhook-Id across retries of the same logical delivery'; endpointIsolation: 'each enabled endpoint retries independently';}Operational retry notes:
- A webhook delivery workflow is started after Plainterms document capture, document approval, landing-page open, and landing-page click events. Failure to start webhook delivery is logged and does not fail the source user-facing action.
- Enabled endpoints are delivered in parallel.
- Each endpoint delivery step is configured with
maxRetries = 5. Treat exact retry scheduling as runtime-controlled; do not depend on a precise backoff interval unless Plainterms documents one separately. - The outbound HTTP request is aborted after about 10 seconds. The delivery
failure is recorded as
Request timed out. - The same
Webhook-Idis reused across retries of the same logical delivery. Use it as the idempotency key, and update receipt metadata for later attempts rather than blindly dropping all duplicate IDs. - Document event and document-backed
landing_page.clickedpayload bases are loaded once for the delivery workflow. During each delivery attempt, Plainterms may create a fresh temporarydownload_urlanddownload_url_expires_at, so those fields can differ between attempts with the sameWebhook-Id. - For
landing_page.clicked, branded PDF URL creation is optional enrichment. If the grant cannot be created, Plainterms can still deliver the click event withoutdownload_urlanddownload_url_expires_at. - Plainterms records each attempt in the delivery log with attempt number, HTTP status, success flag, response body, error text, request payload, and delivery timestamp.
- Delivery-log payload copies redact branded PDF bearer tokens from
download_url. Stored response bodies also redact branded PDF bearer tokens and are truncated to about 2000 characters.
Example document.approved payload:
{ "event": "document.approved", "timestamp": "2026-06-19T19:00:00.000Z", "document_id": "00000000-0000-4000-8000-000000000001", "organization_id": "00000000-0000-4000-8000-000000000002", "extracted_data": { "carrier": { "name": "Example Carrier" } }, "value": { "status": "READY", "issuerId": "example-carrier", "issuerName": "Example Carrier", "policies": [ { "id": "00000000-0000-4000-8000-000000000001", "issuer": "example-carrier", "policyType": "PERSONAL_AUTO", "premiumCents": 123456 } ] }, "download_url": "https://app.plainterms.io/api/external/branded-pdf/example-token", "download_url_expires_at": "2026-06-26T19:00:00.000Z"}Example landing_page.opened payload:
{ "event": "landing_page.opened", "timestamp": "2026-06-19T19:05:00.000Z", "organization_id": "00000000-0000-4000-8000-000000000002", "magic_link_id": "00000000-0000-4000-8000-000000000003", "target_type": "document", "document_id": "00000000-0000-4000-8000-000000000001", "bundle_id": null, "landing_page_url": "https://app.plainterms.io/public/landing/example", "user_agent": "Mozilla/5.0 ..."}Example landing_page.clicked payload:
{ "event": "landing_page.clicked", "timestamp": "2026-06-19T19:06:00.000Z", "organization_id": "00000000-0000-4000-8000-000000000002", "document_id": "00000000-0000-4000-8000-000000000001", "extracted_data": { "carrier": { "name": "Example Carrier" } }, "value": { "status": "READY", "issuerId": "example-carrier", "issuerName": "Example Carrier", "policies": [ { "id": "00000000-0000-4000-8000-000000000001", "issuer": "example-carrier", "policyType": "PERSONAL_AUTO", "premiumCents": 123456 } ] }, "magic_link_id": "00000000-0000-4000-8000-000000000003", "target_type": "document", "bundle_id": null, "landing_page_url": "https://app.plainterms.io/public/landing/example", "user_agent": "Mozilla/5.0 ...", "add_on_product_selected": true, "selected_add_on_product": { "id": "00000000-0000-4000-8000-000000000004", "title": "Vehicle Service Coverage", "description": "Mechanical repair protection", "callouts": ["Factory warranty gap"], "price_schedule": [{ "label": "Monthly protection", "amount": "$49" }], "period_note": "per month", "featured": true }, "download_url": "https://app.plainterms.io/api/external/branded-pdf/example-token", "download_url_expires_at": "2026-06-26T19:06:00.000Z"}Troubleshooting flow:
- Confirm the webhook is enabled.
- Check Settings -> Webhooks for recent delivery status.
- Open the per-webhook Delivery log.
- Filter by status or event if needed.
- Open a delivery row to inspect event ID, attempt, HTTP status, request body, response body, and error text.
- For no deliveries, confirm the triggering Plainterms event happened.
- For failed deliveries, fix receiver URL reachability, response status, timeout behavior, or signature validation.
Screenshots in this repo are sanitized examples:
assets/screenshots/webhooks-overview.pngassets/screenshots/webhook-delivery-log.png