Four phases shipped together. Each is a logical deploy unit on its own;
keeping the diff atomic so the rename runbook + migrations stay aligned.
Phase 1 — restore HP's formal review workflow
- Prisma: FeedbackItem, ReviewSession, ReviewSessionItem + enums
- New ApprovalType (NONE | SIMPLE | FORMAL) on PipelineStageDefinition
and PipelineStageTemplate. Stage row UI branches per type.
- feedback-service + review-session-service ported from HP (no ColorProbe)
- annotation-service auto-creates a FeedbackItem; revision-service
carries forward unresolved action items into the new revision.
- API: /api/reviews/*, /api/stages/[id]/feedback, /api/feedback/[id]
- Hooks: use-feedback, use-review-sessions
- UI: feedback-checklist, feedback-item-card, feedback-progress-bar,
create-session-dialog, session-builder, session-presenter,
session-summary, plus a new stage-review-panel
- Pages: /reviews list + detail, deliverable annotation review page
- Pipeline editor gets the approvalType select; sidebar gets Reviews
Phase 2 — full Dow Jones → L'Oréal rebrand + slug rename
- URL slug /dow-prod-tracker → /loreal-prod-tracker (next.config,
base path, redirects)
- docker-compose name + DB → loreal_prod_tracker; server path
/opt/loreal-prod-tracker; apache template renamed
- All visible strings → L'Oréal; sidebar bg #002B5C → black
- docs/RENAME_RUNBOOK.md describes the one-shot server migration
- Internal modules dow-excel-service/dow-import + OMG webhook domain
dowjones.com deliberately preserved (orthogonal to the rebrand)
Phase 3 — external /api/v1 for projects + deliverables
- API-key auth already in middleware; finished idempotency support
via new IdempotencyRecord model + src/lib/api/idempotency.ts
- Default-pipeline fallback in createProject when no template id given
- POST/GET /api/v1/projects + POST /api/v1/projects/[id]/deliverables
- docs/EXTERNAL_API.md with curl examples
Phase 4 — Box bidirectional integration
- JWT app-auth via jose (no extra deps). Config mounted as a docker
compose secret; deploy.sh stubs an empty {} so compose can start
before the operator drops the real JSON.
- Outbound: pushDeliverableToBox auto-fires on !APPROVED → APPROVED
in deliverable-status-service; "Send to client (Box)" manual button
on the approval stage row. Folder naming
{omgJobNumber}_{slug}_v{round}. 3-attempt exp backoff. BoxPushLog
audit.
- Inbound: /api/webhooks/box receives Box's signed events, matches by
OMG # + slug, creates a new Revision, routes to assignee or notifies
project owner. BoxInboundLog audit + two new NotificationType
values (BOX_UNMATCHED_FILE, NEW_FILE_AWAITING_REVIEWER).
- Naming-convention logic isolated in external-delivery-service so an
OMG-API transport can swap in later without touching matchers.
- Admin /settings/box page surfaces config status + recent activity.
Three Prisma migrations to apply on next deploy:
20260512000000_restore_review_workflow
20260512100000_idempotency_records
20260512200000_box_integration
URL rename is a one-shot — see docs/RENAME_RUNBOOK.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 KiB
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
<Header>: sha256=<hex_digest>
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
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
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
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=<hex> (HMAC of raw body with
BRIEF_WEBHOOK_SECRET).
Body
{
// 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
{
"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=<hex> (HMAC of raw body with
OMG_WEBHOOK_SECRET).
Body
{
// 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
{
"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 — essentials:
{
"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=<hex> (HMAC of raw body with
DELIVERABLE_WEBHOOK_SECRET).
Body
{
"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
{
"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
projectOmgJobNumberdoesn't resolve, you'll get404 { 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).
{
"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:
- Create a brief (
POST /api/webhooks/briefswithexternalId). A producer triages it in the/briefsinbox. - 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. - If you create a Project directly (bypassing the brief intake):
POST /api/webhooks/omgwithjob.numberset. The Project is created and all its pipeline stages are scaffolded. - For each deliverable under that project:
POST /api/webhooks/deliverablesreferencingprojectOmgJobNumber. 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}.