PowerMarketing Docs
Back to App

Admin Guide

PowerMarketing runs as a single Docker container backed by a SQLite database. This guide covers first deployment, configuration, day-to-day operations, and troubleshooting.


Architecture

Internet
  │
  ▼
Nginx (HTTPS + CORS)
  ├─► marketing.planningpowertools.com     → Static HTML/CSS/JS (web host)
  └─► marketing-api.planningpowertools.com → powermarketing-api:8070 (Docker)
ComponentDetails
Containerpowermarketing-api running FastAPI + APScheduler on port 8070
DatabaseSQLite at /data/powermarketing.db on volume powermarketing-data
FrontendStatic files served from any web host (no Node.js required)

First Deployment

Step 1 — Generate AUTH_SECRET

Generate a strong random secret:

openssl rand -hex 32

Copy the output. This is your AUTH_SECRET. Losing it invalidates all active sessions.

Step 2 — Write docker-compose.yml

services:
  powermarketing-api:
    image: powermarketing-api:latest
    container_name: powermarketing-api
    restart: always
    ports:
      - "8070:8070"
    volumes:
      - powermarketing-data:/data
    environment:
      - DATABASE_PATH=/data/powermarketing.db
      - SCHEMA_PATH=/app/schema.sql
      - SEED_DATA_PATH=/app/seed-data.sql
      - CORS_ORIGINS=*
      - AUTH_SECRET=<your-generated-secret>
      - ACCESS_TOKEN_EXPIRE_MINUTES=480
      - BOOTSTRAP_ADMIN_USERNAME=admin
      - BOOTSTRAP_ADMIN_PASSWORD=<strong-password-no-dollar-signs>
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8070/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 5s
    networks:
      - pdp-network

volumes:
  powermarketing-data:
    name: powermarketing-data

networks:
  pdp-network:
    external: true

Warning about $ in passwords: In docker-compose YAML, $$ is escaped to $. If your bootstrap password contains $, use $$ to represent each $. Example: password Pass$word → write Pass$$word in the YAML. The simplest approach is to use a password without $ for bootstrap, then change it via the UI after first login.

Step 3 — Start the container

docker compose up -d

Step 4 — Verify health

curl https://marketing-api.planningpowertools.com/health
# Expected: {"status":"healthy","service":"PowerMarketing"}

Step 5 — First login

Navigate to https://marketing.planningpowertools.com/login.html. Sign in with:

  • Username: value of BOOTSTRAP_ADMIN_USERNAME (default: admin)
  • Password: value of BOOTSTRAP_ADMIN_PASSWORD

Change your password immediately after first login via Settings → Users → Change My Password.


Environment Variables Reference

VariableRequiredDefaultDescription
DATABASE_PATHYes/data/powermarketing.dbSQLite file path
SCHEMA_PATHYes./schema.sqlSchema file for DB init
SEED_DATA_PATHYes./seed-data.sqlSeed data for DB init
CORS_ORIGINSYes*Comma-separated allowed origins
AUTH_SECRETYeschange-me-in-productionJWT signing secret — must be changed
ACCESS_TOKEN_EXPIRE_MINUTESNo480Session duration in minutes
BOOTSTRAP_ADMIN_USERNAMENoadminFirst admin username
BOOTSTRAP_ADMIN_PASSWORDNo(empty)First admin password — required for bootstrap
PLATFORM_CLAUDE_API_KEYNo(empty)Shared fallback Claude key — see Shared AI Allowance below
PLATFORM_DEEPSEEK_API_KEYNo(empty)Shared fallback DeepSeek key
PLATFORM_MOONSHOT_API_KEYNo(empty)Shared fallback Moonshot key

Per-brand LLM, Resend, and Tavily API keys are stored in the database — not here. The PLATFORM_* keys above are the operator's own, used only as a fallback for brands that have supplied none.

Shared AI Allowance

