v1  Developer API

NicheRanker Public
Orders
API (v1)

Place orders programmatically using your account balance, submit the required intake in the same request, and receive deliverables via webhook when each order is completed.

Base URL
https://www.nicheranker.com/api/v1 (use www — requests to nicheranker.com redirect and many HTTP clients drop the Authorization header)
Authentication
API key (Bearer token)
Payment
account balance only (top up from your dashboard). Subscription products (reporting plans, managed-budget monthly, etc.) cannot be ordered via the API.
Limits
each account can hold up to 150 projects and 150 reports (across the dashboard and the API). Check current usage with GET /api/v1/account.

Manage your API keys and webhook endpoint at Account → API & Webhooks (/dashboard/settings/api).

Authentication

Send your API key as a Bearer token on every request:

HTTP
Authorization: Bearer nr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keys 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 GET endpoints plus POST /api/v1/orders/validate (a side-effect-free dry run). Every other endpoint returns 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:

JSON
{ "error": { "code": "insufficient_balance", "message": "Insufficient balance. Current: $5.00, required: $49.00." } }
HTTPcodeMeaning
401unauthorizedMissing or invalid API key
400invalid_body, invalid_query, invalid_file, product_unavailable, project_limit_reached, subscription_not_supportedBad request / invalid query parameter / unsupported or oversized file / product not orderable / project cap hit / subscription product (balance checkout only)
402insufficient_balance, funding_required, no_report_subscription, report_limit_reachedNot enough account balance / marketplace funding gate not met (currently disabled) / no active reporting plan / report cap hit
403insufficient_scopeThe API key is read-only and the endpoint is not a GET or POST /api/v1/orders/validate
404product_not_found, gig_not_found, order_not_found, project_not_found, report_not_foundResource not found
409slug_taken, domain_taken, order_cancelled, order_refunded, already_has_issue, price_exceeded, price_changed, idempotency_in_progressSlug 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)
422invalid_intake, invalid_link_attribute, invalid_slug, invalid_domain, quote_invalid, quote_expired, idempotency_key_reuseIntake, 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
429rate_limitedEndpoint rate limit exceeded (see Retry-After header)
502domain_provisioning_failedNetlify domain registration failed
500internal_error, order_creation_failed, payment_failedServer error

Subscription products cannot be ordered through the API (including Custom Managed Backlink Budget (Monthly)). GET /api/v1/products 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

JSON
{
  "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/v1/orders, 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.

Shell
curl -X POST https://www.nicheranker.com/api/v1/intake-attachments \
  -H "Authorization: Bearer nr_live_..." \
  -F "file=@brief.pdf"

Response 201:

JSON
{
  "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_id expires 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:

JSON
{
  "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://...", "filename": "brief.pdf" }). 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/v1/marketplace/listings for those — or freelance gigs — use GET /api/v1/gigs.

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):

