Skip to content

Webhooks — API Reference

ContentsPal can send HTTP POST requests to an external URL when job-related events occur. This allows integrating with external systems (ERPs, CRMs, etc.) in near real-time.


Organization admins configure webhooks under Organization → Settings → Integrations → Webhooks:

  • Endpoint URL — HTTPS URL that will receive POST requests.

  • Enabled — Toggle to enable/disable delivery without removing the URL.

  • Signing Secret (optional) — Shared secret for HMAC-SHA256 payload signature verification.

The webhook config is stored on the organization’s settings.webhook field.

Forms can also be sent and tracked over the REST API and from an AI assistant connected over MCP.

  • REST API — GET /v1/forms/templates lists the templates available to send; POST /v1/jobs/{jobId}/forms creates a form from one; POST /v1/jobs/{jobId}/forms/{formId}/send emails an individual signer; GET /v1/jobs/{jobId}/forms/{formId} reports status and returns the signed PDF; DELETE on the same path cancels it. See the API reference for full details.

  • MCP — the listFormTemplates, getFormTemplate, listForms, getForm, sendForm, sendFormInvitation and cancelForm tools.

Creating a form does not email anyone: signers are invited only when you call the send endpoint for each of them, which lets an integration check the pre-filled values first. Templates are built in the ContentsPal web app and are read-only on both surfaces.

Managing the configuration programmatically

Section titled “Managing the configuration programmatically”

The same settings can be read and changed over the REST API and from an AI assistant connected over MCP, so an integration can point ContentsPal at its own endpoint without anyone logging into the app.

  • REST API — GET, PUT and DELETE /v1/org/webhook, and POST /v1/org/webhook/test to send a test. PUT is a partial update: fields you leave out are unchanged, so you can toggle enabled without resending the URL. See the API reference for full details.

  • MCP — the getWebhookConfig, setWebhookConfig, deleteWebhookConfig and sendTestWebhook tools.

On both surfaces the signing secret is write-only. You can set it, replace it, or clear it, but it is never returned — reads report only whether a secret is stored and its last four characters. Endpoint URLs must use HTTPS, and are rejected at the point you save them rather than failing silently at delivery time.

Event Trigger
job.created A new job is created in the organization, or an archived job is restored
job.updated A job’s name, identifier, status, customer, or insurance details are modified
job.deleted A job is deleted or archived
report.generated A report finishes generating and becomes available to download
form.completed Every signer has signed an e-signature form, and the signed PDF is ready
form.declined A signer declined to sign an e-signature form
webhook.test Sent on demand when an admin clicks Test in the webhook settings (or sends a test over the REST API or MCP)

job.* events fire only for jobs linked to the organization (via sharing.organizationId); report.generated and form.* fire for the organization’s reports and forms. The event type is also sent in the X-Webhook-Event header.

Method: POST

Headers:

Header Description
Content-Type application/json
User-Agent ContentsPal-Webhook/1.0
X-Webhook-Event Event type (e.g. job.created)
X-Webhook-Id Unique id of the event. Retries of the same event carry the same id, so use it to ignore duplicates. Not sent on webhook.test.
X-Webhook-Signature sha256={hex} — HMAC-SHA256 of the request body using the signing secret. Only present when a signing secret is configured.

Timeout: 30 seconds. Requests that take longer are aborted.

Every delivery shares the same envelope — event, timestamp, organizationId — plus one event-specific object: a job object for the job.* events and webhook.test, a report object for report.generated, or a form object for the form.* events.

{ "event": "job.created", "timestamp": "2026-03-03T14:30:00.000Z", "organizationId": "abc123", "job": { "id": "job-uuid", "name": "Smith Residence - Fire Damage", "identifier": "1201-260001", "status": "inventory", "customer": { "name": "John Smith", "email": "john@example.com", "phone": "555-1234" }, "insurance": { "claimNumber": "CLM-2026-001", "company": "State Farm", "lossType": "fire", "policyNumber": "POL-123", "agent": "Jane Doe", "agentPhone": "555-5678", "agentEmail": "jane@statefarm.com" }, "ownerUserId": "user-uuid", "createdAt": "2026-03-01T10:00:00.000Z", "updatedAt": "2026-03-03T14:30:00.000Z" }, "changes": [ { "field": "settings.customer", "oldValue": { "name": "J Smith" }, "newValue": { "name": "John Smith", "email": "john@example.com", "phone": "555-1234" } } ]}
Field Type Description
event string One of job.created, job.updated, job.deleted
timestamp string ISO-8601 timestamp of when the event happened. Retries keep the original timestamp.
organizationId string Organization UUID
job.id string Job UUID
job.name string Job name
job.identifier string? The job’s identifier — either one you set yourself (jobIdentifier on POST/PATCH /v1/jobs, or the Job Identifier field in the app) or one generated by the organization’s job identifier scheme, when configured. Populated for jobs created in the app and via the API (REST, inbound webhook, and AI/MCP).
job.status string? Job status. For organizations with a custom status list this is the custom value (e.g. new); otherwise a built-in status (e.g. inventory, packout, completed).
job.customer object? Customer details (name, email, phone)
job.insurance object? Insurance details (claimNumber, company, lossType, etc.)
job.ownerUserId string UUID of the user who owns the job
job.createdAt string Job creation timestamp
job.updatedAt string Job last-update timestamp
changes array? Only present for job.updated. Lists which watched fields changed with old/new values.