Without a key of its own a brand cannot generate anything, which blocks a non-technical user on day one. Setting a PLATFORM_* key lets eligible brands generate on yours instead.

Resolution order for every LLM call:

  1. The brand's own key for that provider.
  2. The platform key — only if the brand's plan has platform_llm_enabled.
  3. Otherwise an actionable HTTP 400 pointing at Settings → API Keys.

Two guards keep the cost bounded:

  • Opt-in. The keys default to empty. Until you set one, behaviour is unchanged.
  • Metered. Every fallback call is counted in platform_llm_usage (one row per brand per month). When a brand reaches its plan's platform_llm_calls_per_month, generation stops with a 400 telling the user to add their own key. A brand's own key is never metered.

Usage is shown to the user in Settings → API Keys and on the Command Center setup checklist ("12 of 100 calls used this month"). Reading that status does not consume quota.


User Management

Bootstrap Admin

On first startup, if no users exist and BOOTSTRAP_ADMIN_PASSWORD is set, a superadmin user is created. This only runs once. Check the container log for: Bootstrap: created superadmin 'admin'.

Creating Additional Users

As superadmin, go to the Users page (/users.html) and click + Add User. Assign brands and a role per user.

Roles

RoleAccess
superadminAll brands, user management, system settings
adminAssigned brands — full settings including credentials
operatorAssigned brands — content, decisions, calendar, leads
viewerAssigned brands — read-only

Brand Setup

New brands need 4 things before dispatch works: brand voice, content pillars, an LLM API key, and a calendar entry. The fastest path:

  1. Create the brand in Settings
  2. Go to Settings → Brand Profile → use Quick-Start (AI fills everything from 3 inputs)
  3. Go to Settings → API Keys → add the LLM provider key
  4. Create a calendar entry for today
  5. Run dispatch — the readiness check will tell you if anything is still missing

Per-Brand API Keys

All API keys are stored in the database per-brand, not in environment variables. Configure in Settings → API Keys.

KeyUsed for
Claude API keyAnthropic/Claude content generation
DeepSeek API keyDeepSeek content generation
Moonshot API keyMoonshot/Kimi content generation
Resend API keyEmail broadcasts
Resend webhook secretEmail delivery events
Tavily API keyLive trend search

Scheduler

The in-process APScheduler runs the following recurring jobs:

JobSchedule
Per-brand content dispatchPolling every 15 min; fires when the brand's effective dispatch time (fixed dispatch_time, or the learned suggested_dispatch_time in auto mode) falls within ±7 min of current time
Due nurture stepsEvery 15 minutes
Dispatch retry pass (failed/late entries)Hourly at :45
Post metrics refresh + best-time learningDaily at 04:00 UTC
Weekly planningMondays at 05:00 UTC
Calendar top-up (gap-fill next 3 days, non-manual brands)Daily at 05:15 UTC
Evergreen recycling (opt-in per brand)Wednesdays at 05:30 UTC
Autonomy check (earned auto-publish proposals)Mondays at 05:45 UTC
Newsletter digest buildDaily at 06:00 UTC
Approval digest email (pending decisions)Daily at 06:30 UTC

To verify scheduling is working: check docker logs for lines starting with Dispatch result for brand.

To manually trigger dispatch for a brand: Command Center → Run Agents Now, or POST /api/scheduler/dispatch-now?brand_id=<id>.

Single-instance requirement: The scheduler is in-process and does not use a distributed lock. Do not run more than one API replica — jobs will double-fire if multiple instances are active.


Optional Cost-Control Gateway (LiteLLM + Ollama)

By default this gateway is off. The production server compose does not include it — each brand falls back to its own per-brand provider API key. Enable it only when you want centralised spend caps, model routing, or a local model tier.

Enabling the gateway

Add two services to your docker-compose.yml and set one env var on the api service:

