Complete reference for the SignalTower REST API. Use these endpoints to submit content for review, manage brand guidelines, receive real-time notifications, and submit AI review verdicts.
Base URL: https://app.signaltower.ai/api/v1
Two ways to create content
This API is for bringing your own agent: your agent generates content and submits it here for human review. If you'd rather not run an agent, SignalTower can also generate content for you in-app from a brief (Studio plan) — that flow lives entirely in the dashboard and needs none of these endpoints. Both paths share the same review queue, brand context, and publishing handoff.
All API requests require a Bearer token in the Authorization header. There are two kinds of keys:
Workspace keys (stk_...) — the shared key from Settings → API & Webhooks. Identity is derived from the agent_name field on each submission: the first submission with a new name creates that agent on the roster.
Authorization: Bearer stk_your_api_key_here
Agent keys (stk_agent_...) — a key that belongs to one agent, minted when you add the agent or from its profile. Work done with an agent key is attributed to that agent no matter what agent_name the payload carries, GET /assignments returns only that agent's assignments, and plan proposals default the proposer. Rotate or revoke it on the agent's profile without touching any other integration.
Authorization: Bearer stk_agent_your_agent_key_here
POST /content — Submit content for human review.
titlestringrequiredShort descriptive title for the content item.
contentstringrequiredThe full content text (post copy, article body, caption).
platformstringrequiredTarget platform: x, instagram, linkedin, blog, facebook, tiktok, reddit, threads, youtube, pinterest, snapchat, whatsapp, telegram, mastodon, discord, medium, newsletter, other.
reasonstringrequiredWhy this content was created. Reference the strategy or brief that prompted it.
agent_namestringYour agent's name. Defaults to "AI Agent".
languagestringISO 639-1 code of the language the content is written in (e.g. "ro"). Workspaces have a content language (shown in assignments as language) — match it unless told otherwise.
accountstringTarget account handle (e.g. @brand). For workspaces managing multiple accounts on a platform. Max 100 characters.
suggested_datestringSuggested publish date in YYYY-MM-DD format.
callback_urlstringURL to receive the review decision webhook. Overrides the workspace default.
metadataobjectArbitrary JSON returned unchanged in callbacks. Use for revision tracking (see Revisions section).
mediaarrayArray of media attachments: [{ "id": "med_xxx", "role": "main" }]. Upload media first, then reference IDs here. Roles: main, carousel, thumbnail, attachment.
variantsarraySubmit 2-10 content alternatives for A/B testing. See Variants section below.
Example — basic submission:
POST /content
Content-Type: application/json
Authorization: Bearer stk_...
{
"title": "Month-end invoice guilt",
"content": "Month-end is here. That means three things...",
"platform": "linkedin",
"reason": "Monthly pain-point content targeting SMB owners",
"agent_name": "Content Bot",
"suggested_date": "2026-04-10"
}One message, adapted per platform, published together. Pass an items array to POST /content — one entry per platform — and the siblings are created atomically sharing a group_id. Each sibling has a fully independent lifecycle: its own status, feedback, edits, and revision thread. The reviewer decides each platform separately; revise only the sibling that was sent back. An items array of length one creates a plain item, no group.
titlestringrequiredShared title for the group (per-item title optional).
reasonstringrequiredWhy this content was created (shared).
itemsarrayrequiredOne entry per platform: { platform, content, title?, media? }. Max 8, platforms unique.
metadataobjectShared. Include assignment_id to fulfill a multi-platform assignment with the whole group.
POST /content
{
"title": "Month-end invoice guilt",
"reason": "Monthly pain-point push",
"agent_name": "Content Bot",
"items": [
{ "platform": "linkedin", "content": "Month-end is here. Three things..." },
{ "platform": "x", "content": "Month-end again. A thread on the 3 things..." },
{ "platform": "instagram", "content": "Month-end doesn't have to hurt..." }
]
}
→ { "group_id": "grp_ab12cd34ef", "items": [
{ "id": "...", "platform": "linkedin", "status": "pending_review" }, ...
] }Callbacks fire per sibling decision. Every callback in a group carries the group context, so simple agents can ignore it and sophisticated ones can react per platform immediately:
{
"event": "content.approved",
"content_id": "...",
"group_id": "grp_ab12cd34ef",
"group_status": "2/3 approved",
"group": {
"total": 3, "approved": 2, "changes_requested": 1, "rejected": 0, "pending": 0,
"siblings": [
{ "content_id": "...", "platform": "linkedin", "status": "approved" },
{ "content_id": "...", "platform": "x", "status": "changes_requested" },
{ "content_id": "...", "platform": "instagram", "status": "approved" }
]
},
...
}Groups and variants are different things: variants are alternatives (the reviewer picks one), group siblings are adaptations (all publish, each decided). A sibling may itself carry variants.
Submit 2-10 alternative versions of the same content. Reviewers see all variants in a tabbed interface and select which one(s) to approve. Variants count as a single submission toward your monthly limit.
Each variant object:
labelstringrequiredShort label for this variant (max 60 chars). E.g. "Casual tone", "Question hook".
contentstringrequiredThe full content text for this variant.
media_idsstring[]Media IDs specific to this variant. Different variants can have different media.
Example — submission with variants:
POST /content
{
"title": "Spring campaign tweet",
"platform": "x",
"reason": "Testing three different hooks for Q2 campaign",
"agent_name": "Content Bot",
"variants": [
{
"label": "Casual hook",
"content": "Spring cleaning your routine? Start with the miles."
},
{
"label": "Question hook",
"content": "What if your training plan adapted to you?"
},
{
"label": "Bold statement",
"content": "Your next PR starts with a better plan."
}
]
}When using variants, the top-level content field is optional (defaults to the first variant's content). The reviewer sees tabs for each variant and can select one or more to approve.
POST /media — Upload images or videos before attaching to content.
Option 1 — multipart form data:
POST /media Content-Type: multipart/form-data Authorization: Bearer stk_... file: <binary> alt_text: "Description of the image"
Option 2 — URL reference (SignalTower downloads the file):
POST /media
Content-Type: application/json
Authorization: Bearer stk_...
{
"source_url": "https://example.com/image.jpg",
"alt_text": "Description of the image"
}Response returns a media ID to use in content submissions:
{ "id": "med_abc123", "type": "image", "url": "https://cdn.signaltower.ai/..." }Media roles: main (primary image), carousel (multi-image posts),thumbnail (video thumbnails), attachment (supplementary files).
Instead of uploading actual media, agents can submit a text prompt describing the desired image or video. The prompt appears as a placeholder in the review UI. Reviewers can then upload media manually or trigger generation via a connected media agent.
Submit a media prompt:
POST /media
Content-Type: application/json
Authorization: Bearer stk_...
{
"prompt": "Runner checking watch at mile 20, golden hour, shot from behind",
"prompt_type": "image",
"alt_text": "Runner at golden hour"
}promptstringrequiredText description of the desired image or video. Max 2000 characters.
prompt_typestring"image" or "video". Defaults to "image".
alt_textstringOptional alt text for the future generated media.
Response returns a media ID with generation_status: "pending":
{
"id": "med_abc123",
"type": "image",
"url": null,
"prompt": "Runner checking watch at mile 20...",
"prompt_type": "image",
"generation_status": "pending"
}Use this media ID in content submissions the same way as uploaded media. The reviewer sees the prompt text as a placeholder where the image would normally appear.
Media is always optional. An agent can submit content with no media, with uploaded files, with prompts, or any combination. If content is approved with unfulfilled prompts, the callback includes a media_prompts array so the receiving agent knows what visuals were envisioned.
When a reviewer clicks Generate on a media prompt, SignalTower dispatches the prompt to your configured generation webhook. Configure image and video generation webhooks in Agents → Webhooks.
Webhook payload sent to your generation agent:
POST your-generation-webhook-url
X-SignalTower-Signature: sha256=...
{
"event": "media.generate",
"media_id": "med_abc123",
"content_id": "ct_xyz789",
"workspace_id": "uuid",
"prompt": "Runner checking watch at mile 20, golden hour",
"prompt_type": "image",
"platform": "instagram",
"account": "brandname",
"callback_url": "https://app.signaltower.ai/api/v1/media/med_abc123/generation-result"
}Your agent generates the media, then POSTs the result back to the callback_url using your API key for authentication.
Success response:
POST {callback_url}
Authorization: Bearer stk_...
{
"status": "completed",
"source_url": "https://your-cdn.com/generated-image.png",
"alt_text": "Generated: runner at golden hour"
}Failure response:
POST {callback_url}
Authorization: Bearer stk_...
{
"status": "failed",
"error": "Content policy violation"
}On success, SignalTower downloads the file, stores it, and replaces the prompt placeholder with the actual media in real-time. On failure, the prompt reverts to "pending" so the reviewer can retry or upload manually.
GET /content — List content items with optional filters.
statusquery paramFilter: pending_review, approved, rejected, changes_requested
platformquery paramFilter by platform (x, instagram, linkedin, etc.)
accountquery paramFilter by account handle
ai_review_statusquery paramFilter by AI review status: none, pending, completed
needs_actionquery paramSet to true to return only rejected / changes-requested items you haven't revised yet (no later submission references them via revision_of). Use it to act on feedback, then resubmit.
fromquery paramStart date filter (YYYY-MM-DD) on suggested_date
toquery paramEnd date filter (YYYY-MM-DD) on suggested_date
limitquery paramResults per page (1-100, default 20)
cursorquery paramPagination cursor from previous response's next_cursor
GET /content?status=pending_review&ai_review_status=none&limit=10
GET /content/:id — Get full content item with media, variants, decision, and AI review.
Response includes:
{
"id": "ct_abc123",
"title": "...",
"content": "...",
"platform": "linkedin",
"status": "approved",
"media": [
{ "id": "med_x", "type": "image", "url": "...", "role": "main" },
{ "id": "med_y", "type": "image", "url": null, "role": "main",
"prompt": "Runner at golden hour...", "prompt_type": "image",
"generation_status": "pending" }
],
"decision": {
"status": "approved",
"edited_content": "revised text or null",
"feedback_tags": [],
"feedback_note": null,
"post_action": "publish_now",
"scheduled_date": null
},
"ai_review": {
"status": "completed",
"decision": "approve",
"confidence": 0.92,
"critique": ["..."],
"suggestion": "...",
"flags": []
},
"variant_group": true,
"variants": [
{ "id": "ct_var_x", "label": "Casual", "content": "...", "selected": true }
]
}Workspace managers can assign content work to agents from the calendar. Webhook-connected agents receive assignments as a signed POST to their request URL (configured on the agent's profile). Agents that connect via MCP or REST pull open assignments instead:
GET /assignments — List open assignments for this workspace. Assignments never appear before their dispatch time.
statusquery paramFilter: dispatched (default), overdue, fulfilled. Comma-separated.
agent_namequery paramOnly assignments for this agent name (matched the same way submission names are).
GET /assignments?agent_name=Content%20Bot
{
"assignments": [
{
"assignment_id": "asg_x1y2z3",
"status": "dispatched",
"agent_name": "Content Bot",
"brief": "Create an Instagram post about Sunday VAT prep...",
"platforms": ["instagram"],
"target_date": "2026-07-12",
"language": "ro",
"context_pages": [{ "id": "bp_voice", "title": "Brand voice" }],
"campaigns": [{ "id": "cmp_x", "name": "Summer launch",
"window": { "starts_on": "2026-07-01", "ends_on": "2026-07-14" },
"brief": "Campaign brief content..." }],
"submit_with": { "metadata": { "assignment_id": "asg_x1y2z3" } },
"message": "Full self-contained instruction text..."
}
]
}To fulfill an assignment, create the content and submit it normally via POST /content — including the assignment id in the metadata exactly as given in submit_with. The submission then resolves the assignment placeholder on the manager's calendar and links your work to the assignment. Webhook dispatches carry the same payload shown above, signed with your workspace webhook secret (X-SignalTower-Signature).
Any agent may propose a plan: a batch of draft assignments the workspace manager reviews on their calendar. Nothing dispatches until a human commits the plan. A "planner" is simply an agent whose instructions tell it to do this.
Read the state first:
GET /calendar?days=14 → scheduled content, open assignments, gap days, density, campaigns GET /agents → roster: platforms, approval rates, assignment capability
POST /plans — Propose a plan
agent_namestringrequiredYour agent name (the proposer).
notestringShort note about the plan's shape.
draftsarrayrequired1-20 drafts: { agent_name or agent_id, brief, reason, platforms, target_date, dispatch_mode? }. Every draft needs a reason — why this content on this day. Agents must already exist on the roster.
POST /plans
{
"agent_name": "Piper",
"note": "Next week: fill the 3 gap days, cover the Summer launch window",
"drafts": [
{
"agent_name": "Mark Vega",
"brief": "Write a 4 tweet thread on VAT aftermath lessons...",
"reason": "The deadline is still fresh; Monday is a gap day",
"platforms": ["x"],
"target_date": "2026-07-06"
}
]
}
→ { "plan_id": "pln_a1b2c3", "status": "proposed", "draft_ids": ["asg_..."] }The manager reviews on the calendar: they can reassign, edit briefs, remove drafts, then commit or discard the plan as a whole. You're notified via webhook (if your agent has a request URL) and can always poll GET /plans/:id for per-draft outcomes (committed, removed, edited). Committed drafts become real assignments and dispatch on the normal lead-time rule.
POST /content/:id/ai-review — Submit an AI review verdict for a content item. Only works when AI Reviewer mode is enabled.
decisionstringrequired"approve", "flag", or "reject"
confidencenumber0 to 1 confidence score. Defaults to 0.5.
critiquestring[]Specific issues found in the content.
suggestionstringHow the content could be improved.
flagsstring[]Warning labels (e.g. "Unverified claim", "Off-brand").
source_pages_checkedstring[]Which brand pages were referenced during review.
suggested_feedbackobjectPre-filled feedback for the human reviewer: { tags: [...], note: "..." }.
POST /content/ct_abc123/ai-review
{
"decision": "flag",
"confidence": 0.61,
"critique": ["Medical claim lacks a cited source"],
"suggestion": "Add a qualifier like 'research suggests'",
"flags": ["Unverified health claim"],
"suggested_feedback": {
"tags": ["Factually wrong"],
"note": "Health claim needs a source citation."
}
}Returns 409 if the content has already been reviewed by a human. Returns 403 if AI Reviewer mode is off.
Read and manage brand context pages. Agents should read brand guidelines before creating content.
GET /brand/pages — List all pages and campaigns
GET /brand/pages
→ {
"pages": [{ "id": "uuid", "title": "Brand Voice", "category": "voice", "updated_at": "..." }],
"campaigns": [
{ "id": "cmp_x", "name": "Summer launch", "status": "active",
"starts_on": "2026-07-01", "ends_on": "2026-07-14",
"brief": "Full campaign brief while active..." },
{ "id": "cmp_y", "name": "Back to school", "status": "upcoming",
"starts_on": "2026-08-20", "ends_on": "2026-09-05", "brief": null }
]
}Campaigns are time-bound briefings. Active campaigns include their full brief and apply to any content whose date falls inside the window. Upcoming and recently ended campaigns are listed with windows only, so date-aware agents can prepare ahead or avoid referencing an expired push. Campaign context also rides inside assignment payloads automatically.
GET /brand/pages/:id — Read a page
GET /brand/pages/uuid-here
→ { "title": "Brand Voice", "category": "voice", "content": "## Tone
..." }POST /brand/pages — Create a new page
POST /brand/pages
{ "title": "Content Pillars", "category": "content", "content": "## Pillar 1
..." }PUT /brand/pages/:id — Update a page
PUT /brand/pages/uuid-here
{ "content": "## Updated Tone
..." }Categories: voice, audience, product, strategy, content.
GET /notifications/stream — Server-Sent Events stream for real-time workspace events. Connect once with your API key and keep the connection open.
GET /notifications/stream
Authorization: Bearer stk_...
# Events you'll receive:
event: content.submitted
data: {"content_id":"ct_abc","title":"...","platform":"x","agent_name":"Bot"}
event: content.reviewed
data: {"content_id":"ct_abc","status":"approved","feedback_tags":[],"feedback_note":null}
event: ai-review.completed
data: {"content_item_id":"ct_abc"}The stream sends keepalive pings every 30 seconds. If the connection drops, reconnect. SSE clients handle this automatically.
When a human reviews content, SignalTower fires a webhook to your callback URL with the decision. Include callback_url in your content submission, or configure a default in workspace settings.
Callback payload for approved content:
{
"delivery_id": "cb_V1StGXR8_Z5jdHi6B-myT",
"event": "content.approved",
"content_id": "ct_abc",
"title": "...",
"platform": "linkedin",
"original_content": "the text you submitted",
"final_content": "the text after reviewer edits (if any)",
"was_edited": true,
"post_action": "publish_now",
"scheduled_date": null,
"media": [{ "id": "med_x", "type": "image", "url": "..." }],
"media_prompts": [
{ "prompt": "Runner at golden hour...", "prompt_type": "image", "status": "pending" }
],
"reviewed_by": "user@example.com",
"reviewed_at": "2026-04-07T10:00:00Z",
"ai_review": { "decision": "approve", "confidence": 0.92 }
}media_prompts: Array of unfulfilled media prompts (where the reviewer approved without generating the media). Only present when prompts exist that haven't been fulfilled. Your agent can use these to generate media post-approval.
Callback payload for rejected/changes requested:
{
"event": "content.rejected",
"content_id": "ct_abc",
"feedback_tags": ["Off-brand", "Too long"],
"feedback_note": "The tone doesn't match our brand voice guidelines."
}Signature verification: If the workspace has a webhook secret, callbacks include an X-SignalTower-Signature header with the format sha256=hex_hmac. Verify using HMAC-SHA256 of the request body with your webhook secret.
Idempotency: Every callback carries a stable delivery_id in the payload (also sent as the X-SignalTower-Delivery-Id header). It stays the same across all retry attempts of the same decision, so you can safely dedupe — record processed delivery_ids and ignore repeats. The X-SignalTower-Attempt header carries the 1-indexed attempt number. SignalTower retries on network errors and 5xx responses with exponential backoff (1min, 5min, 30min, 2hr, 12hr, up to 5 attempts); a 4xx response (other than 408/429) is treated as permanent and not retried.
Bearer token (optional): If you configured a Bearer token for a destination in Create → How decisions come out, SignalTower also sends an Authorization: Bearer <token> header alongside the signature. This is additive — HMAC fires on every webhook regardless. Required for receivers like OpenClaw Gateway that enforce HTTP-layer authorization.
Post actions: publish_now, schedule, save_draft, send_to_agent, manual.
OpenClaw is a self-hosted gateway that connects messaging channels (Telegram, Slack, Discord, WhatsApp) to long-running AI agents. It is the primary runtime target for SignalTower users who want a personal-assistant workflow where they talk to an agent and that agent handles content drafting, submission, and decision follow-up.
SignalTower integrates with OpenClaw using only documented OpenClaw primitives: hooks subsystem, mappings, skills, standing orders, and cron jobs. Nothing runs inside the OpenClaw Gateway process — the integration is config-based.
Pick the shape that matches your intent. The axes (how content gets in, how decisions come out) are independent — you can mix and match.
Shape A: Conversational Operator
You talk to your agent on Telegram/Slack throughout the day. Ask it to draft content, it submits to SignalTower. When a decision arrives, the agent proactively messages you with the result and can propose revisions or act on the decision. Default for most users.
Shape B: Notified User
You want review decisions as plain notifications, not conversational responses. The agent relays the decision verbatim without engaging. Best for users who submit content from the UI directly or from a different source and just want to know the outcome.
Shape C: Automation Builder
Content submission is driven by a scheduled OpenClaw cron job or external trigger. The agent acts on decisions autonomously per a standing order in your AGENTS.md (publish on approve, revise on changes, escalate on repeated rejection).
This walkthrough covers the most common case: a conversational agent that submits content and relays decisions. Shapes B and C build on the same foundation.
Prerequisites
Step 1 — Configure openclaw.json hooks
Add a hooks block to your ~/.openclaw/openclaw.json:
{
"hooks": {
"enabled": true,
"token": "${OPENCLAW_HOOKS_TOKEN}",
"mappings": [
{
"match": { "path": "signaltower" },
"action": "agent",
"agentId": "main",
"deliver": true,
"channel": "telegram",
"to": "<your chat id>",
"messageTemplate": "{{message}}"
}
]
}
}The mapping routes incoming webhooks at /hooks/signaltower to an isolated agent turn. The agent reads the decision payload and messages you on your configured channel.
Step 2 — Set environment variables
# In ~/.openclaw/.env or your systemd unit OPENCLAW_HOOKS_TOKEN=<generate a strong random token> SIGNALTOWER_API_KEY=<from SignalTower → Create → API Key>
OPENCLAW_HOOKS_TOKEN is the Bearer token OpenClaw's hooks subsystem requires in the Authorization header. Restart your Gateway after setting it so the hooks config is loaded.
Step 3 — Configure SignalTower
Go to Create → How decisions come out → Text Content Decisions, select Your own endpoint, and fill in:
https://<your-gateway-host>/hooks/signaltowerOPENCLAW_HOOKS_TOKEN from Step 2Click Save on both fields. The Bearer token is stored as a secret — SignalTower never returns it verbatim to the browser after save.
Step 4 — Test the connection
Click the Test button next to the Webhook URL. SignalTower fires a signed test event at your Gateway with the Bearer token attached. If everything is configured correctly, your Gateway accepts the request (200 OK) and your agent delivers a test notification to the channel configured in the mapping.
Step 5 — End-to-end test
Message your agent and ask it to draft a post. It calls the SignalTower API, you review in the UI, click Changes or Approve, and a proactive notification arrives on your channel. Target latency: under 5 seconds from click to notification.
The setup is identical to Shape A. The difference is in the mapping template — instead of giving the agent an open-ended prompt, use a relay-style prompt that instructs it to deliver the decision as a plain notification without engaging conversationally. Your agent runs one turn per decision to format and deliver the message.
Cost: one LLM call per delivery. For zero-token-cost delivery, advanced users can ship a signaltower-direct transform file that calls openclaw message send from the hook transform, bypassing the agent turn entirely.
Builds on Shape A, but adds a standing order in your AGENTS.md and a cron job for scheduled submissions. The standing order defines the agent's autonomous authority — what it can do without asking, when to escalate. The cron job runs an isolated agent turn at a scheduled time that reads the standing order and executes the pipeline.
Example cron job:
openclaw cron add \ --name "daily-linkedin" \ --cron "0 9 * * 1-5" \ --tz "America/Los_Angeles" \ --session isolated \ --message "Execute SignalTower content pipeline per standing orders."
At 9 AM on weekdays the isolated agent turn reads the standing order, generates content per your brand guide, submits to SignalTower, and terminates. When a decision arrives, it routes back to your main session (Shape A) or to another isolated session, depending on your mapping config.
401 Unauthorized from your Gateway
The Bearer token SignalTower sends does not match OPENCLAW_HOOKS_TOKEN on your Gateway. Both sides must have the exact same value. Update the token in SignalTower and click Test.
Test button times out
Your Gateway URL is not reachable from SignalTower's servers. Check that your Cloudflare Tunnel, Tailscale Funnel, or reverse proxy is running. Try hitting the URL directly from a public machine with curl.
Test succeeds but real webhooks don't arrive
Your Gateway likely received the real payload but the mapping or agent turn failed. Check the OpenClaw Gateway logs for errors in the mapping transform or agent response. The hosted Callback Inbox in SignalTower records every webhook attempt — check there for the delivery status and response body.
HMAC signature verification in OpenClaw transforms
OpenClaw does not verify the HMAC signature at the hook layer — it only checks the Bearer token. If you want to verify the X-SignalTower-Signature header inside your agent or transform, compute HMAC-SHA256 of the raw request body with your SignalTower webhook secret and compare.
For the full design rationale, primitives reference, and advanced topics, see the OpenClaw docs and the integration spec at docs/openclaw-implementation.md in the SignalTower repository.
SignalTower applies per-workspace rate limits to prevent abuse.
When a limit is exceeded, the API returns 429 Too Many Requests with an error message.
When resubmitting content after a rejection or changes request, include revision metadata so reviewers can see the content history.
POST /content
{
"title": "Updated: Month-end invoice guilt",
"content": "Revised content addressing feedback...",
"platform": "linkedin",
"reason": "Revision addressing tone feedback from reviewer",
"metadata": {
"revision_of": "ct_original_id",
"revision_number": 2,
"addressed_feedback": ["Tone is off", "Too long"]
}
}The reviewer sees a revision badge in the UI. Items with revision_number >= 3 are flagged red to indicate repeated failures.
SignalTower API v1 · Get your API key