# Dow Jones Studio Tracker — Upstream Integration Guide **For teams pushing data into the Studio Tracker.** You can create Briefs, Projects, and Deliverables either via signed webhooks (push — recommended) or via REST calls made by an authenticated user account. This document is the spec you can work against directly; a worked example curl + signing snippet is included for each endpoint. --- ## Base URL ``` https://optical-dev.oliver.solutions/loreal-prod-tracker ``` Every URL in this doc is relative to that base. --- ## At-a-glance | Resource | Push (webhook) | Pull-equivalent REST | | --- | --- | --- | | Brief | `POST /api/webhooks/briefs` | `POST /api/briefs` | | Project | `POST /api/webhooks/omg` | `POST /api/projects` | | Deliverable | `POST /api/webhooks/deliverables` | `POST /api/projects/:projectId/deliverables` | **Recommendation:** use the webhooks. They're designed for machine-to- machine integration with HMAC signing, they're idempotent on replay, and they don't require your system to manage user accounts + session cookies. REST endpoints are provided for cases where a human operator is making the call (e.g., internal tooling, back-office corrections). --- ## Webhook authentication (all three) All three webhooks use the same HMAC-SHA256 signing scheme. Only the secret and the signature header name differ per endpoint. | Resource | Secret env var | Signature header | | --- | --- | --- | | Brief | `BRIEF_WEBHOOK_SECRET` | `X-Brief-Signature` | | Project | `OMG_WEBHOOK_SECRET` | `X-OMG-Signature` | | Deliverable | `DELIVERABLE_WEBHOOK_SECRET` | `X-Deliverable-Signature` | ### Signature format ```
: sha256= ``` where `hex_digest` is the lowercase hex of `HMAC-SHA256(secret, request_body_bytes)`. > Sign the **raw request body**, byte-for-byte. Do NOT re-serialise the > JSON before signing — any whitespace or key-order change will break > the signature. ### Signing examples **Bash** ```bash BODY='{"title":"New brief","priority":"HIGH"}' SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$BRIEF_WEBHOOK_SECRET" | awk '{print $2}') curl -X POST https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/briefs \ -H "Content-Type: application/json" \ -H "X-Brief-Signature: sha256=$SIG" \ -d "$BODY" ``` **Python** ```python import hmac, hashlib, json, requests body = json.dumps({"title": "New brief", "priority": "HIGH"}, separators=(",", ":")) sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() requests.post( "https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/briefs", headers={"Content-Type": "application/json", "X-Brief-Signature": f"sha256={sig}"}, data=body, # pass the SAME string we signed; don't re-serialise ) ``` **Node.js** ```js import crypto from "node:crypto"; const body = JSON.stringify({ title: "New brief", priority: "HIGH" }); const sig = crypto.createHmac("sha256", secret).update(body).digest("hex"); await fetch("https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/briefs", { method: "POST", headers: { "Content-Type": "application/json", "X-Brief-Signature": `sha256=${sig}` }, body, }); ``` ### Error responses | Status | Meaning | | --- | --- | | `401 { error: "missing signature header" }` | No signature sent | | `401 { error: "signature mismatch" }` | Signature doesn't match body | | `401 { error: "BRIEF_WEBHOOK_SECRET not configured" }` | Our end has no secret — contact us | | `400 { error: "invalid JSON body" }` | Body isn't valid JSON | | `400 { error: "invalid payload", issues: [...] }` | Valid JSON, wrong shape — see `issues` | | `404 { error: "parent project not found" }` | (deliverables webhook) Project doesn't exist | | `500 { error: "upsert failed", detail: "..." }` | Our side broke — include the `detail` when reporting | --- ## 1. Briefs A **brief** is an incoming request that has not yet been accepted as a project. Briefs land in the `/briefs` inbox and a producer decides whether to promote them. The original brief stays as audit trail after promotion, with `convertedProjectId` pointing at the new project. ### Webhook — `POST /api/webhooks/briefs` **Auth:** `X-Brief-Signature: sha256=` (HMAC of raw body with `BRIEF_WEBHOOK_SECRET`). **Body** ```jsonc { // Optional: your stable ID for this brief. Providing it enables // idempotent retries — the same externalId always updates the same // Brief row (unique per organisation). Omit it and each call creates // a new brief. "externalId": "INTAKE-2026-00042", // Required "title": "New homepage takeover for Q2 launch", // Optional — Markdown is fine; rendered on the /briefs page "description": "Need 3 variants for A/B test, dark mode required.", // Optional "requestorName": "Alex Smith", "requestorEmail": "alex.smith@dowjones.com", "priority": "HIGH", // LOW | MEDIUM | HIGH | URGENT "requestedDueDate": "2026-05-15T17:00:00Z", // ISO-8601 // Optional — the Dow-side team this brief is for. Must match a // ClientTeam slug (currently: brand, events, b2b, content, // briefing-team, performance). Unknown slugs are silently ignored // (the brief is still created, just with no team assigned). "clientTeamSlug": "events", // Optional — source tag ("mkto", "jira", "slack-bot"…). We prefix // with "webhook:" so the brief's source field will read // "webhook:mkto". "system": "mkto", // Optional — any extra fields you want preserved, passed through // verbatim into Brief.rawPayload. Useful when your system has data // we don't map to a first-class field yet. "rawPayload": { "campaignId": "c-8829", "internalBudget": 12500 } } ``` **Success** — `200 OK` ```json { "ok": true, "briefId": "clxy12abc...", "status": "PENDING" } ``` **Idempotency:** when `externalId` is set, a second call with the same `externalId` updates the existing brief rather than inserting a new one. If you're syncing from a source-of-truth upstream system, **always send `externalId`** so retries are safe. ### REST — `POST /api/briefs` Same JSON body as the webhook, but authenticated via the signed-in user (session cookie). The producer hitting this endpoint needs the `PROJECT_CREATE` permission (ADMIN or PRODUCER role). Use this when a human operator is creating a brief from an internal tool that already has a user session; use the webhook otherwise. --- ## 2. Projects A **project** is an accepted piece of work with deliverables attached. The canonical key is `omgJobNumber` (the OMG tracking number) — all intake paths (XLSX upload, webhook, REST) upsert on it. ### Webhook — `POST /api/webhooks/omg` **Auth:** `X-OMG-Signature: sha256=` (HMAC of raw body with `OMG_WEBHOOK_SECRET`). **Body** ```jsonc { // Optional metadata "event": "job.updated", // job.created | job.updated | job.assigned | job.status_changed | job.completed "timestamp": "2026-04-22T14:00:00Z", // Required. Canonical project key. "job": { "number": "OMG-12345", // REQUIRED. Project.omgJobNumber (unique) "name": "Q2 Launch Homepage Takeover", // Human-readable project name // Optional "client": "Dow Jones", "team": "Events", // Mapped to ClientTeam slug (case-insensitive) "category": "Display", // Dow Project Category — stored on Project.businessUnit "priority": "high", // "priority" | "high" | "medium" | "low" "status": "In Production", // Free-form — mapped via internal STATUS_MAP: // "brief in review" → PIPELINE // "amends" | "in production" | "client review" → ACTIVE // "on hold" → ON_HOLD // Anything unmapped defaults to ACTIVE "outputs": 3, // # of deliverables this project generates "notes": "Priority — board sign-off required.", // Optional — first email here becomes Project.requestor "assignees": [ { "email": "alex.smith@dowjones.com", "role": "Project Lead" }, { "email": "bob.jones@oliver.agency" } ], // Optional — ISO-8601 "dates": { "accepted": "2026-04-20T09:00:00Z", // Project.startDate "externalDeadline": "2026-05-15T17:00:00Z" // Project.dueDate } }, // Optional — anything extra lands on Project.customFields JSON "raw": { "costCenter": "CC-4821", "legalReviewRequired": true } } ``` **Success** — `200 OK` ```json { "ok": true, "projectId": "clyz34def...", "action": "created" // or "updated" } ``` **Idempotency:** project is upserted on `job.number` (→ `Project.omgJobNumber`). Replaying the same payload is safe — second call updates the same project, no duplicates. ### REST — `POST /api/projects` Auth via session cookie (requires `PROJECT_CREATE`). Full field list in [API.md](./API.md) — essentials: ```jsonc { "projectCode": "DJ-2026-0042", // REQUIRED, unique "name": "Q2 Launch Homepage", // REQUIRED "status": "ACTIVE", // REQUIRED: PIPELINE | ACTIVE | ON_HOLD | COMPLETED | CANCELED | ARCHIVED "priority": "HIGH", // REQUIRED: LOW | MEDIUM | HIGH | URGENT "omgJobNumber": "OMG-12345", // Optional but strongly recommended — intake key "clientTeamId": "cm123teamid", // Optional — ClientTeam.id (not slug) "businessUnit": "Display", // Dow Project Category "description": "...", "startDate": "2026-04-20", "dueDate": "2026-05-15", "requestor": "alex.smith@dowjones.com" } ``` --- ## 3. Deliverables A deliverable is a single unit of work attached to a project — e.g., "Homepage desktop hero variant A". Stages (Pipeline → New → ... → Completed) are auto-created the first time a deliverable is inserted. ### Webhook — `POST /api/webhooks/deliverables` **Auth:** `X-Deliverable-Signature: sha256=` (HMAC of raw body with `DELIVERABLE_WEBHOOK_SECRET`). **Body** ```jsonc { "event": "deliverable.updated", // Optional "timestamp": "2026-04-22T14:30:00Z", // Optional "deliverable": { // REQUIRED. Links the deliverable to an existing Project by its // OMG job number. If no project with that number exists, the call // returns 404. Create the project first (via the OMG webhook). "projectOmgJobNumber": "OMG-12345", // REQUIRED. Natural key within the project — same (project, name) // on a retry updates the existing deliverable rather than inserting. "name": "Homepage desktop hero — variant A", // Optional "priority": "HIGH", // LOW | MEDIUM | HIGH | URGENT "status": "IN_PROGRESS", // NOT_STARTED | IN_PROGRESS | IN_REVIEW | APPROVED | ON_HOLD "dueDate": "2026-05-10T17:00:00Z", // ISO-8601 "notes": "Must match Q2 launch brand guidelines v3.", "cmfSku": "CMF-8829-A", // Internal SKU if you track one "assetCount": 5 // Asset files expected for this deliverable }, // Optional — lands on Deliverable.customFields JSON (pass-through) "raw": { "figmaFrameId": "fig-12:345", "copywriter": "patrick.mccarthy@oliver.agency" } } ``` **Success** — `200 OK` ```json { "ok": true, "deliverableId": "clab56ghi...", "action": "created" // or "updated" } ``` **Idempotency:** keyed on `(projectId, deliverable.name)` within a project. Replays with the same name update the existing deliverable. > Parent project **must exist first.** If `projectOmgJobNumber` > doesn't resolve, you'll get `404 { error: "parent project not > found", hint: "Project must be created via POST /api/webhooks/omg > first." }`. The typical integration flow is: OMG sends the project > webhook on job creation, then fires deliverable webhooks for each > asset as they're spun up. ### REST — `POST /api/projects/:projectId/deliverables` Auth via session cookie (requires `DELIVERABLE_CREATE`). Note the URL shape: deliverables are nested under a specific project by its internal `id` (not `omgJobNumber`). ```jsonc { "name": "Homepage desktop hero — variant A", // REQUIRED "priority": "HIGH", // REQUIRED "dueDate": "2026-05-10", "notes": "...", "cmfSku": "CMF-8829-A", "assetCount": 5 } ``` --- ## Recommended integration flow For a typical upstream system sending many briefs/jobs/deliverables per day: 1. **Create a brief** (`POST /api/webhooks/briefs` with `externalId`). A producer triages it in the `/briefs` inbox. 2. **When the brief is accepted**, the producer promotes it to a project inside the UI — we create the Project and link back to the Brief via `convertedProjectId`. You don't need to do anything here. 3. **If you create a Project directly** (bypassing the brief intake): `POST /api/webhooks/omg` with `job.number` set. The Project is created and all its pipeline stages are scaffolded. 4. **For each deliverable under that project**: `POST /api/webhooks/deliverables` referencing `projectOmgJobNumber`. On first call the deliverable is created and its 11 pipeline stages are auto-populated; subsequent calls with the same `(project, name)` update the existing deliverable. Retries on any of the three are safe as long as you send a stable external key (`externalId` / `job.number` / `(project, name)`). --- ## Rate limiting Each webhook route caps traffic at **100 requests / minute per source IP**. Over the limit, the response is: ``` HTTP/1.1 429 Too Many Requests Retry-After: 37 Content-Type: application/json {"error":"rate limit exceeded"} ``` Normal upstream operation stays well below this — even a morning burst of 70 projects × a few events each fits comfortably. The limit is a safety rail against runaway integrations, not a throttling policy for legitimate load. If you're hitting it, back off for the number of seconds in the `Retry-After` header and resume. If you expect sustained throughput that exceeds the cap, let us know and we'll raise it for your source IP. --- ## Requesting secrets Each webhook secret is generated per environment and shared out-of-band (not committed, not in email). To get yours, contact: - **Dave Porter** — daveporter@oliver.agency Include: - Which environment you're integrating with (currently only `optical-dev.oliver.solutions`; prod URL TBD) - Which of the three webhooks you need access to (briefs / projects / deliverables — usually all three) - A rough idea of your expected call volume (helps us size alerting) --- ## Testing against the live endpoint Every webhook has a dev-mode bypass for local/stub testing. We will NOT enable it against shared environments, but if you're building a client locally you can start your own instance with `BRIEF_WEBHOOK_ALLOW_INSECURE=true` (or the matching var) — then omit the signature header for unsigned POSTs. Never enable this in shared environments. For integration testing against our shared dev environment, use the real HMAC and a low-volume test batch. Every call is logged with the `externalId`/job number, so we can tell genuine replays from duplicates. --- ## Changelog - **2026-04-22** — initial document. Three webhooks live on `/loreal-prod-tracker/api/webhooks/{omg,deliverables,briefs}`.