Skip to content

Webhooks

Plainterms sends outbound webhooks for document processing, threshold confirmation, document rejection, and public landing-page activity.

Administrators manage webhook endpoints in Settings -> Webhooks.

  1. Add a public HTTPS receiver URL.
  2. Choose whether to sign deliveries.
  3. If signing is enabled, let Plainterms generate a secret or enter your own.
  4. Copy any generated or rotated secret when Plainterms displays it.
  5. 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.

  • 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-Id and Webhook-Timestamp, but no Webhook-Signature.

Plainterms currently delivers these event names:

EventWhen it is sentdownload_url enrichment
document.capturedA document has been captured after extraction.Yes, when the event should advertise a downloadable PDF.
document.approvedA document has been approved after extraction.Yes, when the event should advertise a downloadable PDF.
confirmation.requiredThe document currently requires threshold acknowledgment.No.
confirmation.receivedThreshold acknowledgment was first recorded.Yes, when the event should advertise a downloadable PDF.
document.rejectedExtraction or persistence ended in a terminal rejection state.No.
landing_page.openedA public landing page was opened.No.
landing_page.clickedA 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.

Every delivery includes:

HeaderMeaning
Content-Typeapplication/json.
Webhook-IdStable event delivery id. Use this for idempotency.
Webhook-TimestampUnix timestamp in seconds.
Webhook-SignatureOptional. 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 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 2xx response as success.
  • Non-2xx responses, network errors, and timeouts are failures and can be retried by the workflow runtime.
  • The same Webhook-Id is reused across retries of the same logical event.
  • A retry can include download_url when the event qualifies, but it points to the same API PDF resource shape.
  • Delivery-log payload copies may include the /api/v1 download_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.

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.id is an existing document — for example idempotent_reuse or duplicate_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 that document.id.
  • Sibling creation (duplicate_content — the response’s document.id is 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.

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:

ValueMeaning
Webhook-Id headerStable delivery event id.
webhook_deliveries.event_idStored copy of Webhook-Id.
webhook_deliveries.webhook_idEndpoint configuration UUID. Not the idempotency key.
Payload eventEvent name inside the JSON body.
webhook_deliveries.event_typeStored copy of payload event.
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;

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:

SymptomWhat to check
No deliveriesConfirm the endpoint is enabled and the triggering Plainterms event happened.
Uploaded a duplicate, no webhook arrivedExpected for same-document reuse (no new events); sibling creations (duplicate_content) do emit events. See Re-Uploads And Duplicates.
Signature mismatchVerify t.<rawBody>, not raw body alone. Use the t from Webhook-Signature.
Duplicate side effectsDeduplicate by Webhook-Id.
TimeoutRespond within about 10 seconds after durable receipt/queueing.
Missing download_urlCheck event type, whether the landing event is document-backed, and whether confirmation is still required.
download_url returns 202The webhook advertised the PDF resource, but rendering or processing is still in flight. Retry after the response’s Retry-After value.
download_url returns 409The PDF is confirmation-gated or otherwise not ready. Inspect the JSON error.code and polling.state.
download_url returns 410The document reached a terminal no-download state. Treat the JSON error.message or document metadata reason as the final explanation.