services:
  litellm:
    image: ghcr.io/berriai/litellm:main-latest
    container_name: litellm
    restart: always
    ports:
      - "4000:4000"
    volumes:
      - ./litellm/config.yaml:/app/config.yaml
    networks:
      - pdp-network

  ollama:
    image: ollama/ollama
    container_name: ollama
    restart: always
    ports:
      - "11434:11434"
    networks:
      - pdp-network

On the powermarketing-api service, add:

environment:
  # ... existing vars ...
  - LITELLM_BASE_URL=http://litellm:4000
  - LITELLM_MASTER_KEY=<your-litellm-master-key>
  - DEEPSEEK_API_KEY=<your-deepseek-key>
  - ANTHROPIC_API_KEY=<your-anthropic-key>

litellm/config.yaml — model tiers

litellm/config.yaml defines the model tiers routed through the gateway. A representative configuration:

model_list:
  - model_name: local
    litellm_params:
      model: ollama/llama3.1:8b
      api_base: http://ollama:11434

  - model_name: standard
    litellm_params:
      model: deepseek/deepseek-chat
      api_key: os.environ/DEEPSEEK_API_KEY

  - model_name: premium
    litellm_params:
      model: anthropic/claude-3-5-sonnet-latest
      api_key: os.environ/ANTHROPIC_API_KEY

Per-brand spend caps are derived from each brand's monthly budget setting. A per-brand LiteLLM virtual key is stored on the brand record.

Pull the local model

Run once after the Ollama container starts to download the model weights:

docker exec ollama ollama pull llama3.1:8b

Audience Ingest Keys

Each brand has an ingest_public_key and a webhook secret. These are auto-generated when the brand is created. On every container startup, any brand missing keys is backfilled automatically — the operation is idempotent and requires no manual intervention.

Admins and operators can rotate keys from Settings → Ingestion:

POST /api/brands/{id}/ingest-keys/rotate

Rotating keys immediately invalidates the previous key pair. Update any external systems that post to the ingest webhook before rotating in production.

Per-brand Resend webhook (delivery events)

Email engagement events (opens, clicks, bounces, complaints) come back from each brand's own Resend account. Because every brand has a separate Resend account with its own signing secret, the webhook endpoint is per brand:

POST /api/webhooks/email/{ingest_public_key}

In that brand's Resend account → Webhooks, point the endpoint to this URL (shown with a copy button in Settings → Ingestion), then paste Resend's signing secret into the brand's Settings → API Keys → Resend webhook secret. The signature is verified against that brand's secret; if no secret is configured the event is accepted but logged as unverified. A legacy global endpoint /api/webhooks/email remains for single-account setups.


Onboarding & Plans

Tenants are provisioned by a superadmin from inside the app: + Add brand in the brand selector, then Users → + Add User with brand access ticked. Both POST /api/brands and brand deletion are superadmin-only.

The public signup endpoint has been removed. POST /api/onboarding/signup and signup.html no longer exist — they were unauthenticated and unhardened, and onboarding is now operator-only by design.

Plan tiers and their limits:

PlanMonthly BudgetSeat LimitShared AI calls / month
Free$51100
Starter$5030 — own key required
Pro$500100 — own key required

Deliverability

A deliverability guardian monitors complaint and bounce events per brand. When the threshold is reached (default: 3 events within 30 days), the guardian automatically sets sending_paused on the brand, halting all outbound email.

To resume sending after resolving the underlying issue:

  1. Investigate and fix the cause (list hygiene, content, authentication records).
  2. Clear sending_paused on the brand (Settings → Brand Profile or directly via the API).

Check the Agent Log (/log.html) and your email provider's complaint/bounce dashboard to identify the affected contacts before re-enabling sending.


Database

Backup

docker cp powermarketing-api:/data/powermarketing.db ./backup_$(date +%Y%m%d).db

Run this from the host. The SQLite file contains all data. Schedule with cron for automated backups.

Migrations

