# RankRight API — developer & agent guide

Base URL: `https://app.rankright.dev` — API surface: `/api/v1`

## Auth
```
Authorization: Bearer rrk_<token>
```
Org-scoped keys are confined to their org and metered; partner/platform keys are cross-org service accounts. Keys minted without the "write" scope are read-only — write RPCs and job dispatch return 403. Mint keys at /platform/api-keys or via an owner in the UI. Third-party apps and MCP clients should use OAuth 2.0 instead of keys (see the top-level "oauth" block): Authorization: Bearer rro_… works everywhere a key does.

## Conventions
- **errors**: Non-2xx responses are JSON: {"error", "code", "detail", "hint"} — "code" is a stable machine string, "hint" says what an agent should try next.
- **results**: Successful RPC calls return 200 with {"result": <method-specific value>}.
- **idempotency**: Send an Idempotency-Key header on POST /api/v1/rpc/* or /api/v1/jobs to make retries safe; replays return the stored response with Idempotency-Replayed: true. Same key + different body is a 409 (code idempotency_key_reused: mint a fresh key). Racing the still-running original is a 409 (code idempotency_in_flight: retry the SAME key after Retry-After to get the stored result).
- **rate_limits**: Per-key limits; every /api/v1 response carries X-RateLimit-Limit / -Remaining / -Reset. A 429 includes Retry-After (seconds).
- **write_gating**: When the operator has writes disabled, write RPCs and job creation return 503 (code writes_disabled); reads keep working. Check the top-level "writes_enabled" field here.
- **jobs**: POST /api/v1/jobs is one-at-a-time per client/group: a 409 (code job_already_running) means poll the running job, then retry. Cancelling a finished job is a 409 (code job_already_terminal).
- **pagination**: RPC entries with "paginated": true accept limit/offset params; all others return the full set in one response.
- **schemas**: Where an RPC entry carries "returns", it is a best-effort JSON schema of the value inside {"result": ...}, derived from the method's type annotations.
- **webhooks**: Instead of polling, register_webhook(url, events) and receive signed POSTs (X-RankRight-Signature = t=<unix>,v1=<HMAC-SHA256 of "<t>.<body>">) for job.completed / job.failed / aeo.capture.ingested / aeo.report.saved / aeo.item.status_changed / action_item.status_changed. Retries with backoff for ~2 h; list_webhook_deliveries shows every attempt. See the top-level "webhooks" block.

## Jobs
- `POST /api/v1/jobs` — Queue a background job. Body: {"kind", "client_id" | "group_id", "payload"?}. Kinds: strategist, bulk_strategist, execute_tier_a, keyword_generation, blog_pipeline, fulfill_selected, autopilot, content_edit, ad_copy, prompted_edit, export_org. prompted_edit takes payload {"instruction": "<plain-English edit>", "pages"?, "dry_run"?, "refresh"?} and stages proposed site_edits (operation elementor_rewrite) to approve with apply_site_edit and undo with rollback_site_edit. export_org needs no client: it zips the whole organization (JSON + CSV + reports) — download from result.download_url. Returns 202 {job_id, status_url}.
- `GET /api/v1/exports/<org_id>/<filename>` — Download an organization export produced by the export_org job (kept 7 days).
- `GET /api/v1/jobs/<id>` — Job status + progress + result when done.
- `POST /api/v1/jobs/<id>/cancel` — Cancel a queued or running job.

## Meta
- `GET /api/v1/capabilities` — This document (auth required).
- `GET /api/v1/openapi.json` — OpenAPI 3.1 spec (public).
- `GET /api/v1/health` — Liveness (public).
- `GET /llms.txt` — Agent orientation doc (public, plain text).
- `GET /developers.md` — Full API guide as markdown (public).
- `GET /changelog.md` — API changelog (public): additive changes as they ship, deprecations with sunset dates.
- `GET /sdk/rankright-python.zip` — Python SDK (pip-installable archive; also on PyPI as `rankright`).
- `GET /.well-known/security.txt` — Vulnerability-disclosure contact (RFC 9116); security posture at https://rankright.dev/security.html (public).
- `POST /api/v1/mcp` — MCP server (Streamable HTTP, stateless JSON-RPC 2.0; same bearer key). tools = every org-scoped RPC + list_rpcs / describe_rpc / call_rpc for the rest; resources = developers.md, llms.txt, openapi.json; prompts = the workflows.

## Workflows

Call sequences an agent runs end to end (method names link to the tables below).

### AI Visibility (AEO) for a client

Find out whether AI answer engines (ChatGPT, Claude, OpenAI, Gemini) name and cite a client for buyer questions, track what to fix, and report monthly. All calls are org-scoped by client_id.

1. add_client (or use an existing client_id) with website_url, services and locations filled in
2. generate_aeo_questions(client_id) — builds the buyer-style question set from the client's services/locations (get_aeo_queries to review; update_aeo_query to activate/deactivate)
3. set_aeo_cadence(client_id, samples_per_month=1, engines=['claude', 'chatgpt-web']) — enrols the client; the daily sweep queues runs, or call enqueue_due_aeo_runs(client_id=...) now
4. wait: server-API engines (claude/openai/gemini) answer within minutes; chatgpt-web runs when an operator device is online — get_aeo_run_stats(client_id) shows progress
5. get_aeo_summary(client_id) — presence / named-first per service, open tasks, queue, cadence
6. get_aeo_items(client_id, status="open") — the action list: tier A/B = website work, C = Google Business / directory / review work, X = market-driven (tracked, not promised)
7. update_aeo_item_status(item_id, "done", client_id=...) as work completes
8. aeo_report_html(client_id, month) — white-label monthly report (update_organization_branding sets the name/logo/accent it carries)

### Export your organization

Data portability in one call: every table the organization owns as JSON + CSV, plus filed report files, in a zip with a manifest (secrets excluded).

1. POST /api/v1/jobs {"kind": "export_org"} (owner key; a service account adds "org_id") → 202 {job_id, status_url}
2. poll GET /api/v1/jobs/<id> until status is done — result.download_url and result.counts are there (or subscribe to the job.completed webhook)
3. GET /api/v1/exports/<org_id>/<filename> with the same key → the zip (kept 7 days; list_org_exports shows what exists)

### Plain-English site edit

Stage an edit to a client site from an instruction, then approve or undo it.

1. POST /api/v1/jobs {"kind": "prompted_edit", "client_id": N, "payload": {"instruction": "..."}}
2. poll GET /api/v1/jobs/<id> until done; result.pages[].edit_id lists the staged edits
3. apply_site_edit(edit_id) to publish, rollback_site_edit(edit_id) to undo

## Versioning & deprecation

Current: **API v1** at `/api/v1` (X-RankRight-API-Version on every /api response). Changelog: `https://app.rankright.dev/changelog.md`.

- **Additive changes:** New RPC methods, optional parameters, response fields, MCP tools, webhook events and job kinds may appear at any time and are listed in the changelog; clients must ignore fields they do not know.
- **Breaking changes:** Only in a new major version under a new path (/api/v2); the previous major stays available for at least 12 months after the new one ships.
- **Retiring a method:** A documented method, tool or event is retired only after 60 days' notice: organization owners are emailed, the api.deprecation webhook event fires, capabilities/openapi mark it deprecated, and every call answers with Deprecation, Sunset and Link headers until the sunset date. Security fixes may shorten this.
- **Experimental:** Anything labelled experimental in its summary may change without notice.
- **SDKs:** SDKs follow semver; a major SDK bump never requires an API version change.
- **Currently deprecated:** nothing.

## SDKs

- **Python:** `pip install rankright` (or `pip install https://app.rankright.dev/sdk/rankright-python.zip`), source `https://app.rankright.dev/sdk/rankright.py`, MIT. any RPC as a method, jobs (dispatch + wait), exports, OAuth login for CLIs/agents (PKCE, loopback), webhook signature verification, 429 retries, idempotency keys, error envelope as an exception.
- **TypeScript:** planned; the OpenAPI spec generates a client today: npx openapi-typescript https://app.rankright.dev/api/v1/openapi.json
- **Other languages:** generate from https://app.rankright.dev/api/v1/openapi.json (bearerAuth or oauth2 security schemes).

## OAuth 2.0 (for apps and MCP clients)

OAuth 2.0 authorization code + PKCE (S256) only; refresh tokens rotate; no implicit, password or client-credentials grants.

- **Discovery:** `https://app.rankright.dev/.well-known/oauth-authorization-server`, `https://app.rankright.dev/.well-known/oauth-protected-resource/api/v1/mcp`
- **Registration:** https://app.rankright.dev/oauth/register — RFC 7591 dynamic registration (public clients, https or loopback redirect URIs); MCP clients do this automatically.
- **Endpoints:** authorize `https://app.rankright.dev/oauth/authorize`, token `https://app.rankright.dev/oauth/token`, revoke `https://app.rankright.dev/oauth/revoke`
- **Scopes:** `read` — Read clients, keywords, rankings, action items, AI Visibility data and reports; `write` — Change data: add clients and questions, update items, enrol AI Visibility, register webhooks; `jobs` — Run background jobs (audits, strategist, content, exports); `offline_access` — Stay connected (issue a refresh token so you are not asked again every hour)
- **Lifetimes:** access token 60 min (opaque rro_…), refresh token 30 days sliding (rrr_…, rotated on use; reuse revokes the grant), code 10 min single-use.
- **Who can authorize:** Owners, SEO editors and web builders — each capped by their own role; viewers cannot authorize applications.
- **Consent:** Shown inside the normal RankRight login (password, Google, or company SSO); carries the organization's brand name.
- **Using a token:** Send the access token as Authorization: Bearer rro_… to /api/v1/* or the MCP server — same scoping, limits and audit as rrk_ keys. 401 responses carry WWW-Authenticate with the resource metadata URL; 403 with insufficient_scope names the scope to re-consent for.
- **Managing grants:** Account → Connected apps lists grants (app, member, scopes, last used) with revoke; owners see the whole organization.
- **MCP quick start:** Add https://app.rankright.dev/api/v1/mcp as a custom connector in Claude (or `claude mcp add --transport http rankright https://app.rankright.dev/api/v1/mcp`) — no key needed; the client discovers OAuth and opens the consent page.

## Sandbox (try it without an account)

A public, read-only, rate-limited key over fictional data (Harbor Light Home Services, `client_id` 108, `org_id` 14). Shared, read-only, rate-limited. Every read RPC, the MCP server and the docs work with it; writes and jobs answer 403 with a hint — except export_org: POST /api/v1/jobs {"kind": "export_org"} runs (one per 10 minutes) and GET result.download_url returns the zip, so the export can be evaluated end to end. Data is fictional and periodically reset.

```
Authorization: Bearer rrk_u154NZ009Kk_dbtPPuXeCpYbEZQgX6gziL-i7uZIN8k
```

```bash
curl -X POST https://app.rankright.dev/api/v1/rpc/get_aeo_summary -H 'Authorization: Bearer rrk_u154NZ009Kk_dbtPPuXeCpYbEZQgX6gziL-i7uZIN8k' -H 'Content-Type: application/json' -d '{"client_id": 108}'
```

MCP: `claude mcp add --transport http rankright https://app.rankright.dev/api/v1/mcp --header "Authorization: Bearer rrk_u154NZ009Kk_dbtPPuXeCpYbEZQgX6gziL-i7uZIN8k"`

## Single sign-on

Protocol: OpenID Connect 1.0 — authorization code + PKCE (S256), scopes "openid email profile".

| Provider | Issuer | Start sign-in |
|---|---|---|
| Google | `https://accounts.google.com` | `GET https://app.rankright.dev/auth/oidc/google` |

Redirect URI to register with your IdP: `https://app.rankright.dev/auth/oidc/<provider>/callback`

- **Verification:** id_token signature via the provider JWKS; issuer, audience, expiry and nonce checked; state is single-use with a 10-minute TTL; only verified emails are accepted.
- **Account matching:** The verified email is matched to an existing RankRight user (any org). An SSO sign-in stands in for the TOTP second factor.
- **Provisioning:** Unknown emails are provisioned as owner of a new organization only when the provider has allow_signup, the email domain is in its allowed_domains, or public signup is open; otherwise the user is told to ask an owner for an invite.
- **Bring your own IdP:** Self-serve: an organization owner adds any OIDC issuer (Google Workspace, Microsoft Entra, Okta, OneLogin, JumpCloud, Vendasta) under Account → Single sign-on — issuer URL, client id/secret, allowed email domains, default role for new staff. Register the redirect_uri above with the IdP. Staff then sign in at /login/sso with their work email; unknown staff on an allowed domain are provisioned into that organization. RPCs: list/add/update/delete_org_sso_provider.
- **Company sign-in page:** `https://app.rankright.dev/login/sso`
- **SAML:** SAML 2.0 supported, self-serve per organization (Account → Single sign-on → SAML): SP-initiated, HTTP-Redirect AuthnRequest, HTTP-POST Response; signed Responses or Assertions verified against the IdP certificate; Conditions, Audience, Recipient and InResponseTo checked; encrypted assertions not supported. Give the IdP the SP metadata URL https://app.rankright.dev/saml/<provider key>/metadata (ACS https://app.rankright.dev/saml/<provider key>/acs). Staff sign in at https://app.rankright.dev/login/sso.
- **API access:** SSO governs interactive sign-in only; API and MCP access always use rrk_ bearer keys.

## Webhooks

Register: `register_webhook(url, events?, description?) → {id, url, events, secret} (secret shown once)`. Manage: list_webhooks, update_webhook, rotate_webhook_secret, delete_webhook, test_webhook (sends ping), list_webhook_deliveries, redeliver_webhook.

| Event | When |
|---|---|
| `job.completed` | A background job finished successfully (kind, client_id, result summary, status_url). |
| `job.failed` | A background job failed or was failed by the watchdog (error). |
| `job.cancelled` | A background job was cancelled. |
| `aeo.capture.ingested` | An AI-engine answer was captured and analyzed for a client (engine, month, scorecard, item counts). |
| `aeo.report.saved` | A client's monthly AI Visibility report was filed (month, files). |
| `aeo.item.status_changed` | A Non-SEO tracker item's status changed (item_key, status, note). |
| `action_item.status_changed` | An SEO action item's status changed (action_type, page_url, status). |
| `api.deprecation` | A documented API method, MCP tool or event is scheduled for removal (60 days ahead): method, sunset, replacement. |
| `ping` | Test event sent by test_webhook / the Test button. |

Request: POST application/json, headers `X-RankRight-Event`, `X-RankRight-Delivery`, `X-RankRight-Timestamp`, `X-RankRight-Signature`, body `{"id", "event", "created_at", "org_id", "client_id", "data": {...}}`.

Signature: X-RankRight-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>; reject when |now - t| > 300 s. Respond 2xx within 10 s; anything else is retried.

Retries: 8 attempts with exponential backoff (1, 2, 4 … 64 min); a hook is disabled after 100 consecutive failures.

URL policy: https only (http for localhost); hosts resolving to private/loopback ranges are refused.

Verify (Python):
```python
import hmac, hashlib, time
def verify(secret, header, body, tolerance=300):
    parts = dict(p.split('=', 1) for p in header.split(','))
    t, v1 = int(parts['t']), parts['v1']
    expected = hmac.new(secret.encode(), f'{t}.{body}'.encode(), hashlib.sha256).hexdigest()
    return abs(time.time() - t) <= tolerance and hmac.compare_digest(expected, v1)
```

## Read RPCs (222)

| Method | Domain | Org-scoped | Params | Summary |
|---|---|---|---|---|
| `aeo_report_html` | ai-visibility | yes | client_id, month? | White-label AI Visibility report for a month as HTML: {ok, html, month, trend_ready, months}; {ok: False, error} when the month has no captures. PDF comes from the web route /clients/<id>/non-seo/report.pdf. |
| `count_active_clients` | clients |  | org_id | Billable-seat count = the Stripe subscription quantity (status='active' only). Paused is DELIBERATELY excluded here: a paused client is non-renewing, so it must drop out of the NEXT renewal's quantity. It is still "billed this cycle" because the current cycle's invoice was already issued while it was active and pausing carries proration_behavior='none' (no refund) — so reducing the quantity the mo |
| `count_aeo_runs_done_today` | ai-visibility |  | agent_id | Runs this agent completed in the last 24h (server truth, so a restarted agent doesn't forget its daily cap). |
| `count_batch_requests` | billing |  | batch_job_id | Count requests by status for a batch job. |
| `count_images_out_of_sync` | images |  |  | Rows where our featured_image_url differs from the last confirmed WP-side URL. Only counts rows where we HAVE an image locally. |
| `count_pending_site_edits` | site-edits |  | client_id |  |
| `count_recent_meta_optimizations` | action-items |  | client_id, page_id, days? | How many applied meta_optimizations for (client, page) in the last `days` days. Used by the operator UI to flag whether a meta change is already in flight before pushing another. |
| `count_recent_published_blogs` | content |  | client_id, days? | How many blog topics for this client were published in the last `days` days. Used by the operator UI for activity awareness. |
| `count_scheduled_blog_topics_by_client` | content |  | org_id, month | {client_id: count} of blog topics scheduled to publish in `month` (YYYY-MM), per client in the org. Powers the /clients list 'Blogs' column (scheduled / monthly target). One GROUP BY, not per-client. |
| `count_site_changes_by_month` | clients |  | client_id, months? | Per-month rollup of logged site changes for a client, newest month first: [{'month': 'YYYY-MM', 'changes': rows, 'pages': distinct pages}]. detected_at is the DB's CURRENT_TIMESTAMP (UTC). Drives the Competitor Monitor updates-per-month line charts. |
| `count_site_edit_attempts_last_hour` | site-edits |  |  | Apply attempts across ALL clients in the trailing hour — the denominator for the executor's blast-radius cap. |
| `count_unpushed_articles` | site-edits |  |  | Written blog articles that haven't been exported to WP yet. |
| `count_unpushed_content_for_client` | site-edits |  | client_id | Written blog articles not yet exported for a specific client. |
| `count_unresolved_app_errors` | monitoring |  |  |  |
| `count_unresolved_push_failures` | site-edits |  | client_id?, categories? | Count push_failures with resolved_at IS NULL. Optionally filter by client_id and/or a set of category labels (e.g. ['captcha','network', 'timeout']). Both filters are AND'd. |
| `count_unresolved_push_failures_for_org` | site-edits |  | org_id | Org-scoped unresolved count — powers the nav badge. |
| `count_unscheduled_articles` | content |  |  | Written blog articles that don't have a scheduled_publish_date. |
| `find_action_item_id` | action-items |  | client_id, month, action_type, page_url?, description_contains? | Look up a single action_item id by (client_id, action_type) with optional month + page_url + description-substring filters. Returns the first matching id or None. Used by schema_emitter to detect whether a schema_add / schema_data_backfill / seo_plugin_upgrade item already exists before creating a new one. |
| `get_action_item` | action-items |  | item_id | Get a single action item by ID. |
| `get_action_items` | action-items |  | client_id, month?, status?, action_type?, include_null_month?, completed_between? | Get action items, optionally filtered by month / status / action_type. |
| `get_active_cooldowns` | monitoring |  |  |  |
| `get_active_pages_with_intent` | clients |  | client_id | Active pages for a client with their cached intent classification. Returns id, url, intent, intent_confidence per row. Used by schema_emitter to decide which schemas to generate per page. |
| `get_ad_copy_set` | content |  | set_id, client_id | Wire contract v1 read: ONE shaped set dict, or None when the set does not exist OR is not owned by client_id (same answer either way — never confirm another client's set exists). |
| `get_aeo_agents` | ai-visibility |  |  | Registered capture agents (operator devices + the server-API agent) with online flag, capabilities {engines, geo, max_per_day}, current run and run counts. |
| `get_aeo_cadence` | ai-visibility | yes | client_id | The client's capture cadence {enabled, samples_per_month, engines, geo_state}; {} when never enrolled. |
| `get_aeo_capture` | ai-visibility |  | capture_id | One capture in full: the raw engine record (record_json, '' once pruned) and its analysis (report_json: scorecard, source_table, claim_map, entity_table, findings). |
| `get_aeo_capture_records` | ai-visibility |  | client_id | Every capture of a client WITH its raw record, oldest first — the feed for `aeo_tracker.reanalyze_client`. |
| `get_aeo_captures` | ai-visibility | yes | client_id, month?, query_id?, limit? | Captures for a client, newest first. Includes report_json (the monthly report reads scorecards out of it); record_json is omitted. |
| `get_aeo_item` | ai-visibility |  | item_id | One tracker item by id (evidence, query_ids and per-month hits decoded). |
| `get_aeo_item_by_key` | ai-visibility |  | client_id, item_key | One tracker item by its stable item_key — the finding id, e.g. 'G3-RETRIEVED- DIRECTORY::clutch.co'. |
| `get_aeo_items` | ai-visibility | yes | client_id, status? | All tracked items for a client (or one status), tier A→X then title. |
| `get_aeo_months` | ai-visibility | yes | client_id | Distinct capture months for a client, ascending. |
| `get_aeo_queries` | ai-visibility | yes | client_id | Recurring queries for a client with capture_count + last_month, ordered for a run: active first, then priority, then age. |
| `get_aeo_query` | ai-visibility |  | query_id | One recurring question row (prompt, engine, service, kind, priority, active). |
| `get_aeo_queue_overview` | ai-visibility |  | org_id? | Everything the queue monitor needs in one call: status counts, per-client backlog with oldest-queued age, queued runs per market state, throughput (done last 24h / 7d, per agent), recent failures, and the agent registry. |
| `get_aeo_run_stats` | ai-visibility | yes | client_id?, month? | Queue counts for a client (or all clients) and month: queued / claimed / running / done / failed / cancelled / total. |
| `get_aeo_runs` | ai-visibility |  | client_id, month?, limit? | Queue rows for a client, newest first: engine, status, agent_id, attempts, error, capture_id. |
| `get_aeo_summary` | ai-visibility | yes | client_id, month? | One-call status card for a client: active question count, captures this month, per- service presence / named-first rates, open task count, queue counts and cadence. Start here before drilling into get_aeo_items / get_aeo_captures. |
| `get_agency_name_for_client` | clients |  | client_id | White-label 'Generated by …' name for a client's deliverables. |
| `get_agent_status` | jobs |  | within_seconds? | Laptop cron-agent liveness. `online` is True if any agent has pinged within `within_seconds`. Returns the freshest age + the list of recently-active agents (supports a 'string of laptops'). |
| `get_all_associations_for_client` | clients |  | client_id | Get all keyword-page associations for a client |
| `get_all_client_groups` | clients |  | active_only?, org_id? | Get all client groups. org_id, when given, restricts to that organization — the web layer always passes the caller's org_id. |
| `get_all_client_settings` | clients |  | client_id | Get all settings for a client as a dict. |
| `get_all_client_slugs` | clients |  |  | All (id, slug) pairs with a non-empty slug — feeds the routing cache. |
| `get_all_clients` | clients | yes | active_only?, include_deleted?, include_paused?, include_staging?, org_id? | Get all clients. Deleted clients are excluded unless include_deleted=True. |
| `get_all_content_for_client` | content |  | client_id | Get all written content for a client (exported and unexported). |
| `get_all_costs_summary` | billing |  | days? | Get cost summary grouped by client across all operations. |
| `get_app_errors` | monitoring |  | limit?, unresolved_only? |  |
| `get_associations_for_keyword` | search-console |  | keyword_id | Get all page associations for a keyword |
| `get_associations_for_page` | clients |  | page_id | Get all keyword associations for a page |
| `get_attention_flags_for_org` | org-admin |  | org_id | {client_id: flag_text} for clients in the org with a non-empty `attention_flag` setting. Powers the ⚑ shown next to client names for clients that need special handling (non-WP / proprietary CMS, etc.). One query so list views don't round-trip per client. |
| `get_available_rank_dates` | search-console |  | client_id | Get list of distinct dates when rank checks were done for a client |
| `get_available_snapshot_dates` | monitoring |  | client_id | Get list of distinct dates when snapshots were taken for a client |
| `get_batch_job` | jobs |  | job_id | Get a batch job by ID. |
| `get_batch_jobs` | jobs |  | status?, client_id?, limit? | Get batch jobs, optionally filtered by status and/or client. |
| `get_batch_request_by_custom_id` | billing |  | custom_id | Get a batch request by its custom_id. |
| `get_batch_requests` | billing |  | batch_job_id, status? | Get all requests for a batch job. |
| `get_blog_calendar_month` | content |  | org_id, month | All blog posts scheduled in `month` (YYYY-MM) across the org — date, client, title, delivery state. Powers the /blog-calendar grid. |
| `get_blog_content` | content |  | content_id | Fetch a blog_content row by its primary key id. |
| `get_blog_content_for_topic` | content |  | topic_id |  |
| `get_blog_content_summary_for_client` | content |  | client_id | Lightweight title + scheduled_publish_date rollup of all blog content for a client. Used for: - title-based slug matching when deciding if a page is a blog post (seo_strategist.is_blog_post_url) - title → date mapping for Article schema's datePublished (schema_emitter) Joins blog_topics so the client_id filter works (blog_content carries topic_id, not client_id directly). |
| `get_blog_filter_options_for_org` | content |  | org_id | Distinct schedule months + clients that have blog content — the /blogs filter dropdowns. |
| `get_blog_images_for_org` | images |  | org_id, month?, client_id?, client_status?, limit?, offset? | Org-wide generated-image gallery: blog posts that HAVE a featured image, newest-first (by created_at), paginated. Returns (rows, total). Filters: month (by scheduled date), client, client_status ('active' / 'inactive' / '' = all). Powers /blogs/images. |
| `get_blog_posts_for_client` | content |  | client_id |  |
| `get_blog_posts_for_org` | content |  | org_id, month?, client_id?, status?, q?, limit?, client_status? | Org-wide blog browser: every generated article with its schedule + delivery state, newest first (unscheduled drafts at the end). Filters: month (YYYY-MM), client, status ('published' / 'scheduled' / 'unscheduled'), title substring, client_status ('active' = active clients only / 'inactive' = paused+inactive / '' or 'all' = no client-status filter). Powers /blogs. |
| `get_blog_schedule` | content |  | client_id |  |
| `get_blog_topic` | content |  | topic_id |  |
| `get_blog_topics_for_client` | content |  | client_id, status? |  |
| `get_cached_description` | content |  | image_url | Look up a cached image description by URL. Returns None on miss. |
| `get_client` | clients | yes | client_id | Get a client by ID |
| `get_client_activity` | clients |  | client_id, limit? | Chronological feed of everything the system did to/for a client: entity changes (pushes, meta/image/date mutations), background jobs, and push failures — merged, newest first. Powers /clients/<id>/activity. |
| `get_client_change_history` | clients |  | client_id, limit?, entity_type? |  |
| `get_client_cooldown` | monitoring |  | client_id, reason? | Return the soonest-expiring active cooldown for this client, or None. |
| `get_client_costs` | billing |  | client_id, days? | Get cost summary grouped by operation for a client. |
| `get_client_deactivated_at` | clients |  | client_id |  |
| `get_client_ftp` | clients |  | client_id | Get FTP credentials — reads from client_settings first, falls back to clients columns. |
| `get_client_group` | clients |  | group_id | Get a single client group by ID |
| `get_client_id_by_slug` | clients |  | slug | Resolve a URL slug to a client id (global lookup; ownership is enforced downstream by _load_owned_client). None if unknown. |
| `get_client_org_id` | org-admin |  | client_id | Return the org_id that owns a client, or None if the client does not exist. Used by the RPC layer to validate that an org-scoped caller is only touching clients in their own org. |
| `get_client_setting` | clients |  | client_id, key | Get a per-client setting value. |
| `get_client_slug` | clients |  | client_id | The client's URL slug, or '' if not set. |
| `get_client_ssh_pubkey` | clients |  | client_id | The per-client SSH public key a self-hosted client should authorize (generates the keypair on first request). Exposed as a read RPC so Operations Manager can embed the right key in that client's connect kit — one key per client, so a leak opens one site, not all of them. |
| `get_client_webhook` | clients |  | client_id | Get webhook credentials — reads from client_settings first, falls back to clients columns. |
| `get_client_wp_post_ids` | site-edits |  | client_id | {slug: wp_post_id} for a client — captured from plugin push responses (and /export backfill). Powers exact wp-admin edit deep-links. |
| `get_clients_in_group` | clients |  | group_id | Get all clients belonging to a group |
| `get_compare_report` | reports |  | report_id | Full comparison report (incl. HTML + org_id for the ownership check). |
| `get_compare_reports_for_org` | reports |  | org_id, limit? | Recent A/B comparison reports for the org (no HTML — list view). Joins client names for display. |
| `get_consecutive_rollover_months` | billing |  | client_id | Count how many consecutive *past* months had unused hours rolling over. Skips the current month (which may be in progress). Used to detect on-page SEO exhaustion signal. |
| `get_content_page` | site-edits |  | page_id |  |
| `get_cost_detail` | billing |  | client_id?, operation?, days?, limit? | Get individual API call records with optional filters. |
| `get_cron_trigger_result` | jobs |  | trigger_id | Return {status, rendered_at, html, meta} for a trigger, or None. Pollers (content_extractor.fetch_via_laptop) wait until rendered_at is set, then read the relayed real-browser HTML. |
| `get_deleted_clients` | clients |  |  | Get all deleted clients. |
| `get_elementor_page` | site-edits |  | project_id, wp_post_id | Get a single exported Elementor page. |
| `get_elementor_page_stamps` | site-edits |  | project_id | Lightweight listing for the freshness check + UI page pickers: [{wp_post_id, title, slug, url, post_type, modified_gmt, updated_at}] — no elementor_data blob, so it stays cheap on sites with hundreds of pages. |
| `get_elementor_pages` | site-edits |  | project_id | Get all exported Elementor pages for a project. |
| `get_entity_changes` | monitoring |  | client_id, month, actions? | entity_change_log rows for a client + month-string, optionally filtered to a set of action labels (e.g. ['set', 'pushed']). `month` matches against strftime('%Y-%m', created_at). |
| `get_entity_changes_between` | monitoring |  | client_id, start, end, actions? | entity_change_log rows for a client whose `created_at` day falls in the inclusive [start, end] window ('YYYY-MM-DD' dates), optionally filtered to a set of action labels. The date-range analogue of `get_entity_changes`, used by the arbitrary-period client report. |
| `get_entity_history` | monitoring |  | entity_type, entity_id, limit? |  |
| `get_first_snapshot_dates` | monitoring |  | client_id | {page_id: 'YYYY-MM-DD HH:MM:SS'} of each page's EARLIEST snapshot — i.e. when we first saw the page. Snapshots are append-only, so this is how 'new page since <date>' is answered (pages has no created_at). |
| `get_ftp_capable_client_ids` | clients |  | org_id | Client ids with FTP-inbox delivery configured (client_settings.ftp_credentials — mirrors InboxPusher.is_configured). Used with webhook_url to decide whether the browser can auto-deliver blogs for a client (PushRouter handles either transport). |
| `get_generated_content_for_page` | content |  | sitemap_page_id |  |
| `get_generated_content_for_project` | content |  | project_id |  |
| `get_groups_for_client` | clients |  | client_id | Get all groups a client belongs to |
| `get_gsc_capture_keywords` | search-console |  | capture_id | Return {keyword: {position, impressions, clicks, ctr}} for a capture. |
| `get_gsc_snapshot` | search-console |  | client_id, period_start, period_end, level | Retrieve GSC snapshot rows for a given period/level. |
| `get_hour_budgets` | billing |  | client_id, limit? | Get recent monthly budgets, ordered by month descending. |
| `get_image_catalog` | images |  | project_id, status? | Get all image catalog entries for a project, optionally filtered by status. |
| `get_image_catalog_for_page` | images |  | project_id, wp_post_id | Get image catalog entries for a specific page. |
| `get_image_catalog_stats` | images |  | project_id | Get counts of images by replacement status. |
| `get_image_history` | images |  | content_id | Full change log for one blog content row. |
| `get_image_stats` | images |  | client_id | Return image counts by status for a client. |
| `get_image_sync_states_for_client` | images |  | client_id | Aggregate counts of blog_content image-sync states for a client. Returns {no_image, synced, never_pushed, stale}. Mirrors the inline SQL formerly in main.py:321 (client image stats panel). |
| `get_image_type_stats` | images |  | client_id | Return image counts by type for a client. |
| `get_images_for_client` | images |  | client_id, status?, image_type? | Get images for a client, optionally filtered by status and/or image_type. image_type can be a single string or a list of strings for multi-type filtering. |
| `get_images_out_of_sync` | images |  |  | List of out-of-sync rows with client name for the review screen. |
| `get_jobs_progress_for_org` | reports |  | org_id, recent_minutes? | Active (queued/running) jobs + those that FINISHED in the last `recent_minutes`, with live progress + client name. Powers the real-time fleet-progress panel on the dashboard / clients list — the recently-finished rows let the panel show 'X done of N' as a batch drains, since finished jobs otherwise vanish from the active set. Running first, then queued, then finished (newest). |
| `get_keyword` | search-console |  | keyword_id | Fetch one keyword by id, or None. Caller is responsible for verifying the keyword's client_id matches the caller's org scope. |
| `get_keyword_ids_by_text` | search-console |  | keyword_text | Get all keyword IDs that have the same keyword text (case-insensitive) |
| `get_keywords_for_client` | search-console |  | client_id, active_only? | Get all keywords for a client |
| `get_keywords_without_pages` | search-console |  | client_id | Get keywords that don't appear prominently on any page |
| `get_last_audit_at` | reports |  | client_id | Timestamp of this client's most recent audit run (newest audit_onboard job; falls back to the client row's created_at, since the original audit is what created the row). Powers the "last audited" column on the Audits list. |
| `get_last_gsc_keyword_capture` | search-console |  | client_id, before_id? | Return the most recent capture for a client, or None. |
| `get_last_image_push_timestamp_for_client` | site-edits |  | client_id | Latest wp_image_pushed_at across all blog content for a client. Returns None if no image has ever been pushed. |
| `get_last_n_ranks_for_keyword` | search-console |  | keyword_id, n? | Last N rank_results rows for a keyword, newest first. Used to compute rank movement (e.g. n=2 → current vs previous). |
| `get_last_scan_session` | monitoring |  | client_id, scan_type? | Get the last scan session for a client |
| `get_latest_meta_for_page_url` | clients |  | client_id, page_url | Most-recent page_snapshots.meta_{title,description} for a page identified by (client_id, page_url). Returns None if no snapshot exists for that URL. |
| `get_latest_page_summaries` | clients |  | client_id | One row per page for a client, joined with its most recent snapshot. Returns url + meta_title + meta_description + h1_tags + word_count. Used by the progress-report summary section. |
| `get_latest_rank` | search-console |  | keyword_id | Get the most recent rank for a keyword |
| `get_latest_rank_for_keyword` | search-console |  | keyword_id | Most-recent rank_results row for a keyword. Returns dict with position (and any other columns); None if no rank has been recorded yet. |
| `get_latest_render` | monitoring |  | client_id, url | Most recent relayed render for (client, url): {html, meta, rendered_at}, or None. Lets callers reuse a fresh render instead of queueing another laptop round-trip. |
| `get_latest_snapshot` | monitoring |  | page_id | Get the most recent snapshot for a page |
| `get_locations_for_client` | clients |  | client_id |  |
| `get_meta_optimization_stats` | action-items |  | client_id | Return meta optimization counts by status. |
| `get_meta_optimizations` | action-items |  | client_id, status? | Get meta optimizations for a client, optionally filtered by status. |
| `get_month_topup_paid_cents` | billing |  | org_id, month? | Sum of the actual dollars PAID for top-ups in `month` (default current), in cents — for the /billing 'this period' breakdown. |
| `get_month_topup_tokens` | billing |  | org_id, month? | Sum of token-USD bought as top-ups for `month` (default current). |
| `get_org_activity` | org-admin |  | org_id, days?, user_id?, client_id?, limit? | Org activity rows (newest first) joined with the actor's email and the target client's name. |
| `get_org_billing_user_id` | billing |  | org_id | The user whose id stamps metered jobs for this org — its 'owner' member, else the earliest member. A job stamped with this user runs through the worker as a real (non-service) caller, so api_client.call_or_queue meters it against the org's credit balance. Returns None if the org has no members. |
| `get_org_credit_status` | billing |  | org_id | Credit balance + gate state for an org, for the Operations Manager billing panel. balance is whole credits (1 credit = $0.01). |
| `get_org_cycle_overview` | org-admin |  | org_id, month, current_month | Per-client monthly delivery-cycle rollup for the dashboard. |
| `get_org_id_by_stripe_customer` | billing |  | customer_id | Reverse-lookup an org from its Stripe customer id — used by the webhook to attribute subscription/invoice events that don't carry an org_id in metadata. |
| `get_org_invoices` | org-admin |  | org_id, limit? | Recent captured Stripe invoices for an org, newest first — powers the /billing invoice-history panel. |
| `get_org_month_ai_spend_usd` | org-admin |  | org_id, month? | Total AI spend (all services: anthropic + anthropic_batch + fal) for an org in `month` (YYYY-MM, default current). Resets naturally each calendar month — there's no carryover, it's just a per-month SUM. |
| `get_org_reference_doc` | org-admin |  | doc_id | One reference doc by id (incl. org_id + deleted for caller scoping). |
| `get_org_reference_docs` | org-admin |  | org_id | Active reference docs for an org, newest first, with uploader email. |
| `get_org_revenue_series` | org-admin |  | org_id, months? | Per-month revenue + profit for one org (newest-last), for the platform revenue graph. revenue = paid Stripe invoices (by invoiced_at) + allowance top-ups (by month); cost = AI spend (api_costs); profit = revenue − cost. All amounts in dollars. |
| `get_org_token_allowance_status` | billing |  | org_id, month? | Monthly token-allowance status for an org's shared pool. |
| `get_outstanding_summary` | billing |  | org_id | One-call 'do I still have work?' rollup for the whole org. Powers the nav badge (every page), the action-items banner, AND the per-client blog 'to publish' counts + the standalone 'Blogs to publish' push targets — so all four reconcile to the same numbers. Counts: tasks = committed-but-unfinished action items (pending / in_progress) blogs = scheduled-but-unpublished articles that are DUE (this mon |
| `get_page_images` | images |  | page_id | Get all images for a page. |
| `get_page_intent_cache` | clients |  | page_id | Cached intent classification for a page. Returns dict with intent, intent_confidence, intent_reasoning, intent_classified_at, last_scanned — or None if the page row doesn't exist. Used by page_intent_classifier.classify_page for its cache hit check. |
| `get_pages_for_client` | clients |  | client_id, active_only? | Get all pages for a client |
| `get_pages_without_keywords` | search-console |  | client_id | Get pages that don't have any prominent keyword targeting |
| `get_pending_batch_jobs` | jobs |  |  | Get all submitted/in_progress batch jobs. |
| `get_plugin_states` | site-edits |  |  | Return per-client plugin_state caches as parsed dicts. |
| `get_posts_needing_date_fix` | content |  | client_id, only_past? | Exported blog posts with a scheduled_publish_date. When only_past=True, restrict to dates before today. Returns dicts with id, title, scheduled_publish_date, topic_id, ordered by scheduled_publish_date ascending. Replaces the identical raw-SQL method that used to live in blog_pusher.py. |
| `get_previous_rank` | search-console |  | keyword_id | Get the previous rank for a keyword (for comparison) |
| `get_previous_snapshot` | monitoring |  | page_id | Get the second most recent snapshot for a page (for comparison) |
| `get_projects_for_client` | clients |  | client_id |  |
| `get_published_blog_content_between` | content |  | client_id, start, end | Exported blog articles whose `scheduled_publish_date` day falls in the inclusive [start, end] window ('YYYY-MM-DD' dates). The date-range analogue of `get_published_blog_content_for_month`, used by the arbitrary-period client report. `target_keywords` left as JSON string. |
| `get_published_blog_content_for_month` | content |  | client_id, month | Exported blog articles whose scheduled_publish_date falls in the given YYYY-MM. Joins blog_topics to surface target keywords / service / location. `target_keywords` is left as a JSON string (the report renderer is responsible for parsing it). |
| `get_rank_for_date` | search-console |  | keyword_id, target_date | Get rank result for a keyword closest to (but not after) the target date |
| `get_rank_history` | search-console |  | keyword_id, limit? | Get rank history for a keyword |
| `get_recent_topic_profile` | content |  | client_id, days? | Build a profile of recently covered topics for diversity scoring. |
| `get_resolved_cycle_pairs` | general |  | org_id | Set of (client_id, month) that are RESOLVED (final_done=1) for the org — every closed cycle, across all months. Used to decide which report-generated cycles still need a Resolve (independent of whichever month the action-items view happens to be focused on). |
| `get_scheduled_blogs_in_date_range` | content |  | client_id, start_iso, end_iso | Blog content scheduled (any export state) within a date range. Returns id + scheduled_publish_date per row, ordered ascending. Used by main.py:_reschedule_plan to project existing slots. |
| `get_seat_charges_for_month` | billing |  | org_id, month? | This month's seat-charge summary for the billing page: {count, total_cents, rows:[{client_id, client_name, amount_cents, created_at}]}. |
| `get_seat_credit` | billing |  | client_id, month |  |
| `get_seat_credits_for_org` | billing |  | org_id, month? | Granted seat credits for an org (default: current month) — for the /billing 'credits applied next renewal' display. |
| `get_section_template` | site-edits |  | template_id | Get a single section template by ID. |
| `get_section_templates` | site-edits |  | category? | Get all section templates, optionally filtered by category. |
| `get_seo_insights` | action-items |  | client_id, insight_type?, applied?, source?, limit? | Get SEO insights for a client. |
| `get_services_for_client` | clients |  | client_id |  |
| `get_site_architecture` | clients |  | client_id | The site structure this business SHOULD have, and what's missing. |
| `get_site_changes` | clients |  | client_id, limit?, since?, attribution? | Get recent site changes for a client. |
| `get_site_changes_for_page` | clients |  | page_id, limit? | Get recent changes for a specific page. |
| `get_site_edit` | site-edits |  | edit_id |  |
| `get_site_edits` | site-edits |  | client_id, status? |  |
| `get_sitemap_page` | clients |  | page_id |  |
| `get_sitemap_pages_for_project` | clients |  | project_id, status? |  |
| `get_snapshot_for_date` | monitoring |  | page_id, target_date | Get snapshot for a page closest to (but not after) the target date |
| `get_snapshots_for_page` | monitoring |  | page_id, limit? | Get recent snapshots for a page |
| `get_survey` | clients |  | survey_id |  |
| `get_surveys_for_client` | clients |  | client_id |  |
| `get_template_catalog_entry` | site-edits |  | library_client_id, template_id |  |
| `get_template_images_report` | site-edits |  | client_id | Get a summary of template image status for a client. |
| `get_template_page` | site-edits |  | page_id |  |
| `get_template_pages_for_project` | site-edits |  | project_id |  |
| `get_total_cost` | billing |  | client_id?, days? | Get the total USD cost, optionally filtered by client and time range. |
| `get_unapplied_insights` | action-items |  | client_id, limit? | Get unapplied SEO insights for a client, ordered by recency. |
| `get_unapplied_seat_credits` | billing |  | org_id | Seat credits not yet pushed to Stripe (any month) — the /billing reconcile applies these to the customer balance. |
| `get_unexported_content` | content |  | client_id |  |
| `get_unique_keywords_needing_metrics` | search-console |  | limit?, exclude_keywords? | Get unique keyword texts that need metrics updates, ordered by staleness. Returns list of dicts with 'keyword' and list of 'keyword_ids' sharing that text. |
| `get_unresolved_push_failures` | site-edits |  |  | Return all unresolved failure rows (joined with client name). |
| `get_unresolved_push_failures_for_org` | site-edits |  | org_id, limit? | Org-scoped unresolved push failures for the /push-failures review page (joined with client name; newest first). |
| `get_unscheduled_blog_content_ids` | content |  | client_id | IDs of blog_content rows for a client that have no scheduled publish date. Returned in creation order. Used by both the interactive reschedule flow and the group-pipeline scheduler. |
| `get_valid_password_reset` | org-admin |  | token_hash | Unused, unexpired reset row for this token hash, or None. |
| `get_website_project` | clients |  | project_id |  |
| `has_recent_password_reset` | org-admin |  | user_id, minutes? | True if a reset token was created for this user in the last `minutes` — the forgot-password rate limit (anti-mailbomb). |
| `is_client_in_cooldown` | monitoring |  | client_id, reason? |  |
| `is_job_cancel_requested` | jobs |  | job_id | Worker-side check — fast, single-row read. |
| `is_runaffiliate_org` | org-admin |  | org_id | True only for orgs provisioned for the RunAffiliate / Operations Manager scope. The jobs endpoint uses this to refuse any metered (OM-triggered) run whose target is NOT in that scope — so a bug or a mistyped id can never reach the real agency clients again. |
| `list_action_item_months_for_client` | action-items |  | client_id | Per-client version of list_action_item_months_for_org. Used by the per-client Delivered-window month picker so each client's dropdown only offers months that actually have data. |
| `list_action_item_months_for_org` | action-items |  | org_id | Distinct YYYY-MM values present in the org's action_items.month column, newest first. Powers the fulfillment-month picker on /action-items, /jobs, and the per-client Delivered window. Excludes empty strings + NULL. |
| `list_action_items_for_org_filtered` | action-items |  | org_id, month?, status?, completed_at_present?, completed_since? | Cross-client action items in the org with optional filters. |
| `list_ad_copy_ad_groups` | content |  | set_id, client_id | Wire contract v1 read: shaped ad-group dicts for one OWNED set. Ownership failure returns the standard {'ok': False, 'reason'} envelope with the same wording as a missing set — no existence leak. |
| `list_ad_copy_sets_for_client` | content |  | client_id, status? | Wire contract v1 read: shaped set dicts for one client, newest first (list_-prefixed: readable via the RPC prefix rule). `status` optionally filters to one lifecycle state. |
| `list_all_site_edits` | site-edits |  | status?, limit? | Every site edit across ALL clients (newest first) with the client name attached — the portfolio audit feed Operations Manager renders. Read-only; reachable as a read RPC by the list_ prefix (service account only — org users are deny-by-default on unregistered methods, which is correct for a cross-client view). |
| `list_api_keys` | org-admin | yes | org_id?, include_revoked? | List API keys (hashes never leave this layer). |
| `list_content_pages` | site-edits |  | client_id, status? | Drafts for one client, newest first (list_-prefixed: OM's service account reads this without RPC registration). |
| `list_cycle_tier_a_todo` | billing |  | org_id, month | Tier-A items committed to `month` that are NOT done yet (status pending/in_progress) — the fulfillment to-do list. Powers the Fulfillment Review view. Joined with client name, ordered by client + priority. |
| `list_gsc_keyword_captures` | search-console |  | client_id, limit? | Return recent captures for a client, newest first. |
| `list_job_months_for_org` | jobs |  | org_id | Distinct YYYY-MM values present in jobs.created_at for an org. Powers the /jobs month picker. Newest first. |
| `list_org_exports` | org-admin | yes | org_id | Org data exports on disk (newest first): filename, size, created_at, expires_at, download_url. Produce one with POST /api/v1/jobs {"kind": "export_org"}; files are kept 7 days. |
| `list_org_saml_providers` | org-admin | yes | org_id | The organization's SAML identity providers: label, IdP entity id, SSO URL, allowed_domains, default_role, active, and the SP metadata / ACS / login URLs to give the IdP (certificate included — it is public). |
| `list_org_sso_providers` | org-admin | yes | org_id | The organization's own identity providers (never the client secret): label, issuer, allowed_domains, default_role, active, login_url. |
| `list_redirects` | site-edits |  | client_id, applied_only? | List redirects for a client. applied_only=True filters to those pushed live. |
| `list_seat_credit_eligible` | billing |  | org_id | Inactive clients in the org eligible for an unused-seat credit this month — for the /billing 'reclaim seat credit' section. |
| `list_template_catalog` | site-edits |  | library_client_id, role? | List catalog entries for a library, optionally filtered by role. |
| `list_webhook_deliveries` | general | yes | org_id, webhook_id?, limit? | Recent deliveries for the org (newest first): event, status, attempts, last_status_code, last_error, timestamps. |
| `list_webhooks` | general | yes | org_id | The org's webhooks (url, events, active, failure_count, last success / failure, secret hint) — never the secret itself. |
| `oauth_list_grants` | general | yes | org_id, user_id? | Connected apps: one row per live grant (app, member, scopes, created, last used, refresh expiry). `user_id` limits to one member. |
| `org_within_token_allowance` | billing |  | org_id | True when the org may still consume tokens/AI this month (under 99.9%, or billing-exempt). |

## Write RPCs (116)

| Method | Domain | Org-scoped | Params | Summary |
|---|---|---|---|---|
| `add_aeo_query` | ai-visibility |  | org_id, client_id, prompt, engine? | Get-or-create the recurring query row for (client, prompt). Dedup is case/punctuation-insensitive so a re-typed question maps to the same series. The question set is engine-neutral (one row per question, asked on several engines), so a row for the same prompt under any engine is reused before a new one is created — otherwise every API-engine capture would spawn a service-less duplicate. |
| `add_batch_request` | billing |  | batch_job_id, custom_id, operation, model, system_prompt, user_prompt, max_tokens?, client_id?, context_json?, prompt_type?, org_id? | Add a request to a batch job. `org_id` is stored so the batch result handler (running async, after current_caller is gone) can debit the right org's credits. Returns request ID. |
| `add_client` | clients | yes | client | Add a new client and return its ID. org_id comes from the Client dataclass — the web RPC layer stamps the caller's org onto it before this runs; the local CLI leaves it at the default (1 = Innersite). |
| `add_client_image` | images |  | image | Insert a discovered image. Uses INSERT OR IGNORE for idempotency. |
| `add_keyword` | search-console |  | keyword | Add a new keyword to track |
| `add_location` | clients |  | location |  |
| `add_org_credits` | billing |  | org_id, credits, source, note?, granted_by_user_id?, stripe_payment_id? | Add credits to an org. Logs the grant to credit_grants for the audit trail. Returns the new balance. |
| `add_org_saml_provider` | org-admin | yes | org_id, label, allowed_domains?, default_role?, metadata_xml?, idp_entity_id?, sso_url?, certificate?, email_attribute?, allow_unsolicited? | Self-serve SAML: register the organization's SAML 2.0 IdP from its metadata XML (entityID, SSO URL and signing certificate are read from it) or from those three values. Returns the provider with the SP metadata URL / ACS URL to register at the IdP; staff sign in at /login/sso. |
| `add_org_sso_provider` | org-admin | yes | org_id, label, issuer, client_id, client_secret?, allowed_domains?, default_role?, hosted_domain? | Self-serve SSO: register the organization's OIDC identity provider (issuer URL, client id/secret from the IdP, the email domains whose staff may sign in / be provisioned into this org, and the role new staff get). The issuer's discovery document is fetched to validate it. Register https://<host>/auth/oidc/<provider_key>/callback with the IdP. |
| `add_page` | clients |  | page | Add a new page and return its ID |
| `add_service` | clients |  | service |  |
| `add_snapshot` | monitoring |  | snapshot | Add a new page snapshot |
| `add_website_project` | clients |  | project |  |
| `apply_site_edit` | site-edits |  | edit_id, approved_by? | RPC entry: OM applies an approved proposal to the live site. |
| `approve_ad_copy_set` | content |  | set_id, client_id, approved_by? | RPC entry (WRITE_RPCS): OM approves a proposed, owned set for export. approved_by is REQUIRED non-empty (review M3): approval is an accountable human action, and the stamp is what the export audit trail hangs off. Only a 'proposed' set may be approved. |
| `auto_resolve_push_failures` | site-edits |  | client_id, operation, note? | Mark all unresolved failures for (client, operation) as resolved. Called when a retry succeeds. Returns number of rows resolved. |
| `bulk_update_image_types` | images |  | updates | Bulk update image types from WordPress data. updates: list of {'filename': str, 'client_id': int, 'image_type': str, 'wp_attachment_id': int} |
| `cancel_aeo_runs` | ai-visibility | yes | client_id, query_id?, month? | Drop queued runs (a question was deactivated/deleted, or a client left). |
| `claim_aeo_runs` | ai-visibility |  | agent_id, capabilities_json, limit? | Atomically claim up to `limit` queued runs this agent can serve: engine in its list, and geo_state matching its state (or agent geo 'any', or the run has no geo). Returns rows joined with the prompt and client name. Disjoint across agents (UPDATE ... WHERE status='queued'). |
| `complete_aeo_run` | ai-visibility |  | run_id, agent_id, capture_id | Capture-agent protocol: the run produced capture_id (from ingest_aeo_capture); marks it done. |
| `complete_scan_session` | monitoring |  | session_id, pages_scanned?, keywords_checked?, status? | Complete a scan session |
| `create_api_key` | org-admin |  | name, org_id?, scopes?, created_by_user_id?, notes? | Mint a new API key. org_id=None => partner/platform key (cross-org service-account semantics); org_id set => org-scoped agent key confined + metered to that org (enforced in web.py — ORG_SCOPED_RPCS injects the caller's org so owners can only mint for their own org). |
| `create_content_page` | site-edits |  | client_id, wp_post_id, slug?, title?, status?, job_id?, verify_json? | Insert one draft row (called by content_edit_pipeline.run_assemble right after verify_draft). Returns the new row id. |
| `create_site_edit` | site-edits |  | client_id, operation, payload, page_url? |  |
| `deactivate_pages_not_in_list` | clients |  | client_id, active_urls | Mark pages as inactive if they're not in the current sitemap |
| `dedupe_aeo_queries` | ai-visibility |  | client_id | Merge duplicate question rows (same prompt_key, different engine — the shape API-engine captures used to create) into the canonical row (the one with a service, else the oldest): captures, runs and item query links are re-pointed, then the duplicates are deleted. |
| `delete_action_items_for_month` | action-items |  | client_id, month, preserve_statuses? | Delete action items for a specific month (before regenerating). |
| `delete_aeo_items_for_client` | ai-visibility |  | client_id | Wipe every tracker item for a client. reanalyze_aeo_client rebuilds them from the captures and keeps statuses; calling this alone loses them. |
| `delete_aeo_queries_for_client` | ai-visibility |  | client_id, uncaptured_only? | Bulk reset of a client's question set. Default removes only rows that never produced a capture (a bad generate, an over-broad location list); rows with history are kept (deactivate those individually). Returns rows deleted. |
| `delete_aeo_query` | ai-visibility |  | query_id | Remove a question that has never been captured; one with history is deactivated instead (its captures and item links stay). Returns True when the row was actually deleted. |
| `delete_keyword` | search-console |  | keyword_id | Soft delete a keyword |
| `delete_locations_for_client` | clients |  | client_id |  |
| `delete_org_saml_provider` | org-admin | yes | org_id, provider_id |  |
| `delete_org_sso_provider` | org-admin | yes | org_id, provider_id |  |
| `delete_section_template` | site-edits |  | template_id | Delete a section template. |
| `delete_services_for_client` | clients |  | client_id |  |
| `delete_webhook` | general | yes | org_id, webhook_id | Delete a webhook and its delivery history. False when not the org's. |
| `detect_replaced_images` | images |  | client_id, page_id, current_urls | Mark template images that are no longer on the page as replaced. |
| `emit_event` | general | yes | org_id, event, data?, client_id? | Queue `event` for every active webhook of the org subscribed to it. Returns the number of deliveries queued. Never raises — a webhook problem must not fail the operation that produced the event. |
| `enqueue_aeo_runs` | ai-visibility |  | org_id, client_id, rows | Batch enqueue; rows = [{query_id, engine, month, sample_no, geo_state, priority}]. The UNIQUE key makes re-enqueuing a no-op. |
| `enqueue_cron_trigger` | jobs |  | client_id, url | Queue a cron-trigger / render request for the laptop agent. Returns the trigger id. Deduped per (client, url): if a pending trigger for this client+url already exists, that row's id is returned and no new row is added. Per-url (not per-client) dedup so a targeted page render (content_extractor.fetch_via_laptop) coexists with the homepage cron fire — same homepage url still collapses duplicate fire |
| `enqueue_due_aeo_runs` | ai-visibility | yes | month?, client_id? | Daily-sweep entry point (idempotent): for every active client with an active question set, make sure this month's runs exist per the client's cadence (`client_settings.aeo_cadence`: {samples_per_month, engines, geo_state}; default 1 sample, chatgpt-web, geo from the HQ state). |
| `fail_aeo_run` | ai-visibility |  | run_id, agent_id, error, requeue? | Requeue (until AEO_RUN_MAX_ATTEMPTS) or mark failed. Returns new status. |
| `generate_aeo_questions` | ai-visibility | yes | client_id, use_ai? | Build (or refresh) the client's AI-engine question set from its services and locations: buyer-style questions per core service, stored as aeo_queries (review with get_aeo_queries, toggle with update_aeo_query). use_ai=True lets Claude propose extra referral categories, added inactive for review. Returns the generation summary. |
| `get_or_create_hour_budget` | billing |  | client_id, month, allocated_hours? | Get existing budget for month, or create with allocation + rollover from previous. |
| `heartbeat_aeo_agent` | ai-visibility |  | agent_id, run_id? | Capture-agent protocol: mark the agent alive (and which run it is on) — a run whose agent stops heartbeating is requeued by the maintenance sweep. |
| `ingest_aeo_capture` | ai-visibility |  | client_id, convo_json, month?, query_id? | Server-side ingest for agents: ONE RPC carrying the raw conversation. Runs aeo_tracker.ingest_capture against the local DB (the per-finding upserts stay in-process instead of ~40 remote calls per capture). |
| `link_survey_to_project` | clients |  | survey_id, project_id | Attach a client_surveys row to a website_projects row. Identical UPDATE used in three places in main.py — consolidating keeps the SQL in one place and gets all three callers on the RPC-safe path automatically. |
| `log_anthropic_call` | billing |  | client_id, operation, model, input_tokens?, output_tokens?, cache_creation_tokens?, cache_read_tokens?, batch? | Scalar-only equivalent of log_anthropic_response. |
| `log_anthropic_response` | billing |  | client_id, operation, model, response, batch? | Log cost from an Anthropic API response object. Returns the cost. |
| `log_api_cost` | billing |  | client_id, service, operation, model, input_tokens?, output_tokens?, cost_usd?, metadata? | Log an API call with cost. Auto-calculates cost if not provided. Stamps org_id via _resolve_org_id so per-org cost rollups work, and key_id (m0019) from the ambient caller context so AI spend is attributable to the API key that caused it. |
| `log_entity_change` | monitoring |  | entity_type, entity_id, field, action, client_id?, old_value?, new_value?, source?, note? | Append an audit row for any tracked entity change. |
| `log_image_change` | images |  | content_id, image_url, action, source?, prompt?, note? | Append an audit entry to image_change_log. |
| `log_push_failure` | site-edits |  | client_id, operation, message, category?, context? | Record a failed WP-side push (blog_push, plugin_update, date_sync, image_refresh, trust_ip, companion_keys). Categorizes automatically if category not given. Mirrors to entity_change_log so client-level change feeds include failures. |
| `log_site_change` | clients |  | client_id, page_id?, change_type?, field_changed?, old_summary?, new_summary?, attribution?, linked_action_type?, linked_action_id?, scan_session_id? | Log a detected site change. |
| `mark_ad_copy_set_exported` | content |  | set_id, client_id | RPC entry (WRITE_RPCS): OM records the manual CSV export of an approved, owned set. This is a bookkeeping stamp — the export itself is a Google Ads Editor CSV downloaded in OM; nothing touches any Ads API. Re-marking an already-exported set is idempotent (ok: true, original exported_at kept). |
| `mark_compare_report_converted` | reports |  | report_id, count | Stamp a comparison report as converted-to-tasks (idempotency guard for the convert-to-tasks feature, so a second click doesn't re-create). |
| `mark_cron_trigger_done` | jobs |  | trigger_id | Mark a cron-trigger fired (best-effort — the agent calls this after attempting the page-load regardless of outcome). |
| `mark_image_has_alt` | images |  | client_id, filename, alt_text | Mark an image as already having alt text in WordPress. Sets status to 'has_alt' so it's skipped during analysis. |
| `mark_image_synced` | images |  | content_id, image_url | Record that WP has confirmed this URL is attached to the post. |
| `mark_images_applied` | images |  | client_id, filenames | Bulk mark images as 'applied' after PHP updater runs. |
| `oauth_revoke_grant` | general | yes | org_id, grant_id, user_id? | Revoke a connected app (all its tokens). Non-owners may only revoke their own. |
| `propose_site_edits` | site-edits |  | client_id | RPC entry: autopilot/OM asks to propose file edits for a client. |
| `provision_client_billing` | billing |  | client_id, org_name, billing_email? | Give a client its OWN metered organization, so its AI spend draws down a dedicated credit balance instead of the platform's exempt house org. Idempotent: if the client is already in a non-exempt org that has a billing user, returns it unchanged. |
| `provision_runaffiliate_client` | clients |  | name, website_url, billing_email? | Create a NEW RankRight client inside a NEW RunAffiliate-managed, non-exempt org with a no-BYOK billing user. This is the ONLY door Operations Manager has into RankRight: it always creates fresh, so OM can never attach to a pre-existing client or org — the real agency clients are structurally out of reach. |
| `prune_rpc_audit` | reports |  | days_keep? | Delete rpc_audit rows older than `days_keep` days. Returns the number of rows removed. Call periodically to keep the audit table bounded — no automatic retention by default. |
| `publish_page_draft` | clients |  | page_id, approved_by? | RPC entry: OM publishes a verified content_edit draft — flip the WP post to 'publish' via the Site Manager plugin and stamp the row. |
| `reanalyze_aeo_client` | ai-visibility |  | client_id | Recompute every capture's findings and rebuild the tracker from them (after an analyzer change). Statuses/notes survive by item key. |
| `redeliver_webhook` | general | yes | org_id, delivery_id | Re-queue a failed or delivered delivery (same payload, same id). |
| `register_aeo_agent` | ai-visibility |  | agent_id, name, host, capabilities_json, version? | Capture-agent protocol (devices + server): announce an agent and its capabilities JSON {engines, geo, max_per_day}; idempotent upsert by agent_id. |
| `register_webhook` | general | yes | org_id, url, events?, description? | Register an outbound webhook for the org: an https URL and the events it wants (names from the webhooks catalog, or '*' for all). Returns the hook with its signing `secret` — shown ONCE; later reads only carry a hint. Verify deliveries with the X-RankRight-Signature header (HMAC-SHA256 of '<timestamp>.<body>'). |
| `reject_ad_copy_set` | content |  | set_id, client_id, reason?, rejected_by? | RPC entry (WRITE_RPCS): OM rejects a proposed, owned set. `reason` is REQUIRED non-empty engine-side (review Low-1) — a reasonless rejection tells the next run nothing to improve. Only a 'proposed' set may be rejected. |
| `reject_site_edit` | site-edits |  | edit_id |  |
| `requeue_failed_aeo_runs` | ai-visibility |  | client_id?, org_id? | Operator retry: failed runs back to the queue with a fresh attempt budget. |
| `requeue_stale_aeo_runs` | ai-visibility |  | lease_minutes? | Daily-sweep / safety net: runs claimed or running whose heartbeat is older than the lease go back to the queue (or fail after max attempts). |
| `resolve_all_push_failures` | site-edits |  | note? | Mark every unresolved failure resolved. |
| `resolve_push_failure` | site-edits |  | failure_id, note? | Mark one failure row resolved. |
| `revoke_api_key` | org-admin |  | key_id, org_id? | Revoke a key (idempotent). org_id=None => operator: any key. org_id set (injected for org callers) => only that org's keys — an owner can't revoke partner keys or another org's keys. Returns True if a live key was revoked this call. |
| `revoke_client_access` | clients |  | client_id | RPC entry: offboarding — give back the access a client granted us. |
| `rollback_site_edit` | site-edits |  | edit_id, actor? | RPC entry: OM reverts an applied change — restore the backup over the live file and confirm byte-for-byte. 'elementor_rewrite' rows carry their pre-image in the payload instead of a file backup, so they revert by pushing the recorded before-settings back. |
| `rotate_webhook_secret` | general | yes | org_id, webhook_id | Issue a new signing secret (returned once); the old one stops validating immediately. |
| `save_action_item` | action-items |  | item | Insert or update an SEO action item. Returns its ID. |
| `save_aeo_capture` | ai-visibility |  | org_id, client_id, query_id, month, engine, prompt, report_json?, record_json?, captured_at? | Low-level: store an already-analyzed capture row. Agents should call ingest_aeo_capture (raw conversation in, analysis + items out) instead. |
| `save_aeo_item` | ai-visibility |  | item | Insert (no `id`) or partial-update (with `id`) an aeo_items row. Only known columns are written; unknown keys are ignored. |
| `save_compare_report` | reports |  | org_id, client_id, competitor_id, focus, title, html | Store an A/B comparison report; returns its id. |
| `save_page_intent` | clients |  | page_id, intent, confidence, reasoning? | Persist intent classification result. Sets intent_classified_at = now(). Replaces the inline `_save` helper in page_intent_classifier that took a raw connection. |
| `save_section_template` | site-edits |  | name, category, description, template_json, slots_json, preview_text?, source_client_id?, source_wp_post_id? | Save a section template and return its ID. |
| `set_ad_copy_ad_group_excluded` | content |  | group_id, client_id, excluded? | RPC entry (WRITE_RPCS): operator drops (or restores) one weak group while the owning set is still 'proposed'. An approved or exported set is a frozen artifact (review H4): what the operator approved is what gets exported, byte for byte — no post-approval edits through any path. Ownership chains group -> set -> client; a group under another client's set answers like a missing group. |
| `set_aeo_cadence` | ai-visibility | yes | client_id, samples_per_month?, engines?, geo_state?, enabled? | Enrol a client in monthly AI-engine capture. samples_per_month 1-10; engines any of 'chatgpt-web' (operator device fleet), 'claude', 'openai', 'gemini' (server-side official APIs); geo_state = two-letter US state a device must sit in ('' = any); enabled=False pauses the client and cancels its queued runs. Returns the stored cadence. |
| `set_client_setting` | clients |  | client_id, key, value | Set a per-client setting value. |
| `set_featured_image_prompt` | images |  | topic_id, prompt | Cache the Haiku-generated featured image prompt on a blog topic. |
| `start_aeo_run` | ai-visibility |  | run_id, agent_id | Capture-agent protocol: a claimed run has started executing on this agent. |
| `start_scan_session` | monitoring |  | client_id, scan_type | Start a new scan session |
| `test_webhook` | general | yes | org_id, webhook_id | Queue a `ping` delivery to one webhook (the Test button). |
| `update_action_item_status` | action-items |  | item_id, status, completed_at?, manual_note?, _caller_module? | Update an action item's status. |
| `update_aeo_capture_report` | ai-visibility |  | capture_id, report_json | Low-level: overwrite one capture's analysis JSON (used by reanalyze_aeo_client). |
| `update_aeo_item_status` | ai-visibility | yes | item_id, status, note?, client_id? | Set a tracker item's status: 'open' \| 'in_progress' \| 'done' \| 'dismissed' ('resolved' is reserved for the auto-resolver). `note` is appended, dated, to the item's history rather than replacing it. `client_id` (required over the org-scoped RPC) pins the item to that client so a partner key can never touch another client's item. |
| `update_aeo_query` | ai-visibility |  | query_id, active?, priority?, service?, prompt? | Operator edits to a question row (toggle / reprioritise / relabel). |
| `update_client` | clients | yes | client | Update a client — saves all editable fields |
| `update_content_schedule` | content |  | content_id, publish_date |  |
| `update_elementor_image_description` | site-edits |  | entry_id, description | Update the AI-edited content_description on a row in elementor_image_catalog. Called from the interactive elementor image regeneration menu. |
| `update_hour_budget_used` | billing |  | client_id, month, hours_delta, action_type? | Add hours_delta to used_hours for the given month. Blog topics are tracked separately from the on-page SEO budget and skipped here. |
| `update_image_alt_text` | images |  | image_id, alt_text | Update just the alt_text field (for manual edits). |
| `update_image_status` | images |  | image_id, status, alt_text? | Update image status and optionally set alt_text + timestamps. |
| `update_image_type` | images |  | image_id, image_type, wp_attachment_id? | Update the image type (product/page/other) and optionally WP attachment ID. |
| `update_org_saml_provider` | org-admin | yes | org_id, provider_id, active?, allowed_domains?, default_role?, certificate?, allow_unsolicited?, label? | Enable/disable, change domains, default role, certificate (rotation), IdP-initiated flag or label. |
| `update_org_sso_provider` | org-admin | yes | org_id, provider_id, active?, allowed_domains?, default_role?, client_secret?, label? | Enable/disable, change domains, default role, secret or label. |
| `update_organization_branding` | ai-visibility | yes | org_id, brand_name?, brand_logo_url?, brand_accent? | White-label: the name/logo/accent partners' clients see on reports. |
| `update_page_scanned` | clients |  | page_id, last_modified? | Update the last_scanned timestamp for a page, optionally storing last_modified. |
| `update_project_status` | clients |  | project_id, status |  |
| `update_webhook` | general | yes | org_id, webhook_id, url?, events?, active?, description? | Change a webhook's url / events / active flag / description. Re-activating resets the consecutive failure counter. |
| `upsert_aeo_queries` | ai-visibility |  | org_id, client_id, engine, rows | Batch form of upsert_aeo_query for the generator - the whole question set in one call/one connection (the remote API rate-limits per request). Same rules per row. Returns {'created': n, 'updated': n}. |
| `upsert_aeo_query` | ai-visibility |  | org_id, client_id, prompt, engine, service, kind, priority, source, active | Generator upsert. New prompt → row with these defaults. Existing prompt → fill in service/kind/source where the row only knew it from a capture, but NEVER touch `active` / `priority` (operator-owned once the row exists). Returns [id, created]. |
| `upsert_elementor_page` | site-edits |  | project_id, wp_post_id, title, slug, url, post_type, builder, template, parent_id, menu_order, seo_data, featured_image, elementor_data, widget_summary, blocks_data, modified_gmt?, content_hash? | Insert or update an exported Elementor page. |
| `upsert_keyword_page_association` | search-console |  | keyword_id, page_id, found_in_title?, found_in_meta_desc?, found_in_h1?, found_in_h2?, found_in_h3?, found_in_body?, occurrence_count?, prominence_score? | Insert or update a keyword-page association |
| `upsert_page_images` | images |  | client_id, page_id, images, is_first_scan? | Insert or update page images from a scan. |

---
This document is generated from the live RPC registries at request time — it cannot drift from the enforced surface.
