Developers
Webhooks
Production Engine can send a signed JSON POST to your server when something happens in a workspace: an invoice is paid, an estimate is accepted, a project is created or changed, a deliverable moves or gets a new cut, a review note comes in, a shoot day is added or changed, a call sheet goes out, or the bank feed needs attention.
Add an endpoint
- In Production Engine, an owner or admin opens Company Settings, then Integrations.
- Under Webhooks and Slack, choose the type Webhook, give it a name, and paste your URL.
- Choose the events to send. At least one is required.
- Copy the signing secret. It starts with
whsec_and is shown once. - Click Send test to check your receiver.
The URL must use https, cannot contain a username or password, and must point at a public address. Redirects are not followed.
The type Slack takes a Slack incoming webhook URL (https://hooks.slack.com/services/...) and posts each event's summary sentence to the channel. Slack deliveries carry no signature and no envelope; everything below is about the Webhook type.
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: ProductionEngine-Webhooks/1
PE-Event: client_invoice_paid
PE-Delivery: cmexampledelivery0000001
PE-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd{
"id": "cmexampledelivery0000001",
"event": "client_invoice_paid",
"occurredAt": "2026-10-01T15:04:05.000Z",
"summary": "Invoice 1007 paid in full: $22,500.00 (Spring Spot)",
"data": {
"invoiceId": "cmexampleinvoice00000001",
"invoiceNumber": "1007",
"projectId": "cmexampleproject00000001",
"projectName": "Spring Spot",
"totalCents": 2250000,
"currency": "USD",
"paidAt": "2026-10-01T15:04:05.000Z"
}
}| Field | Meaning |
|---|---|
id | The delivery id, also in PE-Delivery. It stays the same across retries, so use it to ignore a repeat. |
event | The event name, also in PE-Event. |
occurredAt | When the change happened, ISO 8601 in UTC. |
summary | One sentence a person can read, the same text a Slack endpoint posts. |
data | Facts about the change. Money is integer cents in fields ending in Cents. The seven project, deliverable, review and shoot day events send the REST API's own object for the record, with snake_case fields and no money. |
Events
| Event | Sent when | data fields |
|---|---|---|
client_invoice_paidClient invoice paid | A client invoice becomes paid in full. | invoiceId, invoiceNumber, projectId, projectName, totalCents, currency, paidAt |
crew_bill_paidCrew bill paid | A crew bill is marked paid, by hand or from QuickBooks. | crewInvoiceId, crewMemberName, amountCents, projectId, paidAt, source (manual or quickbooks) |
estimate_acceptedEstimate accepted | A client approves an estimate in the portal, or a producer converts one to a project. | estimateId, estimateNumber, title, totalCents, clientName, projectId, projectCreated, message |
project_createdProject created | A project is created, however it started. | projectId, name, clientName, source (new_project, estimate_accepted, estimate_converted, duplicated, brief_promoted, approval or api) |
call_sheet_sentCall sheet sent | A call sheet is emailed to its crew and cast. | callSheetId, projectId, projectName, shootDate (for example Monday, October 5, 2026), sent, failed, skipped |
bank_deposit_receivedLarge bank deposit received | A connected bank account receives a deposit at or above the workspace's large-deposit level. | transactionId, amountCents, date, name |
bank_low_balanceBank balance below your warning level | Cash across connected bank accounts drops below the workspace's warning level. Sent once per dip. | availableCents, thresholdCents |
bank_connection_needs_attentionBank connection needs attention | A connected bank stops updating, for example because it needs a new sign-in. | itemId, institutionName, errorCode, reconnect |
project_updatedProject updated | A project's details change: name, status, phase, dates, client or description. Creating a project sends project_created instead. | The REST API's project object |
deliverable_createdDeliverable added | A deliverable is added to a project. | The REST API's deliverable object |
deliverable_status_changedDeliverable status changed | A deliverable's status moves, by hand, by a review decision, or because a new cut reopened review. Sent only when the status really changes. | The REST API's deliverable object, previous_status |
deliverable_version_createdNew cut posted for review | A new cut is posted for review, as an upload or a link. | The REST API's deliverable_version object |
review_note_createdReview note added | A client or team member leaves a review note or a reply. Internal team notes are never sent. | The REST API's review_note object |
schedule_day_createdShoot day added | A shoot day is added to the schedule. | The REST API's shoot_day object |
schedule_day_updatedShoot day changed | A shoot day changes: date, call or wrap time, hold status, label or location, including from the call sheet editor. | The REST API's shoot_day object |
Send test posts an event named test with an empty data object, straight away and outside the retry queue. The settings page shows what your server answered.
Manage endpoints through the REST API
A token with the webhooks:manage scope can create, list, pause, resume, edit, delete and test its own endpoints at /api/v1/webhook-endpoints. See the endpoint reference.
- Only these events can be subscribed through the API:
project_updated,deliverable_created,deliverable_status_changed,deliverable_version_created,review_note_created,schedule_day_created,schedule_day_updated. Money events and the other events above stay in Settings; asking for one answers 422. - Each event also needs the read scope for its record:
projects:readforproject_updated,deliverables:readfor the deliverable and review note events, andschedule:readfor the shoot day events. Without it the request answers 403. - Revoking a token pauses the endpoints it created. An endpoint whose token is revoked or expired gets no further deliveries.
- The signing secret is in the create response only. A retry with the same Idempotency-Key returns the endpoint without it.
- A token sees and changes only the endpoints it created. Endpoints added in Settings, or by another token, answer 404.
- Deliveries, signatures and retries work exactly as described on this page.
{
"id": "cmexampledelivery1",
"event": "project_updated",
"occurredAt": "2026-09-30T15:36:00.000Z",
"summary": "Project updated: Spring Hero Spot",
"data": {
"id": "cmexampleproject01",
"name": "Spring Hero Spot",
"status": "pre_production",
"phase": "shoot",
"client_id": "cmexampleclient001",
"contact_id": "cmexamplecontact01",
"description": "Two-day studio shoot for the spring launch.",
"agency": null,
"project_type": "Commercial",
"start_date": "2026-11-02",
"end_date": "2026-11-03",
"created_at": "2026-09-14T15:02:11.000Z",
"updated_at": "2026-09-28T19:40:03.000Z"
}
}{
"id": "cmexampledelivery1",
"event": "deliverable_created",
"occurredAt": "2026-09-30T15:36:00.000Z",
"summary": "Deliverable added: Cutdown :15",
"data": {
"id": "cmexampledeliver02",
"project_id": "cmexampleproject01",
"title": "Cutdown :15",
"description": null,
"format": "ProRes 422 HQ",
"duration": ":30",
"platform": "Broadcast",
"status": "NOT_STARTED",
"due_date": "2026-12-01",
"delivered_at": null,
"external_url": null,
"client_visible": true,
"sort_order": 1,
"created_at": "2026-09-15T14:05:00.000Z",
"updated_at": "2026-09-29T16:30:00.000Z"
}
}{
"id": "cmexampledelivery1",
"event": "deliverable_status_changed",
"occurredAt": "2026-09-30T15:36:00.000Z",
"summary": "Hero :30 moved from IN_REVIEW to APPROVED",
"data": {
"id": "cmexampledeliver01",
"project_id": "cmexampleproject01",
"title": "Hero :30",
"description": null,
"format": "ProRes 422 HQ",
"duration": ":30",
"platform": "Broadcast",
"status": "APPROVED",
"due_date": "2026-12-01",
"delivered_at": null,
"external_url": null,
"client_visible": true,
"sort_order": 0,
"created_at": "2026-09-15T14:05:00.000Z",
"updated_at": "2026-09-29T16:30:00.000Z",
"previous_status": "IN_REVIEW"
}
}{
"id": "cmexampledelivery1",
"event": "deliverable_version_created",
"occurredAt": "2026-09-30T15:36:00.000Z",
"summary": "Version 2 of Hero :30 is ready for review",
"data": {
"id": "cmexampleversion01",
"deliverable_id": "cmexampledeliver01",
"version_number": 2,
"kind": "link",
"file_name": "vimeo.com",
"external_url": "https://vimeo.com/123456789",
"duration_ms": null,
"width": null,
"height": null,
"decision": "PENDING",
"decided_at": null,
"created_at": "2026-09-29T16:30:00.000Z"
}
}{
"id": "cmexampledelivery1",
"event": "review_note_created",
"occurredAt": "2026-09-30T15:36:00.000Z",
"summary": "Jordan (Brand) left a review note on Hero :30 v2",
"data": {
"id": "cmexamplenote00001",
"version_id": "cmexampleversion01",
"parent_id": null,
"body": "Hold the logo two frames longer.",
"time_ms": 27400,
"end_time_ms": null,
"author_kind": "CLIENT",
"author_name": "Jordan (Brand)",
"resolved_at": null,
"created_at": "2026-09-29T18:02:00.000Z",
"updated_at": "2026-09-29T18:02:00.000Z"
}
}{
"id": "cmexampledelivery1",
"event": "schedule_day_created",
"occurredAt": "2026-09-30T15:36:00.000Z",
"summary": "Shoot day added on Spring Hero Spot: 2026-11-03",
"data": {
"id": "cmexampleshootday2",
"project_id": "cmexampleproject01",
"date": "2026-11-03",
"label": "Day 2",
"call_time": "07:00",
"wrap_time": "19:00",
"hold_status": "first_hold",
"time_zone": "America/Los_Angeles",
"location": {
"id": "cmexamplelocation1",
"name": "Eastside Stage",
"address": "100 Main St, Austin, TX"
},
"call_sheet": null,
"created_at": "2026-09-15T14:00:00.000Z",
"updated_at": "2026-09-27T21:12:00.000Z"
}
}{
"id": "cmexampledelivery1",
"event": "schedule_day_updated",
"occurredAt": "2026-09-30T15:36:00.000Z",
"summary": "Shoot day changed on Spring Hero Spot: 2026-11-02",
"data": {
"id": "cmexampleshootday1",
"project_id": "cmexampleproject01",
"date": "2026-11-02",
"label": "Day 1",
"call_time": "06:30",
"wrap_time": "19:00",
"hold_status": "confirmed",
"time_zone": "America/Los_Angeles",
"location": {
"id": "cmexamplelocation1",
"name": "Eastside Stage",
"address": "100 Main St, Austin, TX"
},
"call_sheet": {
"id": "cmexamplecallsheet",
"status": "sent"
},
"created_at": "2026-09-15T14:00:00.000Z",
"updated_at": "2026-09-27T21:12:00.000Z"
}
}Verify the signature
PE-Signature is t=<unix seconds>,v1=<hex>. The hex value is an HMAC-SHA256, keyed with your signing secret, of the timestamp, a period, and the raw request body: <t>.<raw body>.
- Read the raw body as bytes before parsing it. Re-serialized JSON will not match.
- Recompute the HMAC and compare it to
v1in constant time. - Reject a timestamp more than 300 seconds from your clock. That stops a captured request from being replayed later.
Node
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
/** rawBody is the exact request body as a string, before any JSON parsing. */
export function verifyProductionEngineWebhook(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((part) => part.split("=", 2)),
);
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest();
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
// Express: keep the raw body for the signature check.
// app.post("/webhooks/pe", express.raw({ type: "application/json" }), (req, res) => {
// const raw = req.body.toString("utf8");
// // signingSecret: the whsec_ value shown when you added the endpoint.
// if (!verifyProductionEngineWebhook(raw, req.get("PE-Signature") ?? "", signingSecret)) {
// return res.status(400).end();
// }
// const event = JSON.parse(raw);
// res.status(200).end();
// });Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_production_engine_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in signature_header.split(",") if "=" in part)
try:
timestamp = int(parts["t"])
given = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(given, expected)Delivery and retries
An event is queued together with the change it reports, or right after the change is saved, so a change that fails sends nothing. A sender runs every minute and posts what is due.
- Any 2xx answer within 10 seconds counts as delivered. Anything else, a redirect, or no answer in 10 seconds is a failure.
- After a failure, the next try waits 1 minute, then 5 minutes, then 15 minutes, then 1 hour, then 3 hours, then 6 hours, then 12 hours. That is 8 attempts over about 22 hours, and the last failure is final.
- When an endpoint gives up, the settings page shows the date. Fix your receiver, then send a test.
- Turning an endpoint off stops queued deliveries from being sent.
- A retried delivery can arrive after a newer one. Use
occurredAt, not arrival order.
Answer quickly and do slow work after responding. Return 2xx once you have stored the event; a 4xx or 5xx will be retried.