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.
Configuration
Section titled “Configuration”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.
Managing forms programmatically
Section titled “Managing forms programmatically”Forms can also be sent and tracked over the REST API and from an AI assistant connected over MCP.
-
REST API —
GET /v1/forms/templateslists the templates available to send;POST /v1/jobs/{jobId}/formscreates a form from one;POST /v1/jobs/{jobId}/forms/{formId}/sendemails an individual signer;GET /v1/jobs/{jobId}/forms/{formId}reports status and returns the signed PDF;DELETEon the same path cancels it. See the API reference for full details. -
MCP — the
listFormTemplates,getFormTemplate,listForms,getForm,sendForm,sendFormInvitationandcancelFormtools.
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,PUTandDELETE /v1/org/webhook, andPOST /v1/org/webhook/testto send a test.PUTis a partial update: fields you leave out are unchanged, so you can toggleenabledwithout resending the URL. See the API reference for full details. -
MCP — the
getWebhookConfig,setWebhookConfig,deleteWebhookConfigandsendTestWebhooktools.
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.
Events
Section titled “Events”| 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.
HTTP Request
Section titled “HTTP Request”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.
Payload Schema
Section titled “Payload Schema”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.
job.created / job.updated / job.deleted
Section titled “job.created / job.updated / job.deleted”{ "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 Details
Section titled “Field Details”| 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. |
report.generated
Section titled “report.generated”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 Details
Section titled “Field Details”| 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 / form.declined
Section titled “form.completed / form.declined”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 Details
Section titled “Field Details”| 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.
webhook.test
Section titled “webhook.test”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).
Watched Fields (for job.updated)
Section titled “Watched Fields (for job.updated)”-
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.)
Verifying Signatures
Section titled “Verifying Signatures”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);}Error Handling
Section titled “Error Handling”-
Your endpoint should return a 2xx status code to acknowledge receipt.
-
Failed deliveries are retried. A timeout, network error,
408,429or5xxresponse 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 example400or401) 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-Idheader, and comparejob.updatedAtbefore applying ajob.*event. -
The test webhook is sent once and never retried.
