Until API
API walkthrough
Four calls: dry-run, buy credits, create a watch, read the receipt. Machine-readable versions: openapi.json and llms.txt.
1. Dry-run a condition (free)
POST /v1/check fetches the page once and tells you whether it is watchable and whether the condition is true right now. It never charges and is limited to 30 requests per hour per IP.
urlstring · required- The public page to fetch (http or https, up to 2048 characters).
conditionobject · required- A deterministic condition.
curl -X POST https://untilapi.com/v1/check \
-H 'content-type: application/json' \
-d '{"url":"https://example.com","condition":{"type":"text_appears","text":"Example Domain"}}'Response (abridged):
{
"charged": false,
"accepted": true,
"fetch": { "http_status": 200, "final_url": "https://example.com/", "content_sha256": "..." },
"page": { "usable": true },
"evaluation": { "evaluable": true, "matches_now": true, "observed_value": "...", "detail": null, "source": null },
"warning": { "code": "already_true", "would_http_status": 409, "message": "..." }
}A dry-run is always HTTP 200 once the page was fetched. It does not use 409/422 itself; it tells you what a real watch would do. Check accepted (false = the page is unusable or the condition cannot be evaluated; see page.code / evaluation.detail) and warning, which is null or {code, would_http_status, message}: already_true (409) when matches_now is true, since POST /v1/watch then refuses with 409 already_true unless you pass allow_already_true; or a 422 code such as bot_blocked, js_required or condition_not_evaluable. Only POST /v1/watch returns the 409/422 status codes. A URL that cannot be fetched at all (DNS failure, too many redirects) or a blocked address returns an error status instead (422 / 400).
Every /v1/check response includes X-RateLimit-Limit (30), X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds when the hourly window resets). A 429 adds Retry-After (seconds). Counters are kept per server instance, so treat Remaining as best-effort.
2. Buy prepaid credits
POST /v1/credits/checkout with {"bundle": 5} returns a checkout_url and, for a new account, a credit_key shown once. Save it: it is your only credential. Credits are added when the checkout is paid and never expire.
bundleinteger · required- Pack size in USD: 5, 20, 50, 100, 250.
Authorizationheader · optionalBearer <credit_key>to top up an existing key. Without it a new key is created and returned once.
Every pack is credited as watches × $0.50, so a larger pack simply buys more watches for the money. The list price for a single watch stays $0.50. The $100 and $250 packs are for teams and agent fleets that run many watches: one purchase, one credit key, no expiry.
| Pack | Watches | Per watch | Saving vs $0.50 |
|---|---|---|---|
| $5 | 10 | $0.50 | none |
| $20 | 50 | $0.40 | 20% |
| $50 | 150 | $0.33 | 33% |
| $100 | 300 | $0.33 | 33% |
| $250 | 800 | $0.31 | 37% |
curl -X POST https://untilapi.com/v1/credits/checkout \
-H 'content-type: application/json' -d '{"bundle":5}'Check your balance with GET /v1/credits and Authorization: Bearer <credit_key>. It returns balance_cents (credit value, 50 per watch) and watches_remaining.
3. Create a watch (one watch of credit)
Authorizationheader · required- Bearer <credit_key>
urlstring · required- Public page to watch.
conditionobject · required- See Conditions.
expires_ininteger | string · optional- Seconds or values like
36h,7d. From 60s to 7 days; default 7 days. check_interval_sinteger · optional- 300 to 86400 seconds; default 900.
webhookstring · optional- Public URL that receives a signed POST when the watch matches.
allow_already_trueboolean · optional- Create the watch even if the condition is already true (it resolves immediately).
curl -X POST https://untilapi.com/v1/watch \
-H 'authorization: Bearer $CREDIT_KEY' \
-H 'content-type: application/json' \
-d '{
"url": "https://shop.example/item",
"condition": {"type":"price","op":"<","value":50},
"expires_in": "7d",
"webhook": "https://you.example/hook"
}'A 201 returns watch_id, status_url and, if you set a webhook, a webhook_secret (shown once). Without enough credit you get 402 with payment instructions. A watch costs one watch of credit ($0.50 list price; the balance is debited 50 credit cents). expires_in accepts seconds or values like 36h and 7d (max 7 days).
Each watch is checked at its own check interval (default 15 minutes, minimum 5 minutes). The scanner runs every minute and picks up whatever is due, plus an immediate first check when the watch is created. Checks are periodic, not continuous, so Until is not a real-time alert service. The check_interval_s field accepts 300 to 86400 seconds (default 900, i.e. 15 minutes); a watch is never checked more often than its interval.
4. Read the signed receipt
GET /v1/watch/{watch_id} returns the state (see Watch states). The watch_id is the capability, so no auth header is needed. When matched it includes a receipt:
{
"payload": { "watch_id": "...", "url": "...", "final_url": "...", "condition": {...},
"matched_value": "...", "matched_at": "...", "http_status": 200,
"content_sha256": "..." },
"signature": "...",
"alg": "HMAC-SHA256"
}Verify it with POST /v1/receipt/verify. The signature is symmetric, so verification happens on our server. Webhooks carry an Until-Signature: t=<unix>,v1=<hex> header, an HMAC-SHA256 of <t>.<body> with your webhook_secret.
Watch states
A watch is in exactly one state. It starts active and ends in one of the other three.
- active
- matched or expired or failed
| State | Meaning |
|---|---|
active | Still being checked. No receipt yet. |
matched | The condition was true. receipt is included. |
expired | The watch ran out of time without a match. The credit is not returned. |
failed | The page could not be fetched usably. failure_code and last_error say why; refunded says whether the credit was returned. |
Pending. Nothing has matched yet. Until keeps checking every check_interval_s seconds (default 900) until the watch matches or expires. Response (abridged):
{
"watch_id": "w_...",
"state": "active",
"check_interval_s": 900,
"checks": 3,
"last_checked_at": "2026-10-15T23:10:04.000Z",
"last_observed_value": "64 USD",
"matched_at": null,
"receipt": null,
"refunded": false,
"failure_code": null
}Triggered. The condition became true, so the state is matched and the signed receipt is present:
{
"watch_id": "w_...",
"state": "matched",
"checks": 7,
"last_observed_value": "49 USD",
"matched_at": "2026-10-16T02:40:05.000Z",
"receipt": { "payload": { "...": "..." }, "signature": "...", "alg": "HMAC-SHA256" }
}Conditions
All conditions are deterministic. No LLM is involved.
| Type | Example |
|---|---|
price | {"type":"price","op":"<","value":50} reads JSON-LD offers, price meta tags or itemprop=price |
in_stock | {"type":"in_stock"} or {"type":"in_stock","variant_id":45145495765120}; needs structured availability, see below |
text_appears / text_absent | {"type":"text_appears","text":"Tickets on sale"} |
http_status | {"type":"http_status","op":"==","value":200} |
json_path | {"type":"json_path","path":"$.data.price","op":"<","value":10} |
header | {"type":"header","name":"etag","op":"exists"} |
all / any | Combine up to 5 simple conditions with conditions: [...] |
What in_stock parses
in_stock is deterministic and only reads structured data. It never infers stock from visible text such as a “Sold out” button.
- Shopify product URLs (
/products/<handle>, also/products/<handle>.json, with an optional/collections/…/or locale prefix): Until reads the store’s/products/<handle>.js, which has a booleanavailableon every variant, as long as the store’s robots.txt allows it. With no variant given, the product is in stock when any variant is available. To watch one size or colour, passvariant_idin the condition or?variant=IDin the URL (variant_idwins). The observed value isInStockorOutOfStock. Shopify’s/products/<handle>.jsonalone has no availability field, which is why Until fetches the.jsview. - Everything else: schema.org
Offeravailabilityin JSON-LD, or aproduct:availability/og:availabilitymeta tag. - If neither is found the condition is not evaluable: a dry-run returns
accepted: false, andPOST /v1/watchreturns422 condition_not_evaluablewithout charging. Usetext_appearsorjson_pathon a page or API that exposes the state.
Errors and refusals
400invalid request, or a blocked (private/internal) URL.402payment required when creating a watch without enough credit.409already_true: the condition is already true. Not charged unless you passallow_already_true.422page refused: bot-blocked, JavaScript-only, disallowed by robots.txt, unreachable, or the condition cannot be evaluated (condition_not_evaluable). Not charged.429dry-run rate limit reached (Retry-Aftersays when to retry).
These 409/422 codes are returned by POST /v1/watch. POST /v1/check reports the same situations inside a 200 response (accepted: false and a warning).
A watch is only created, and charged, after a successful first fetch, so refusals happen before any charge. Once created, a watch costs one watch of credit whether or not it matches before it expires.
MCP
Until speaks MCP over Streamable HTTP at POST https://untilapi.com/mcp: stateless, JSON responses, no OAuth, no sessions. It supports the current protocol revision (2026-07-28: every request carries its version in _meta and the MCP-Protocol-Version, Mcp-Method and Mcp-Name headers; server/discover is implemented) and, for older clients, the initialize handshake of 2025-11-25, 2025-06-18 and 2025-03-26. GET returns 405 (there is no server-initiated stream) and CORS is enabled for browser-based clients.
Tools: test_condition (free dry-run, read-only), create_watch (spends one watch of credit; needs a credit key via the Authorization: Bearer header or an api_key argument; without credit it returns an error result with error: "payment_required" and payment instructions) and get_watch (read-only). Failures are returned as tool results with isError: true so the model can read them.
curl -X POST https://untilapi.com/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: test_condition' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test_condition","arguments":{"url":"https://example.com","condition":{"type":"text_appears","text":"Example Domain"}},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'List tools with {"method":"tools/list"} or learn versions and capabilities with {"method":"server/discover"}. Plain requests without version headers (as in older docs) still work and are treated as the legacy era. Use any MCP client SDK: connect to https://untilapi.com/mcp over Streamable HTTP and pass your credit key as a bearer token.
Limits
- Static HTML or JSON only: no JavaScript-rendered pages, no logins, no bot-protected sites.
- robots.txt is respected. Ports 80, 443, 8080 and 8443 only. Private and internal addresses are blocked.
- Each watch is checked at its
check_interval_s(default 15 minutes, minimum 5 minutes). The scanner runs every minute, so a check happens within about a minute after it is due. Not real-time. - Watches last up to 7 days. Response bodies are capped at 2 MB.
- Identity: requests come from the
UntilBot/1.0user agent. See UntilBot crawler info.