Webhooks
Plainterms sends outbound webhooks for document processing, threshold confirmation, document rejection, and public landing-page activity.
Configure
Section titled “Configure”Administrators manage webhook endpoints in Settings -> Webhooks.
- Add a public HTTPS receiver URL.
- Choose whether to sign deliveries.
- If signing is enabled, let Plainterms generate a secret or enter your own.
- Copy any generated or rotated secret when Plainterms displays it.
- Leave the endpoint enabled, disable it temporarily, rotate/clear the signing secret, or delete the endpoint when it is no longer needed.
Webhook signing is optional in the current UI, but signed HTTPS delivery is the recommended production configuration.
Security Notes
Section titled “Security Notes”- Generated webhook secrets are
whsec_plus 32 random bytes encoded as hex. - Custom webhook secrets must be 16 to 512 characters after trimming.
- Webhook signing secrets are stored in Supabase Vault.
- Plaintext webhook secrets are readable only by service-role server code for delivery signing.
- A webhook without a signing secret receives
Webhook-IdandWebhook-Timestamp, but noWebhook-Signature.
Events
Section titled “Events”Plainterms currently delivers these event names:
| Event | When it is sent | download_url enrichment |
|---|---|---|
document.captured | A document has been captured after extraction. | Yes, when the event should advertise a downloadable PDF. |
document.approved | A document has been approved after extraction. | Yes, when the event should advertise a downloadable PDF. |
confirmation.required | The document currently requires threshold acknowledgment. | No. |
confirmation.received | Threshold acknowledgment was first recorded. | Yes, when the event should advertise a downloadable PDF. |
document.rejected | Extraction or persistence ended in a terminal rejection state. | No. |
landing_page.opened | A public landing page was opened. | No. |
landing_page.clicked | A visitor clicked the landing-page action. | Yes for document-backed links when confirmation state allows it. |
When present, download_url is an absolute URL to the API-key-authenticated
/api/v1/documents/{documentId}.pdf resource. The receiver must fetch it with
the same Plainterms API key used for /api/v1; it is not a bearerless signed
URL and there is no download_url_expires_at field.
Headers And Signatures
Section titled “Headers And Signatures”Every delivery includes:
| Header | Meaning |
|---|---|
Content-Type | application/json. |
Webhook-Id | Stable event delivery id. Use this for idempotency. |
Webhook-Timestamp | Unix timestamp in seconds. |
Webhook-Signature | Optional. Present only when signing is enabled: t=<timestamp>,v1=<hex hmac>. |
The signature is HMAC-SHA-256 using the webhook signing secret. The HMAC input is:
<timestamp>.<raw JSON body>The timestamp in the HMAC input is the t value from Webhook-Signature.
import { createHmac, timingSafeEqual } from 'node:crypto';
function headerValue(headers, name) { const value = headers[name] ?? headers[name.toLowerCase()]; return Array.isArray(value) ? value[0] : value;}
function parsePlaintermsSignature(header) { const parts = Object.fromEntries( String(header ?? '') .split(',') .map((part) => part.split('=').map((value) => value.trim())), ); if (!parts.t || !parts.v1) return null; return { timestamp: parts.t, signature: parts.v1 };}
export function verifyPlaintermsWebhook(rawBody, headers, secret) { const parsed = parsePlaintermsSignature(headerValue(headers, 'webhook-signature')); if (!parsed) return false;
const expectedHex = createHmac('sha256', secret) .update(`${parsed.timestamp}.${rawBody}`) .digest('hex');
const received = Buffer.from(parsed.signature, 'hex'); const expected = Buffer.from(expectedHex, 'hex'); return received.length === expected.length && timingSafeEqual(received, expected);}
export function isFreshPlaintermsWebhook(headers, toleranceSeconds = 300) { const parsed = parsePlaintermsSignature(headerValue(headers, 'webhook-signature')); const timestamp = Number(parsed?.timestamp ?? 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 signature against the exact raw request body bytes. Do not parse and
reserialize JSON before verifying. Use Webhook-Id as the idempotency key so
retries do not repeat downstream side effects.
Delivery Behavior
Section titled “Delivery Behavior”- Delivery starts after the source Plainterms action. Startup is best-effort for document and landing-page events unless the source route explicitly surfaces a failed workflow start.
- Enabled endpoints are delivered independently.
- Each endpoint delivery step has
maxRetries = 5. - The outbound request times out after about 10 seconds and records
Request timed out. - Plainterms treats any HTTP
2xxresponse as success. - Non-
2xxresponses, network errors, and timeouts are failures and can be retried by the workflow runtime. - The same
Webhook-Idis reused across retries of the same logical event. - A retry can include
download_urlwhen the event qualifies, but it points to the same API PDF resource shape. - Delivery-log payload copies may include the
/api/v1download_url; it carries no token and still requires API-key auth. Stored response bodies are truncated and still redact legacy branded-PDF bearer tokens if a receiver echoes one.
Re-Uploads And Duplicates
Section titled “Re-Uploads And Duplicates”Webhook expectations after a duplicate upload depend on what the reservation did (Handling Duplicate Uploads):
- Same-document reuse (any reuse where the response’s
document.idis an existing document — for exampleidempotent_reuseorduplicate_reuse, whatever the outcome string): the document is returned untouched and nothing is re-extracted. For a document that had already settled, no new webhook events fire — do not wait for a webhook; poll the document’s metadata instead. If the reuse resolves to a document still mid-extraction (an in-flight duplicate), the original run continues and its completion events still fire once — keep webhook correlation open for thatdocument.id. - Sibling creation (
duplicate_content— the response’sdocument.idis a new document): the sibling runs the normal document lifecycle and emits the normal completion events (document.captured,document.approved, or the confirmation/rejection paths). - Recovery (
reopened_for_reupload: true): the document re-enters extraction after the re-upload; whether completion events re-emit for the same document has not been confirmed — treat as undefined until pagerguild/plainterms#2 resolves.
Delivery Data Model
Section titled “Delivery Data Model”Webhook endpoint rows are stored in org_webhooks.
export interface WebhookEndpointConfiguration { id: string; organization_id: string; url: string; enabled: boolean; signing_secret_id: string | null; signing_secret_set_at: string | null; created_at: string; updated_at: string;}Delivery attempts are stored in webhook_deliveries.
export interface WebhookDeliveryLogEntry { id: string; webhook_id: string; organization_id: string; event_id: string | null; event_type: PlaintermsWebhookEventName; status_code: number | null; success: boolean; attempt: number; payload: PlaintermsWebhookPayload | null; response_body: string | null; error_message: string | null; delivered_at: string;}Use this mapping when reconciling receiver logs with Plainterms:
| Value | Meaning |
|---|---|
Webhook-Id header | Stable delivery event id. |
webhook_deliveries.event_id | Stored copy of Webhook-Id. |
webhook_deliveries.webhook_id | Endpoint configuration UUID. Not the idempotency key. |
Payload event | Event name inside the JSON body. |
webhook_deliveries.event_type | Stored copy of payload event. |
TypeScript Contracts
Section titled “TypeScript Contracts”export type UUID = string;export type ISO8601Timestamp = string;export type UrlString = string;
export type PlaintermsWebhookEventName = | 'document.captured' | 'document.approved' | 'confirmation.required' | 'confirmation.received' | 'document.rejected' | 'landing_page.opened' | 'landing_page.clicked';
export interface PlaintermsWebhookHeaders { 'content-type': 'application/json'; 'webhook-id': `evt_${string}` | string; 'webhook-timestamp': `${number}`; 'webhook-signature'?: `t=${number},v1=${string}`;}
export interface PlaintermsWebhookUserIdentity { id: UUID; email: string | null;}
export type PlaintermsDocumentConfirmationState = | 'not_required' | 'required' | 'received';
export interface PlaintermsDocumentConfirmation { state: PlaintermsDocumentConfirmationState; acknowledged_at: ISO8601Timestamp | null; acknowledged_by: PlaintermsWebhookUserIdentity | null;}
export interface SavvyTrellisName { firstName?: string; middleName?: string; lastName?: string;}
export interface SavvyTrellisAddress { number?: string; street?: string; type?: string; sec_unit_type?: string; sec_unit_num?: string; city?: string; state?: string; zip?: string; plus4?: string; suffix?: string; prefix?: string;}
export interface SavvyPersonalAutoCoverage { name?: string; premiumCents?: number; isDeclined?: boolean; perPersonLimitCents?: number; perAccidentLimitCents?: number; deductibleCents?: number; glassDeductibleCents?: number; perDayLimitCents?: number; perMonthLimitCents?: number; perWeekLimitCents?: number;}
export interface SavvyPersonalAutoDiscount { name?: string; discountCents?: number; rawName?: string;}
export interface SavvyPersonalAutoOperator { name?: SavvyTrellisName; gender?: string; maritalStatus?: string; relationship?: string; birthday?: string; isPrimary?: boolean; addressRaw?: string; address?: SavvyTrellisAddress;}
export interface SavvyPersonalAutoVehicle { year?: string; vin?: string; make?: string; model?: string; type?: string | null; driver?: SavvyTrellisName; use?: string; garagingLocationRaw?: string; garagingLocation?: SavvyTrellisAddress; discounts?: SavvyPersonalAutoDiscount[]; discountTotalCents?: number; premiumCents?: number; coverages?: SavvyPersonalAutoCoverage[];}
export interface SavvyPersonalAutoPolicy { id?: UUID; issuer?: string; policyNumber?: string; policyType: 'PERSONAL_AUTO'; policyHolder?: { name?: SavvyTrellisName; address?: SavvyTrellisAddress; }; policyTermMonths?: number; paymentScheduleMonths?: number; numberOfPayments?: number; issueDate?: string; renewalDate?: string; effectiveDate?: string; expirationDate?: string; premiumCents?: number; operators?: SavvyPersonalAutoOperator[]; vehicles?: SavvyPersonalAutoVehicle[]; discounts?: SavvyPersonalAutoDiscount[];}
export interface SavvyPersonalAutoWebhookValue { status: 'READY'; issuerId?: string; issuerName?: string; policies: SavvyPersonalAutoPolicy[];}
export interface PlaintermsDocumentWebhookPayload { event: | 'document.captured' | 'document.approved' | 'confirmation.required' | 'confirmation.received' | 'document.rejected'; timestamp: ISO8601Timestamp; document_id: UUID; customer_document_id: string | null; organization_id: UUID; uploaded_by: PlaintermsWebhookUserIdentity; confirmation: PlaintermsDocumentConfirmation; reason?: string | null; extracted_data: Record<string, unknown> | null; value: SavvyPersonalAutoWebhookValue | null; download_url?: UrlString;}
export interface PlaintermsLandingPageOpenedPayload { event: 'landing_page.opened'; timestamp: ISO8601Timestamp; organization_id: UUID; magic_link_id: UUID; target_type: 'document' | 'bundle'; document_id: UUID | null; 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 { event: 'landing_page.clicked'; timestamp: ISO8601Timestamp; organization_id: UUID; document_id: UUID | null; customer_document_id?: string | null; extracted_data: Record<string, unknown> | null; value: SavvyPersonalAutoWebhookValue | null; uploaded_by?: PlaintermsWebhookUserIdentity; confirmation?: PlaintermsDocumentConfirmation; 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; download_url?: UrlString;}
export type PlaintermsWebhookPayload = | PlaintermsDocumentWebhookPayload | PlaintermsLandingPageOpenedPayload | PlaintermsLandingPageClickedPayload;Troubleshooting
Section titled “Troubleshooting”Open Settings -> Webhooks, then select Delivery log for an endpoint. The log supports filtering by status and event type, search across event IDs, payloads, responses, and errors, and a detail drawer for request/response bodies.
Common checks:
| Symptom | What to check |
|---|---|
| No deliveries | Confirm the endpoint is enabled and the triggering Plainterms event happened. |
| Uploaded a duplicate, no webhook arrived | Expected for same-document reuse (no new events); sibling creations (duplicate_content) do emit events. See Re-Uploads And Duplicates. |
| Signature mismatch | Verify t.<rawBody>, not raw body alone. Use the t from Webhook-Signature. |
| Duplicate side effects | Deduplicate by Webhook-Id. |
| Timeout | Respond within about 10 seconds after durable receipt/queueing. |
Missing download_url | Check event type, whether the landing event is document-backed, and whether confirmation is still required. |
download_url returns 202 | The webhook advertised the PDF resource, but rendering or processing is still in flight. Retry after the response’s Retry-After value. |
download_url returns 409 | The PDF is confirmation-gated or otherwise not ready. Inspect the JSON error.code and polling.state. |
download_url returns 410 | The document reached a terminal no-download state. Treat the JSON error.message or document metadata reason as the final explanation. |