---
title: Webhooks
description: Configure Plainterms webhooks, verify signatures, handle payloads,
  and debug deliveries.
editUrl: true
head: []
tableOfContents:
  minHeadingLevel: 2
  maxHeadingLevel: 4
template: doc
sidebar:
  order: 4
  label: Webhooks
  hidden: false
  attrs: {}
pagefind: true
draft: false
---

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

## Configure

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.

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

## 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

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:

```text
<timestamp>.<raw JSON body>
```

The timestamp in the HMAC input is the `t` value from `Webhook-Signature`.

```js
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

- 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.

### Re-Uploads And Duplicates

:::note
Same-document reuse behavior was observed and verified against production on
2026-08-01. Sibling-creation (`duplicate_content`) behavior is verified
against platform source only — it is feature-gated and has not been observed
in production. Authoritative confirmation is tracked in
[pagerguild/plainterms#2](https://github.com/pagerguild/plainterms/issues/2).
:::

Webhook expectations after a duplicate upload depend on what the reservation
did ([Handling Duplicate Uploads](/public-api/#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](https://github.com/pagerguild/plainterms/issues/2)
  resolves.

## Delivery Data Model

Webhook endpoint rows are stored in `org_webhooks`.

```ts
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`.

```ts
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

```ts
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

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](#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. |