{"openapi":"3.1.0","info":{"title":"CurlShip API","version":"1.0.0","summary":"Bot-friendly directory. Submit and manage listings with a single curl command.","description":"CurlShip is a 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\nStart at [`/agents.json`](/agents.json). Featured is the first tool. [`/agents.md`](/agents.md) is the same list in markdown. [`/llms.txt`](/llms.txt) is the long brief, and [`/edit`](/edit) is 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."},{"name":"x402","description":"USDC payments over the x402 protocol — a self-hosted facilitator settles on-chain; no checkout session, no keys to sign up for."}],"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."},"created_at":{"type":["string","null"],"format":"date-time","example":"2026-10-12T09:04:05Z","description":"When the listing was created, UTC."}}},"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. Retry-After is the wait in seconds. RateLimit-Policy names the window that was hit. RateLimit repeats that name with r=0 and t equal to Retry-After. Windows: submit 10 per 3600s, verify 30 per 3600s, checkout 30 per 3600s, edit 5 per 3600s per IP, edit-listing 3 per 86400s per listing.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}},"RateLimit-Policy":{"description":"The window that was hit. Example: \"submit\";q=10;w=3600.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining quota. Example: \"submit\";r=0;t=120.","schema":{"type":"string"}}},"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."},"created_at":{"type":["string","null"],"format":"date-time","example":"2026-10-12T09:04:05Z","description":"When the listing was created, UTC."}}},"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. Retry-After is the wait in seconds. RateLimit-Policy names the window that was hit. RateLimit repeats that name with r=0 and t equal to Retry-After. Windows: submit 10 per 3600s, verify 30 per 3600s, checkout 30 per 3600s, edit 5 per 3600s per IP, edit-listing 3 per 86400s per listing.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}},"RateLimit-Policy":{"description":"The window that was hit. Example: \"submit\";q=10;w=3600.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining quota. Example: \"submit\";r=0;t=120.","schema":{"type":"string"}}},"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. Retry-After is the wait in seconds. RateLimit-Policy names the window that was hit. RateLimit repeats that name with r=0 and t equal to Retry-After. Windows: submit 10 per 3600s, verify 30 per 3600s, checkout 30 per 3600s, edit 5 per 3600s per IP, edit-listing 3 per 86400s per listing.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}},"RateLimit-Policy":{"description":"The window that was hit. Example: \"submit\";q=10;w=3600.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining quota. Example: \"submit\";r=0;t=120.","schema":{"type":"string"}}},"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. Retry-After is the wait in seconds. RateLimit-Policy names the window that was hit. RateLimit repeats that name with r=0 and t equal to Retry-After. Windows: submit 10 per 3600s, verify 30 per 3600s, checkout 30 per 3600s, edit 5 per 3600s per IP, edit-listing 3 per 86400s per listing.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}},"RateLimit-Policy":{"description":"The window that was hit. Example: \"submit\";q=10;w=3600.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining quota. Example: \"submit\";r=0;t=120.","schema":{"type":"string"}}},"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 active listings","description":"With no query string, returns every active listing as JSON, in the same order as before. Optional query parameters narrow that list and do not add a new response envelope.\n\n`limit` and `offset` page the directory (default limit 50). `q` is a substring of the title, description, or URL (default limit 100). `since` is an ISO timestamp; only listings created at or after that moment are returned. Each listing includes `created_at`. There is no category or tag filter.","operationId":"listListings","parameters":[{"name":"limit","in":"query","required":false,"description":"Maximum number of listings. Defaults to 50 with offset, and to 100 with q.","schema":{"type":"integer","minimum":0,"example":50}},{"name":"offset","in":"query","required":false,"description":"How many matching listings to skip.","schema":{"type":"integer","minimum":0,"example":0}},{"name":"q","in":"query","required":false,"description":"Substring matched against title, description, and URL. LIKE wildcards are literal.","schema":{"type":"string","example":"makiroll"}},{"name":"since","in":"query","required":false,"description":"ISO timestamp. Keep listings created at or after this moment.","schema":{"type":"string","format":"date-time","example":"2026-10-01T00:00:00Z"}}],"responses":{"200":{"description":"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."},"created_at":{"type":["string","null"],"format":"date-time","example":"2026-10-12T09:04:05Z","description":"When the listing was created, UTC."}}}}}}}}},"400":{"description":"limit or offset is not a non-negative integer, or since is not an ISO timestamp","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/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."},"created_at":{"type":["string","null"],"format":"date-time","example":"2026-10-12T09:04:05Z","description":"When the listing was created, UTC."}}}}}}}},"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/stats/daily":{"get":{"tags":["Stats"],"summary":"Listings added per UTC day","description":"The zero-filled daily series behind the public [/stats](/stats) page: one entry per calendar day from the first listing to today (UTC), each with `added` and the `cumulative` running total since day one. `range` selects the window — `all` (default) or an integer 1-3650 for the last N days; a window's last `cumulative` is always the site total. Anything else gets a 400.","operationId":"getDailyStats","parameters":[{"name":"range","in":"query","required":false,"description":"`\"all\"` (default) for the whole series, or an integer 1-3650 for the last N days.","schema":{"type":"string","example":"all"}}],"responses":{"200":{"description":"The daily series","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"timezone":{"type":"string","example":"UTC"},"range":{"oneOf":[{"type":"string","example":"all"},{"type":"integer","example":30}],"description":"The window that was applied."},"first_day":{"type":["string","null"],"format":"date","example":"2026-03-20","description":"First day of the returned series."},"last_day":{"type":"string","format":"date","example":"2026-10-13","description":"Always today, UTC."},"total":{"type":"integer","example":2044,"description":"Total active listings counted in the series — the same number /api/stats reports, minus rows whose created_at cannot be parsed or predates the 2026-01-01 floor; those are skipped."},"days":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","format":"date","example":"2026-10-12"},"added":{"type":"integer","example":118,"description":"Listings created on that UTC day."},"cumulative":{"type":"integer","example":2044,"description":"Running total since the very first listing."}}}}}},"example":{"ok":true,"timezone":"UTC","range":"all","first_day":"2026-03-20","last_day":"2026-10-13","total":2044,"days":[{"date":"2026-10-12","added":118,"cumulative":2043},{"date":"2026-10-13","added":1,"cumulative":2044}]}}}},"400":{"description":"range is neither \"all\" nor an integer 1-3650","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/stats/dr":{"get":{"tags":["Stats"],"summary":"Domain Rating history","description":"Every recorded Ahrefs Domain Rating, one per UTC day, ascending — the series behind the [/stats](/stats#dr) chart. `current` is the latest reading (null until one is set). `change` compares the latest reading to the most recent one with a different value — the last actual step — and is null while fewer than two exist or every reading is equal. `growth` compares the latest reading to the very first and is null with fewer than two. History starts at the first recorded reading and grows by one row per day the DR is checked.","operationId":"getDrHistory","responses":{"200":{"description":"The DR series","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"current":{"oneOf":[{"type":"object","properties":{"value":{"type":"integer","minimum":0,"maximum":100,"example":46},"checked_at":{"type":"string","format":"date","example":"2026-10-03","description":"The day the DR was read off Ahrefs."}}},{"type":"null"}],"description":"The site's Ahrefs DR, or null while unset."},"history":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","format":"date","example":"2026-10-02"},"value":{"type":"integer","minimum":0,"maximum":100,"example":37}}}},"change":{"oneOf":[{"type":"object","properties":{"delta":{"type":"integer","example":9,"description":"Latest minus the last differing value — signed."},"since":{"type":"string","format":"date","example":"2026-10-02","description":"The last differing reading's day."},"from":{"type":"integer","example":37,"description":"The last differing reading's value."}}},{"type":"null"}],"description":"Latest vs the most recent reading with a different value — the last step. Null with fewer than two readings, or when every reading is equal."},"growth":{"oneOf":[{"type":"object","properties":{"delta":{"type":"integer","example":9,"description":"Latest minus first value — signed."},"since":{"type":"string","format":"date","example":"2026-10-02","description":"The first reading's day."},"from":{"type":"integer","example":37,"description":"The first reading's value."}}},{"type":"null"}],"description":"Latest vs first reading, or null with fewer than two."}}},"example":{"ok":true,"current":{"value":46,"checked_at":"2026-10-03"},"history":[{"date":"2026-10-02","value":37},{"date":"2026-10-03","value":46}],"change":{"delta":9,"since":"2026-10-02","from":37},"growth":{"delta":9,"since":"2026-10-02","from":37}}}}}}}},"/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.\n\nThe body cannot set a title, description, or image. Those are copied from the page (`og:title`, `og:description`, `og:image`, else the title tag and the description meta). Emailing replacement copy does not change the card.\n\n**Removing a free listing.** Send `remove: true` with `listing_id` and `proof`, and no `url` or `email`. Confirm it the same way as any other edit. The registered address gets a 7-day undo link. A paid listing, or the listing that holds the Featured spot, is refused (409) and stays a mail to support@curlship.com, because a payment is attached. One listing per root domain: to point the same listing at a different page, send a new `url` instead of `remove`.","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)"},"remove":{"type":"boolean","description":"true takes a free listing offline. Send no url and no email with it. A paid listing or the Featured holder is refused."},"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"}}}},"409":{"description":"Removal refused because the listing is paid or holds the Featured spot, or the registered address has permanently rejected mail","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded. Retry-After is the wait in seconds. RateLimit-Policy names the window that was hit. RateLimit repeats that name with r=0 and t equal to Retry-After. Windows: submit 10 per 3600s, verify 30 per 3600s, checkout 30 per 3600s, edit 5 per 3600s per IP, edit-listing 3 per 86400s per listing.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}},"RateLimit-Policy":{"description":"The window that was hit. Example: \"submit\";q=10;w=3600.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining quota. Example: \"submit\";r=0;t=120.","schema":{"type":"string"}}},"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."},"created_at":{"type":["string","null"],"format":"date-time","example":"2026-10-12T09:04:05Z","description":"When the listing was created, UTC."}}}}}}}},"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. Retry-After is the wait in seconds. RateLimit-Policy names the window that was hit. RateLimit repeats that name with r=0 and t equal to Retry-After. Windows: submit 10 per 3600s, verify 30 per 3600s, checkout 30 per 3600s, edit 5 per 3600s per IP, edit-listing 3 per 86400s per listing.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}},"RateLimit-Policy":{"description":"The window that was hit. Example: \"submit\";q=10;w=3600.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining quota. Example: \"submit\";r=0;t=120.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/x402/submit":{"post":{"tags":["x402"],"summary":"Buy a tier with USDC (x402)","description":"`bronze_lifetime` is $30 USDC once — the listing is owned forever. `platinum` is $49 USDC for a prepaid year (renewals extend from the current expiry, never shorten it). Without `PAYMENT-SIGNATURE` the response is 402 + a `PAYMENT-REQUIRED` quote; send the signed payment and the tier is granted after on-chain settlement. Same URL/email/listing rules as POST /api/submit's paid path. Rate limit: 30 checkout requests/hour per IP.","operationId":"x402Submit","parameters":[{"name":"PAYMENT-SIGNATURE","in":"header","required":false,"description":"base64 JSON x402 v2 PaymentPayload: the client's EIP-3009 TransferWithAuthorization signature, echoing the quote as `accepted`.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","email","tier"],"properties":{"url":{"type":"string","format":"uri","example":"https://yourapp.com"},"email":{"type":"string","format":"email","example":"you@email.com"},"tier":{"type":"string","enum":["bronze_lifetime","platinum"]}}},"example":{"url":"https://yourapp.com","email":"you@email.com","tier":"platinum"}}}},"responses":{"200":{"description":"Payment settled and granted. `PAYMENT-RESPONSE` (base64 JSON) carries the settlement receipt; `tx_hash` is also in the body.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"tx_hash":{"type":"string","example":"0x…"},"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."},"created_at":{"type":["string","null"],"format":"date-time","example":"2026-10-12T09:04:05Z","description":"When the listing was created, UTC."}}}}}}}},"202":{"description":"The payment was broadcast but is not confirmed yet. The tx hash rides `PAYMENT-RESPONSE` and `tx_hash` in the body — retry the same PAYMENT-SIGNATURE and the ledger reconciles it; nothing is granted until the payment confirms.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Malformed input, or a PAYMENT-SIGNATURE that is not a readable x402 v2 exact-EVM payment (v1 envelopes and Permit2-shaped payloads are refused before any verification).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Payment required — or the supplied payment could not be verified/settled (the reason is in `error`). `PAYMENT-REQUIRED` (base64 JSON, x402Version 2) and the body both carry `accepts[0]`: scheme `exact`, the configured network, the USDC asset, the atomic amount, `payTo`, `maxTimeoutSeconds` 300, and `extra` with the EIP-712 token name/version plus an HMAC quote binding. Echo `accepts[0]` back as `accepted` inside a signed PAYMENT-SIGNATURE to pay. A tampered quote re-402s with a fresh one.","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"The payment settled but the grant hit a business conflict — booked refund-needed for a human to reconcile — or a replayed payment already reached a reconciled state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded. Retry-After is the wait in seconds. RateLimit-Policy names the window that was hit. RateLimit repeats that name with r=0 and t equal to Retry-After. Windows: submit 10 per 3600s, verify 30 per 3600s, checkout 30 per 3600s, edit 5 per 3600s per IP, edit-listing 3 per 86400s per listing.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}},"RateLimit-Policy":{"description":"The window that was hit. Example: \"submit\";q=10;w=3600.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining quota. Example: \"submit\";r=0;t=120.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The x402 rail is enabled but not serving (misconfiguration or an unhealthy chain probe at startup) — it fails closed instead of minting quotes that could never settle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/x402/feature":{"post":{"tags":["x402"],"summary":"Bid on the Featured spot with USDC (x402)","description":"Same auction as POST /api/feature — `amount_usd` defaults to the current minimum — but payment is a signed x402 authorization, settled on-chain before the bid is marked paid. A losing bid in a concurrent race is booked refund-needed, never silently stranded. Rate limit: 30 checkout requests/hour per IP.","operationId":"x402Feature","parameters":[{"name":"PAYMENT-SIGNATURE","in":"header","required":false,"description":"base64 JSON x402 v2 PaymentPayload, echoing the quote as `accepted`.","schema":{"type":"string"}}],"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."}}},"example":{"url":"https://yourapp.com","amount_usd":51}}}},"responses":{"200":{"description":"Payment settled and granted. `PAYMENT-RESPONSE` (base64 JSON) carries the settlement receipt; `tx_hash` is also in the body.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"tx_hash":{"type":"string","example":"0x…"},"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."},"created_at":{"type":["string","null"],"format":"date-time","example":"2026-10-12T09:04:05Z","description":"When the listing was created, UTC."}}}}}}}},"202":{"description":"The payment was broadcast but is not confirmed yet. The tx hash rides `PAYMENT-RESPONSE` and `tx_hash` in the body — retry the same PAYMENT-SIGNATURE and the ledger reconciles it; nothing is granted until the payment confirms.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Malformed input, or a PAYMENT-SIGNATURE that is not a readable x402 v2 exact-EVM payment (v1 envelopes and Permit2-shaped payloads are refused before any verification).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Payment required — or the supplied payment could not be verified/settled (the reason is in `error`). `PAYMENT-REQUIRED` (base64 JSON, x402Version 2) and the body both carry `accepts[0]`: scheme `exact`, the configured network, the USDC asset, the atomic amount, `payTo`, `maxTimeoutSeconds` 300, and `extra` with the EIP-712 token name/version plus an HMAC quote binding. Echo `accepts[0]` back as `accepted` inside a signed PAYMENT-SIGNATURE to pay. A tampered quote re-402s with a fresh one.","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"The payment settled but the grant hit a business conflict — booked refund-needed for a human to reconcile — or a replayed payment already reached a reconciled state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded. Retry-After is the wait in seconds. RateLimit-Policy names the window that was hit. RateLimit repeats that name with r=0 and t equal to Retry-After. Windows: submit 10 per 3600s, verify 30 per 3600s, checkout 30 per 3600s, edit 5 per 3600s per IP, edit-listing 3 per 86400s per listing.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}},"RateLimit-Policy":{"description":"The window that was hit. Example: \"submit\";q=10;w=3600.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining quota. Example: \"submit\";r=0;t=120.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The x402 rail is enabled but not serving (misconfiguration or an unhealthy chain probe at startup) — it fails closed instead of minting quotes that could never settle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No listing found for that URL","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."},"created_at":{"type":["string","null"],"format":"date-time","example":"2026-10-12T09:04:05Z","description":"When the listing was created, UTC."}}},"Error":{"type":"object","properties":{"ok":{"type":"boolean","example":false},"error":{"type":"string","example":"url is required and must start with http"}},"required":["ok","error"]}}}}