Database migrations run automatically on every container start. They are idempotent — safe to run repeatedly. No manual migration steps required after updates.

Inspect

docker exec powermarketing-api sqlite3 /data/powermarketing.db ".tables"
docker exec powermarketing-api sqlite3 /data/powermarketing.db "SELECT username, role FROM users;"

Deleting a brand

A superadmin can permanently delete a brand and everything it owns from Settings → Brand Profile → Danger Zone (type the brand code to confirm), or via the API: DELETE /api/brands/{id}?confirm=<code>. Deletion cascades through every brand-scoped table (posts, calendar, contacts, subscribers, leads, decisions, metrics, segments, credentials, logs) and cannot be undone.

Back up first. There is no soft-delete or trash — take a database backup (above) before removing a brand you might need to recover. Users are global and survive; only their access mapping to the deleted brand is removed.


Updates

docker pull powermarketing-api:latest
docker compose up -d

Migrations run automatically on the new container start. The database volume persists across updates.


Monitoring

MethodWhat to check
GET /healthContainer alive
docker logs powermarketing-apiErrors, bootstrap messages, dispatch results
/log.htmlAll agent runs with status and errors
Command CenterOvernight summary, recent activity

CORS

PowerMarketing is a headless, multi-brand engine: each product embeds the subscribe widget (or calls the ingest API) from its own domain, and you do not know every product domain in advance — new brands bring new sites. For that reason the recommended setting is open CORS:

CORS_ORIGINS=*
Why * is safe here. The two browser-reachable surfaces are both safe to call cross-origin:
  • Operator API (brands, contacts, decisions, publishing…) is protected by a JWT Bearer token, not cookies — so a permissive CORS policy cannot be abused via ambient credentials (there are none to ride on).
  • Public ingest (/api/ingest/{public_key}/subscribe) is meant to be called from any product site, and is gated by the per-brand ingest_public_key plus mandatory double opt-in — the worst a stranger can do is trigger a confirmation email to an address they typed.

If you prefer to lock it down (single-brand, known domains only), set an explicit allow-list instead — but remember to add every product domain that hosts the widget, including the www. and bare-apex variants:

CORS_ORIGINS=https://marketing.planningpowertools.com,https://www.planningpowertools.com,https://yourproduct.com

Troubleshooting

"Invalid credentials" on first login

  • Check docker logs for Bootstrap: created superadmin. If missing, BOOTSTRAP_ADMIN_PASSWORD was empty — set it and restart.
  • If your password contains $: docker-compose YAML escapes $$$. What was stored may differ from what you typed. Try with one fewer $.

Bootstrap admin not created

The DB already has users (from a previous run). Reset: docker volume rm powermarketing-data and restart (destroys all data — do this only on a fresh server).

Dispatch not running automatically

  • Check dispatch_time is set in Settings → Automation and is in UTC.
  • The scheduler checks every 15 minutes within ±7 min of the configured time.
  • Check docker logs for APScheduler entries.

Posts stuck in Decision Inbox (Track 1 not auto-publishing)

  • Check Settings → Automation → Publication Rules — the content type must have auto-publish enabled.
  • Check the brand has a credential for the target platform (Settings → Credentials).

API key errors

  • Settings → Brand Profile → Test Connection confirms the LLM key works.
  • API key errors appear in the Agent Log (/log.html) and, when generating from the UI, as a clear message ("Add it in Settings → API Keys") rather than a generic error.

Video generates a script but no finished video

  • Script, storyboard, and voiceover are produced by the LLM and need only a brand LLM key. Rendering an actual video file requires a video provider (HeyGen, Runway, or a generic REST provider) configured under Settings → Video Providers, each with its own API key.
  • Without a provider, the Videos page will only ever show the generated script — this is expected, not a fault.

Container crashes on startup

  • Check docker logs powermarketing-api for Python tracebacks.
  • Common causes: missing required env var, malformed CORS_ORIGINS.