Proposals
Propose actions for a human to approve, list their status, and read one back with the proposals endpoints.
/v1/proposalsFree (proposing is free)API keyProposals are how an agent asks for permission. You describe an action (submit URLs to IndexNow, send outreach emails), RankParse stores it as a pending item in the website owner's approval inbox, and nothing runs until a person approves it in the RankParse web app.
All four routes on this page cost 0 credits. Credits are spent only when an approved action runs: an IndexNow submission costs 1 credit at that moment, outreach costs 0.
Approval only happens in the web app. There is no API route to approve, skip, retry or execute a proposal, and an API key cannot be used to approve one. This is deliberate: a leaked key can only fill the inbox, never act on it.
Authentication and limits
- Auth:
X-API-Keyheader, the same key as every other endpoint. See Authentication. - Rate limit: 60 requests per minute per key. A 61st call in the window returns
429withcode: "rate_limited". - The workspace is always the one your key belongs to. A
site_idor proposal id from another workspace returns404, never data. - The routes on this page are not part of the secure OAuth connector's tool catalog today. See the MCP server page.
Create a proposal
POST /v1/proposals takes a JSON body with a kind plus that kind's fields.
Common fields
| Field | Type | Required | Description |
|---|---|---|---|
kind | string | Yes | indexnow_submit or outreach_send. Any other value returns 400 invalid_proposal_input. |
site_id | string | Depends | A website id from GET /v1/websites. Required to create a new outreach campaign; for IndexNow it is optional and, when given, must match the website that owns the URLs' host. |
note | string | No | Why you are proposing this, in one or two plain sentences. Cleaned of control characters, cut to 1000 characters, and shown to the reviewer labelled as the agent's note. |
client_request_id | string | No | 1 to 64 characters of A-Z a-z 0-9 _ -. Validated and otherwise ignored: duplicate detection is automatic (see Idempotency). |
Unknown fields are rejected with 400 invalid_proposal_input. A workspace id can never be passed in the body.
Kind: indexnow_submit
Asks to notify Bing, Yandex, Seznam, Naver and other IndexNow engines about changed URLs.
| Field | Type | Required | Description |
|---|---|---|---|
urls | string[] | Yes | 1 to 1000 full http(s) URLs, each at most 2048 characters, no explicit port, all on one host. The host must be one of your RankParse websites. The stored list is de-duplicated and sorted, and its total size is capped at about 1.9 MB. |
The host must already have an IndexNow key (see POST /v1/indexnow/key); otherwise the call returns 400 indexnow_key_not_found. Whether the key file is actually hosted is checked when the proposal runs, not when it is created.
Kind: outreach_send
Asks to send link-building emails from the workspace's connected Gmail account. Two shapes:
Inline contacts. RankParse creates (or reuses) a draft campaign and adds the contacts, then proposes sending them.
| Field | Type | Required | Description |
|---|---|---|---|
campaign_name | string | Yes | 1 to 200 characters. Reuses a live Gmail campaign with the same name on the same website. |
contacts | object[] | Yes | 1 to 100 contacts, one per domain. See the contact fields below. |
site_id | string | Yes | The website the campaign belongs to. |
Existing campaign. Propose the contacts a campaign already holds.
| Field | Type | Required | Description |
|---|---|---|---|
campaign_id | string | Yes | A campaign you own. Its Gmail pipeline must be connected. |
contacts | object[] | No | Optional extra contacts (up to 100) to add before proposing. Omit to propose the campaign's current copy-ready contacts. |
Contact fields: email (required, max 320), domain (required, the prospect domain, max 253), subject (required, max 500), body (required, plain text, max 10,000), name (optional, max 200), domain_authority (optional number; out-of-range values are stored as unknown).
An agent proposal snapshots at most 100 copy-ready contacts. Contacts that were already emailed in an earlier campaign are skipped, and a campaign holds at most 500 contacts.
Example
curl -X POST "https://api.rankparse.com/v1/proposals" \
-H "X-API-Key: rp_your_key" \
-H "Content-Type: application/json" \
-d '{
"kind": "indexnow_submit",
"site_id": "site_abc123",
"urls": ["https://example.com/blog/new-post", "https://example.com/pricing"],
"note": "Published a new post and changed the pricing page."
}'import requests
res = requests.post(
"https://api.rankparse.com/v1/proposals",
headers={"X-API-Key": "rp_your_key"},
json={
"kind": "indexnow_submit",
"site_id": "site_abc123",
"urls": [
"https://example.com/blog/new-post",
"https://example.com/pricing",
],
"note": "Published a new post and changed the pricing page.",
},
)
print(res.status_code, res.json())const res = await fetch("https://api.rankparse.com/v1/proposals", {
method: "POST",
headers: {
"X-API-Key": "rp_your_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
kind: "indexnow_submit",
site_id: "site_abc123",
urls: ["https://example.com/blog/new-post", "https://example.com/pricing"],
note: "Published a new post and changed the pricing page.",
}),
});
console.log(res.status, await res.json());Response
Most calls return 202 with a pending proposal:
{
"status": "awaiting_approval",
"proposal_id": "prp_V1StGXR8_Z5jdHi6B-myT",
"review_url": "https://rankparse.com/dashboard/sites/site_abc123/today?proposal=prp_V1StGXR8_Z5jdHi6B-myT",
"title": "Submit 2 URLs on example.com to IndexNow",
"dedupe_hit": false,
"message": "Waiting for the user's approval in RankParse: Submit 2 URLs on example.com to IndexNow. Nothing happens until they approve it. Tell the user to review it at the review_url, and check list_proposals later for the outcome."
}| Field | Type | Description |
|---|---|---|
status | string | awaiting_approval, approved or executed. See the table below. |
proposal_id | string | The proposal's id, prp_ followed by url-safe characters. Use it with GET /v1/proposals/:id. |
review_url | string | Where the reviewer approves it. Show this link to the user. It contains only ids, never the proposal's content. A proposal without a website opens the workspace inbox instead. |
title | string | Built by RankParse from the stored payload (counts and host), never from your text. |
dedupe_hit | boolean | true when an identical open proposal already existed and was returned instead of creating a second one. |
message | string | Plain-text guidance for an agent to relay to the user. |
Possible outcomes
| HTTP | status | Meaning |
|---|---|---|
202 | awaiting_approval | The proposal is pending. Nothing has happened. This is the normal result, and the only one for outreach. |
202 | approved | The same action was already approved and is running (dedupe_hit: true, with a review_url), or the owner's policy for this website allows IndexNow automatically and the run will be retried within the hour (the body then carries only status, proposal_id and message). |
200 | executed | IndexNow only: the owner's policy for this website is Automatic and the submission ran immediately. The body carries proposal_id and outcome (status, result, errorCode). The 1 credit is charged. |
Outreach is never automatic. Whatever the website's settings say, an outreach_send proposal always waits for a person.
List proposals
/v1/proposalsFreeAPI keyReturns the workspace's proposals, newest first, including ones created by RankParse itself (for example "pages not indexed" suggestions).
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
status | string | No | all | One of pending, approved, executing, succeeded, partially_succeeded, failed, skipped, expired, superseded, cancelled. Anything else returns 400 invalid_proposal_input. |
site_id | string | No | all | Only this website's proposals. A website of another workspace returns 404 site_not_found. |
limit | integer | No | 25 | 1 to 100. Larger values are clamped to 100; invalid values fall back to 25. |
curl "https://api.rankparse.com/v1/proposals?status=pending&site_id=site_abc123" \
-H "X-API-Key: rp_your_key"{
"proposals": [
{
"id": "prp_V1StGXR8_Z5jdHi6B-myT",
"kind": "indexnow_submit",
"status": "pending",
"title": "Submit 2 URLs on example.com to IndexNow",
"site_id": "site_abc123",
"source": "agent",
"estimated_credits": 1,
"created_at": 1790000000000,
"expires_at": 1790604800000,
"decided_at": null,
"decided_via": null,
"result": null,
"error_code": null
}
]
}There is no pagination cursor. To see older items, filter by status or site_id.
Get one proposal
/v1/proposals/:idFreeAPI keyReturns { "proposal": { ... } } with the same fields as a list item. An unknown id, a malformed id, and another workspace's id all return the same 404 proposal_not_found.
curl "https://api.rankparse.com/v1/proposals/prp_V1StGXR8_Z5jdHi6B-myT" \
-H "X-API-Key: rp_your_key"Proposal fields
| Field | Type | Description |
|---|---|---|
id | string | Proposal id (prp_...). |
kind | string | indexnow_submit or outreach_send for proposals an agent can create. RankParse can also create investigate_drop and outreach_sending_change proposals itself; you will see them in lists but cannot create them. |
status | string | See Statuses. |
title | string | Server-built summary, for example Send 12 outreach emails to 12 domains. |
site_id | string or null | The website the proposal belongs to. |
source | string | agent (created through this API), system (created by RankParse) or user (a web button). |
estimated_credits | integer | Credits the action will cost when it runs (1 for IndexNow, 0 for outreach). |
created_at, expires_at, decided_at | integer or null | Unix time in milliseconds. decided_at is when it was approved or skipped. |
decided_via | string or null | web (a person approved it), policy (the website's Automatic setting) or system. |
result | object or null | Outcome details once it has run. IndexNow: host, url_count, key_location. Outreach: queued, suppressed, changed, already_queued, removed, changed_since_review. |
error_code | string or null | Why it failed, or null. See Failure codes. |
The summary never contains the URLs, email addresses, subjects or bodies. Those stay in the proposal and are shown only to reviewers in the app.
Statuses
| Status | Meaning |
|---|---|
pending | Waiting for a person. Expires after 7 days (IndexNow) or 14 days (outreach). |
approved | A person (or an Automatic policy) approved it and it is about to run or is queued for retry. |
executing | Running now. |
succeeded | Finished. result has the details. |
partially_succeeded | Outreach only: fewer contacts were queued than the proposal held (some changed after review or were suppressed). |
failed | Did not run to completion. error_code says why. A person can retry it in the app. |
skipped | A person declined it. |
expired | Nobody decided before the expiry. |
superseded | Replaced by a newer proposal for the same campaign (outreach) before anyone decided. |
cancelled | A reviewer removed every email from an outreach proposal, leaving nothing to send. |
Terminal statuses are retained for 400 days. See Approval inbox for retention details.
Idempotency
There is no idempotency key to manage. RankParse derives a dedupe key from the content:
- IndexNow: the host plus the sorted, de-duplicated URL list.
- Outreach: the campaign plus the exact set of contacts that would be sent.
Proposing the same content again while the first is still open (pending, approved or executing) returns the same proposal_id with dedupe_hit: true and does not count against your limits. Retrying after a network error is therefore always safe. If a pending outreach proposal for a campaign is re-proposed with different contacts, the old one becomes superseded and the new one replaces it.
Limits
| Limit | Value | Error |
|---|---|---|
| Pending proposals per website | 25 | 429 too_many_pending_proposals |
| New proposals per account per UTC day | 100 | 429 daily_proposal_limit_reached |
| URLs per IndexNow proposal | 1000 | 400 invalid_proposal_input |
| Contacts per outreach proposal | 100 | 400 invalid_proposal_input |
| Requests per minute | 60 | 429 rate_limited |
Expired pending proposals do not count toward the pending limit.
Errors
Errors use the standard shape, with error set to the same value as code:
{
"error": "indexnow_host_not_a_website",
"code": "indexnow_host_not_a_website",
"message": "shop.example.org is not one of your RankParse websites"
}| HTTP | Code | When |
|---|---|---|
| 400 | invalid_proposal_input | Body is not a JSON object, kind is missing or unsupported, a field has the wrong type or is out of range, an unknown field was sent, site_id is missing where required, or the status filter is not a valid status. |
| 400 | kind_not_available | The kind exists but cannot be proposed yet. |
| 400 | indexnow_invalid_url | A URL is not a valid public http(s) URL, or uses a non-default port. |
| 400 | indexnow_too_many_urls | More than 1000 URLs. |
| 400 | indexnow_host_mismatch | The URLs span more than one host. |
| 400 | indexnow_host_not_a_website | The URLs' host is not one of your RankParse websites. |
| 400 | site_host_mismatch | site_id names a different website than the URLs' host. |
| 400 | indexnow_key_not_found | The host has no IndexNow key yet. Provision one first. |
| 400 | gmail_not_connected | Outreach needs a connected Gmail account. |
| 400 | sending_provider_unsupported | The campaign does not send through Gmail. |
| 400 | campaign_site_mismatch | The campaign belongs to a different website than site_id. |
| 400 | no_contacts | No copy-ready contacts to propose, or none of the supplied contacts could be added. |
| 409 | campaign_not_editable | The campaign is in a state that cannot take new contacts (for example a launch is running right now). Retry in a moment. |
| 401 | invalid_api_key | Missing, invalid or revoked API key. |
| 403 | approval_inbox_not_enabled | The approval inbox is not switched on for this account. Do not retry. |
| 403 | action_disabled_by_policy | The owner turned this action Off for the website. Do not retry. |
| 404 | site_not_found | The website does not exist in this account, or is archived. |
| 404 | campaign_not_found | The campaign does not exist in this account. |
| 404 | proposal_not_found | Unknown proposal id. |
| 429 | too_many_pending_proposals, daily_proposal_limit_reached, rate_limited | See Limits. |
See also Error codes.
Failure codes
When a proposal ends failed (or partially_succeeded), error_code explains why:
| Code | Meaning |
|---|---|
payload_changed_since_approval | The stored proposal no longer matches what was approved, so it did not run. Propose again. |
approver_lost_permission | The person who approved it no longer has permission to run it. Tell the user. |
nothing_to_send | Outreach: every contact was already queued, sent or suppressed by the time it ran. |
indexnow_host_not_a_website | The website was removed before the run. |
indexnow_key_not_verified | The IndexNow key file is not reachable at its key_location. |
insufficient_credits, spend_cap_reached | IndexNow could not be charged. The account balance or monthly spend cap blocked it. |
indexnow_unavailable, indexnow_rate_limited, indexnow_key_rejected, indexnow_url_mismatch, indexnow_invalid_request | The IndexNow service refused or could not take the submission. The credit is refunded. |
execution_attempts_exhausted | The run kept failing and was stopped after 5 attempts. Any charge is refunded. |
A failed IndexNow run never keeps its credit.
Related
Approval inbox
How proposals, approval and policies fit together.
Agent tasks and activity
Read queued tasks and log what your agent did.
Websites
List the site_id values you can propose against.
IndexNow
Provision a key and submit URLs directly.
Outreach
Campaigns, contacts and sending.
MCP tools: propose_indexnow_submit, propose_outreach_send and list_proposals on the API-key MCP endpoint. See MCP server.