Authentication
Send your API key as a Bearer token on every request:
Authorization: Bearer nr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxKeys are shown in full only once, at creation. Only a hash is stored, so a lost key must be revoked and replaced. Each key is tied to your account; orders are paid from your account balance. Keys are displayed in the dashboard only and are never sent by email.
Key scopes
Keys are created as full access or read-only (choose at creation under Account → API & Webhooks):
- Full access — every endpoint, including
POST /api/.v1/ orders - Read-only — all
GETendpoints plusPOST /api/(a side-effect-free dry run). Every other endpoint returnsv1/ orders/ validate 403 insufficient_scope, so a read-only key cannot place orders, spend balance, upload files, or change projects/reports/webhooks.
For a catalogue evaluation that cannot enable spending, create a read-only key on an account with no balance and no payment method — spending is then impossible at both the key level and the account level.
Errors use a consistent shape:
{ "error": { "code": "insufficient_balance", "message": "Insufficient balance. Current: $5.00, required: $49.00." } }| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing or invalid API key |
| 400 | invalid_body, invalid_query, invalid_file, product_unavailable, project_limit_reached, subscription_not_supported | Bad request / invalid query parameter / unsupported or oversized file / product not orderable / project cap hit / subscription product (balance checkout only) |
| 402 | insufficient_balance, funding_required, no_report_subscription, report_limit_reached | Not enough account balance / marketplace funding gate not met (currently disabled) / no active reporting plan / report cap hit |
| 403 | insufficient_scope | The API key is read-only and the endpoint is not a GET or POST /api/ |
| 404 | product_not_found, gig_not_found, order_not_found, project_not_found, report_not_found | Resource not found |
| 409 | slug_taken, domain_taken, order_cancelled, order_refunded, already_has_issue, price_exceeded, price_changed, idempotency_in_progress | Slug or external custom domain already in use / order cannot be flagged (cancelled, refunded, or already has issue) / total passed your max_total_amount or moved since your quote (see error.current_total_amount) / a request with this idempotency key is still running (see Retry-After) |
| 422 | invalid_intake, invalid_link_attribute, invalid_slug, invalid_domain, quote_invalid, quote_expired, idempotency_key_reuse | Intake, slug, or domain failed validation (see error.fields) / link_attribute is invalid or not offered by the listing / quote_id is not valid for this request or has expired / the idempotency key was already used with a different request body |
| 429 | rate_limited | Endpoint rate limit exceeded (see Retry-After header) |
| 502 | domain_provisioning_failed | Netlify domain registration failed |
| 500 | internal_error, order_creation_failed, payment_failed | Server error |
Subscription products cannot be ordered through the API (including Custom Managed Backlink Budget (Monthly)). GET /api/ lists only balance-checkout catalog items. There is no minimum order amount for API purchases — any positive balance-backed total is allowed (Stripe/PayPal checkout minimums do not apply here).
Account
GET /api/ v1/ account
{
"email": "you@example.com",
"balance": 451.00,
"webhook": { "configured": true, "url": "https://...", "events": ["order.completed"] },
"projects": { "used": 12, "limit": 150 },
"reports": { "used": 8, "limit": 150 }
}projects and reports show how many you have created against the account-wide cap of 150 each.
Uploading intake attachments
Some products accept files as part of their intake (for example a brief or a spec sheet on a generic product, or supporting documents on Managed Budget). Because an order does not exist until you call POST /api/, files are uploaded in a separate first step and then referenced by id.
POST /api/ v1/ intake-attachments
Send a single file as multipart/form-data with a file field.
curl -X POST https://www.nicheranker.com/api/v1/intake-attachments \
-H "Authorization: Bearer nr_live_..." \
-F "file=@brief.pdf"Response 201:
{
"upload_id": "6f1c2d3e-....",
"filename": "brief.pdf",
"size": 12345,
"type": "application/pdf",
"expires_at": "2026-07-04T14:00:00Z"
}Limits and rules:
- One file per request. Call the endpoint again for each additional file.
- Maximum 10MB per file. Allowed types: images (JPEG, PNG, GIF, WebP), PDF, plain text, CSV, Word, and Excel. Unsupported files return
400 invalid_file. - Up to 10 files and 50MB total per intake.
- An
upload_idexpires 24 hours after upload and can be used once. Unused uploads are cleaned up automatically.
Referencing uploads in an order
Put the returned upload_id(s) in the intake attachments array:
{
"product_id": "984b41d1-8f90-42eb-95b0-06cfd656b709",
"quantity": 1,
"intake": {
"response": "See the attached brief.",
"attachments": [{ "upload_id": "6f1c2d3e-...." }]
}
}Each attachment is either a staged upload ({ "upload_id": "..." }) or a pre-hosted external reference ({ "url": "https:/). When the order is created, staged files are moved into permanent, order-scoped storage and appear on the intake form. If an upload_id is unknown, expired, or already used, the order is rejected with 422 invalid_intake (see error.fields) and your balance is not charged.
For per-unit intake products, each unit in the intake array may carry its own attachments.
Products & intake discovery
GET /api/ v1/ products
Returns the fixed catalog from /products plus Managed Budget (one-time) at /custom-budget. Does not include Guest Post Marketplace publisher sites — use GET /api/ for those — or freelance gigs — use GET /api/.
Only public catalog rows are returned (the same ones anonymous visitors see on /products). When several database rows share a product name at different prices, special-pricing variants (person email set) are excluded — e.g. public Niche Edits is $29, not client-specific duplicates. Also excludes internal and non-catalog services (community packages, reporting plans, etc.). Subscription products are excluded.
Current catalog (public prices — always confirm id via the endpoint):
| Product | Typical intake type |
|---|---|
| 30 Niche & Local Directories | generic |
| AI Press Release | press_release |
| Cloud Stacking Backlinks | cloud_stacking |
| Custom Managed Backlink Budget (One-Time) | managed_budget |
| Foundation Links | generic |
| GMB Pusher Stack | generic |
| GMB Ranker Links | generic |
| Google Power Stack | generic |
| Guest Post - DR 30+ With Traffic | generic |
| Niche Edit - DR 30+ With Traffic | generic |
| Niche Edits | generic |
| Niche Forum Comments | generic |
| Niche PBN Links | generic |
| Premium Press Release | generic |
| Social Proof Backlinks | social_proof |
| Social Proof Brand Mentions | social_proof |
| Top 10 Listicle | generic |
{
"products": [
{
"id": "0a1b...",
"name": "Cloud Stacking Backlinks",
"description": "...",
"price": 49.0,
"requires_intake": true,
"intake_type": "cloud_stacking",
"per_unit_intake": true
}
]
}Freelance gigs
Every active freelance gig can be bought through the API with the same account-balance checkout used for catalog products. Totals include the buyer processing fee (2.9% + $0.30) and any account discount. Quantity is always 1. Jobs and custom offers are not available via the API.
Gig lifecycle: intake_required → working → delivered → completed. Sellers must submit a formal delivery (notes and/or files) before an order can complete. Files sent only in chat do not count.
GET /api/ v1/ gigs
Browse active gigs. Rate limited to 200 requests / 24 hours per account.
Query: q, category (slug), min_price, max_price, seller_id, page, page_size (max 50).
{
"gigs": [
{
"id": "…",
"slug": "niche-guest-posts",
"title": "Niche guest posts",
"requires_intake": true,
"packages": [
{
"id": "…",
"name": "Standard",
"price": 99.0,
"delivery_days": 5,
"revisions": 1,
"white_label_available": true,
"buyer_fee": 3.27,
"discount_amount": 0,
"total": 102.27
}
]
}
],
"page": 1,
"page_size": 24,
"total": 12,
"total_pages": 1
}buyer_fee and total are computed for the authenticated account.
GET /api/ v1/ gigs/{id}
One active gig by UUID or slug. Same gig object as the list, including packages.
GET /api/ v1/ gigs/{id}/intake-schema
JSON Schema for that gig's seller-defined questions. Optional query package_id scopes white_label_available to that package (otherwise it is true if any package offers white-label).
If the gig has no intake questions and the (selected) package is not white-label, requires_intake is false and schema is null. White-label brand fields are optional — omit them or send empty strings for unbranded delivery.
{
"gig_id": "…",
"package_id": null,
"requires_intake": true,
"white_label_available": true,
"intake_type": "gig",
"questions": [{ "id": "q1", "type": "short_text", "prompt": "Target URL" }],
"schema": {
"type": "object",
"required": ["answers"],
"properties": {
"answers": {
"type": "object",
"required": ["q1"],
"properties": { "q1": { "type": "string", "description": "Target URL", "maxLength": 200 } }
},
"white_label": {
"type": "object",
"properties": {
"brand_name": { "type": "string", "maxLength": 120 },
"website": { "type": "string", "format": "uri" }
}
}
}
}
}Place the order with gig_id + package_id instead of product_id:
{
"gig_id": "…",
"package_id": "…",
"idempotency_key": "gig-ref-001",
"intake": {
"answers": { "q1": "https://example.com" },
"white_label": { "brand_name": "Acme SEO", "website": "https://acme.example" },
"attachments": [{ "upload_id": "6f1c2d3e-...." }]
}
}If the gig has questions, intake.answers is required or the request returns 422 invalid_intake and nothing is charged. If it has none, omit answers. white_label is always optional when the package has white_label_available. The order starts as working when intake is submitted in the same request. max_total_amount and quote_id (see Placing an order) apply to gig orders exactly as they do to catalog orders; the protected amount is the all-in total (package + extras + buyer fee − discount).
POST /api/ accepts the same gig body (no charge).
GET /api/ includes white_label: { available, brand_name, website }.
To finish intake on a web-purchased order that is still intake_required, or to set/update the white-label brand while the order is working:
POST /api/ v1/ orders/{id}/intake
{
"answers": { "q1": "https://example.com" },
"white_label": { "brand_name": "Acme SEO", "website": "https://acme.example" },
"attachments": [{ "upload_id": "…" }]
}White-label-only updates (no answers) are allowed on working gig orders whose package was white-label at purchase.
Gig orders use the same message and delivery URLs as catalog orders. While status is delivered, GET /api/ returns the deliverables with ready: false. After accept (or the 3-day auto-accept), ready is true and order.completed fires with data.delivery.
POST /api/ v1/ orders/{id}/accept
Buyer accepts a delivered gig. Sets status to completed.
POST /api/ v1/ orders/{id}/request-revision
{ "note": "Please update the anchor text on the second link." }Sends the order back to working. A non-empty note is required.
GET /api/ v1/ marketplace/ listings
Returns publisher sites from the Guest Post Marketplace — a separate inventory from /products. Publisher domain names are returned in full (unmasked) to authenticated API clients. Supports pagination and metric filters. Totals and deep pagination are computed in Postgres (not subject to Supabase's 1000-row PostgREST cap) — paginate with page until page >= total_pages.
Access requirements
- Funding gate (currently disabled): A one-time funding requirement may apply in the future (accounts must have loaded a minimum amount to their balance at least once). It is currently set to $0, so all authenticated API keys can access listings. If re-enabled, accounts below the threshold would receive
402 funding_required. Rate limits (two buckets):
- Full list — unfiltered
GET /api/requests: 50 per 24 hours per account (rolling window).v1/ marketplace/ listings - Incremental —
GET /api/andv1/ marketplace/ listings? updated_since= ... GET /api/: 500 per 24 hours per account.v1/ marketplace/ listings/ changes - Exceeding a bucket returns
429 rate_limitedwithRetry-After(seconds) andX-RateLimit-*headers. - For day-to-day sync, use the changes feed instead of re-fetching the full catalog.
- Full list — unfiltered
Only these marketplace endpoints are bucket-gated; the rest of the API (including GET /api/ and GET /api/) is unaffected.
Query parameters:
| Param | Default | Description |
|---|---|---|
page | 1 | Page number (offset paging) |
page_size | 20 | Results per page (max 1000) |
after | — | Opaque keyset cursor from a previous pagination.next_after. See Keeping inventory in sync. Ignores page. |
type | guestpost | guestpost or homepage |
updated_since | — | ISO-8601 timestamp. Only return listings whose updated_at is on or after this time. Alias: modified_since. Counts against the incremental rate-limit bucket. |
min_price, max_price | — | Price range |
min_dr, max_dr | — | Ahrefs DR range |
min_da, max_da | — | Moz DA range |
min_traffic, max_traffic | — | Ahrefs traffic range |
min_ref_domains, max_ref_domains | — | Referring domains range |
min_organic_keywords, max_organic_keywords | — | Organic keywords range |
website_niches | — | Comma-separated. Site's website_niche must include one of these, and the site must accept them. |
accepted_niches | — | Comma-separated. Site must accept these (or accept all — see note below). |
languages | — | Comma-separated site languages. |
link_attributes | — | Comma-separated: dofollow, nofollow, sponsored. Returns listings whose seller offers at least one of the given types. Listings with no declared attributes are excluded when this filter is used. Unknown values return 400 invalid_query. |
sort | created_at | price, dr, da, traffic, created_at, or updated_at |
sort_dir | desc | asc or desc |
Discover valid niche and language values with GET /api/. A listing with accepted_niches: null accepts all niches, except that is_clean_content sites never accept Adult, Gambling / Casino, Pharma, or Supplements.
link_attributes lists the link types the seller offers — any of dofollow, nofollow, sponsored; several values mean a mix is available. Listings default to ["dofollow"]. It is null only if the seller explicitly cleared it ("Not Specified" in the marketplace UI). To buy a specific type, pass link_attribute on POST /api/. Attribute changes appear in the changes feed as listing.updated events.
Each listing's metrics object mirrors the domain metrics shown on the marketplace UI (cached Ahrefs/Moz/Majestic data, refreshed periodically — not live). Any field can be null when we do not yet have that metric for a site.
metrics field | Meaning |
|---|---|
dr | Ahrefs Domain Rating |
da | Moz Domain Authority |
pa | Moz Page Authority |
traffic | Ahrefs organic traffic |
traffic_value | Ahrefs traffic value (USD) |
ref_domains | Ahrefs referring domains |
backlinks | Ahrefs backlinks |
organic_keywords | Ahrefs organic keywords |
tf | Majestic Trust Flow |
cf | Majestic Citation Flow |
moz_rank | Moz Rank |
moz_links | Moz Links |
{
"listings": [
{
"id": "9f2a...",
"name": "digitalexample.com",
"price": 150.0,
"type": "Guestpost",
"website_niche": ["Technology"],
"accepted_niches": null,
"website_language": ["English"],
"guest_post_creator": "optional",
"guest_post_types": ["guest_post", "link_insert"],
"link_attributes": ["dofollow", "sponsored"],
"is_clean_content": false,
"metrics": {
"dr": 45,
"da": 32,
"pa": 38,
"traffic": 1200,
"traffic_value": 3400,
"ref_domains": 500,
"backlinks": 9800,
"organic_keywords": 800,
"tf": 22,
"cf": 30,
"moz_rank": 4,
"moz_links": 15000
},
"requires_intake": true,
"intake_type": "generic",
"created_at": "2025-06-01T12:00:00.000Z",
"updated_at": "2026-08-10T08:30:00.000Z"
}
],
"pagination": { "page": 1, "page_size": 1000, "total": 14049, "total_pages": 15, "has_more": true, "next_after": "eyJzIjoxN…" }
}Two pagination modes:
- Offset (default) —
page/page_size, as above. Fine for filtered browsing. The response also includeshas_moreandnext_after, so a full catalogue load can start here and continue in keyset mode. - Keyset — pass the
aftercursor you got from the previous page. The response then contains no totals, only a continuation cursor:
{
"listings": [ /* … */ ],
"pagination": { "page_size": 1000, "has_more": true, "next_after": "eyJzIjoxN…" }
}next_after is opaque: pass it back verbatim as after. It is null on the last page. Keep sort and the filters constant while paging. Keyset paging walks the catalog in a stable order (sort, then name/type), so an unchanged row can never fall out of reach while inventory churns underneath your load. Use sort=created_at for full catalogue loads.
Keeping inventory in sync
Offset pages can skip or repeat rows when inventory changes mid-load. For a gap-free mirror, load with keyset cursors and replay the changes feed on top:
- Capture the feed head —
GET /api/and notev1/ marketplace/ listings/ changes? since= 0& limit= 1 latest_cursor(also present on every other changes response). - Initial load —
GET /api/, then keep followingv1/ marketplace/ listings? sort= created_at& page_size= 1000 pagination.next_after(pass it asafter) untilhas_moreisfalse. Upsert each row bylisting_id(or byname+type). - Replay — poll
GET /api/and apply every change (upsert onv1/ marketplace/ listings/ changes? since= <latest_cursor> created/updated, delete onremoved), advancing your stored cursor tonext_cursorafter each page.
Anything that happened during step 2 (removals included) shows up in step 3, so the end state is exact. Do not split initial load and replay across different days without re-checking retention_watermark (changes are retained 30 days).
- Day-to-day — keep polling the changes feed and/or use
GET /api/.v1/ marketplace/ listings? updated_since= ... - Optional — subscribe to
listing.created,listing.updated, andlisting.removedwebhooks for push notifications.
Inventory is refreshed every ~5 minutes. The updated_at on each listing reflects the last time that listing's API-visible fields changed.
GET /api/ v1/ marketplace/ listings/ changes
Returns an append-only feed of listing creates, updates, and removals since a cursor. This is the recommended way to detect sites leaving the marketplace.
Query parameters:
| Param | Default | Description |
|---|---|---|
since | 0 | Numeric cursor from a previous response's next_cursor, or an ISO-8601 timestamp to start from |
type | — | Optional guestpost or homepage filter |
limit | 200 | Max changes per page (max 200) |
Response:
{
"changes": [
{
"cursor": 42,
"event": "created",
"occurred_at": "2026-08-14T10:05:00.000Z",
"listing_id": "9f2a...",
"previous_listing_id": null,
"listing": {
"id": "9f2a...",
"name": "newsite.com",
"price": 150.0,
"type": "Guestpost",
"created_at": "2025-06-01T12:00:00.000Z",
"updated_at": "2026-08-14T10:05:00.000Z"
}
},
{
"cursor": 43,
"event": "removed",
"occurred_at": "2026-08-14T10:05:00.000Z",
"listing_id": "a1b2...",
"name": "oldsite.com",
"type": "Guestpost"
}
],
"next_cursor": 43,
"has_more": false,
"retention_days": 30,
"retention_watermark": "2026-07-15T10:05:00.000Z",
"latest_cursor": 43,
"server_time": "2026-08-14T10:05:00.000Z"
}Cursor rules
next_cursor— your position after this page. When a page has no changes (the common case when you are caught up), it echoes thesinceyou asked for instead of returningnull. Always storenext_cursoras your cursor; never interpret an emptychangesarray as "reset to zero".latest_cursor— the head of the feed (highest cursor ever issued), independent ofsinceandtype. Capture it before a full catalogue load and replay from it afterwards (see Keeping inventory in sync). It is returned on empty responses too, so a poller can detect it is behind without draining the feed.server_time— the database clock that stampsoccurred_atandretention_watermark. Compare your events against this value, not the HTTPDateheader: it is the same time reference and format as every other timestamp in this response. It is read from the database on every request. All/api/v1/*responses are sent withCache-Control: no-store, soserver_timeandlatest_cursorare never served from a CDN cache; if you see a repeatedserver_time, report theX-Nf-Request-IdandCache-Statusresponse headers.
Change entry shape
| Field | Present on | Meaning |
|---|---|---|
cursor | all | Monotonic integer. Pass as since on the next request. |
event | all | created, updated, or removed |
occurred_at | all | When the change was recorded |
listing_id | all | Current or last-known product id |
previous_listing_id | updated | Prior id when the winning listing row changed for the same domain |
name | removed | Publisher domain |
type | removed | Guestpost or Homepage |
listing | created, updated | Full listing object (same shape as /listings) |
Removals use event: "removed" with listing_id, name, and type. There is no listing object on removals.
Changes are retained for 30 days. If your cursor falls behind retention_watermark, re-seed from the full /listings endpoint.
Counts against the incremental rate-limit bucket (500 / 24h).
GET /api/ v1/ marketplace/ filters
Lists the niches, accepted niches, and languages currently present in API-orderable marketplace inventory, plus the full canonical niche taxonomy. Use it to populate filters before calling GET /api/.
website_niches/accepted_niches/languages— distinct values in current inventory (safe to pass back as listing filter params).taxonomy.regular/taxonomy.sensitive— the complete label set we support, including niches that may not currently have inventory.
{
"website_niches": ["Business", "Finance", "Technology"],
"accepted_niches": ["Finance", "Law", "Technology"],
"languages": ["English", "Spanish"],
"taxonomy": {
"regular": ["Technology", "Health & Wellness", "Finance", "..."],
"sensitive": ["Pharma", "Gambling / Casino", "Adult", "..."]
}
}GET /api/ v1/ products/{id}/intake-schema
Returns a JSON Schema describing the intake fields for a product. Use it to build and validate your request before ordering.
{
"product_id": "0a1b...",
"requires_intake": true,
"intake_type": "cloud_stacking",
"per_unit_intake": true,
"instructions": "Provide the target URL and business details...",
"schema": { "type": "object", "required": ["target_url", "keyword", "business_name", "business_description"], "properties": { } }
}Intake types
These products match the live catalog at /products plus Managed Budget (one-time) at /custom-budget. Guest Post Marketplace publisher sites are a separate inventory — discover them with GET /api/ (see Marketplace listings).
intake_type | Required fields | Products |
|---|---|---|
generic | response | Free-text. Optional attachments ({ url, filename }). From /products: 30 Niche & Local Directories, Foundation Links, GMB Pusher Stack, GMB Ranker Links, Google Power Stack, Guest Post - DR 30+ With Traffic, Niche Edit - DR 30+ With Traffic, Niche Edits, Niche Forum Comments, Niche PBN Links, Premium Press Release, Top 10 Listicle. |
managed_budget | project_id, goals[] | Custom Managed Backlink Budget (One-Time) — ordered via /custom-budget (monthly subscription is not available via the API) |
cloud_stacking | target_url, keyword, business_name, business_description | Cloud Stacking Backlinks |
social_proof | targets[] (brand_name, quantity) | Social Proof Backlinks, Social Proof Brand Mentions. Optional threads[] to supply dialogue (thread_title + comments required per thread; community_slug optional — we assign one if omitted), or omit threads[] for us to generate content. Optional auto_approve_suggestions skips manual content approval. |
press_release | inputs, generated | AI Press Release |
goals values for managed budget: rank_higher_google_business, rank_higher_organically, ai_responses, social_proof. project_id must belong to your account.
Don't guess the fields — see Intake examples by product below for a complete, copy-paste request body for each type. Call
GET /api/(catalog) orv1/ products GET /api/(marketplace) to fetch thev1/ marketplace/ listings idand confirmintake_type.
Testing your integration
You can exercise the whole flow — authentication, file upload, and intake validation — without spending anything. Only the final POST /api/ charges your account balance.
Evaluating the catalogue safely: create a read-only API key and run the evaluation against an account with no balance and no payment method. A read-only key cannot reach POST /api/ at all (403 insufficient_scope), and an account with $0 balance could not be charged even with a full-access key (402 insufficient_balance) — so spending is structurally impossible during the evaluation. Keys are issued and shown once in the dashboard (Account → API & Webhooks) and are never sent by email.
| Step | Endpoint | Cost |
|---|---|---|
| 1. Check your key + balance | GET /api/ | Free |
| 2. Find a product | GET /api/, GET /api/, or GET /api/ | Free |
| 3. Read its intake schema | GET /api/ or GET /api/ | Free |
| 4. Upload a file (optional) | POST /api/ | Free |
| 5. Validate your request | POST /api/ | Free |
| 6. Place the real order | POST /api/ | Charges balance |
A typical dry run with curl:
# 1. Confirm the key works and see your balance.
curl https://www.nicheranker.com/api/v1/account \
-H "Authorization: Bearer nr_live_..."
# 2. (Optional) Upload a file and note the returned upload_id.
curl -X POST https://www.nicheranker.com/api/v1/intake-attachments \
-H "Authorization: Bearer nr_live_..." \
-F "file=@brief.pdf"
# 3. Validate the exact order you plan to place — no charge, no order created.
curl -X POST https://www.nicheranker.com/api/v1/orders/validate \
-H "Authorization: Bearer nr_live_..." \
-H "Content-Type: application/json" \
-d '{
"product_id": "0a1b2c3d-...",
"quantity": 1,
"intake": {
"response": "Anchor text: best plumber austin",
"attachments": [{ "upload_id": "6f1c2d3e-...." }]
}
}'If step 3 returns "valid": true with "balance_sufficient": true, the same body sent to POST /api/ will succeed. A 422 response lists exactly which fields to fix in error.fields. See POST /api/ for the full response shape.
Placing an order
POST /api/ v1/ orders
One call deducts your balance, creates the order, and submits intake. Send either product_id (catalog / guest-post listing) or gig_id + package_id (freelance gig) — not both.
Request:
{
"product_id": "0a1b2c3d-...",
"quantity": 1,
"idempotency_key": "my-ref-001",
"max_total_amount": 55.0,
"link_attribute": "dofollow",
"intake": {
"target_url": "https://example.com",
"keyword": "plumber austin",
"business_name": "Acme Plumbing",
"business_description": "Licensed plumbers serving greater Austin."
}
}Response (201 Created):
{
"orders": [{ "id": "f9e8...", "status": "intake_submitted", "total_amount": 49.0, "product_name": "Cloud Stacking Backlinks", "quantity": 1 }],
"balance_remaining": 402.0,
"idempotent": false
}Notes:
Link attribute. For guest post / homepage listings, send the optional
link_attribute—dofollow,nofollow, orsponsored— to state the link type you want. It is stored on the order, shown to the seller on the order page, echoed back aslink_attributeon the order summary and onGET /api/, and checked up front:v1/ orders/ {id} - If the listing declares the attributes it offers (
link_attributeson the listing), the value must be one of them, otherwise the request is rejected with422 invalid_link_attributeand nothing is charged. - If the listing has not declared any ("Not Specified"), the request is accepted and passed to the seller as your requirement.
- Omit it for no preference. It is not valid for non-marketplace products.
- It is part of the idempotency fingerprint: reusing a key with a different
link_attributeis422 idempotency_key_reuse. POST /api/accepts and checks the same field.v1/ orders/ validate
- If the listing declares the attributes it offers (
Idempotency. Pass a unique
idempotency_keyper purchase. Repeating a request with the same key returns the original order(s) (HTTP 200,idempotent: true) without charging again, even if the price moved in the meantime. Two concurrent requests with the same key: one wins, the other gets409 idempotency_in_progress(withRetry-After) and can simply retry. The same key with a different request body is rejected with422 idempotency_key_reuse. If a request times out before you see the response, retry it as-is, or look it up read-only withGET /api/.v1/ orders? idempotency_key= ... Price protection. The total is computed at request time (prices, buyer fees, and discounts can change between calls). To avoid charging more than you agreed to, send either or both:
max_total_amount— a cap. If the computed total is higher, the request is rejected with409 price_exceededand nothing is charged. The rejection includeserror.current_total_amount.quote_id— thequote.idreturned byPOST /api/(valid 15 minutes). If the total no longer matches the quoted total, the request is rejected withv1/ orders/ validate 409 price_changedand nothing is charged. An unknown, foreign, or mismatched quote is422 quote_invalid; an expired one is422 quote_expired.
The checks run immediately before the balance debit, so a price rejection is atomic: either the exact agreed amount is taken, or nothing happens at all. A replayed idempotent response is returned before any price check, so retrying an already-created order never fails on a later price move.
Per-unit intake. For products with
per_unit_intake: true(andquantity > 1), sendintakeas an array with one object per unit:JSON{ "product_id": "...", "quantity": 2, "intake": [ { /* unit 1 */ }, { /* unit 2 */ } ] }Managed budget with
quantity > 1creates one order per unit; provide one intake per unit (array).Validation errors return HTTP 422 with field detail:
JSON{ "error": { "code": "invalid_intake", "message": "Intake validation failed.", "fields": [ { "field": "keyword", "message": "keyword is required." } ] } }
POST /api/ v1/ orders/ validate
Dry-run an order without any side effects. This runs the exact same checks as POST /api/ — product availability, intake validation, attachment references, and a balance read — but does not charge your balance, create an order, or consume any uploaded files. Use it while building your integration and call it as often as you like.
The request body is identical to POST /api/ (the idempotency_key is ignored):
{
"product_id": "0a1b2c3d-...",
"quantity": 1,
"intake": {
"response": "Anchor text: best plumber austin",
"attachments": [{ "upload_id": "6f1c2d3e-...." }]
}
}Response (200 OK) when the request would be accepted:
{
"valid": true,
"product_id": "0a1b2c3d-...",
"product_name": "Niche Edits",
"requires_intake": true,
"intake_type": "generic",
"per_unit_intake": false,
"quantity": 1,
"unit_price": 49.0,
"total_amount": 49.0,
"balance": 402.0,
"balance_sufficient": true,
"quote": {
"id": "eyJ2IjoxLCJ1aWQiOiJ...",
"total_amount": 49.0,
"unit_price": 49.0,
"expires_at": "2026-08-14T10:20:00.000Z"
}
}Notes:
quotepins the exact all-in total for 15 minutes. Passquote.idasquote_idonPOST /api/to get an atomic rejection (v1/ orders 409 price_changed) if the total moves before the debit. The quote is bound to your account and to this exact request (product/gig, package, extras, quantity) and cannot be used for anything else. Quotes are free to issue and cost nothing when they expire.
Notes:
- Validation and payment are reported separately. A structurally valid request returns
valid: trueeven whenbalance_sufficientisfalse— the realPOST /api/would then fail withv1/ orders 402 insufficient_balance. - Invalid requests return the same errors as a real order, for example
422 invalid_intakewitherror.fields, or404 product_not_found. - Because uploads are not consumed, you can reuse the same
upload_idhere as many times as you want; it is only consumed when a real order is placed.
GET /api/ v1/ orders
Read-only lookup by idempotency key. Answers "what happened to the order for this key?" when a create request timed out before returning its id. Works with read-only keys.
GET /api/
{
"orders": [{ "id": "f9e8...", "status": "working", "total_amount": 49.0, "product_name": "Niche Edits", "quantity": 1 }]
}orders is an empty array when the key is unknown (nothing was created). For managed-budget orders you get one entry per created order.
Retention and replay guarantees
- Lookup by
idempotency_keyworks for the life of the order record (guaranteed minimum 90 days). - Replaying the stored response of a finished request (the
idempotent: truebehaviour onPOST /api/) is guaranteed for 30 days; claims are pruned after that, but the lookup above keeps working.v1/ orders - A failed request that did not move money releases the key immediately, so the same key can be retried. A failure that did move money (for example an order that could not be assembled) is remembered and replayed on retry, so the same key can never charge twice; use a new key to place a fresh attempt.
GET /api/ v1/ orders/{id}
{
"id": "f9e8...",
"status": "working",
"total_amount": 49.0,
"items": [{ "product_name": "Cloud Stacking Backlinks", "quantity": 1 }],
"parent_order_id": null,
"is_sub_order": false,
"has_sub_orders": false,
"kind": "catalog",
"gig_id": null,
"package_id": null,
"acceptance_deadline": null,
"intake": { "required": true, "forms": [{ "id": "...", "submitted": true, "submitted_at": "..." }] },
"delivery": null
}For managed budget orders, has_sub_orders is true on the parent after staff approves the budget and child orders are spawned. Child orders have is_sub_order: true and parent_order_id set.
GET /api/ v1/ orders/{id}/children
List spawned sub-orders for a managed-budget parent (404 if the order does not exist; 400 with no_child_orders if the budget has not been approved yet):
{
"parent_order_id": "f9e8...",
"progress": { "total": 3, "completed": 2, "percent": 67, "all_completed": false },
"children": [
{
"order_id": "a1b2...",
"status": "completed",
"product_name": "Guest Post",
"quantity": 1,
"total_amount": 120.0,
"completed_at": "2026-06-21T17:00:00.000Z",
"delivered_at": "2026-06-21T17:00:00.000Z",
"delivery": { "message": "...", "urls": ["https://..."] }
}
]
}GET /api/ v1/ orders/{id}/delivery
Poll for deliverables. For a normal order this matches the order.completed webhook's data.delivery. For a managed-budget parent, returns a consolidated rollup across all child orders (same shape as the order.fully_delivered webhook):
{ "order_id": "f9e8...", "status": "working", "ready": false, "consolidated": true, "progress": { "total": 3, "completed": 1, "percent": 33, "all_completed": false }, "delivery": { "message": "1 of 3 managed budget items delivered.", "progress": { "..." }, "items": [{ "child_order_id": "...", "product_name": "...", "status": "completed", "delivery": { "..." } }] }, "delivered_at": null }For a child order, includes parent_order_id and that child's own delivery.
Messages
Orders have a message thread you can read and reply to. This is how the author / seller communicates about a guest post (for example, an update on the article where they need a response). Author/seller messages have sender_type: "staff", your replies are sender_type: "user", and automated updates are sender_type: "system".
To receive new author messages in real time, subscribe to the order.message_received webhook event. The endpoints below let you fetch the thread (polling) and post replies.
GET /api/ v1/ orders/{id}/messages
Returns the full thread, oldest first (404 if the order is not on your account):
{
"order_id": "f9e8...",
"messages": [
{
"id": "m1...",
"sender_type": "staff",
"message": "Quick question about the article title — can we shorten it?",
"attachments": [],
"is_read": false,
"created_at": "2026-07-15T13:00:00.000Z"
},
{
"id": "m2...",
"sender_type": "user",
"message": "Yes, please shorten it to under 60 characters.",
"attachments": [],
"is_read": true,
"created_at": "2026-07-15T13:05:00.000Z"
}
]
}POST /api/ v1/ orders/{id}/messages
Post a reply to the thread as the customer. A non-empty message or attachments is required; the assigned author/seller is notified by email.
{ "message": "Yes, please shorten it to under 60 characters." }Returns 201 with the created message:
{
"order_id": "f9e8...",
"message": {
"id": "m2...",
"sender_type": "user",
"message": "Yes, please shorten it to under 60 characters.",
"attachments": [],
"is_read": false,
"created_at": "2026-07-15T13:05:00.000Z"
}
}POST /api/ v1/ orders/{id}/issue
Flag an order as has_issue so staff can review it. Works for any status including completed. A non-empty message describing the problem is required. The message is posted to the order thread (with an ISSUE REPORTED prefix), staff are notified, and an order.status_changed webhook is queued if you subscribe to that event.
Leaving completed clears completed_at (same behavior as flagging from the dashboard).
{ "message": "The delivered link is broken / 404." }Returns 200:
{
"order_id": "f9e8...",
"status": "has_issue",
"message": {
"id": "m3...",
"sender_type": "user",
"message": "🚨 ISSUE REPORTED: The delivered link is broken / 404.",
"attachments": [],
"is_read": false,
"created_at": "2026-08-11T18:00:00.000Z"
}
}| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_body | Missing/empty message or invalid JSON |
| 404 | order_not_found | Order not on your account |
| 409 | order_cancelled | Cancelled orders cannot be flagged |
| 409 | order_refunded | Refunded orders cannot be flagged |
| 409 | already_has_issue | Order is already has_issue |
Intake examples by product
First call GET /api/ (catalog) or GET /api/ (marketplace) to find the product's id and intake_type, then copy the matching example below and replace the values. Every request goes to POST /api/.
Generic backlink / service products
intake_type: generic. Send a free-text response (and optional attachments).
Catalog products on /products that use this intake type:
| Product name |
|---|
| 30 Niche & Local Directories |
| Foundation Links |
| GMB Pusher Stack |
| GMB Ranker Links |
| Google Power Stack |
| Guest Post - DR 30+ With Traffic |
| Niche Edit - DR 30+ With Traffic |
| Niche Edits |
| Niche Forum Comments |
| Niche PBN Links |
| Premium Press Release |
| Top 10 Listicle |
Example request:
{
"product_id": "984b41d1-8f90-42eb-95b0-06cfd656b709",
"quantity": 4,
"idempotency_key": "ref-generic-001",
"intake": {
"response": "Anchor text: best plumber austin\nTarget URL: https://acmeplumbing.com\nPlacement notes: mention 24/7 emergency service in a natural paragraph.",
"attachments": [
{ "upload_id": "6f1c2d3e-...." },
{ "url": "https://files.example.com/brief.pdf", "filename": "brief.pdf" }
]
}
}attachments is optional. response is required. Each attachment is either a staged upload_id (see Uploading intake attachments) or a pre-hosted { url, filename }.
Guest Post Marketplace listings
intake_type: generic — same intake shape as catalog generic products, but publisher sites are not on /products or GET /api/. Each site in the Guest Post Marketplace is its own listing. Discover them with GET /api/; publisher domain names are returned in full (e.g. digitalexample.com).
Example request:
{
"product_id": "9f2a...",
"quantity": 1,
"idempotency_key": "ref-marketplace-001",
"intake": {
"response": "Anchor text: best plumber austin\nTarget URL: https://acmeplumbing.com\nArticle topic: plumbing maintenance tips"
}
}Use the id from GET /api/ as product_id. Intake schema is still at GET /api/.
Cloud Stacking Backlinks
intake_type: cloud_stacking. This product uses per-unit intake: when quantity > 1, send intake as an array with one object per unit (each unit is a separate business/site).
{
"product_id": "REPLACE_WITH_CLOUD_STACKING_PRODUCT_ID",
"quantity": 1,
"idempotency_key": "ref-cloud-001",
"intake": {
"target_url": "https://acmeplumbing.com",
"keyword": "emergency plumber austin",
"business_name": "Acme Plumbing",
"business_description": "Licensed 24/7 plumbers serving greater Austin since 2008.",
"business_language": "en",
"phone_number": "+1-512-555-0100",
"email": "hello@acmeplumbing.com",
"website": "https://acmeplumbing.com",
"country": "United States",
"city": "Austin",
"state": "TX",
"postal_code": "78701",
"street_address": "123 Congress Ave",
"google_map_embed_url": "https://www.google.com/maps/embed?pb=...",
"logo_url": "https://cdn.example.com/acme-logo.png",
"photos": [
"https://cdn.example.com/acme-shop.jpg",
"https://cdn.example.com/acme-team.jpg"
],
"video_urls": ["https://youtube.com/watch?v=abc123"],
"social_profiles": {
"google_business_profile": "https://g.page/acme-plumbing",
"facebook": "https://facebook.com/acmeplumbing",
"instagram": "https://instagram.com/acmeplumbing"
}
}
}Required: target_url, keyword, business_name, business_description. Everything else is optional. Files (logo_url, photos, video_urls) are passed as URLs.
Multiple units (one business per unit):
{
"product_id": "REPLACE_WITH_CLOUD_STACKING_PRODUCT_ID",
"quantity": 2,
"intake": [
{ "target_url": "https://acmeplumbing.com", "keyword": "emergency plumber austin", "business_name": "Acme Plumbing", "business_description": "..." },
{ "target_url": "https://acmehvac.com", "keyword": "ac repair austin", "business_name": "Acme HVAC", "business_description": "..." }
]
}Social Proof (and Social Proof Backlink)
intake_type: social_proof. Provide one or more targets; the sum of their quantity values must equal the order quantity. target_url is required when the product name contains "backlink".
Set auto_approve_suggestions: true to skip the manual content-approval step (same flag used for managed budget). The exact behavior depends on whether you also supply threads[]:
auto_approve_suggestions: true+ suppliedthreads[]→ fully automatic. Your supplied dialogue is treated as final, so the order skips both our staff assembly step and your Approve click in the dashboard. Fulfillment then begins after the standard delayed-publishing window (~1 hour) — you do not need to take any further action.auto_approve_suggestions: truewithoutthreads[]→ our team writes the thread dialogue, and once it is ready the content is approved automatically instead of waiting for you to click Approve.auto_approve_suggestions: false(or omitted) → the order waits for a manual Approve click in the dashboard after content is ready.
Example A — we generate the thread dialogue
Omit threads. Provide targets only; our team writes the back-and-forth and sends it for your approval (unless auto_approve_suggestions is true).
{
"product_id": "REPLACE_WITH_SOCIAL_PROOF_PRODUCT_ID",
"quantity": 5,
"idempotency_key": "ref-social-001",
"intake": {
"auto_approve_suggestions": false,
"targets": [
{
"brand_name": "Acme Plumbing",
"target_url": "https://acmeplumbing.com",
"quantity": 3,
"thread_type": "recommendation",
"competitor_names": "RotoFast, QuickPlumb",
"industry_niche": "Home services / plumbing",
"key_selling_points": "24/7, licensed, upfront pricing",
"preferred_tone": "casual"
},
{
"brand_name": "Acme HVAC",
"target_url": "https://acmehvac.com",
"quantity": 2,
"thread_type": "brand_comparison"
}
]
}
}Example B — you supply the thread dialogue
Include a threads array with one thread per order quantity (5 threads for quantity: 5). Each thread needs:
thread_title— the discussion title shown on the host sitecomments— the dialogue (see below)community_slug— optional; kebab-case category slug (e.g.home-improvement,local-services). If you omit it, we assign one automatically from the thread title and brand context before publishing.
Use comment_1 as the original post; add replies with parent_ref pointing at the comment they reply to (usually comment_1). At most 4 comments per thread (comment_1 plus up to 3 replies) — requests with more are rejected.
{
"product_id": "REPLACE_WITH_SOCIAL_PROOF_BACKLINKS_PRODUCT_ID",
"quantity": 2,
"idempotency_key": "ref-social-002",
"intake": {
"auto_approve_suggestions": true,
"targets": [
{
"brand_name": "Acme Plumbing",
"target_url": "https://acmeplumbing.com",
"quantity": 2,
"thread_type": "recommendation"
}
],
"threads": [
{
"thread_title": "Best emergency plumber in Austin?",
"community_slug": "home-improvement",
"thread_type": "recommendation",
"comments": [
{ "ref": "comment_1", "body": "Need a reliable 24/7 plumber in Austin — any recommendations?" },
{ "ref": "comment_2", "parent_ref": "comment_1", "body": "Acme Plumbing has been great for us. Licensed, upfront pricing, and they actually answer the phone at 2am." }
]
},
{
"thread_title": "Who do you use for slab leaks?",
"community_slug": "local-services",
"thread_type": "recommendation",
"comments": [
{ "ref": "comment_1", "body": "Slab leak under the kitchen — who would you call?" },
{ "ref": "comment_2", "parent_ref": "comment_1", "body": "We used Acme Plumbing last month. Fixed it same day: https://acmeplumbing.com" }
]
}
]
}
}thread_type is one of brand_comparison, recommendation, experience, random. Per target, only brand_name and quantity are required (plus target_url for backlink products). When supplying threads[], each thread requires thread_title and comments (with comment_1 as the opening post). community_slug is optional and auto-assigned if omitted.
Managed Budget
intake_type: managed_budget. Available as Custom Managed Backlink Budget (One-Time) — the same one-time product on /custom-budget. The monthly subscription is not orderable via the API (account balance only). Tie the budget to one of your existing projects and pick the goals. With quantity > 1 this creates one order per unit, so send intake as an array of length quantity.
{
"product_id": "REPLACE_WITH_MANAGED_BUDGET_PRODUCT_ID",
"quantity": 1,
"idempotency_key": "ref-budget-001",
"intake": {
"project_id": "YOUR_PROJECT_UUID",
"goals": ["rank_higher_google_business", "ai_responses"],
"auto_approve_suggestions": true,
"notes": "Focus on the local map pack for Austin."
}
}goals values: rank_higher_google_business, rank_higher_organically, ai_responses, social_proof. project_id must belong to your account.
After staff builds the budget and you approve it (or auto-approve is enabled), we spawn child orders for each line item. Track progress with GET /api/ or poll GET /api/. Subscribe to order.fully_delivered on your webhook to receive one consolidated payload when every child is done. Individual child orders still emit order.completed as each deliverable lands.
AI Press Release
intake_type: press_release. Provide both the inputs you used and the finished, wire-ready generated release.
{
"product_id": "REPLACE_WITH_AI_PRESS_RELEASE_PRODUCT_ID",
"quantity": 1,
"idempotency_key": "ref-pr-001",
"intake": {
"inputs": {
"businessName": "Acme Plumbing",
"industry": "Home services",
"website": "https://acmeplumbing.com",
"city": "Austin",
"stateProvince": "TX",
"country": "United States",
"announcementHeadline": "Acme Plumbing Launches 24/7 Emergency Service",
"announcementDetails": "Acme now guarantees a 60-minute response across greater Austin.",
"contactName": "Jane Doe",
"contactEmail": "pr@acmeplumbing.com",
"contactPhone": "+1-512-555-0100"
},
"generated": {
"title": "Acme Plumbing Launches 24/7 Emergency Service Across Greater Austin",
"summary": "Acme Plumbing now offers round-the-clock emergency plumbing with a 60-minute response guarantee.",
"content": "AUSTIN, TX — Acme Plumbing today announced the launch of its 24/7 emergency plumbing service...",
"categories": ["Business & Professional Services"],
"metaTitle": "Acme Plumbing 24/7 Emergency Service",
"metaDescription": "Acme Plumbing launches 24/7 emergency plumbing in Austin with a 60-minute response guarantee.",
"metaKeywords": "plumber, austin, emergency plumbing"
}
}
}Required in generated: title, content, and at least one categories entry. inputs must include businessName, announcementHeadline, and contactEmail.
Projects
Projects group your work and are referenced by managed-budget orders and reports. Each account can hold up to 150 projects.
POST /api/ v1/ projects
Only name is required. urls (if provided) are stored as the project's URL list.
{
"name": "Acme Plumbing",
"root_domain": "acmeplumbing.com",
"goals_strategy": "Dominate the Austin local map pack.",
"sitemap_url": "https://acmeplumbing.com/sitemap.xml",
"logo_url": "https://cdn.example.com/acme-logo.png",
"gmb": {
"place_id": "ChIJ...",
"name": "Acme Plumbing",
"address": "123 Congress Ave, Austin, TX 78701",
"latitude": 30.2672,
"longitude": -97.7431,
"google_maps_uri": "https://maps.google.com/?cid=...",
"phone": "+1-512-555-0100"
},
"urls": ["https://acmeplumbing.com", "https://acmeplumbing.com/services"]
}Response (201 Created):
{ "project": { "id": "p_123", "name": "Acme Plumbing", "root_domain": "acmeplumbing.com", "created_at": "2026-06-21T17:00:00.000Z" } }Creating the 151st project returns HTTP 400 project_limit_reached.
GET /api/ v1/ projects/{id}
Fetch a single project including its URL list.
PATCH /api/ v1/ projects/{id}
Update any project fields. All body fields are optional, but at least one must be provided. Same shape as create (name, root_domain, goals_strategy, sitemap_url, logo_url, gmb, urls). Passing urls replaces the entire URL list.
{
"name": "Acme Plumbing Austin",
"root_domain": "acmeplumbing.com",
"urls": ["https://acmeplumbing.com", "https://acmeplumbing.com/emergency"]
}GET /api/ v1/ projects
Lists your projects (newest first) so you can fetch a project_id for reports and managed-budget orders.
{ "projects": [{ "id": "p_123", "name": "Acme Plumbing", "root_domain": "acmeplumbing.com", "created_at": "..." }] }Reports
Reports are branded, shareable performance dashboards. Every report gets a default URL at {slug}.nicheranker.com. You can optionally attach your own domain (e.g. reports.yourdomain.com); we register it with Netlify and attempt SSL provisioning automatically (same as the dashboard). Creating a report requires an active reporting subscription, and each account can hold up to 150 reports.
POST /api/ v1/ reports
project_id is required and must belong to your account. If you omit slug, one is derived from the project's domain/name and made unique automatically.
Optional external_custom_domain attaches your own hostname. After creation, point DNS at the returned dns.cname_target (see response). SSL may take a few minutes once DNS propagates; scheduled jobs retry provisioning if needed.
{
"project_id": "p_123",
"slug": "acme-plumbing",
"external_custom_domain": "reports.acmeplumbing.com",
"brand_name": "Acme Plumbing",
"logo_url": "https://cdn.example.com/acme-logo.png",
"primary_color": "#FF6B35",
"font_family": "Inter",
"show_delivery_dates": true,
"hide_keyword_losses": false,
"hide_query_losses": false,
"show_progress_card": true,
"keyword_tracking_country": "us",
"password": "optional-viewer-password"
}Response (201 Created):
{
"report": {
"id": "r_456",
"slug": "acme-plumbing",
"url": "https://acme-plumbing.nicheranker.com",
"external_custom_domain": "reports.acmeplumbing.com",
"external_url": "https://reports.acmeplumbing.com",
"ssl_provisioned": false,
"dns": {
"cname_host": "reports",
"cname_target": "acme-plumbing.nicheranker.com",
"instruction": "Create a CNAME record: reports → acme-plumbing.nicheranker.com"
}
}
}Slug rules: 3–63 characters, lowercase letters, numbers, and single hyphens (^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?); reserved words are rejected. A taken slug returns HTTP 409 slug_taken; an invalid one returns 422 invalid_slug.
External domain rules: your own hostname only (not *.nicheranker.com — use slug for that). Must be a valid DNS name like reports.yourdomain.com. Returns 422 invalid_domain if malformed, 409 domain_taken if already used.
Other responses:
404 project_not_found— theproject_idisn't in your account.402 no_report_subscription— no active reporting plan.402 report_limit_reached— account, plan, or monthly report cap reached.
GET /api/ v1/ reports/{id}
Fetch a single report with branding fields and domain URLs.
PATCH /api/ v1/ reports/{id}
Update an existing report. All fields are optional; provide at least one. Supports the same branding fields as create, plus is_active and external_custom_domain. Set password to null or "" to remove viewer password protection. Changing slug updates the default {slug}.nicheranker.com URL; if you use an external custom domain, update its DNS CNAME to point at the new slug target.
{
"brand_name": "Acme Plumbing",
"primary_color": "#1A73E8",
"show_progress_card": true,
"is_active": true,
"external_custom_domain": "reports.acmeplumbing.com"
}Netlify alias registration and SSL renewal run automatically when external_custom_domain is included.
GET /api/ v1/ reports
Lists your reports (newest first).
{
"reports": [{
"id": "r_456",
"slug": "acme-plumbing",
"project_id": "p_123",
"is_active": true,
"custom_domain": "acme-plumbing.nicheranker.com",
"external_custom_domain": "reports.acmeplumbing.com",
"external_url": "https://reports.acmeplumbing.com",
"url": "https://acme-plumbing.nicheranker.com",
"ssl_provisioned": true
}]
}AI Ranker (Agency plan)
Read and manage your AI Ranker businesses with the same API keys: how often each AI engine recommends a business for its tracked questions, the questions themselves, the pages AI cites (with placement offers), scans and real AI traffic. These endpoints need the Agency plan on the account; other accounts get 403 plan_required. Read-only keys can call every GET endpoint.
GET https://www.nicheranker.com/api/v1/ai-ranker/businesses
Authorization: Bearer nr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxResults are measured from stored weekly answers. We sell placement on the pages AI cites, never an AI recommendation; estimated visits are labelled as estimates.
GET /api/ v1/ ai-ranker/ businesses
Every AI Ranker business (client) on the account with its visibility score (0–100), tracked question count and last scan.
{
"businesses": [
{
"id": "b_123",
"name": "Acme Plumbing",
"website": "https://acmeplumbing.com",
"root_domain": "acmeplumbing.com",
"plan": "agency",
"tracked_questions": 25,
"visibility_score": 34,
"last_scan": {
"id": "run_456",
"kind": "weekly",
"status": "done",
"created_at": "2026-10-12T06:00:00.000Z",
"finished_at": "2026-10-12T07:41:00.000Z"
}
}
]
}GET /api/ v1/ ai-ranker/ businesses/{id}/visibility
The visibility of one business this week and last week. questions[].results has one entry per engine (chatgpt, gemini, ai_overviews, ai_mode, perplexity, claude, grok, deepseek) with state recommended (with position), mentioned, not or none (not asked yet) and change since last week: new, lost, up, down, same or null for the first scan. Also returns the weekly history (3-week rolling), share of voice against the most-named competitors and an estimated monthly visits range. GET /api/ returns the same.
{
"business": {
"id": "b_123",
"name": "Acme Plumbing"
},
"week": "2026-W42",
"previous_week": "2026-W41",
"visibility_score": 34,
"previous_score": 29,
"recommended_answers": 41,
"answers": 150,
"recommendation_share": 0.273,
"engines": [
{
"engine": "chatgpt",
"label": "ChatGPT",
"answered": 25,
"recommended": 9,
"mentioned": 2,
"top_pages": [
{
"url": "https://example-guide.com/best-plumbers-austin",
"domain": "example-guide.com",
"cited": 6
}
]
}
],
"questions": [
{
"id": "q_1",
"text": "best emergency plumber in austin",
"ai_volume": 880,
"engines_recommending": 3,
"engines_recommending_last_week": 2,
"results": {
"chatgpt": {
"state": "recommended",
"position": 2,
"change": "up",
"positions_gained": 1,
"week": "2026-W42",
"cited_pages": [
"https://example-guide.com/best-plumbers-austin"
]
}
},
"placement": null
}
],
"history": [
{
"week": "2026-W42",
"score": 34,
"mention_rate": 0.3,
"rolling_3w": 0.27,
"answers": 150
}
],
"share_of_voice": [
{
"name": "Acme Plumbing",
"you": true,
"answers": 45,
"share": 0.21
}
],
"estimated_monthly_ai_visits": {
"low": 40,
"high": 160,
"estimate": true
},
"placements": []
}GET /api/ v1/ ai-ranker/ businesses/{id}/questions
Tracked questions (?status=suggested for the suggestions, ?status=all for both) and the question limit left. On the Agency plan the limit is pooled across all clients.
{
"questions": [
{
"id": "q_1",
"text": "best emergency plumber in austin",
"status": "tracked",
"pinned": false,
"custom": false,
"ai_volume": 880,
"intent": "best",
"added_at": "2026-09-01T10:00:00.000Z"
}
],
"limit": {
"questions": 300,
"remaining": 112,
"pooled": true
}
}POST /api/ v1/ ai-ranker/ businesses/{id}/questions
Track your own question, phrased the way people ask AI assistants. It is kept only if AI answers to it name at least 2 businesses or products (the recommendation check), and it counts toward the limit. Returns 201.
{
"text": "who should I hire to fix a burst pipe in austin"
}Errors: 400 question_limit_reached, 422 no_recommendations (the answers name fewer than 2 businesses; nothing to track).
DELETE /api/ v1/ ai-ranker/ businesses/{id}/questions/{questionId}
Stop tracking a question. Adding it again later starts its history fresh.
GET /api/ v1/ ai-ranker/ businesses/{id}/opportunities
Pages AI engines cite when answering this business's questions (listicles, news, forums, directories, reviews), most cited first, with domain metrics and the placement offer when the page is priced (price, billing one_time or monthly, slots_left, and saving only when we have the publisher's direct price). action is buy or request. ?limit= up to 200 (default 50).
POST /api/ v1/ ai-ranker/ businesses/{id}/scans
Re-ask every tracked question on the plan's engines now. Returns 202 with a run_id. One scan on demand per business per 24 hours, within the business's monthly scan budget; weekly scans run on their own.
{
"run_id": "run_789",
"status": "queued",
"questions": 25,
"engines": [
"ai_overviews",
"ai_mode",
"gemini",
"perplexity",
"grok",
"deepseek",
"chatgpt",
"claude"
],
"poll": "/api/v1/ai-ranker/scans/run_789"
}Errors: 409 scan_in_progress (with the running run_id), 429 scan_limit (with retry_after), 402 spend_cap_reached, 400 no_questions.
GET /api/ v1/ ai-ranker/ scans/{runId}
Progress of a scan: per engine how many answers are in, and each answer's result as it arrives. Poll every few seconds until status is done.
GET /api/ v1/ ai-ranker/ businesses/{id}/traffic
Real AI traffic: weekly sessions from chatgpt.com, perplexity.ai, gemini.google.com, Copilot and claude.ai for the last 12 complete weeks, measured by the business's connected Google Analytics 4 property (connect it on the business settings page). connected: false until then.
AI Ranker errors
| HTTP | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, malformed or revoked API key. |
| 403 | insufficient_scope | Read-only key on a write endpoint. |
| 403 | plan_required | The account is not on the Agency plan (or, for scans, the business has no weekly plan). |
| 404 | not_found | The business, question or scan is not on this account. |
| 409 | scan_in_progress | A scan is already running. |
| 429 | scan_limit | One scan on demand per business per 24 hours. |
MCP server
The same operations are tools for AI assistants over the Model Context Protocol (Streamable HTTP, stateless) at https:/. Connect with browser sign-in (see MCP) or send your API key as a Bearer token; read-only connections and keys can use the read tools.
{
"mcpServers": {
"ai-ranker": {
"type": "http",
"url": "https://www.nicheranker.com/api/mcp",
"headers": {
"Authorization": "Bearer nr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}
}
}list_businesses— the account's businesses with their visibility scoreget_visibility— per engine and question results, change since last week, history, share of voicelist_questions,add_question,remove_question— manage tracked questionslist_opportunities— cited pages and placement offersstart_scan,get_scan— scan on demand and its progressget_ai_traffic— weekly visits from AI assistants (Google Analytics)
Protocol versions: 2026-07-28 (per-request metadata, server/discover) and 2025-11-25, 2025-06-18, 2025-03-26 (initialize handshake; no session is created). Responses are JSON; GET on the endpoint returns 405.
curl -s https://www.nicheranker.com/api/mcp \
-H "Authorization: Bearer $NR_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-11-25" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'MCP
Everything the API does is also available to AI assistants (Claude, ChatGPT, Cursor, VS Code, Codex, Claude Code and any other MCP client) through one Model Context Protocol server. Set it up for your client on Dashboard → Settings → MCP, which has one-click install where the client supports it.
Server URL and sign-in
https://www.nicheranker.com/api/mcpStreamable HTTP. Clients sign in with your Niche Ranker account: no API key is needed. The first time a client connects it opens a browser tab where you sign in, choose Read only or Read and write, and choose Allow. You can disconnect a client at any time on the MCP settings page.
- Sign-in is OAuth 2.1 (authorization code with PKCE
S256) as in the MCP authorization specification. Clients register themselves with dynamic client registration (POST /api/) or a Client ID Metadata Document.oauth/ register - Discovery:
/.well-known/(protected resource metadata) andoauth-protected-resource/ api/ mcp /.well-known/(authorization server metadata). A request without a valid token getsoauth-authorization-server 401withWWW-Authenticate: Bearer resource_metadata=."…" - Access tokens (
nr_mcp_…) last 1 hour; refresh tokens last 60 days and are replaced on every use. Only hashes are stored. MCP access tokens also work as a Bearer token on the/api/v1endpoints.
# Claude Code
claude mcp add --transport http nicheranker https://www.nicheranker.com/api/mcp
# Codex
codex mcp add nicheranker --url https://www.nicheranker.com/api/mcpScopes
| Choice | Scopes | Allows |
|---|---|---|
| Read only | read | See the account and balance, products and intake schemas, marketplace sites, gigs, orders and their messages, projects, reports and the webhook, and dry-run orders with validate_order. |
| Read and write | read, write | Also place orders that spend your balance, submit intake, send messages, accept deliveries, request revisions, report issues, and change projects, reports and the webhook. |
A write tool on a read-only connection returns a tool error: This connection is read-only. Reconnect and choose Read and write to place orders or make changes.
Tools
Each tool calls the matching API endpoint, so validation, scopes, idempotency, balance checks and error messages are the API's. create_order spends your balance: it requires an idempotency_key, and assistants are told to run validate_order first and show you the total.
| Area | Read | Read and write |
|---|---|---|
| Account | get_account | — |
| Products | list_products, get_product_intake_schema | — |
| Guest post marketplace | list_marketplace_listings, get_marketplace_filters, get_marketplace_changes | — |
| Freelance gigs | list_gigs, get_gig, get_gig_intake_schema | — |
| Orders | validate_order, get_order, find_orders_by_idempotency_key, get_order_children, get_order_delivery, list_order_messages | create_order, submit_order_intake, accept_order, request_order_revision, report_order_issue, send_order_message |
| Projects | list_projects, get_project | create_project, update_project |
| Reports | list_reports, get_report | create_report, update_report |
| Webhooks | get_webhook | set_webhook, delete_webhook, test_webhook |
| AI Ranker (Agency plan) | list_businesses, get_visibility, list_questions, list_opportunities, get_scan, get_ai_traffic | add_question, remove_question, start_scan |
File upload (POST /api/) is not a tool: pass attachments as hosted { url, filename } references, which the API accepts wherever an attachment is allowed. AI Ranker tools need the Agency plan.
Using an API key instead
Clients that cannot sign in can send an API key as a Bearer token, with the key's own scope. Create one under Account → API & Webhooks.
{
"mcpServers": {
"nicheranker": {
"type": "http",
"url": "https://www.nicheranker.com/api/mcp",
"headers": {
"Authorization": "Bearer nr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}
}
}Webhooks
Configure a single endpoint to receive order events. Manage it from the dashboard or via the API.
POST /api/ v1/ webhooks
{ "url": "https://your-app.com/webhooks/nicheranker", "events": ["order.created", "order.completed"], "rotate_secret": false }The signing secret is returned once on creation or when rotate_secret is true. Other endpoints: GET /api/, DELETE /api/, POST /api/.
Events
| Event | When |
|---|---|
order.created | Immediately after an API or dashboard order is placed (including gigs) |
order.completed | When the order is delivered / accepted (includes data.delivery) |
order.status_changed | On other status changes (e.g. gig intake submitted, seller delivered, revision requested, issue flagged) |
order.fully_delivered | Managed budget only: when every child order is complete; parent payload includes consolidated data.delivery.items[] |
order.message_received | When the author / seller posts a message on the order (catalog order_messages or gig freelance_messages); reply with POST /api/ |
listing.created | A new publisher site appears in API marketplace inventory |
listing.updated | An existing listing's API-visible fields change |
listing.removed | A publisher site leaves API marketplace inventory (data contains listing_id, name, type, occurred_at) |
marketplace.listings_bulk_changed | Emitted instead of individual listing events when more than 500 listings change in one refresh cycle; drain /changes using data.since_cursor |
Order event payloads (order.created, order.completed, order.status_changed, order.fully_delivered) include relationship fields on data:
status— current order status (may berefundedafter support resolves a flagged issue)parent_order_id— set on child ordersis_sub_order—truefor spawned child ordershas_sub_orders—trueon managed-budget parent orders after spawn
Payload
{
"id": "evt_...",
"type": "order.completed",
"created_at": "2026-06-21T17:00:00.000Z",
"data": {
"order_id": "f9e8...",
"status": "completed",
"total_amount": 49.0,
"items": [{ "product_id": "...", "product_name": "...", "quantity": 1 }],
"delivery": { "message": "All links are live!", "urls": ["https://..."] },
"parent_order_id": null,
"is_sub_order": false,
"has_sub_orders": false,
"kind": "catalog",
"gig_id": null,
"package_id": null,
"metadata": { "source": "api" }
}
}Managed budget rollup (order.fully_delivered on the parent):
{
"id": "evt_...",
"type": "order.fully_delivered",
"created_at": "2026-06-21T18:00:00.000Z",
"data": {
"order_id": "parent-uuid",
"status": "completed",
"total_amount": 500.0,
"items": [{ "product_name": "Custom Managed Backlink Budget", "quantity": 1 }],
"delivery": {
"message": "All 3 managed budget items have been delivered.",
"progress": { "total": 3, "completed": 3, "percent": 100, "all_completed": true },
"items": [
{
"child_order_id": "child-uuid",
"product_name": "Guest Post",
"quantity": 1,
"status": "completed",
"completed_at": "2026-06-21T17:30:00.000Z",
"delivered_at": "2026-06-21T17:30:00.000Z",
"delivery": { "message": "...", "urls": ["https://..."] }
}
]
},
"parent_order_id": null,
"is_sub_order": false,
"has_sub_orders": true,
"metadata": { "source": "api", "fully_delivered_webhook_at": "2026-06-21T18:00:00.000Z" }
}
}Message received (order.message_received):
{
"id": "evt_...",
"type": "order.message_received",
"created_at": "2026-07-15T13:00:00.000Z",
"data": {
"order_id": "f9e8...",
"message": {
"id": "m1...",
"sender_type": "staff",
"message": "Quick question about the article title — can we shorten it?",
"attachments": [],
"created_at": "2026-07-15T13:00:00.000Z"
}
}
}Fetch the full thread with GET /api/ and reply with POST /api/.
Verifying signatures
Each delivery includes an X-NRNR-Signature header:
X-NRNR-Signature: t=1718990400,v1=hexhmacThe signature is HMAC-SHA256(secret, "{timestamp}.{rawBody}"). Recompute it from the raw request body and compare. Reject requests whose timestamp is too old to prevent replay.
import crypto from "crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Retries
Failed deliveries (non-2xx, timeout) are retried with exponential backoff (1m, 5m, 30m, 2h, 24h) up to 5 attempts. Inspect attempts under Account → API & Webhooks → Recent Deliveries. Respond 2xx quickly to acknowledge receipt.
End-to-end example
# 1. Discover the product and its intake schema
curl -H "Authorization: Bearer $NR_KEY" https://www.nicheranker.com/api/v1/products
curl -H "Authorization: Bearer $NR_KEY" https://www.nicheranker.com/api/v1/marketplace/listings?page_size=5
curl -H "Authorization: Bearer $NR_KEY" https://www.nicheranker.com/api/v1/products/$PID/intake-schema
# 2. Place the order (balance is charged, intake submitted)
curl -X POST https://www.nicheranker.com/api/v1/orders \
-H "Authorization: Bearer $NR_KEY" \
-H "Content-Type: application/json" \
-d '{"product_id":"'$PID'","quantity":1,"idempotency_key":"ref-001","intake":{"response":"Anchor: best plumber; URL: https://example.com"}}'
# 3. Receive `order.completed` at your webhook, or poll:
curl -H "Authorization: Bearer $NR_KEY" https://www.nicheranker.com/api/v1/orders/$ORDER_ID/deliveryRate limits
Treat 60 requests/minute per key as the working ceiling. Use idempotency keys so retries never double-charge.