DM Portal API
API for internal digital marketing reviewers. Unlike the Dashboard API, DM endpoints are cross-tenant — a reviewer sees activities and approvals across all tenants assigned to them.
Related: API Overview | Auth | Dashboard API
Base Prefix
/dm/v1Auth: Bearer token with role: reviewer | admin and appAccess: dm-portal
Tenant filtering: Most list endpoints accept an optional tenantId query param. Omitting it returns data across all tenants the reviewer is assigned to. Reviewers cannot access tenants they are not assigned to — attempting to do so returns 403.
Mission Control
GET /overview
Real-time summary across all assigned tenants — the P1 Mission Control screen data.
Response 200:
{
tenants: Array<{
tenantId: string;
tenantName: string;
pendingApprovals: number;
highRiskApprovals: number;
activeAgents: number;
activitiesInProgress: number;
activitiesBlocked: number;
oldestPendingApprovalAgeH: number; // hours since oldest pending approval was created
}>;
totals: {
pendingApprovals: number;
highRiskApprovals: number;
activitiesInProgress: number;
activitiesBlocked: number;
agentsInError: number;
};
}Activities
GET /activities
List activities across assigned tenants.
Query params:
{
tenantId?: string;
filter?: {
status?: string;
agentRole?: string;
type?: string;
};
limit?: number;
cursor?: string;
}Response 200: Paginated ActivitySummary[] with tenantId and tenantName fields added
GET /activities/:activityId
Full activity detail. Reviewer sees the same fields as the dashboard view plus:
{
// ...same as dashboard ActivityDetail...
tenantId: string;
tenantName: string;
canIntervene: boolean; // false if activity is already completed/cancelled
}GET /activities/:activityId/stream
SSE: live agent output stream. Same event protocol as the Dashboard API stream endpoint.
POST /activities/:activityId/pause
Pause a running activity. The current agent run is allowed to complete its current turn, then no new runs are enqueued until the activity is resumed.
Request:
{ reason?: string; }Response 202:
{ activityId: string; status: 'pausing'; }Errors:
422 UNPROCESSABLE— activity is notin_progress
POST /activities/:activityId/resume
Resume a paused activity. Re-enqueues the activity with wakeReason: 'unblocked'.
Request:
{ note?: string; }Response 202:
{ activityId: string; runId: string; }POST /activities/:activityId/retry
Force-retry a failed activity. Creates a new run.
Request: Empty body
Response 202:
{ activityId: string; runId: string; }POST /activities/:activityId/cancel
Cancel an activity permanently. Sets status to failed, reason cancelled_by_reviewer. No further retries.
Request:
{ reason: string; }Response 200:
{ activityId: string; status: 'failed'; }POST /activities/:activityId/reassign
Reassign an activity to a different agent role or a specific human. The current activity is marked reassigned; a new activity is created for the target.
Request:
{
assigneeType: 'agent' | 'human';
assigneeId: string; // agent role (e.g. 'copywriter') or human user ref_id
reason: string;
note?: string;
}Response 201:
{ originalActivityId: string; newActivityId: string; }POST /activities/:activityId/comments
Add a comment or intervention note. Same shape as the Dashboard API.
GET /activities/:activityId/comments
List comments. Same shape as the Dashboard API.
Approvals
GET /approvals
Cross-tenant approval queue, sorted by risk level (high first) then age (oldest first) by default.
Query params:
{
tenantId?: string;
filter?: {
status?: 'pending' | 'approved' | 'rejected' | 'expired';
type?: string;
riskLevel?: 'low' | 'medium' | 'high';
};
sortBy?: 'risk_then_age' | 'age' | 'created_on'; // default 'risk_then_age'
limit?: number;
cursor?: string;
}Response 200: Paginated list (same shape as dashboard approval list with tenantId/tenantName added)
GET /approvals/:approvalId
Approval detail with full linked activity outputs for side-by-side review.
POST /approvals/:approvalId/resolve
Approve or reject with optional note. Same request/response shape as the dashboard endpoint.
POST /approvals/:approvalId/send-to-client
Forward the approval to the tenant admin for their decision. Creates a new content_review approval linked to the original, notifies the tenant admin.
Request:
{
note?: string; // message to the client
urgency?: 'normal' | 'urgent';
}Response 201:
{
forwardedApprovalId: string; // new approval created for client
notificationSent: boolean;
}POST /approvals/:approvalId/escalate
Escalate an approval to a higher-risk level and reset the expiry window. Use when an approval is aging without response or carries higher actual risk than originally assessed.
Request:
{
newRiskLevel: 'medium' | 'high';
reason: string;
}Response 200:
{
approvalId: string;
riskLevel: string;
newExpiresAt: string;
}POST /approvals/bulk/resolve
Bulk-resolve multiple approvals with the same decision.
Request:
{
approvalIds: string[]; // max 50
resolution: 'approved' | 'rejected';
note?: string; // applies to all; required for 'rejected'
}Response 200:
{
resolved: number;
failed: Array<{ approvalId: string; reason: string }>;
reEnqueuedActivities: number;
}Blog Posts
DM reviewers are the first human gate in the blog post approval chain. After a reviewer approves, the post moves to the client for their own approval.
Status flow: dm_review → (DM approves) → client_review → (client approves) → client_approved
Rejection flow: DM rejects → blog-writer re-runs with reviewer feedback → new version back to dm_review
GET /blog
List blog posts for a tenant in any status.
Query params:
{
tenantId: string; // required
status?: 'dm_review' | 'client_review' | 'client_approved' | 'rejected' | 'published';
page?: number; // default 1
limit?: number; // default 20, max 100
q?: string; // title search
}Response 200:
{
data: Array<{
id: string;
title: string;
slug: string;
status: string;
version: number;
wordCount: number;
metaDescription: string | null;
dmApprovedBy: string | null;
dmApprovedAt: string | null;
clientApprovedBy: string | null;
clientApprovedAt: string | null;
dmRejectionNote: string | null;
createdAt: string;
updatedAt: string;
activity: { id: string; label: string; deliverableType: string } | null;
}>;
total: number;
page: number;
limit: number;
pages: number;
}GET /blog/:id
Single blog post with full Markdown content.
Response 200: Same as list item + content: string field.
Response 404: Post not found or not in the reviewer’s accessible tenants.
POST /blog/:id/dm-approve
DM reviewer approves the post → moves to client_review. Client is notified to review.
Request: (empty body)
Response 200:
{ ok: true; status: "client_review" }Errors:
400 BAD_REQUEST— post is not indm_reviewstatus403 FORBIDDEN— reviewer does not have access to this tenant
POST /blog/:id/dm-reject
DM reviewer rejects the post with feedback. The blog-writer agent is re-queued with the feedback to generate a new version.
Request:
{ feedback: string; } // required — reason and specific revision instructionsResponse 200:
{ ok: true; status: "rejected"; jobId: string }The agent re-runs with wakeReason: "rejection" and reviewerFeedback injected into the prompt. The BlogPost version is incremented on the next generation.
Agents
GET /agents
Status of all agents across assigned tenants.
Query params:
{
tenantId?: string;
filter?: {
status?: 'active' | 'running' | 'error' | 'paused' | 'terminated';
role?: string;
};
}Response 200:
{
data: Array<{
tenantId: string;
tenantName: string;
role: string;
status: string;
runningCount: number;
model: string;
lastErrorAt?: string;
lastErrorMessage?: string;
queueDepth: number; // jobs waiting in BullMQ for this agent
}>;
}GET /agents/:tenantId/:agentRole
Detailed agent status for a specific agent on a specific tenant.
Response 200: Full agent config + recent runs + current queue depth
POST /agents/:tenantId/:agentRole/pause
Pause an agent for a specific tenant. All new jobs are held; running jobs complete normally.
Request:
{ reason?: string; }Response 200: Updated agent status
POST /agents/:tenantId/:agentRole/resume
Resume a paused agent.
Response 200: Updated agent status
POST /agents/:tenantId/:agentRole/clear-error
Clear the error status on an agent after investigating, restoring it to active.
Request:
{ note?: string; }Response 200: Updated agent status
Reports
GET /reports
List reports for all assigned tenants.
Query params:
{
tenantId?: string;
filter?: {
status?: 'draft' | 'ready' | 'delivered';
type?: 'monthly_performance' | 'campaign_summary' | 'custom';
};
limit?: number;
cursor?: string;
}Response 200: Paginated report list
POST /reports
Generate a new performance report for a tenant.
Request:
{
tenantId: string;
type: 'monthly_performance' | 'campaign_summary' | 'custom';
periodStart: string; // ISO 8601 date
periodEnd: string;
campaignIds?: string[]; // for campaign_summary
title?: string;
notes?: string;
}Response 202:
{ reportId: string; status: 'generating'; }Report generation is async. Poll GET /reports/:reportId for status.
GET /reports/:reportId
Report detail and content once generated.
Response 200:
{
id: string;
tenantId: string;
tenantName: string;
type: string;
status: 'generating' | 'ready' | 'delivered';
title: string;
pdfUrl?: string; // pre-signed URL (valid 1h) when status is ready
deliveredAt?: string;
deliveredTo?: string[]; // email addresses
createdBy: string;
createdOn: string;
}POST /reports/:reportId/deliver
Email the report PDF to the client tenant admins and/or specified addresses.
Request:
{
to: string[]; // email addresses; tenant admins always included
subject?: string;
message?: string;
}Response 200:
{ delivered: true; sentTo: string[]; }Team
GET /team
Cross-tenant principal list across assigned tenants.
Query params:
{
tenantId?: string;
filter?: { type?: 'human' | 'agent'; role?: string };
}Response 200: List with tenantId/tenantName added per principal
POST /team/:principalId/reassign-activities
Reassign all open activities from one principal to another (e.g. when a reviewer goes on leave).
Request:
{
toId: string; // target principal ref_id
reason: string;
}Response 200:
{ reassigned: number; }Events (SSE)
GET /events/stream
SSE stream for DM-relevant events across all assigned tenants.
Protocol: text/event-stream
Events:
event: approval_created
data: {"approvalId":"01ARZ...","tenantId":"...","tenantName":"Acme Corp","riskLevel":"high"}
event: approval_expiring
data: {"approvalId":"01ARZ...","tenantId":"...","hoursLeft":3}
event: agent_error
data: {"tenantId":"...","agentRole":"copywriter","error":"All retries exhausted","activityId":"..."}
event: activity_blocked
data: {"activityId":"01ARZ...","tenantId":"...","blockerType":"channel_auth_expired"}