{"openapi":"3.1.0","info":{"title":"Until","version":"0.1.0","description":"No-signup, prepaid \"wake me when it happens\" watch API for agents (price drops, item back in stock, page changes). $0.50 per watch at list price, from prepaid credit packs (larger packs cost less per watch: $5 = 10 watches ($0.50 each); $20 = 50 watches ($0.40 each, save 20%); $50 = 150 watches ($0.33 each, save 33%); $100 = 300 watches ($0.33 each, save 33%); $250 = 800 watches ($0.31 each, save 37%)). Deterministic conditions only (no LLM). Static HTML/JSON only; bot-blocked and JS-only pages are refused with no charge. in_stock needs structured availability (Shopify product pages are supported via /products/<handle>.js)."},"servers":[{"url":"https://untilapi.com"}],"components":{"securitySchemes":{"creditKey":{"type":"http","scheme":"bearer","description":"Credit key from POST /v1/credits/checkout"}},"schemas":{"Condition":{"description":"Deterministic condition. One of the simple types, or all/any of up to 5 simple conditions.","oneOf":[{"type":"object","required":["type","value"],"properties":{"type":{"const":"price"},"op":{"enum":["<","<=",">",">=","==","!="]},"value":{"type":"number"},"currency":{"type":"string"}},"description":"Price from JSON-LD offers / price meta / itemprop=price"},{"type":"object","required":["type"],"properties":{"type":{"const":"in_stock"},"variant_id":{"type":["integer","string"],"description":"Optional Shopify variant id; default is the whole product (any variant available)."}},"description":"Needs structured availability. Shopify product URLs (/products/<handle>, .json too): reads /products/<handle>.js per-variant `available` (when robots.txt allows; variant via variant_id or ?variant=ID in the URL). Elsewhere: JSON-LD Offer availability or a product:availability meta tag; if none is found the condition is not evaluable (use text_appears or json_path)."},{"type":"object","required":["type","text"],"properties":{"type":{"const":"text_appears"},"text":{"type":"string"},"case_sensitive":{"type":"boolean"}}},{"type":"object","required":["type","text"],"properties":{"type":{"const":"text_absent"},"text":{"type":"string"},"case_sensitive":{"type":"boolean"}}},{"type":"object","required":["type","value"],"properties":{"type":{"const":"http_status"},"op":{"enum":["<","<=",">",">=","==","!="]},"value":{"type":"integer"}}},{"type":"object","required":["type","path","op"],"properties":{"type":{"const":"json_path"},"path":{"type":"string","examples":["$.data.price","items[0].stock"]},"op":{"enum":["<","<=",">",">=","==","!=","exists","not_exists","contains"]},"value":{}}},{"type":"object","required":["type","name","op"],"properties":{"type":{"const":"header"},"name":{"type":"string"},"op":{"enum":["==","!=","contains","exists","not_exists"]},"value":{"type":"string"}}},{"type":"object","required":["type","conditions"],"properties":{"type":{"enum":["all","any"]},"conditions":{"type":"array","maxItems":5,"items":{"type":"object"}}}}]},"Error":{"type":"object","properties":{"error":{"type":"string"},"message":{"type":"string"}}}}},"paths":{"/v1/check":{"post":{"summary":"Free dry-run of a condition (never charges; 30/hour/IP)","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","condition"],"properties":{"url":{"type":"string"},"condition":{"$ref":"#/components/schemas/Condition"}}}}}},"description":"Always HTTP 200 for a completed fetch, even when a real watch would be refused: check `accepted`, `page.usable`, `evaluation.evaluable` and the `warning` object (code + would_http_status, e.g. 409 already_true when matches_now is true, or 422 condition_not_evaluable / bot_blocked). Only POST /v1/watch returns those 409/422 status codes. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (epoch seconds); 429 adds Retry-After. Counters are per server instance, so treat Remaining as best-effort.","responses":{"200":{"description":"{charged:false, accepted, fetch, page, evaluation:{evaluable,matches_now,observed_value,detail,source}, warning}. accepted=false means the page was unusable or the condition not evaluable (still HTTP 200)."},"400":{"description":"Blocked URL (SSRF protection) or invalid request"},"422":{"description":"URL could not be fetched at all (DNS failure, too many redirects, connection error)"},"429":{"description":"rate_limited: 30/hour/IP; see Retry-After"}}}},"/v1/credits/checkout":{"post":{"summary":"Buy a prepaid credit bundle by card (Stripe Checkout). Creates a credit key if none is supplied.","description":"Packs: $5 = 10 watches ($0.50 each); $20 = 50 watches ($0.40 each, save 20%); $50 = 150 watches ($0.33 each, save 33%); $100 = 300 watches ($0.33 each, save 33%); $250 = 800 watches ($0.31 each, save 37%). Credits never expire. Pack price is paid by card; the account is credited watches x $0.50, so balance_cents/50 is the number of watches remaining.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["bundle"],"properties":{"bundle":{"enum":[5,20,50,100,250]}}}}}},"responses":{"201":{"description":"checkout_url to pay, and credit_key (shown once)"}}}},"/v1/credits":{"get":{"summary":"Credit balance","security":[{"creditKey":[]}],"responses":{"200":{"description":"balance_cents (credit value; 50 per watch), watches_remaining"}}}},"/v1/watch":{"post":{"summary":"Create a watch (one watch of credit; $0.50 list price)","description":"Returns 402 with payment instructions when no/insufficient credit. Refused pages return 4xx and are not charged: 422 for bot-blocked, JS-only, robots.txt, unreachable pages and condition_not_evaluable; 409 already_true when the condition is already true (unless allow_already_true).","security":[{"creditKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","condition"],"properties":{"url":{"type":"string"},"condition":{"$ref":"#/components/schemas/Condition"},"expires_in":{"description":"seconds or '36h'/'7d'; max 7d (default 7d)","oneOf":[{"type":"integer"},{"type":"string"}]},"check_interval_s":{"type":"integer","minimum":300,"maximum":86400,"default":900,"description":"Seconds between checks (default 900 = 15 min, minimum 300 = 5 min). The scanner runs every minute, so a check happens within about a minute after it is due. Not real-time."},"webhook":{"type":"string","description":"http(s) URL; receives signed POST (Until-Signature: t=..,v1=hmac_sha256(secret, t.body)) on match"},"allow_already_true":{"type":"boolean"}}}}}},"responses":{"201":{"description":"watch_id, state, status_url, webhook_secret (once)"},"402":{"description":"Payment required"},"409":{"description":"already_true (not charged)"},"422":{"description":"Page refused (not charged)"}}}},"/v1/watch/{id}":{"get":{"summary":"Watch state and signed receipt once matched (watch_id is the capability; no auth)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"state: active|matched|expired|failed; receipt when matched"},"404":{"description":"Unknown watch"}}}},"/v1/receipt/verify":{"post":{"summary":"Verify a receipt signature (HMAC, symmetric: verified server-side)","responses":{"200":{"description":"{valid: boolean}"}}}},"/mcp":{"post":{"summary":"MCP server (Streamable HTTP/JSON-RPC): tools create_watch, get_watch, test_condition","responses":{"200":{"description":"JSON-RPC response"}}}}}}