Sent once when a report transitions into a downloadable state (status completed or partial with a download URL).

{ "event": "report.generated", "timestamp": "2026-03-03T14:30:00.000Z", "organizationId": "abc123", "report": { "id": "report-uuid", "reportType": "inventory", "title": "Inventory Report — Smith Residence", "status": "completed", "fileType": "pdf", "downloadUrl": "https://storage.googleapis.com/.../report.pdf", "jobId": "job-uuid", "jobName": "Smith Residence - Fire Damage", "requestingUserId": "user-uuid", "createdAt": "2026-03-03T14:29:00.000Z", "completedAt": "2026-03-03T14:30:00.000Z" }}
Field Type Description
report.id string Report UUID
report.reportType string Report type (e.g. inventory, valuation, custody)
report.title string? Report title, when set
report.status string completed or partial
report.fileType string? File type of the download (e.g. pdf, zip)
report.downloadUrl string Signed URL to download the report file
report.jobId string? UUID of the job the report belongs to, when applicable
report.jobName string? Name of the job the report belongs to, when applicable
report.requestingUserId string? UUID of the user who requested the report
report.createdAt string When the report job was created
report.completedAt string When the report became downloadable

form.completed is sent once every signer has signed and the signed PDF has been stored, so signedPdfUrl is always usable when it arrives. form.declined is sent when a signer declines, and carries their reason when they gave one.

{ "event": "form.completed", "timestamp": "2026-03-03T14:30:00.000Z", "organizationId": "abc123", "form": { "id": "form-uuid", "jobId": "job-uuid", "templateId": "template-uuid", "templateName": "Work Authorization", "type": "standard", "status": "completed", "signers": [ { "role": "policyholder", "name": "John Smith", "email": "john@example.com", "status": "signed", "signedAt": "2026-03-03T14:29:50.000Z" } ], "signedPdfUrl": "https://storage.googleapis.com/.../form.pdf", "auditLogUrl": "https://storage.googleapis.com/.../form-audit.pdf", "createdAt": "2026-03-01T10:00:00.000Z", "completedAt": "2026-03-03T14:30:00.000Z" }}
Field Type Description
form.id string Form UUID
form.jobId string UUID of the job the form was sent on
form.templateId string? Template the form was built from. Absent for the app-generated disposal and inventory approvals, which have no reusable template.
form.templateName string Name of the document that was signed
form.type string standard, disposal, or inventory
form.status string completed or declined
form.signers array One entry per signer: role (policyholder or org_user), name, email, status (pending, viewed, signed, declined), and signedAt when they signed.
form.signedPdfUrl string? Download URL for the signed document. Present on form.completed. Valid for 7 days — download and store it rather than keeping the link.
form.auditLogUrl string? Download URL for the signing audit log, on the same 7-day expiry.
form.declineReason string? Only on form.declined, and only when the signer gave a reason.
form.createdAt string When the form was created
form.completedAt string? When the last signer signed
form.declinedAt string? When the form was declined

Forms that expire or are cancelled do not send an event; poll GET /v1/jobs/{jobId}/forms if you need to track those states.

Sent on demand from the webhook settings (Test) to verify connectivity and signature handling. It uses the same envelope as a job.* event, with a job object containing placeholder values (id test-…, identifier TEST-260001, status active).

  • jobName — The job’s display name

  • jobIdentifier — The job identifier, whether you set it yourself or the organization’s scheme generated it

  • status — The job’s status (built-in or custom status value)

  • settings.customer — Customer contact details (name, email, phone)

  • settings.insurance — Insurance claim details (claimNumber, company, lossType, policyNumber, agent, etc.)

If a signing secret is configured, verify the X-Webhook-Signature header:

const crypto = require('crypto');function verifySignature(body, secret, signatureHeader) { if (typeof signatureHeader !== 'string') { return false; } const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(body, 'utf8') .digest('hex'); const expectedBuffer = Buffer.from(expected, 'utf8'); const receivedBuffer = Buffer.from(signatureHeader, 'utf8'); if (expectedBuffer.length !== receivedBuffer.length) { return false; } return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);}
  • Your endpoint should return a 2xx status code to acknowledge receipt.

  • Failed deliveries are retried. A timeout, network error, 408, 429 or 5xx response is retried with exponential backoff: the first retry comes about a minute later, the gap doubles each time up to about 30 minutes, and retries then continue hourly for up to 24 hours. Any other non-2xx response (for example 400 or 401) is not retried, because sending the same request again would fail the same way.

  • Expect duplicates and out-of-order events. A retry can arrive after a later event for the same job, and an event your endpoint accepted can occasionally arrive twice. Deduplicate on the X-Webhook-Id header, and compare job.updatedAt before applying a job.* event.

  • The test webhook is sent once and never retried.