{
  "openapi": "3.1.0",
  "info": {
    "title": "LinkSwap API",
    "version": "1.3.0",
    "summary": "Agent-native link exchange marketplace",
    "description": "LinkSwap is a free link exchange marketplace for indie founders, site owners, and SEO professionals. LIVE: list marketplace categories, submit a site, verify domain ownership, read a public listing, retrieve rule-ranked matches, poll site status, create and accept link exchanges, and confirm crawler-verified link placements. Humans can run the same submission workflow with the browser form at https://linkswap.click/submit. PLANNED (HTTP 501): the MCP endpoint, daily link monitoring, and language-model reranking. Every error response contains a next_action field so an agent can recover without human input. All exchange writes require the per-site X-Control-Token; all GET endpoints are public.",
    "contact": {
      "name": "LinkSwap agent docs",
      "url": "https://linkswap.click/for-agents"
    },
    "license": {
      "name": "See site terms",
      "url": "https://linkswap.click/terms"
    }
  },
  "servers": [
    {
      "url": "https://linkswap.click/api/v1",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "categories",
      "description": "Discover the marketplace taxonomy and preview how free-text category input is resolved."
    },
    {
      "name": "sites",
      "description": "Submit and manage ownership-verified site listings."
    },
    {
      "name": "matches",
      "description": "Retrieve rule-ranked link partner candidates with written reasons."
    },
    {
      "name": "exchanges",
      "description": "Create and advance link exchange workflows."
    }
  ],
  "paths": {
    "/categories": {
      "get": {
        "tags": [
          "categories"
        ],
        "operationId": "listCategories",
        "summary": "List the marketplace taxonomy",
        "description": "Returns every category with slug, display name, group, aliases, complementary categories, verified-site count, indexability, and its landing page URL. Add ?q=<free text> to preview how the category field of POST /sites would resolve the value: the response reports the matched slug, how it matched (slug, name, alias, keyword, explicit_other, or fallback), and suggestions on ambiguity. Public read endpoint.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Optional free-text category value to resolve, e.g. q=an%20ai%20newsletter.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "The full category taxonomy and, when q is present, a resolution preview.",
            "content": {
              "application/json": {
                "schema": { "type": "object", "additionalProperties": true }
              }
            }
          }
        },
        "x-status": "live"
      }
    },
    "/sites": {
      "post": {
        "tags": [
          "sites"
        ],
        "operationId": "submitSite",
        "summary": "Submit a site for listing",
        "description": "Creates a listing in pending_verification state and returns a private control token. The token is shown once and is the only credential scoped to that site. Supplying a request_key makes retries idempotent. Limit: 5 submissions per key per day.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SiteSubmission"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Site created; ownership verification is pending.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteCreated"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MachineReadableError"
          },
          "429": {
            "$ref": "#/components/responses/MachineReadableError"
          }
        },
        "x-status": "live"
      }
    },
    "/sites/{domain}": {
      "get": {
        "tags": [
          "sites"
        ],
        "operationId": "getSite",
        "summary": "Read a public site listing by domain",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "example.com"
          }
        ],
        "responses": {
          "200": {
            "description": "Public listing.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Site"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/MachineReadableError"
          }
        },
        "x-status": "live"
      }
    },
    "/sites/{id}/verify": {
      "post": {
        "tags": [
          "sites"
        ],
        "operationId": "verifySite",
        "summary": "Trigger an ownership verification check",
        "description": "LinkSwap fetches the submitted domain and looks for the verification token in one of three places: a meta tag named linkswap-verification, a file under /.well-known/, or a DNS TXT record. Verification is automatic and usually completes in seconds. Requires the per-site control token in X-Control-Token; GET endpoints are public.",
        "security": [
          {
            "ControlToken": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "method": {
                    "$ref": "#/components/schemas/VerificationMethod"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Check finished. Inspect status before proceeding.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VerificationResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/MachineReadableError"
          }
        },
        "x-status": "live"
      }
    },
    "/matches": {
      "get": {
        "tags": [
          "matches"
        ],
        "operationId": "getMatches",
        "summary": "Retrieve rule-ranked link partner candidates",
        "description": "Deterministic scoring by category, complementary topic, language, DR proximity, LS Score, dropped-link history, and shared keywords. No LLM call is made; DeepSeek reranking is planned for step 5. Public read endpoint; no credentials required.",
        "parameters": [
          {
            "name": "domain",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 10,
              "default": 8
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ranked candidates.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["results", "engine", "count"],
                  "properties": {
                    "engine": { "type": "string", "example": "rules_v1" },
                    "count": { "type": "integer" },
                    "results": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Match"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "x-status": "live"
      }
    },
    "/exchanges": {
      "post": {
        "tags": [
          "exchanges"
        ],
        "operationId": "createExchange",
        "summary": "Request a link exchange",
        "description": "Creates an exchange in proposed state. The requester is inferred from the X-Control-Token; the target must be an ownership-verified active listing. A request expires automatically after seven days without acceptance, and only one active exchange is allowed per site pair. No email is sent: poll GET /exchanges for the next_action.",
        "security": [
          {
            "ControlToken": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ExchangeRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Exchange created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Exchange"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MachineReadableError"
          },
          "401": {
            "$ref": "#/components/responses/MachineReadableError"
          },
          "404": {
            "$ref": "#/components/responses/MachineReadableError"
          },
          "409": {
            "$ref": "#/components/responses/MachineReadableError"
          }
        },
        "x-status": "live"
      },
      "get": {
        "tags": [
          "exchanges"
        ],
        "operationId": "listExchanges",
        "summary": "List link exchanges for one domain",
        "description": "Public read. Returns every exchange where the domain is the requester or the target, each with a machine-readable next_action. The private request note is never returned. Proposed requests past their expiry are marked expired before the list is produced.",
        "parameters": [
          {
            "name": "domain",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "linear.app"
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Exchange list with counts and per-record next_action.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["domain", "count", "active", "results", "next_action"],
                  "properties": {
                    "domain": { "type": "string" },
                    "count": { "type": "integer" },
                    "active": { "type": "integer" },
                    "results": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/Exchange" }
                    },
                    "next_action": { "type": "string" }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MachineReadableError"
          }
        },
        "x-status": "live"
      }
    },
    "/exchanges/{id}/accept": {
      "post": {
        "tags": [
          "exchanges"
        ],
        "operationId": "acceptExchange",
        "summary": "Accept a proposed link exchange",
        "description": "Only the target site's X-Control-Token is accepted. Moves proposed to accepted and clears the expiry. Repeating the call is idempotent and returns already_accepted=true.",
        "security": [
          {
            "ControlToken": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "Exchange accepted.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Exchange" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/MachineReadableError" },
          "403": { "$ref": "#/components/responses/MachineReadableError" },
          "404": { "$ref": "#/components/responses/MachineReadableError" },
          "409": { "$ref": "#/components/responses/MachineReadableError" }
        },
        "x-status": "live"
      }
    },
    "/exchanges/{id}/confirm": {
      "post": {
        "tags": [
          "exchanges"
        ],
        "operationId": "confirmPlacement",
        "summary": "Report that a link has been placed",
        "description": "Either party may confirm in any order. page_url must be an http(s) URL on the reporting site's own registered domain. LinkSwap fetches the page, looks for an anchor pointing at the partner, records the result in link_checks, and moves the exchange to completed only after both sides are verified. If no anchor is found the response returns placement_verified=false and the exact HTML to add.",
        "security": [
          {
            "ControlToken": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "page_url"
                ],
                "properties": {
                  "page_url": {
                    "type": "string",
                    "format": "uri"
                  },
                  "anchor_text": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Placement check recorded. Inspect placement_verified, status and link_check before proceeding.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Exchange"
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/MachineReadableError" },
          "401": { "$ref": "#/components/responses/MachineReadableError" },
          "403": { "$ref": "#/components/responses/MachineReadableError" },
          "404": { "$ref": "#/components/responses/MachineReadableError" },
          "409": { "$ref": "#/components/responses/MachineReadableError" }
        },
        "x-status": "live"
      }
    },
    "/status": {
      "get": {
        "tags": [
          "exchanges"
        ],
        "operationId": "getStatus",
        "summary": "Poll the full lifecycle of one site",
        "description": "Poll verification state, listing visibility, active exchange count, the last recorded link check, the live capability list, and workflow availability for one domain. Public read endpoint; no credentials required.",
        "parameters": [
          {
            "name": "domain",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "example": "linear.app"
          }
        ],
        "responses": {
          "200": {
            "description": "Current lifecycle state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        },
        "x-status": "live"
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ControlToken": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Control-Token",
        "description": "Private token returned once by POST /sites. Scoped to a single site. Required for POST /sites/{id}/verify, POST /exchanges, POST /exchanges/{id}/accept and POST /exchanges/{id}/confirm. Not required by any GET endpoint or by POST /sites itself."
      },
      "ApiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Optional key. When supplied, the daily submission quota is counted per key instead of per client IP. It is not an authentication credential and does not raise the limit."
      }
    },
    "responses": {
      "MachineReadableError": {
        "description": "Error with a machine-readable recovery hint.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "SiteSubmission": {
        "type": "object",
        "required": [
          "domain"
        ],
        "properties": {
          "domain": {
            "type": "string",
            "description": "Bare domain, lowercased, without protocol or www.",
            "example": "mysite.dev"
          },
          "title": {
            "type": "string",
            "description": "Optional; fetched from the homepage when omitted."
          },
          "description": {
            "type": "string",
            "maxLength": 500
          },
          "category": {
            "type": "string",
            "description": "A category slug, a display name, or free text (for example \"saas\", \"SaaS & Startups\", or \"an ai newsletter\"). Values are resolved by exact match, alias match, then keyword containment. Unmatched text files the listing under \"other\" and the raw text is stored as a reviewable suggestion; the response reports exactly how the value matched via category_resolution. Preview any value first with GET /categories?q=.",
            "examples": ["saas", "SaaS & Startups", "developer-tools", "an ai newsletter"]
          },
          "content_language": {
            "type": "string",
            "description": "ISO 639-1 language code of the site content.",
            "example": "en"
          },
          "link_slots": {
            "type": "integer",
            "minimum": 0,
            "maximum": 50,
            "default": 3
          },
          "dr": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Self-reported Domain Rating, used to pair sites of similar authority."
          },
          "favicon_url": {
            "type": "string",
            "format": "uri",
            "description": "Optional https URL of the site icon, displayed on the listing card. A letter avatar is used when omitted."
          },
          "preview_image_url": {
            "type": "string",
            "format": "uri",
            "description": "Optional https URL of a homepage screenshot or OG image (900×420 works best) shown on the listing card and profile."
          },
          "source": {
            "type": "string",
            "enum": ["dashboard", "human"],
            "description": "Optional label for submissions made through the browser form at /submit. Anything else is classified as agent or api automatically."
          },
          "request_key": {
            "type": "string",
            "description": "Client-generated idempotency key."
          }
        }
      },
      "SiteCreated": {
        "type": "object",
        "properties": {
          "site_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "const": "pending_verification"
          },
          "control_token": {
            "type": "string",
            "description": "Shown once. Store it; it cannot be retrieved again."
          },
          "verification_token": {
            "type": "string"
          },
          "verification_methods": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "example": [
              "meta_tag",
              "file",
              "dns_txt"
            ]
          },
          "category": {
            "type": "string",
            "description": "The resolved category slug."
          },
          "category_resolution": {
            "type": "object",
            "description": "How the submitted category value was resolved: input, matched_by (slug | name | alias | keyword | explicit_other | fallback), slug, and suggestions when the input was ambiguous.",
            "additionalProperties": true
          },
          "category_suggestion_saved": {
            "type": "boolean",
            "description": "True when the raw category text did not match and was stored as a reviewable suggestion."
          },
          "next_action": {
            "type": "string",
            "example": "Publish the verification token, then POST /sites/{id}/verify"
          }
        }
      },
      "Site": {
        "type": "object",
        "properties": {
          "domain": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "example": "https://linkswap.click/sites/example.com"
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "category": {
            "type": "string"
          },
          "content_language": {
            "type": "string"
          },
          "dr": {
            "type": "integer",
            "description": "Self-reported Domain Rating; verified values are added in a later release."
          },
          "ls_score": {
            "type": "integer",
            "description": "Platform-computed trust score including dropped-link history."
          },
          "dropped_links": {
            "type": "integer"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending_verification",
              "active",
              "paused",
              "removed"
            ]
          }
        }
      },
      "VerificationMethod": {
        "type": "string",
        "enum": [
          "meta_tag",
          "file",
          "dns_txt",
          "badge"
        ],
        "description": "Three publishable channels plus a LinkSwap badge on the homepage, which also satisfies ownership when it contains the verification token."
      },
      "VerificationResult": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "verified",
              "pending_verification"
            ]
          },
          "method": {
            "$ref": "#/components/schemas/VerificationMethod"
          },
          "checked_url": {
            "type": "string",
            "format": "uri"
          },
          "next_action": {
            "type": "string"
          }
        }
      },
      "Match": {
        "type": "object",
        "properties": {
          "domain": {
            "type": "string"
          },
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "reason": {
            "type": "string",
            "description": "Short explanation of why the sites are relevant to each other."
          },
          "category": {
            "type": "string"
          },
          "content_language": {
            "type": "string"
          },
          "dropped_links": {
            "type": "integer"
          }
        }
      },
      "ExchangeRequest": {
        "type": "object",
        "required": [
          "target_domain"
        ],
        "properties": {
          "target_domain": {
            "type": "string",
            "description": "Bare domain or URL of an ownership-verified listing. The requester site is inferred from the X-Control-Token and is not sent in the body.",
            "example": "linear.app"
          },
          "message": {
            "type": "string",
            "maxLength": 600,
            "description": "Optional note from the requester. It is stored privately and is never returned by GET /exchanges or /dashboard."
          },
          "request_key": {
            "type": "string",
            "description": "Client-generated idempotency key. Reusing it returns the original exchange with already_exists=true."
          }
        }
      },
      "Exchange": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "status": {
            "type": "string",
            "enum": ["proposed", "accepted", "linking", "completed", "monitoring", "broken", "expired"],
            "description": "proposed → accepted → linking → completed. A proposed request with no acceptance becomes expired after seven days. broken and monitoring are reserved for step 7."
          },
          "source": {
            "type": "string",
            "enum": ["manual", "agent", "three_way"]
          },
          "requester_domain": { "type": "string" },
          "target_domain": { "type": "string" },
          "match_score": { "type": "integer", "nullable": true, "description": "Deterministic rules_v1 score for this pair." },
          "crawler_verified": { "type": "boolean", "description": "True only when both placements were verified." },
          "requester": { "$ref": "#/components/schemas/ExchangeSide" },
          "target": { "$ref": "#/components/schemas/ExchangeSide" },
          "expires_at": { "type": "string", "format": "date-time", "nullable": true },
          "created_at": { "type": "string", "format": "date-time" },
          "updated_at": { "type": "string", "format": "date-time" },
          "completed_at": { "type": "string", "format": "date-time", "nullable": true },
          "placement_verified": { "type": "boolean", "description": "Present on POST /exchanges/{id}/confirm responses only." },
          "link_check": { "$ref": "#/components/schemas/LinkCheck" },
          "already_exists": { "type": "boolean" },
          "already_accepted": { "type": "boolean" },
          "created": { "type": "boolean" },
          "ttl_days": { "type": "integer", "example": 7 },
          "next_action": { "type": "string", "description": "Exact next call or fix for an agent; present on every successful exchange response." }
        }
      },
      "ExchangeSide": {
        "type": "object",
        "properties": {
          "page_url": { "type": "string", "format": "uri", "nullable": true },
          "anchor_text": { "type": "string", "nullable": true },
          "confirmed_at": { "type": "string", "format": "date-time", "nullable": true, "description": "Set only after a crawler-verified link was found." }
        }
      },
      "LinkCheck": {
        "type": "object",
        "properties": {
          "found": { "type": "boolean" },
          "anchor_text": { "type": "string", "nullable": true },
          "rel_value": { "type": "string", "nullable": true, "description": "dofollow, nofollow, sponsored or ugc, as found on the anchor." },
          "state": { "type": "string", "enum": ["active", "soft_drop", "removed", "redirect"] },
          "checked_url": { "type": "string", "format": "uri" },
          "note": { "type": "string" }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error",
          "next_action"
        ],
        "properties": {
          "error": {
            "type": "string",
            "example": "missing_control_token"
          },
          "message": {
            "type": "string"
          },
          "next_action": {
            "type": "string",
            "description": "Exact instruction for the agent's next call or the exact fix to apply.",
            "example": "Add <meta name=\"linkswap-verification\" content=\"TOKEN\"> to your homepage, then retry POST /sites/{id}/verify"
          },
          "retryable": {
            "type": "boolean"
          }
        }
      }
    }
  },
  "x-capability-status": {
    "live": [
      "GET /categories",
      "POST /sites",
      "GET /sites/{domain}",
      "POST /sites/{id}/verify",
      "GET /matches",
      "GET /status",
      "POST /exchanges",
      "GET /exchanges",
      "POST /exchanges/{id}/accept",
      "POST /exchanges/{id}/confirm"
    ],
    "planned": [
      "/api/mcp",
      "daily-link-monitoring (step 7)",
      "llm-reranking (step 5)"
    ],
    "note": "Generated from src/lib/api-contract.ts. A capability is never documented as live before its route exists."
  }
}
