Overview

Connect your own platform to the LinkWatcher marketplace with an API key. Access, keys, authentication, rate limits, errors and pagination.

The Partner API lets an external platform read the LinkWatcher marketplace catalog, place orders that are paid from your LinkWatcher wallet, and track those orders until delivery. It is built for partners who resell or embed our marketplace inside their own product.

Base URL: https://app.linkwatcher.io/api/v1

Everything is JSON over HTTPS. Timestamps are ISO-8601 in UTC (2026-09-24T08:15:30.000Z). Money is in US dollars as plain numbers (52.5).

The API is split over four articles: this overview, the catalog, orders, and security.

Getting access

  1. Partner API access is part of Marketplace Enterprise ($45/month): everything in Marketplace Pro plus the API. Upgrade under Marketplace Settings > Marketplace Pro in the LinkWatcher app. Existing Pro subscribers upgrade in place and only pay the prorated difference. Agencies and platforms with special requirements can also write to support@linkwatcher.io.
  2. Once Enterprise is active, open Marketplace Settings > API Access.
  3. Create a key. The full key is shown exactly once. Store it in your platform's secret manager; it can never be displayed again.
  4. Top up your LinkWatcher wallet in the dashboard. Orders placed through the API are paid from this balance (see Payments and balance).

What a key carries

SettingMeaningDefault
Scopescatalog:read (catalog), orders:read (your orders), orders:write (place orders, approve or revise delivered work). A key only gets the scopes you tick.catalog and orders read; write is opt-in
IP allowlistIPv4/IPv6 addresses or CIDR ranges. Requests from any other address are rejected with 403 ip_not_allowed. Empty means any IP.empty
Max amount per orderOrders above this total are refused with 403 order_cap_exceeded.$250 (max $2,000)
Spend cap per 24 hoursRolling window over orders paid with this key. Refused with 403 daily_cap_exceeded.$500 (max $5,000)
Expiry30 days, 90 days, 1 year or never. Expired keys get 401 api_key_expired.1 year

You can have up to 5 active keys. Give every integration its own key and revoke it from the same page the moment you suspect a leak. Creating and revoking keys always emails the account owner.

Authentication

Send the key as a bearer token on every request:

curl https://app.linkwatcher.io/api/v1/me \
  -H "Authorization: Bearer lw_live_YOUR_KEY"

Response:

{
  "userId": "e66f5d28-...",
  "email": "you@example.com",
  "fullName": "Your Name",
  "apiKey": { "id": "...", "name": "production", "prefix": "lw_live_45d0e7b1", "scopes": ["catalog:read", "orders:read", "orders:write"] }
}

Rules:

  • HTTPS only. Plain HTTP is refused.
  • Keys look like lw_live_ followed by 64 hex characters. Only a hash is stored on our side.
  • A key belongs to one LinkWatcher account. Everything it reads or writes is scoped to that account.
  • Keys cannot create, list or revoke keys; that only happens in the dashboard with a logged-in session.

Quick start for developers

Every request is a plain HTTPS call with one header. Below are the same two calls in Node.js and Python; the full contract is also published as an OpenAPI 3.1 document at linkwatcher.io/partner-api/openapi.yaml for client generators and API tools.

Node.js (18+, built-in fetch):

const BASE = "https://app.linkwatcher.io/api/v1";
const headers = { Authorization: `Bearer ${process.env.LINKWATCHER_API_KEY}`, "Content-Type": "application/json" };

async function api(path, init = {}) {
  const res = await fetch(BASE + path, { ...init, headers });
  const body = await res.json();
  if (!res.ok) throw Object.assign(new Error(body.error?.message), { code: body.error?.code, status: res.status, body });
  return body;
}

const me = await api("/me");
const page = await api("/catalog/domains?hasCompletedOrders=true&sortBy=completed&limit=100&page=1");
const order = await api("/orders", {
  method: "POST",
  body: JSON.stringify({
    domainId: page.domains[0].id,
    publisherId: page.domains[0].offerings[0].publisherId,
    serviceType: "link_insertion",
    targetUrl: "https://client-site.com/page",
    anchorText: "best running shoes",
    externalRef: "your-order-123",
  }),
});
console.log(order.order.orderNumber, order.order.status);

Python (3.9+, requests):

import os, requests

BASE = "https://app.linkwatcher.io/api/v1"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['LINKWATCHER_API_KEY']}"

def api(method, path, **kwargs):
    r = S.request(method, BASE + path, timeout=30, **kwargs)
    body = r.json()
    if not r.ok:
        raise RuntimeError(f"{r.status_code} {body['error']['code']}: {body['error']['message']}")
    return body

me = api("GET", "/me")
page = api("GET", "/catalog/domains", params={"hasCompletedOrders": "true", "sortBy": "completed", "limit": 100, "page": 1})
order = api("POST", "/orders", json={
    "domainId": page["domains"][0]["id"],
    "publisherId": page["domains"][0]["offerings"][0]["publisherId"],
    "serviceType": "link_insertion",
    "targetUrl": "https://client-site.com/page",
    "anchorText": "best running shoes",
    "externalRef": "your-order-123",
})
print(order["order"]["orderNumber"], order["order"]["status"])

