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
- 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.
- Once Enterprise is active, open Marketplace Settings > API Access.
- Create a key. The full key is shown exactly once. Store it in your platform's secret manager; it can never be displayed again.
- 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
| Setting | Meaning | Default |
|---|---|---|
| Scopes | catalog: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 allowlist | IPv4/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 order | Orders above this total are refused with 403 order_cap_exceeded. | $250 (max $2,000) |
| Spend cap per 24 hours | Rolling window over orders paid with this key. Refused with 403 daily_cap_exceeded. | $500 (max $5,000) |
| Expiry | 30 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:
- Catalog sync (daily). Page through
GET /catalog/domains?hasCompletedOrders=true&limit=100and store each domain with the offering you chose (see Catalog endpoints for the selection recipe). On later runs passupdatedAfter=<last sync>to fetch only changes, and drop domains that no longer come back. - Ordering. Re-fetch the domain with
GET /catalog/domains/{id}right before ordering (price and availability can change), thenPOST /orderswith your own id inexternalRef. Store the returnedorder.idandorderNumber. Handle402 insufficient_balanceby topping up the wallet in the dashboard; nothing was charged. - Tracking (every 15 to 60 minutes).
GET /orders?statusIn=paid,accepted,in_progress,clarification_requested,submitted,revision_requestedand update your records fromstatus,publishedUrlanddeadlineAt. Answerclarification_requestedorders promptly, and approve or request a revision onsubmittedones 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.
| Bucket | Endpoints | Limit |
|---|---|---|
| catalog | GET /catalog/* | 60 requests per minute |
| read | GET /me, GET /balance, GET /orders* | 120 requests per minute |
| write | POST /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"
}
}
| HTTP | code | When |
|---|---|---|
| 401 | missing_api_key | No Authorization: Bearer header |
| 400 | https_required | Request was not HTTPS |
| 400 | validation_error | A field is missing, malformed or too long (the message says which) |
| 400 | unsupported_in_v1 | Content writing or coupons were requested |
| 401 | invalid_api_key | Key unknown or malformed |
| 401 | api_key_revoked | Key was revoked |
| 401 | api_key_expired | Key passed its expiry |
| 402 | insufficient_balance | Wallet does not cover the order total. Nothing was charged. |
| 402 | payment_failed | The wallet debit could not complete. Nothing was charged. |
| 403 | api_access_disabled | Partner API access was switched off for the account |
| 403 | account_suspended | The account is suspended |
| 403 | insufficient_scope | The key lacks the scope for this endpoint (requiredScope in body) |
| 403 | ip_not_allowed | Source IP is outside the key's allowlist |
| 403 | order_cap_exceeded | Order total above the key's max amount per order |
| 403 | daily_cap_exceeded | Order would exceed the key's 24-hour spend cap |
| 404 | not_found | Domain or order not found (or not yours) |
| 404 | domain_unavailable | Domain inactive |
| 404 | offering_unavailable | No active, approved offering from that publisher on that domain |
| 409 | publisher_unavailable | Publisher is not accepting orders |
| 409 | service_unavailable | The requested service is not offered by that publisher |
| 409 | invalid_transition | The order is not in a state that allows the action (currentStatus in body) |
| 409 | conflict | The order changed while processing; fetch and retry |
| 413 | payload_too_large or content_too_large | Body over 1.5 MB or articleContent over 1,000,000 characters |
| 422 | word_count_out_of_range | Article outside the publisher's word range (wordCount, minWordCount, maxWordCount in body) |
| 429 | rate_limited | Slow down (Retry-After) |
| 503 | not_configured or service_unavailable | Temporary 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 path | Scope | Purpose |
|---|---|---|
GET /me | any | Identify the key owner and the key in use |
GET /balance | orders:read | Wallet balance |
GET /catalog/categories | catalog:read | Category ids for filtering |
GET /catalog/domains | catalog:read | Paged catalog with offerings, prices and delivery stats |
GET /catalog/domains/{domainId} | catalog:read | One domain |
POST /orders | orders:write | Place and pay an order |
GET /orders | orders:read | List your orders |
GET /orders/{orderId} | orders:read | One order with history |
POST /orders/{orderId}/approve | orders:write | Accept delivered work |
POST /orders/{orderId}/revision | orders:write | Send delivered work back |
POST /orders/{orderId}/clarification-answer | orders:write | Answer 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.