ProductTypical intake type
30 Niche & Local Directoriesgeneric
AI Press Releasepress_release
Cloud Stacking Backlinkscloud_stacking
Custom Managed Backlink Budget (One-Time)managed_budget
Foundation Linksgeneric
GMB Pusher Stackgeneric
GMB Ranker Linksgeneric
Google Power Stackgeneric
Guest Post - DR 30+ With Trafficgeneric
Niche Edit - DR 30+ With Trafficgeneric
Niche Editsgeneric
Niche Forum Commentsgeneric
Niche PBN Linksgeneric
Premium Press Releasegeneric
Social Proof Backlinkssocial_proof
Social Proof Brand Mentionssocial_proof
Top 10 Listiclegeneric
JSON
{
  "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).

JSON
{
  "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.

JSON
{
  "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:

JSON
{
  "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/v1/orders/validate accepts the same gig body (no charge).

GET /api/v1/orders/{id} 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

JSON
{
  "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/v1/orders/{id}/delivery 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

JSON
{ "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/v1/marketplace/listings requests: 50 per 24 hours per account (rolling window).
    • Incremental — GET /api/v1/marketplace/listings?updated_since=... and GET /api/v1/marketplace/listings/changes: 500 per 24 hours per account.
    • Exceeding a bucket returns 429 rate_limited with Retry-After (seconds) and X-RateLimit-* headers.
    • For day-to-day sync, use the changes feed instead of re-fetching the full catalog.

Only these marketplace endpoints are bucket-gated; the rest of the API (including GET /api/v1/account and GET /api/v1/marketplace/filters) is unaffected.

Query parameters:

ParamDefaultDescription
page1Page number (offset paging)
page_size20Results per page (max 1000)
after—Opaque keyset cursor from a previous pagination.next_after. See Keeping inventory in sync. Ignores page.
typeguestpostguestpost 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.
sortcreated_atprice, dr, da, traffic, created_at, or updated_at
sort_dirdescasc or desc

Discover valid niche and language values with GET /api/v1/marketplace/filters. 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/v1/orders. 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 fieldMeaning
drAhrefs Domain Rating
daMoz Domain Authority
paMoz Page Authority
trafficAhrefs organic traffic
traffic_valueAhrefs traffic value (USD)
ref_domainsAhrefs referring domains
backlinksAhrefs backlinks
organic_keywordsAhrefs organic keywords
tfMajestic Trust Flow
cfMajestic Citation Flow
moz_rankMoz Rank
moz_linksMoz Links
JSON
{
  "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 includes has_more and next_after, so a full catalogue load can start here and continue in keyset mode.
  • Keyset — pass the after cursor you got from the previous page. The response then contains no totals, only a continuation cursor:
JSON
{
  "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:

  1. Capture the feed head — GET /api/v1/marketplace/listings/changes?since=0&limit=1 and note latest_cursor (also present on every other changes response).
  2. Initial load — GET /api/v1/marketplace/listings?sort=created_at&page_size=1000, then keep following pagination.next_after (pass it as after) until has_more is false. Upsert each row by listing_id (or by name + type).
  3. Replay — poll GET /api/v1/marketplace/listings/changes?since=<latest_cursor> and apply every change (upsert on created/updated, delete on removed), advancing your stored cursor to next_cursor after 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).

  1. Day-to-day — keep polling the changes feed and/or use GET /api/v1/marketplace/listings?updated_since=....
  2. Optional — subscribe to listing.created, listing.updated, and listing.removed webhooks 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:

ParamDefaultDescription
since0Numeric cursor from a previous response's next_cursor, or an ISO-8601 timestamp to start from
type—Optional guestpost or homepage filter
limit200Max changes per page (max 200)

Response:

JSON
{
  "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 the since you asked for instead of returning null. Always store next_cursor as your cursor; never interpret an empty changes array as "reset to zero".
  • latest_cursor — the head of the feed (highest cursor ever issued), independent of since and type. 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 stamps occurred_at and retention_watermark. Compare your events against this value, not the HTTP Date header: 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 with Cache-Control: no-store, so server_time and latest_cursor are never served from a CDN cache; if you see a repeated server_time, report the X-Nf-Request-Id and Cache-Status response headers.

Change entry shape

FieldPresent onMeaning
cursorallMonotonic integer. Pass as since on the next request.
eventallcreated, updated, or removed
occurred_atallWhen the change was recorded
listing_idallCurrent or last-known product id
previous_listing_idupdatedPrior id when the winning listing row changed for the same domain
nameremovedPublisher domain
typeremovedGuestpost or Homepage
listingcreated, updatedFull 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/v1/marketplace/listings.

  • 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.
JSON
{
  "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.

JSON
{
  "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/v1/marketplace/listings (see Marketplace listings).

intake_typeRequired fieldsProducts
genericresponseFree-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_budgetproject_id, goals[]Custom Managed Backlink Budget (One-Time) — ordered via /custom-budget (monthly subscription is not available via the API)
cloud_stackingtarget_url, keyword, business_name, business_descriptionCloud Stacking Backlinks
social_prooftargets[] (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_releaseinputs, generatedAI 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/v1/products (catalog) or GET /api/v1/marketplace/listings (marketplace) to fetch the id and confirm intake_type.

Testing your integration

You can exercise the whole flow — authentication, file upload, and intake validation — without spending anything. Only the final POST /api/v1/orders 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/v1/orders 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.

StepEndpointCost
1. Check your key + balanceGET /api/v1/accountFree
2. Find a productGET /api/v1/products, GET /api/v1/marketplace/listings, or GET /api/v1/gigsFree
3. Read its intake schemaGET /api/v1/products/{id}/intake-schema or GET /api/v1/gigs/{id}/intake-schemaFree
4. Upload a file (optional)POST /api/v1/intake-attachmentsFree
5. Validate your requestPOST /api/v1/orders/validateFree
6. Place the real orderPOST /api/v1/ordersCharges balance

A typical dry run with curl:

Shell
# 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/v1/orders will succeed. A 422 response lists exactly which fields to fix in error.fields. See POST /api/v1/orders/validate 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:

JSON
{
  "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):

JSON
{
  "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, or sponsored — to state the link type you want. It is stored on the order, shown to the seller on the order page, echoed back as link_attribute on the order summary and on GET /api/v1/orders/{id}, and checked up front:

    • If the listing declares the attributes it offers (link_attributes on the listing), the value must be one of them, otherwise the request is rejected with 422 invalid_link_attribute and 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_attribute is 422 idempotency_key_reuse.
    • POST /api/v1/orders/validate accepts and checks the same field.
  • Idempotency. Pass a unique idempotency_key per 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 gets 409 idempotency_in_progress (with Retry-After) and can simply retry. The same key with a different request body is rejected with 422 idempotency_key_reuse. If a request times out before you see the response, retry it as-is, or look it up read-only with GET /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 with 409 price_exceeded and nothing is charged. The rejection includes error.current_total_amount.
    • quote_id — the quote.id returned by POST /api/v1/orders/validate (valid 15 minutes). If the total no longer matches the quoted total, the request is rejected with 409 price_changed and nothing is charged. An unknown, foreign, or mismatched quote is 422 quote_invalid; an expired one is 422 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 (and quantity > 1), send intake as an array with one object per unit:

    JSON
    { "product_id": "...", "quantity": 2, "intake": [ { /* unit 1 */ }, { /* unit 2 */ } ] }
  • Managed budget with quantity > 1 creates 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/v1/orders — 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/v1/orders (the idempotency_key is ignored):

JSON
{
  "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:

JSON
{
  "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:

  • quote pins the exact all-in total for 15 minutes. Pass quote.id as quote_id on POST /api/v1/orders to get an atomic rejection (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: true even when balance_sufficient is false — the real POST /api/v1/orders would then fail with 402 insufficient_balance.
  • Invalid requests return the same errors as a real order, for example 422 invalid_intake with error.fields, or 404 product_not_found.
  • Because uploads are not consumed, you can reuse the same upload_id here 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/v1/orders?idempotency_key=my-ref-001

JSON
{
  "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_key works for the life of the order record (guaranteed minimum 90 days).
  • Replaying the stored response of a finished request (the idempotent: true behaviour on POST /api/v1/orders) is guaranteed for 30 days; claims are pruned after that, but the lookup above keeps working.
  • 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}

JSON
{
  "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):

JSON
{
  "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):

JSON
{ "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):

JSON
{
  "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.

JSON
{ "message": "Yes, please shorten it to under 60 characters." }

Returns 201 with the created message:

JSON
{
  "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).

JSON
{ "message": "The delivered link is broken / 404." }

Returns 200:

JSON
{
  "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"
  }
}
HTTPcodeMeaning
400invalid_bodyMissing/empty message or invalid JSON
404order_not_foundOrder not on your account
409order_cancelledCancelled orders cannot be flagged
409order_refundedRefunded orders cannot be flagged
409already_has_issueOrder is already has_issue

Intake examples by product

First call GET /api/v1/products (catalog) or GET /api/v1/marketplace/listings (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/v1/orders.

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:

JSON
{
  "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/v1/products. Each site in the Guest Post Marketplace is its own listing. Discover them with GET /api/v1/marketplace/listings; publisher domain names are returned in full (e.g. digitalexample.com).

Example request:

JSON
{
  "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/v1/marketplace/listings as product_id. Intake schema is still at GET /api/v1/products/{id}/intake-schema.

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).

JSON
{
  "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):

JSON
{
  "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": "..." }
  ]
}

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 + supplied threads[] → 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: true without threads[] → 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).

JSON
{
  "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 site
  • comments — 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.

JSON
{
  "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.

JSON
{
  "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/v1/orders/{parent_id}/children or poll GET /api/v1/orders/{parent_id}/delivery. 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.

JSON
{
  "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.

JSON
{
  "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):

JSON
{ "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.

JSON
{
  "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.

JSON
{ "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.

JSON
{
  "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):

JSON
{
  "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 — the project_id isn'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.

JSON
{
  "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).

JSON
{
  "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.

HTTP
GET https://www.nicheranker.com/api/v1/ai-ranker/businesses
Authorization: Bearer nr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Results 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.

JSON
{
  "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/v1/ai-ranker/businesses/{id} returns the same.

JSON
{
  "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.

JSON
{
  "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.

JSON
{
  "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.

JSON
{
  "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

HTTPCodeMeaning
401unauthorizedMissing, malformed or revoked API key.
403insufficient_scopeRead-only key on a write endpoint.
403plan_requiredThe account is not on the Agency plan (or, for scans, the business has no weekly plan).
404not_foundThe business, question or scan is not on this account.
409scan_in_progressA scan is already running.
429scan_limitOne 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://www.nicheranker.com/api/mcp. 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.

JSON
{
  "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 score
  • get_visibility — per engine and question results, change since last week, history, share of voice
  • list_questions, add_question, remove_question — manage tracked questions
  • list_opportunities — cited pages and placement offers
  • start_scan, get_scan — scan on demand and its progress
  • get_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.

Shell
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

HTTP
https://www.nicheranker.com/api/mcp

Streamable 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/oauth/register) or a Client ID Metadata Document.
  • Discovery: /.well-known/oauth-protected-resource/api/mcp (protected resource metadata) and /.well-known/oauth-authorization-server (authorization server metadata). A request without a valid token gets 401 with WWW-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/v1 endpoints.
Shell
# 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/mcp

Scopes

ChoiceScopesAllows
Read onlyreadSee 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 writeread, writeAlso 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.

AreaReadRead and write
Accountget_account—
Productslist_products, get_product_intake_schema—
Guest post marketplacelist_marketplace_listings, get_marketplace_filters, get_marketplace_changes—
Freelance gigslist_gigs, get_gig, get_gig_intake_schema—
Ordersvalidate_order, get_order, find_orders_by_idempotency_key, get_order_children, get_order_delivery, list_order_messagescreate_order, submit_order_intake, accept_order, request_order_revision, report_order_issue, send_order_message
Projectslist_projects, get_projectcreate_project, update_project
Reportslist_reports, get_reportcreate_report, update_report
Webhooksget_webhookset_webhook, delete_webhook, test_webhook
AI Ranker (Agency plan)list_businesses, get_visibility, list_questions, list_opportunities, get_scan, get_ai_trafficadd_question, remove_question, start_scan

File upload (POST /api/v1/intake-attachments) 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.

JSON
{
  "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

JSON
{ "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/v1/webhooks, DELETE /api/v1/webhooks, POST /api/v1/webhooks/test.

Events

EventWhen
order.createdImmediately after an API or dashboard order is placed (including gigs)
order.completedWhen the order is delivered / accepted (includes data.delivery)
order.status_changedOn other status changes (e.g. gig intake submitted, seller delivered, revision requested, issue flagged)
order.fully_deliveredManaged budget only: when every child order is complete; parent payload includes consolidated data.delivery.items[]
order.message_receivedWhen the author / seller posts a message on the order (catalog order_messages or gig freelance_messages); reply with POST /api/v1/orders/{id}/messages
listing.createdA new publisher site appears in API marketplace inventory
listing.updatedAn existing listing's API-visible fields change
listing.removedA publisher site leaves API marketplace inventory (data contains listing_id, name, type, occurred_at)
marketplace.listings_bulk_changedEmitted 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 be refunded after support resolves a flagged issue)

  • parent_order_id — set on child orders

  • is_sub_order — true for spawned child orders

  • has_sub_orders — true on managed-budget parent orders after spawn

Payload

JSON
{
  "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):

JSON
{
  "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):

JSON
{
  "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/v1/orders/{id}/messages and reply with POST /api/v1/orders/{id}/messages.

Verifying signatures

Each delivery includes an X-NRNR-Signature header:

HTTP
X-NRNR-Signature: t=1718990400,v1=hexhmac

The 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));
}

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

Shell
# 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/delivery

Rate limits

Treat 60 requests/minute per key as the working ceiling. Use idempotency keys so retries never double-charge.

Checkout