{"openapi":"3.1.0","info":{"title":"CurlShip API","version":"1.0.0","summary":"Bot-friendly SaaS directory. Submit and manage listings with a single curl command.","description":"CurlShip is a SaaS directory that welcomes automated submissions — **bots, agents, and AI are explicitly welcome**. No CAPTCHA, no login, no approval queue. Submit a product, verify your dofollow badge on demand, change a listing you own, or upgrade to a paid tier, all over plain JSON.\n\nSee also [`/llms.txt`](/llms.txt) for a plain-text agent brief, and [`/edit`](/edit) for the human guide to changing a listing.","contact":{"name":"CurlShip Support","email":"support@curlship.com","url":"https://curlship.com"},"license":{"name":"Proprietary"}},"servers":[{"url":"https://curlship.com","description":"Production"}],"externalDocs":{"description":"Badge guide & instant verify","url":"https://curlship.com/badge"},"tags":[{"name":"Listings","description":"Create and read directory listings."},{"name":"Stats","description":"Live site counters."},{"name":"Badge","description":"Dofollow badge verification."},{"name":"Billing","description":"Paid-tier upgrades."},{"name":"Edits","description":"Self-service listing changes, with proof of ownership."}],"paths":{"/api/submit":{"post":{"tags":["Listings"],"summary":"Submit a product listing","description":"Add a product to the directory. Goes live instantly — no review queue. One listing per root domain. Rate limit: 10 submissions/hour per IP.\n\nFree tier gets a `nofollow` link; add the [badge](/badge) for `dofollow`. Pass `tier` to start a paid checkout instead: the listing is created (free until paid) and the response carries a `checkout_url`. `tier: \"featured\"` bids on the auction spot above Platinum — `amount_usd` optionally sets the bid, defaulting to the current minimum. If the payment system is unavailable the listing still lands as free and the response says so with `checkout_url: null`. An already-listed URL with a paid tier gets an upgrade checkout instead of a dead end.","operationId":"submitListing","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","email"],"properties":{"url":{"type":"string","format":"uri","example":"https://yourapp.com"},"email":{"type":"string","format":"email","example":"you@email.com"},"tier":{"type":"string","enum":["bronze","bronze_lifetime","silver","gold","platinum","featured"],"description":"Optional. Omit for the free tier. \"featured\" bids on the auction spot."},"amount_usd":{"type":"number","example":51,"description":"Featured only — the bid in USD, defaulting to the current minimum. Same rules as POST /api/feature."}}},"example":{"url":"https://yourapp.com","email":"you@email.com"}}}},"responses":{"201":{"description":"Listing created","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"message":{"type":"string"},"listing":{"type":"object","properties":{"id":{"type":"integer","example":2076},"url":{"type":"string","format":"uri","example":"https://yourapp.com/"},"tier":{"type":"string","enum":["free","bronze","silver","gold","platinum"]},"title":{"type":["string","null"],"example":"YourApp — ship faster"},"description":{"type":["string","null"]},"image":{"type":["string","null"],"format":"uri"},"has_badge":{"type":"boolean","description":"True if a dofollow link back to curlship.com was found on the site."}}},"badge_html":{"type":"string","description":"Ready-to-paste badge snippet."},"badge_guide":{"type":"string","format":"uri"}}}}}},"200":{"description":"Already listed (idempotent)","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Invalid input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"URL on a blocklist (adult/phishing/malware)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"A listing for this domain already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/verify-badge":{"post":{"tags":["Badge"],"summary":"Verify your badge on demand","description":"Immediately re-fetch your listed site and check for a link back to `curlship.com`. This is the manual trigger for the otherwise-hourly badge cron — use it right after adding the badge. A qualifying link (not `rel=nofollow/ugc/sponsored`) upgrades a free listing to `dofollow`. Paid tiers are always `dofollow`. Rate limit: 30/hour per IP.","operationId":"verifyBadge","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","example":"https://yourapp.com"}}},"example":{"url":"https://yourapp.com"}}}},"responses":{"200":{"description":"Check completed","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"has_badge":{"type":"boolean"},"dofollow":{"type":"boolean"},"tier":{"type":"string"},"listing":{"type":"object","properties":{"id":{"type":"integer","example":2076},"url":{"type":"string","format":"uri","example":"https://yourapp.com/"},"tier":{"type":"string","enum":["free","bronze","silver","gold","platinum"]},"title":{"type":["string","null"],"example":"YourApp — ship faster"},"description":{"type":["string","null"]},"image":{"type":["string","null"],"format":"uri"},"has_badge":{"type":"boolean","description":"True if a dofollow link back to curlship.com was found on the site."}}},"message":{"type":"string"}}},"example":{"ok":true,"has_badge":true,"dofollow":true,"tier":"free","message":"Badge verified — your link is now dofollow. 🎉"}}}},"400":{"description":"Missing or malformed url","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No listing found for that URL","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/upgrade":{"post":{"tags":["Billing"],"summary":"Upgrade a listing to a paid tier","description":"Returns a hosted checkout URL for the chosen tier. The listing must already exist. Rate limit: 30 upgrade requests/hour per IP.","operationId":"upgradeListing","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","tier"],"properties":{"url":{"type":"string","format":"uri","example":"https://yourapp.com"},"tier":{"type":"string","enum":["bronze","bronze_lifetime","silver","gold","platinum"],"description":"bronze_lifetime is a one-time $30 purchase; the listing stays on bronze and never expires."}}},"example":{"url":"https://yourapp.com","tier":"gold"}}}},"responses":{"200":{"description":"Checkout URL created","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"checkout_url":{"type":"string","format":"uri"}}}}}},"409":{"description":"Listing already has Bronze Lifetime","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"400":{"description":"Invalid tier","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Listing not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Payments not configured for this tier","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/featured":{"get":{"tags":["Billing"],"summary":"The Featured auction spot","description":"One listing sits above Platinum: the Featured spot, held by whoever paid the most (one-time payment). Returns the current holder (or `null`) and what taking the spot costs right now — the holder's price plus $1, or $50 when nobody holds it.","operationId":"getFeatured","responses":{"200":{"description":"Current holder and next bid","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"featured":{"oneOf":[{"$ref":"#/components/schemas/Listing"},{"type":"null"}],"description":"The holding listing plus `amount_usd` (what they paid), or null."},"next_bid_usd":{"type":"number","example":50},"next_bid_cents":{"type":"integer","example":5000},"currency":{"type":"string","example":"USD"}}},"example":{"ok":true,"featured":null,"next_bid_usd":50,"next_bid_cents":5000,"currency":"USD"}}}}}}},"/api/feature":{"post":{"tags":["Billing"],"summary":"Bid on the Featured spot","description":"Returns a hosted checkout URL for a Featured bid. The listing must already exist; its registered email gets billed. `amount_usd` is optional and defaults to the current minimum (holder's price + $1, or $50 with no holder); a lower bid is refused with 409. Pay and the spot is yours — always dofollow, no subscription — until someone outbids you. **No refund when outbid**: that is the game. Rate limit: 30 checkout requests/hour per IP.","operationId":"bidFeatured","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","example":"https://yourapp.com"},"amount_usd":{"type":"number","example":51,"description":"Your bid in USD, whole cents at most. Omit to bid the current minimum. Ceiling: $100,000."}}},"example":{"url":"https://yourapp.com","amount_usd":51}}}},"responses":{"200":{"description":"Checkout URL created","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"checkout_url":{"type":"string","format":"uri"},"amount_usd":{"type":"number","example":51},"amount_cents":{"type":"integer","example":5100},"message":{"type":"string"}}}}}},"400":{"description":"Unreadable URL, non-numeric amount, or a bid above the $100,000 ceiling","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Listing not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Bid below the current minimum (the response carries required_usd and current_usd), or the listing is not active","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Error"},{"type":"object","properties":{"required_usd":{"type":"number","example":51},"current_usd":{"type":"number","example":50}}}]}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Featured product not configured","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/listings":{"get":{"tags":["Listings"],"summary":"List all active listings","description":"Returns every active listing as JSON. Good for agents building a directory mirror or picking a tier.","operationId":"listListings","responses":{"200":{"description":"All active listings","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"listings":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer","example":2076},"url":{"type":"string","format":"uri","example":"https://yourapp.com/"},"tier":{"type":"string","enum":["free","bronze","silver","gold","platinum"]},"title":{"type":["string","null"],"example":"YourApp — ship faster"},"description":{"type":["string","null"]},"image":{"type":["string","null"],"format":"uri"},"has_badge":{"type":"boolean","description":"True if a dofollow link back to curlship.com was found on the site."}}}}}}}}}}}},"/api/listing/{id}":{"get":{"tags":["Listings"],"summary":"Fetch a single listing by id","description":"Returns one active listing as JSON. The id is the one shown in the submit response and in /l/<id> URLs.","operationId":"getListing","parameters":[{"name":"id","in":"path","required":true,"description":"Listing id","schema":{"type":"integer","example":2142}}],"responses":{"200":{"description":"The listing","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"listing":{"type":"object","properties":{"id":{"type":"integer","example":2076},"url":{"type":"string","format":"uri","example":"https://yourapp.com/"},"tier":{"type":"string","enum":["free","bronze","silver","gold","platinum"]},"title":{"type":["string","null"],"example":"YourApp — ship faster"},"description":{"type":["string","null"]},"image":{"type":["string","null"],"format":"uri"},"has_badge":{"type":"boolean","description":"True if a dofollow link back to curlship.com was found on the site."}}}}}}}},"400":{"description":"Id is not an integer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No active listing with that id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/stats":{"get":{"tags":["Stats"],"summary":"Live site stats","description":"Site-wide counters, refreshed about once a minute: listings created in the last rolling 24 hours, total active listings, and the site's Domain Rating by Ahrefs (`domain_rating` is `null` until one is set — refreshed daily from the free Ahrefs API, with set_dr.py as the manual fallback). The same numbers sit in the cmd-bar at the bottom of every page.","operationId":"getStats","responses":{"200":{"description":"The stats","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"domain_rating":{"oneOf":[{"type":"object","properties":{"value":{"type":"integer","minimum":0,"maximum":100,"example":37},"checked_at":{"type":"string","format":"date","example":"2026-09-20","description":"The day the DR was read off Ahrefs."},"source":{"type":"string","example":"Domain Rating by Ahrefs"},"license":{"type":"string","format":"uri","example":"http://ahrefs.com/legal/domain-rating-license"}}},{"type":"null"}],"description":"The site's Ahrefs DR, or null while unset."},"new_24h":{"type":"integer","example":12,"description":"Active listings created in the last rolling 24h."},"total":{"type":"integer","example":2044}}},"example":{"ok":true,"domain_rating":{"value":37,"checked_at":"2026-09-20","source":"Domain Rating by Ahrefs","license":"http://ahrefs.com/legal/domain-rating-license"},"new_24h":12,"total":2044}}}}}}},"/api/edit-request":{"post":{"tags":["Edits"],"summary":"Ask to change your listing","description":"Starts a change; applies nothing. Prove ownership one of two ways.\n\n`proof: \"email\"` mails a one-time confirmation link to the address the listing is registered to — the response never contains the token.\n\n`proof: \"domain\"` returns a token you must publish at `/.well-known/curlship-verify.txt` (as a whole line) or as `<meta name=\"curlship-verify\" content=\"…\">` on **both** the currently listed site and the new one, then confirm via `/api/edit-confirm`. Publishing it only on the new domain proves you own the destination, not the listing, and is refused — as is a domain proof when the listed site is unreachable.\n\nChanging `email` always requires `proof: \"email\"`. Requests expire after 24 hours. Rate limits: 5/hour per IP, 3/day per listing.\n\n**Refreshing stale metadata.** Send only `listing_id` and `proof` — no `url`, no `email` — to mean \"re-read the title, description and image from my page\". Use it when your site's copy changed and the directory card still shows what it said on the day you submitted. The URL is not modified, the badge is not reset, and the page scraped is always the one already on the listing. A refresh whose fetch fails, or that comes back with a bot-wall, parking or error-page title, is refused rather than written.\n\nConfirming a refresh takes **two** calls to `/api/edit-confirm`: the first returns `applied: false` with the proposed `changed` values and an `apply` digest and writes nothing; send that digest back to save it. The page is re-read on apply, so if it changed in between you get a fresh preview rather than a write you never saw.","operationId":"createEditRequest","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["listing_id"],"properties":{"listing_id":{"type":"integer","example":2142},"url":{"type":"string","format":"uri","description":"New product URL"},"email":{"type":"string","format":"email","description":"New registered address (needs proof=email)"},"proof":{"type":"string","enum":["email","domain"],"default":"email"}}},"example":{"listing_id":2142,"url":"https://new-url.example/","proof":"domain"}}}},"responses":{"201":{"description":"Request created — pending proof","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"request_id":{"type":"integer"},"proof":{"type":"string","enum":["email","domain"]},"status":{"type":"string","example":"pending"},"token":{"type":"string","description":"Domain proof only — publish this. Never returned for email proof."},"expires_at":{"type":"string"},"publish":{"type":"array","description":"Domain proof only — where the token has to appear.","items":{"type":"object"}},"message":{"type":"string"}}}}}},"400":{"description":"Bad body, bad URL, or email change without email proof","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No active listing with that id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Confirmation email could not be sent","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/edit-request/{id}":{"get":{"tags":["Edits"],"summary":"Poll an edit request","description":"Status of a request: pending, applied, expired or cancelled. Never echoes the token.","operationId":"getEditRequest","parameters":[{"name":"id","in":"path","required":true,"description":"Edit request id","schema":{"type":"integer","example":7}}],"responses":{"200":{"description":"The request","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"request":{"type":"object","properties":{"id":{"type":"integer"},"listing_id":{"type":"integer"},"proof":{"type":"string"},"status":{"type":"string","enum":["pending","applied","expired","cancelled"]},"changes":{"type":"object"},"created_at":{"type":"string"},"expires_at":{"type":"string"}}}}}}}},"400":{"description":"Id is not an integer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No request with that id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/edit-confirm":{"post":{"tags":["Edits"],"summary":"Confirm an edit request and apply it","description":"For a domain proof, this re-fetches every affected site and checks the token before writing anything. For an email proof the same thing happens when the recipient opens the mailed link and presses the confirm button (the link itself only displays the pending change — mail scanners fetch links, and the token is single use), so calling this endpoint by hand is only needed if you kept the token.\n\nOn success: the change is written, every field is logged with its old value, the registered address is emailed an undo link valid 7 days, and a URL change resets the badge to unverified.\n\n**A metadata refresh takes two calls here.** The first returns `applied: false`, `status: \"preview\"`, the proposed `changed` values and an `apply` digest, and writes NOTHING — do not read a 200 as \"done\", check `applied`. Send the digest back as `apply` to save it. The page is re-read on apply and the digest re-checked, so if it changed in between you get a fresh preview instead of a write nobody saw. URL and email changes stay one-step: those values came from you and are not re-read.","operationId":"confirmEditRequest","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["request_id","token"],"properties":{"request_id":{"type":"integer","example":7},"token":{"type":"string"},"apply":{"type":"string","description":"Metadata refresh only. The digest returned by the preview call. Omit it to get the preview; send it back to write. Ignored for url/email changes."}}},"example":{"request_id":7,"token":"Kx9…"}}}},"responses":{"200":{"description":"Applied, OR — for a metadata refresh sent without a matching `apply` digest — a preview that wrote nothing. Check `applied`.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"applied":{"type":"boolean","description":"false means nothing was written — this is a preview."},"status":{"type":"string","enum":["preview"],"description":"Present only on a preview."},"changed":{"type":"object","nullable":true,"description":"Preview only: field -> {from, to}. null when nothing would change."},"apply":{"type":"string","description":"Preview only: send this back to write."},"listing_id":{"type":"integer","description":"Preview only."},"url":{"type":"string","description":"Preview only: the page that was read."},"then":{"type":"object","description":"Preview only: the exact follow-up call that writes."},"message":{"type":"string"},"listing":{"type":"object","properties":{"id":{"type":"integer","example":2076},"url":{"type":"string","format":"uri","example":"https://yourapp.com/"},"tier":{"type":"string","enum":["free","bronze","silver","gold","platinum"]},"title":{"type":["string","null"],"example":"YourApp — ship faster"},"description":{"type":["string","null"]},"image":{"type":["string","null"],"format":"uri"},"has_badge":{"type":"boolean","description":"True if a dofollow link back to curlship.com was found on the site."}}}}}}}},"400":{"description":"Missing request_id or token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No request with that id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Bad token, expired, already used, or proof not satisfied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"schemas":{"Listing":{"type":"object","properties":{"id":{"type":"integer","example":2076},"url":{"type":"string","format":"uri","example":"https://yourapp.com/"},"tier":{"type":"string","enum":["free","bronze","silver","gold","platinum"]},"title":{"type":["string","null"],"example":"YourApp — ship faster"},"description":{"type":["string","null"]},"image":{"type":["string","null"],"format":"uri"},"has_badge":{"type":"boolean","description":"True if a dofollow link back to curlship.com was found on the site."}}},"Error":{"type":"object","properties":{"ok":{"type":"boolean","example":false},"error":{"type":"string","example":"url is required and must start with http"}},"required":["ok","error"]}}}}