API Reference
Base URL: https://marketing-api.planningpowertools.com
Interactive documentation (Swagger UI): https://marketing-api.planningpowertools.com/docs
All endpoints marked 🔒 Auth required need Authorization: Bearer <token> in the request header.
Authentication
POST /api/auth/login
No auth required. Exchange credentials for a JWT.
Request:
{ "username": "admin", "password": "yourpassword" }
Response:
{
"access_token": "eyJ...",
"token_type": "bearer",
"user": { "id": 1, "username": "admin", "role": "superadmin", "brand_ids": [1, 2] }
}
Store access_token in localStorage and send as Authorization: Bearer <token> on all subsequent requests.
GET /api/auth/me 🔒
Returns the current authenticated user's profile and brand access list.
POST /api/auth/change-password 🔒
{ "current_password": "old", "new_password": "newpassword" }
Users (Superadmin only)
All user endpoints require superadmin role.
| Method | Path | Description |
|---|---|---|
| GET | /api/users | List all users with brand access |
| POST | /api/users | Create user |
| PATCH | /api/users/{id} | Update email, role, password |
| PUT | /api/users/{id}/brands | Replace brand access list |
| DELETE | /api/users/{id} | Deactivate user (soft delete) |
POST /api/users body:
{ "username": "jsmith", "password": "min8chars", "email": "j@co.com", "role": "operator", "brand_ids": [1] }
Brands
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/brands | 🔒 | List brands (filtered by user access) |
| POST | /api/brands | 🔒 Admin+ | Create brand |
| GET | /api/brands/{id} | 🔒 | Get brand |
| PATCH | /api/brands/{id} | 🔒 Admin+ | Update brand fields |
| DELETE | /api/brands/{id}?confirm={code} | 🔒 Superadmin | Permanently delete a brand and ALL its data (cascades) |
| GET | /api/brands/{id}/readiness | 🔒 | Brand readiness check |
| POST | /api/brands/{id}/quick-start | 🔒 | AI fills all profile fields |
| POST | /api/brands/{id}/test-llm | 🔒 | Test LLM connection |
| GET | /api/brands/{id}/automation | 🔒 | Get automation settings |
| PATCH | /api/brands/{id}/automation | 🔒 Admin+ | Update automation settings |
| GET | /api/brands/{id}/api-keys | 🔒 Admin+ | Get masked API keys |
| PATCH | /api/brands/{id}/api-keys | 🔒 Admin+ | Update API keys |
| POST | /api/brands/{id}/profile-assist | 🔒 | AI assist for a profile field |
GET /api/brands/{id}/readiness response:
{
"score": 75,
"ready": false,
"blocking": ["platform_credential"],
"warnings": ["tavily_configured"],
"checks": [
{ "id": "brand_voice", "status": "ok", "message": "Brand voice is set", "fix_url": "/settings.html#brand" },
{ "id": "platform_credential", "status": "fail", "message": "No LinkedIn credential", "fix_url": "/settings.html#credentials" }
]
}
POST /api/brands/{id}/quick-start body:
{ "brand_name": "Acme Corp", "industry": "Supply chain software", "what_you_sell": "AI demand planning", "target_audience": "Supply chain VPs" }
PATCH /api/brands/{id}/automation body:
{ "dispatch_time": "07:00", "web_search_enabled": true, "auto_publish_content_types": ["news", "trend_scan"] }
DELETE /api/brands/{id} — 🔒 Superadmin only. Requires ?confirm=<brand code> matching the brand's code, or the call returns 400. On success every brand-scoped row (posts, calendar, contacts, subscribers, leads, decisions, metrics, segments, credentials, logs) is removed via ON DELETE CASCADE. This is irreversible.
DELETE /api/brands/7?confirm=ACME
→ { "deleted": true, "brand_id": 7, "name": "Acme Co" }
Calendar
| Method | Path | Description |
|---|---|---|
| GET | /api/calendars?brand_id= | List calendars |
| POST | /api/calendars | Create calendar |
| DELETE | /api/calendars/{id} | Delete calendar |
| GET | /api/calendars/entries?brand_id=&month=YYYY-MM | List all entries for brand |
| POST | /api/calendars/{id}/entries | Add entry to calendar |
| PATCH | /api/calendars/entries/{id} | Update entry |
| DELETE | /api/calendars/entries/{id} | Delete entry |
| POST | /api/calendar/auto-fill | Generate weekly preview (not inserted) |
| POST | /api/calendar/auto-fill/confirm | Insert confirmed entries |
POST /api/calendars/{id}/entries body:
{ "topic": "AI in demand planning", "content_type": "article", "platforms": "linkedin", "scheduled_date": "2026-06-02", "notes": null, "require_approval": 0 }
POST /api/calendar/auto-fill body:
{ "brand_id": 1, "week_start_date": "2026-06-02", "num_entries": 5 }
Returns array of suggested entries (preview — not saved). Pass to /confirm to insert.
Posts
| Method | Path | Query params |
|---|---|---|
| GET | /api/posts | brand_id, status, platform |
| POST | /api/posts | — |
| GET | /api/posts/{id} | — |
| PATCH | /api/posts/{id} | — |
| DELETE | /api/posts/{id} | — |
Content Generation
| Method | Path | Description |
|---|---|---|
| POST | /api/content/generate | Generate post content |
| POST | /api/content/refine | Refine existing content |
| POST | /api/content/repurpose | Repurpose post for another platform |
| GET | /api/content/trends | Fetch live trend snippets |
POST /api/content/generate body:
mode is optional. Omit it and the server derives the generation angle from the post's content_type (news / trend_scan → trend_scan; everything else → product_topic) using the content-type registry. Pass it explicitly only to override — e.g. competitor_signal.
{
"brand_id": 1, "post_id": 10,
"topic": "AI demand planning ROI",
"objective": "awareness",
"audience_segment": "Supply chain VPs",
"tone_override": "authoritative",
"key_points": ["30% forecast improvement"],
"avoid": "buzzwords",
"cta": "Book a demo"
}
GET /api/content/trends: ?q=AI+supply+chain&brand_id=1&max_results=5
Decisions
| Method | Path | Query |
|---|---|---|
| GET | /api/decisions | brand_id, status=pending|approved|rejected |
| POST | /api/decisions/{id}/approve | — |
| POST | /api/decisions/{id}/reject | — |
| POST | /api/decisions/batch | — |
| GET | /api/public/decisions/approve | token (no auth — HMAC-signed) |
POST /api/decisions/{id}/reject — 🔒 Auth required. Optionally accepts a JSON body:
{ "reason_code": "off_brand", "notes": "Tone does not match brand voice guidelines." }
Both fields are optional. Omitting the body entirely is still valid.
POST /api/decisions/{id}/approve — optionally accepts { "edited": true } to record that the reviewer changed the content before approving (feeds the earned-autonomy stats). Content edits made via PATCH /api/posts/{id} while a review is pending are flagged automatically.
POST /api/decisions/batch — 🔒 Auth required. Resolve many decisions at once:
{ "ids": [12, 13, 14], "action": "approve" }
Returns { "resolved": n, "skipped": n, "errors": [] }; already-resolved or missing ids are counted as skipped. For "action": "reject", optional reason_code/notes apply to every item.
GET /api/public/decisions/approve?token=… — one-click approve used by the daily digest email. The token is an HMAC signature over the decision id; links are single-use (a resolved decision returns 400).
Decision types beyond review_post and plan: autonomy_upgrade (approving inserts an autonomy_grants row that lets the content type auto-publish) and segment_proposal (approving saves the proposed segment).
Dashboard
| Method | Path | Description |
|---|---|---|
| GET | /api/dashboard/summary | Aggregated Command Center data |
| POST | /api/dashboard/approve | Inline approve a decision |
GET /api/dashboard/summary?brand_id=1&date=2026-06-02 response:
{
"date": "2026-06-02",
"readiness": { "ready": true, "score": 90, "blocking": [], "warnings": [] },
"pipeline": [{ "entry_id": 1, "topic": "...", "content_type": "article", "platforms": ["linkedin"], "posts": [...] }],
"auto_published_today": [{ "post_id": 8, "topic": "...", "platform": "linkedin", "published_at": "07:12" }],
"pending_decisions": [{ "decision_id": 5, "topic": "...", "content_preview": "..." }],
"pending_decisions_count": 1,
"dispatch_preview": { "entries_pending": 2, "platforms_count": 3, "ready": true }
}
Scheduler
POST /api/scheduler/dispatch-now
Query params:
brand_id(int, optional) — dispatch a single brand; omit to dispatch alltarget_date(string, optional) — YYYY-MM-DD; defaults to todayforce(bool, default false) — re-dispatch already-dispatched entriesskip_readiness(bool, default false) — bypass readiness check
Response (per brand):
{
"brand_id": 1,
"date": "2026-06-02",
"entries_processed": 2,
"posts_created": 3,
"posts_generated": 3,
"posts_auto_published": 2,
"posts_pending_review": 1,
"posts_failed": 0,
"decisions_created": 1,
"errors": []
}
Returns 412 with the readiness payload if the brand is not ready and skip_readiness is not set.
Publishing, Videos, Leads, Subscribers, Broadcasts
| Method | Path | Description |
|---|---|---|
| POST | /api/publish | Publish approved post to social platform |
| GET | /api/videos?brand_id= | List videos |
| POST | /api/videos/generate | Generate video script + storyboard |
| GET | /api/leads?brand_id= | List leads |
| POST | /api/leads | Create lead |
| PATCH | /api/leads/{id} | Update lead |
| DELETE | /api/leads/{id} | Delete lead |
| POST | /api/leads/{id}/nurture | AI-draft nurture message |
| GET | /api/subscribers?brand_id= | List subscribers |
| POST | /api/subscribers | Add subscriber |
| GET | /api/broadcasts?brand_id= | List broadcasts |
| POST | /api/broadcasts | Create broadcast |
| POST | /api/broadcasts/{id}/send | Send broadcast |
People (unified audience) 🔒
| Method | Path | Description |
|---|---|---|
| GET | /api/people?brand_id= | Unified list (contacts joined with subscriber/lead links). Optional: stage, q (search), tag |
| GET | /api/people/stats?brand_id= | Totals by stage + confirmed-consent count |
| POST | /api/people/import?brand_id=&subscribe=&consent= | CSV body (header: email,name,company,role,tags). Upserts by normalized email; subscribe=true also creates subscriber rows; consent=confirmed marks imported contacts as opted-in (with a consent_log entry) so they're eligible for nurture/newsletter segments. Returns { created, updated, skipped } |
Creating a subscriber or a lead (with an email) automatically upserts the matching person, so /api/people is always the complete view.
Segments 🔒
| Method | Path | Description |
|---|---|---|
| GET | /api/segments?brand_id= | List saved segments |
| POST | /api/segments | Create — { brand_id, name, definition } (definition = JSON string: stage, min_score, tags, source_product, consent_status) |
| GET | /api/segments/{id}/preview | Resolve — returns { count, sample } |
| DELETE | /api/segments/{id} | Delete segment |
| POST | /api/segments/ai-propose?brand_id= | LLM proposes up to 3 segments as segment_proposal decisions |
Segment definitions also accept { "segment_id": N } anywhere a segment JSON is used (e.g. newsletter schedules), resolving to the saved segment.
Agent Log
GET /api/log?brand_id=1&limit=50
Returns recent agent runs with: agent name, trigger_type, status, started_at, completed_at, tokens_used, summary, error.
System
GET /health — public, no auth required.
{ "status": "healthy", "service": "PowerMarketing" }
Ingestion (public — no auth)
These endpoints are keyed by a public ingest key ({public_key}) and do not require a Bearer token.
POST /api/ingest/{public_key}/subscribe
No auth required. Creates or deduplicates a contact in a pending-consent state, then sends a double opt-in confirmation email (best-effort).
Response: 202 Accepted
Request body:
{
"email": "ada@example.com",
"name": "Ada Lovelace",
"company": "Acme Corp",
"role": "Supply Chain VP",
"source": "website-footer",
"utm": { "source": "linkedin", "campaign": "q2-launch" }
}
email is required; all other fields are optional.
Response:
{
"status": "pending_confirmation",
"contact_id": 42,
"created": true
}
created is false when the email was already known (deduped).
GET /api/ingest/{public_key}/confirm
No auth required. Confirms the double opt-in for a contact.
Query params: token=<confirmation_token>
Response:
{ "status": "confirmed" }
GET /api/ingest/{public_key}/unsubscribe
No auth required. Unsubscribes a contact via a signed token (included in sent emails).
Query params: token=<unsubscribe_token>
Response:
{ "status": "unsubscribed" }
Contacts 🔒
All endpoints require Authorization: Bearer <token> and are scoped to a brand.
GET /api/contacts
List contacts for a brand.
Query params: brand_id=<int> (required)
Response: array of contact objects.
GET /api/contacts/{id}
Get a single contact with its event history.
Response:
{
"contact": { "id": 42, "email": "ada@example.com", "name": "Ada Lovelace", "status": "confirmed", ... },
"events": [ { "event_type": "email_opened", "occurred_at": "2026-05-28T10:00:00Z" }, ... ]
}
POST /api/ingest/{brand_id}/connectors/run
🔒 Auth required. Imports contacts via a connector (e.g. CSV upload, CRM sync).
Request body:
{
"connector_type": "csv",
"config": { }
}
Response:
{
"connector": "csv",
"imported": 150,
"fetched": 155
}
Brands — Ingest Keys 🔒
Auth required. Admin+ role.
POST /api/brands/{id}/ingest-keys/rotate
Rotates the brand's public ingest key and webhook secret. Existing tokens issued with the old key are invalidated.
Response:
{
"ingest_public_key": "pk_live_...",
"ingest_webhook_secret": "whs_..."
}
Nurture 🔒
All endpoints require Authorization: Bearer <token> and are scoped to a brand.
| Method | Path | Description |
|---|---|---|
| GET | /api/nurture/sequences?brand_id= | List nurture sequences for a brand |
| POST | /api/nurture/sequences | Create a nurture sequence |
| POST | /api/nurture/sequences/{id}/steps | Add a step to a sequence |
| POST | /api/nurture/sequences/{id}/enroll | Enroll a contact in a sequence |
| POST | /api/nurture/run-due?brand_id= | Process all due nurture steps for a brand |
| POST | /api/nurture/preview | Preview AI-generated nurture message |
POST /api/nurture/sequences body:
{ "brand_id": 1, "name": "New Subscriber Welcome", "trigger_stage": "confirmed" }
POST /api/nurture/sequences/{id}/steps body:
{ "step_order": 1, "delay_hours": 24, "channel": "email", "subject": "Welcome to Acme", "prompt": "Write a warm welcome email..." }
POST /api/nurture/sequences/{id}/enroll body:
{ "contact_id": 42 }
POST /api/nurture/run-due response:
{ "processed": 5, "results": [ { "enrollment_id": 12, "step": 1, "status": "sent" }, ... ] }
POST /api/nurture/preview body & response:
{ "brand_id": 1, "prompt": "Write a re-engagement email for a contact who hasn't opened in 30 days." }
{ "preview": "Subject: We miss you!\n\nHi {{name}}, ..." }
Newsletters 🔒
All endpoints require Authorization: Bearer <token> and are scoped to a brand.
| Method | Path | Description |
|---|---|---|
| GET | /api/newsletters/schedules?brand_id= | List newsletter schedules for a brand |
| POST | /api/newsletters/schedules | Create a newsletter schedule |
| POST | /api/newsletters/build?brand_id= | Build next newsletter for a brand |
| POST | /api/newsletters/run-due?brand_id= | Send all due newsletters for a brand |
POST /api/newsletters/schedules body:
{ "brand_id": 1, "name": "Weekly Roundup", "cadence_days": 7, "segment_json": { "status": "confirmed" } }
POST /api/newsletters/build response:
{ "built": true, "broadcast_id": 18, "decision_id": 27, "post_count": 4 }
POST /api/newsletters/run-due response:
{ "processed": 2 }
Command Center 🔒
All endpoints require Authorization: Bearer <token> and are scoped to a brand.
GET /api/workflow/status
Query params: brand_id=<int>
Live status of the automation pipeline for the Workflow page. Returns one node per stage (plan, calendar, create, review, publish, learn), each with status (ok | active | attention | idle), a one-line summary, and a detail object shown when the node is clicked.
{
"brand_id": 1,
"autopilot_level": "review",
"sending_paused": false,
"nodes": [ { "key": "plan", "label": "Plan", "status": "ok", "summary": "...", "detail": { ... } }, ... ]
}
GET /api/command-center/summary
Query params: brand_id=<int>
Returns a combined status snapshot.
Response:
{
"audience": { ... },
"funnel": { ... },
"newsletter": { ... },
"approvals": { ... },
"health": { ... }
}
GET /api/command-center/approvals
Query params: brand_id=<int>
Returns items awaiting human review.
Response:
[
{ "id": 27, "decision_type": "newsletter", "title": "Weekly Roundup — 2026-06-02", "created_at": "2026-06-01T18:00:00Z" },
...
]
PATCH /api/command-center/autopilot
Sets the automation level for a brand.
Query params: brand_id=<int>, level=manual|review|auto-guardrails
No request body. Returns the updated autopilot setting.
POST /api/command-center/command
Issue a natural-language command to the AI planner.
Request body:
{ "brand_id": 1, "text": "Pause all LinkedIn posts for this week and run a re-engagement nurture." }
Response:
{ "decision_id": 34, "plan": "I will pause LinkedIn entries for 2026-06-02–06 and enroll all lapsed contacts in the Re-engagement sequence." }
Planner & Insights 🔒
All endpoints require Authorization: Bearer <token> and are scoped to a brand.
POST /api/planner/propose
Query params: brand_id=<int>
Asks the AI planner to generate a strategic plan for the brand. Creates a pending decision.
Response:
{ "decision_id": 35, "plan": "Recommended actions for the next 7 days: ..." }
GET /api/insights/rejection-summary
Query params: brand_id=<int>, days=30
Summarises rejected decisions over the requested window.
Response:
{ "summary": "Most rejections cited off-brand tone on LinkedIn posts.", "total": 12 }
POST /api/insights/suggest-prompt-tweaks
Request body: { "brand_id": 1 }
Analyses recent rejections and returns prompt improvement suggestions.
Response:
{ "suggestions": [ "Add 'avoid jargon' to the brand voice prompt.", "Specify max post length for LinkedIn." ] }
Onboarding
POST /api/onboarding/signup has been removed. It was public and unauthenticated. Tenants are now provisioned by a superadmin via POST /api/brands (superadmin-only) and POST /api/users.
OAuth — Google / YouTube
Mirrors the LinkedIn OAuth endpoints. Full walkthrough: YouTube Publishing.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/oauth/google/app | 🔒 | Read the brand's Google app config (never returns the secret) |
| POST | /api/oauth/google/app | 🔒 | Upsert Client ID / Secret / redirect URI; a blank secret keeps the stored one |
| DELETE | /api/oauth/google/app | 🔒 | Remove the app config |
| GET | /api/oauth/google/authorize | 🔒 | Returns { authorize_url } to send the user to Google |
| GET | /api/oauth/google/callback | — | Google's redirect target. Exchanges the code, resolves the channel, stores the credential |
Scopes requested: youtube.upload and youtube.readonly, with access_type=offline and prompt=consent so Google issues a refresh token. Access tokens are renewed automatically about two minutes before expiry — see app/services/google_token.py.
Publishing to YouTube requires a video. The adapter needs options["video_url"]; the publisher fills it from the post's linked video (source_video_id). A text-only YouTube post raises a clear error rather than failing silently.
Content Types
The canonical content-type registry. This is the single source of truth — Create, Calendar, and Publication Rules all build their lists from it, so never hardcode a content-type list in a page.
GET /api/content-types
Public — the login page loads it before a session exists. Optional ?creatable=true returns only the types a human may pick in Create / Calendar.
[
{ "type": "news", "label": "News", "track": "auto", "creatable": true, "mode": "trend_scan" },
{ "type": "post", "label": "Post", "track": "approval", "creatable": true, "mode": "product_topic" },
{ "type": "video", "label": "Video", "track": "locked", "creatable": true, "mode": "product_topic" }
]
| Field | Meaning |
|---|---|
track | auto = may be enabled for auto-publish · approval = always reviewed · locked = always reviewed, brand cannot override |
creatable | Whether a human may pick it; false types are produced only by agents or a dedicated flow |
mode | The generation angle this type implies — supplied to the content agent automatically |
Invariant. Every track: "auto" type must also be creatable, otherwise a Publication Rule would exist that no post could ever match. This is enforced by a test.
Automation Schedule
GET /api/automation/schedule
Everything the platform runs on a timer, annotated with each job's real next run from the live scheduler and sorted soonest first. Rendered in Settings → Automation as Upcoming Automation.
[
{
"id": "brand_dispatch_poll",
"label": "Content dispatch",
"cadence": "Every 15 minutes",
"description": "Checks which brands have reached their dispatch window …",
"next_run": "2026-07-27T22:00:00+00:00"
}
]
next_run is null when the scheduler is not running; the job still appears so the list is never blank.
Role Permissions Summary
| Endpoint group | viewer | operator | admin | superadmin |
|---|---|---|---|---|
| Read brands, posts, calendar | ✓ | ✓ | ✓ | ✓ |
| Create/edit posts, calendar, decisions | ✗ | ✓ | ✓ | ✓ |
| Settings: brand profile, prompts | ✗ | ✗ | ✓ | ✓ |
| Settings: credentials, API keys | ✗ | ✗ | ✓ | ✓ |
| User management | ✗ | ✗ | ✗ | ✓ |
Error Responses
| Status | Meaning |
|---|---|
| 400 | Bad request — invalid body or missing required field |
| 401 | Not authenticated — missing or invalid Bearer token |
| 403 | Insufficient permissions — valid token but wrong role or brand |
| 404 | Resource not found |
| 412 | Precondition failed — brand not ready for dispatch |
| 422 | Validation error — see detail array |
| 500 | Server error — check container logs |