PowerMarketing Docs
Back to App

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.

MethodPathDescription
GET/api/usersList all users with brand access
POST/api/usersCreate user
PATCH/api/users/{id}Update email, role, password
PUT/api/users/{id}/brandsReplace 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

MethodPathAuthDescription
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}🔒 SuperadminPermanently 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

MethodPathDescription
GET/api/calendars?brand_id=List calendars
POST/api/calendarsCreate calendar
DELETE/api/calendars/{id}Delete calendar
GET/api/calendars/entries?brand_id=&month=YYYY-MMList all entries for brand
POST/api/calendars/{id}/entriesAdd entry to calendar
PATCH/api/calendars/entries/{id}Update entry
DELETE/api/calendars/entries/{id}Delete entry
POST/api/calendar/auto-fillGenerate weekly preview (not inserted)
POST/api/calendar/auto-fill/confirmInsert 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

MethodPathQuery params
GET/api/postsbrand_id, status, platform
POST/api/posts
GET/api/posts/{id}
PATCH/api/posts/{id}
DELETE/api/posts/{id}

Content Generation

MethodPathDescription
POST/api/content/generateGenerate post content
POST/api/content/refineRefine existing content
POST/api/content/repurposeRepurpose post for another platform
GET/api/content/trendsFetch 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_scantrend_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

MethodPathQuery
GET/api/decisionsbrand_id, status=pending|approved|rejected
POST/api/decisions/{id}/approve
POST/api/decisions/{id}/reject
POST/api/decisions/batch
GET/api/public/decisions/approvetoken (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

MethodPathDescription
GET/api/dashboard/summaryAggregated Command Center data
POST/api/dashboard/approveInline 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 all
  • target_date (string, optional) — YYYY-MM-DD; defaults to today
  • force (bool, default false) — re-dispatch already-dispatched entries
  • skip_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

MethodPathDescription
POST/api/publishPublish approved post to social platform
GET/api/videos?brand_id=List videos
POST/api/videos/generateGenerate video script + storyboard
GET/api/leads?brand_id=List leads
POST/api/leadsCreate lead
PATCH/api/leads/{id}Update lead
DELETE/api/leads/{id}Delete lead
POST/api/leads/{id}/nurtureAI-draft nurture message
GET/api/subscribers?brand_id=List subscribers
POST/api/subscribersAdd subscriber
GET/api/broadcasts?brand_id=List broadcasts
POST/api/broadcastsCreate broadcast
POST/api/broadcasts/{id}/sendSend broadcast

People (unified audience) 🔒

MethodPathDescription
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 🔒

MethodPathDescription
GET/api/segments?brand_id=List saved segments
POST/api/segmentsCreate — { brand_id, name, definition } (definition = JSON string: stage, min_score, tags, source_product, consent_status)
GET/api/segments/{id}/previewResolve — 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.

MethodPathDescription
GET/api/nurture/sequences?brand_id=List nurture sequences for a brand
POST/api/nurture/sequencesCreate a nurture sequence
POST/api/nurture/sequences/{id}/stepsAdd a step to a sequence
POST/api/nurture/sequences/{id}/enrollEnroll a contact in a sequence
POST/api/nurture/run-due?brand_id=Process all due nurture steps for a brand
POST/api/nurture/previewPreview 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.

MethodPathDescription
GET/api/newsletters/schedules?brand_id=List newsletter schedules for a brand
POST/api/newsletters/schedulesCreate 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.

MethodPathAuthDescription
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/callbackGoogle'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" }
]
FieldMeaning
trackauto = may be enabled for auto-publish · approval = always reviewed · locked = always reviewed, brand cannot override
creatableWhether a human may pick it; false types are produced only by agents or a dedicated flow
modeThe 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 groupvieweroperatoradminsuperadmin
Read brands, posts, calendar
Create/edit posts, calendar, decisions
Settings: brand profile, prompts
Settings: credentials, API keys
User management

Error Responses

StatusMeaning
400Bad request — invalid body or missing required field
401Not authenticated — missing or invalid Bearer token
403Insufficient permissions — valid token but wrong role or brand
404Resource not found
412Precondition failed — brand not ready for dispatch
422Validation error — see detail array
500Server error — check container logs