Integration walkthrough

A typical integration has three jobs:

  1. Catalog sync (daily). Page through GET /catalog/domains?hasCompletedOrders=true&limit=100 and store each domain with the offering you chose (see Catalog endpoints for the selection recipe). On later runs pass updatedAfter=<last sync> to fetch only changes, and drop domains that no longer come back.
  2. Ordering. Re-fetch the domain with GET /catalog/domains/{id} right before ordering (price and availability can change), then POST /orders with your own id in externalRef. Store the returned order.id and orderNumber. Handle 402 insufficient_balance by topping up the wallet in the dashboard; nothing was charged.
  3. Tracking (every 15 to 60 minutes). GET /orders?statusIn=paid,accepted,in_progress,clarification_requested,submitted,revision_requested and update your records from status, publishedUrl and deadlineAt. Answer clarification_requested orders promptly, and approve or request a revision on submitted ones within 72 hours. See Order endpoints.

Treat every non-2xx response as described in the error table below, honour Retry-After on 429, and never log the key.

Rate limits

Limits are per key, in fixed one-minute windows.

BucketEndpointsLimit
catalogGET /catalog/*60 requests per minute
readGET /me, GET /balance, GET /orders*120 requests per minute
writePOST /orders*30 requests per minute

Every successful response carries X-RateLimit-Limit and X-RateLimit-Remaining. When you exceed a limit you get 429 rate_limited with a Retry-After header (seconds) and retryAfter in the body. Back off until then. Unknown keys are throttled per source IP as well.

Errors

Every error uses the same envelope:

{
  "error": {
    "code": "insufficient_balance",
    "message": "Insufficient wallet balance. Top up your LinkWatcher balance in the dashboard and retry.",
    "required": 52.5,
    "available": 20,
    "topUpUrl": "https://app.linkwatcher.io/marketplace/balance"
  }
}
HTTPcodeWhen
401missing_api_keyNo Authorization: Bearer header
400https_requiredRequest was not HTTPS
400validation_errorA field is missing, malformed or too long (the message says which)
400unsupported_in_v1Content writing or coupons were requested
401invalid_api_keyKey unknown or malformed
401api_key_revokedKey was revoked
401api_key_expiredKey passed its expiry
402insufficient_balanceWallet does not cover the order total. Nothing was charged.
402payment_failedThe wallet debit could not complete. Nothing was charged.
403api_access_disabledPartner API access was switched off for the account
403account_suspendedThe account is suspended
403insufficient_scopeThe key lacks the scope for this endpoint (requiredScope in body)
403ip_not_allowedSource IP is outside the key's allowlist
403order_cap_exceededOrder total above the key's max amount per order
403daily_cap_exceededOrder would exceed the key's 24-hour spend cap
404not_foundDomain or order not found (or not yours)
404domain_unavailableDomain inactive
404offering_unavailableNo active, approved offering from that publisher on that domain
409publisher_unavailablePublisher is not accepting orders
409service_unavailableThe requested service is not offered by that publisher
409invalid_transitionThe order is not in a state that allows the action (currentStatus in body)
409conflictThe order changed while processing; fetch and retry
413payload_too_large or content_too_largeBody over 1.5 MB or articleContent over 1,000,000 characters
422word_count_out_of_rangeArticle outside the publisher's word range (wordCount, minWordCount, maxWordCount in body)
429rate_limitedSlow down (Retry-After)
503not_configured or service_unavailableTemporary outage on our side; retry with backoff

Error messages never contain your input, stack traces or internal ids.

Pagination

List endpoints take page (from 1) and limit (1 to 100, default 50) and return:

"pagination": { "page": 1, "limit": 50, "total": 312, "totalPages": 7 }

Pages are stable between calls (deterministic ordering), so you can walk them safely.

Endpoints at a glance

Method and pathScopePurpose
GET /meanyIdentify the key owner and the key in use
GET /balanceorders:readWallet balance
GET /catalog/categoriescatalog:readCategory ids for filtering
GET /catalog/domainscatalog:readPaged catalog with offerings, prices and delivery stats
GET /catalog/domains/{domainId}catalog:readOne domain
POST /ordersorders:writePlace and pay an order
GET /ordersorders:readList your orders
GET /orders/{orderId}orders:readOne order with history
POST /orders/{orderId}/approveorders:writeAccept delivered work
POST /orders/{orderId}/revisionorders:writeSend delivered work back
POST /orders/{orderId}/clarification-answerorders:writeAnswer a publisher question

Changelog

  • 2026-09-24: v1 released. Catalog, wallet balance, place and track orders, approve, revision, clarification answers. Access is included in Marketplace Enterprise.