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

  1. In Production Engine, an owner or admin opens Company Settings, then Integrations.
  2. Under Webhooks and Slack, choose the type Webhook, give it a name, and paste your URL.
  3. Choose the events to send. At least one is required.
  4. Copy the signing secret. It starts with whsec_ and is shown once.
  5. 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

Headers
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
Body
{
  "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"
  }
}
FieldMeaning
idThe delivery id, also in PE-Delivery. It stays the same across retries, so use it to ignore a repeat.
eventThe event name, also in PE-Event.
occurredAtWhen the change happened, ISO 8601 in UTC.
summaryOne sentence a person can read, the same text a Slack endpoint posts.
dataFacts 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

EventSent whendata fields
client_invoice_paid
Client invoice paid
A client invoice becomes paid in full.invoiceId, invoiceNumber, projectId, projectName, totalCents, currency, paidAt
crew_bill_paid
Crew bill paid
A crew bill is marked paid, by hand or from QuickBooks.crewInvoiceId, crewMemberName, amountCents, projectId, paidAt, source (manual or quickbooks)
estimate_accepted
Estimate 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_created
Project 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_sent
Call 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_received
Large 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_balance
Bank 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_attention
Bank connection needs attention
A connected bank stops updating, for example because it needs a new sign-in.itemId, institutionName, errorCode, reconnect
project_updated
Project 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_created
Deliverable added
A deliverable is added to a project.The REST API's deliverable object
deliverable_status_changed
Deliverable 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_created
New 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_created
Review 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_created
Shoot day added
A shoot day is added to the schedule.The REST API's shoot_day object
schedule_day_updated
Shoot 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:read for project_updated, deliverables:read for the deliverable and review note events, and schedule:read for 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.
project_updated
{
  "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"
  }
}
deliverable_created
{
  "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"
  }
}
deliverable_status_changed
{
  "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"
  }
}
deliverable_version_created
{
  "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"
  }
}
review_note_created
{
  "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"
  }
}
schedule_day_created
{
  "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"
  }
}
schedule_day_updated
{
  "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>.

  1. Read the raw body as bytes before parsing it. Re-serialized JSON will not match.
  2. Recompute the HMAC and compare it to v1 in constant time.
  3. Reject a timestamp more than 300 seconds from your clock. That stops a captured request from being replayed later.

Node

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

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