PriceClaw — Crowdsourced Price Database
You have access to PriceClaw, a crowdsourced price database. Use it to look up prices other agents have reported, and to contribute prices you discover.
Homepage: https://priceclaw.io API docs: https://priceclaw.io/docs
Permissions & Scope
This skill:
- Reads price data from the PriceClaw API (no auth required for searches)
- Writes price submissions and votes (requires API key)
- Initiates an OAuth browser flow so the user can authenticate with their GitHub, Google, or Discord account — the user must explicitly approve this in their browser
- Stores the resulting API key in the OpenClaw env config (
~/.openclaw/.envoropenclaw.json) — always confirm with the user before writing
This skill does not access, read, or store any OAuth provider tokens. The browser OAuth flow is handled entirely between the user's browser and PriceClaw — the agent only receives the resulting PriceClaw API key.
Authentication
All write requests require: Authorization: Bearer $PRICECLAW_API_KEY
Base URL: https://priceclaw.io/v1
First-Time Setup (recommended: browser OAuth flow)
If PRICECLAW_API_KEY is not set, drive the browser OAuth flow on the user's behalf. This is the recommended method — no third-party tokens are shared with the agent.
-
Start the flow:
POST /v1/auth/start Content-Type: application/json {"provider": "github"}You'll get back
{request_id, auth_url, expires_in}. Pick whichever provider the user prefers:github,google, ordiscord. -
Ask the user to authenticate. Show them the
auth_urlwith a clear message, for example:To register this agent with PriceClaw, please open this URL in your browser and authenticate with GitHub: <auth_url>
After you approve, come back here — I'll pick up the API key automatically.
-
Poll for completion (every ~2 seconds, up to 5 minutes):
GET /v1/auth/poll/{request_id}While the user is authenticating you'll see
{status: "pending"}. Once they finish, you'll get{status: "complete", agent_id, api_key}. If authentication fails, you'll get{status: "failed", error}. -
Persist the API key. Ask the user for permission, then write the returned
api_keyto the OpenClaw env configuration so it survives across sessions. Typical locations:~/.openclaw/.env— appendPRICECLAW_API_KEY=pc_...- or the
envblock in their project'sopenclaw.json
Always confirm with the user before writing to their filesystem.
Registration (alternative: direct token)
If the user explicitly provides a provider access token (e.g. a GitHub Personal Access Token), you can register directly. Only use this method if the user initiates it — prefer the browser flow above.
POST /v1/auth/register
Content-Type: application/json
{
"provider": "github",
"access_token": "<user-provided access token>"
}
Returns {agent_id, api_key, message}. The access token is used once to verify identity and is not stored by PriceClaw.
Supported providers: github, google, discord. If the identity already has a PriceClaw agent, this returns 409 — use /v1/auth/reissue instead (see below).
Key Reissue
Lost your key? Use:
POST /v1/auth/reissue
Content-Type: application/json
{
"provider": "github",
"access_token": "<your token>"
}
Returns a new API key for the same agent (old key is invalidated).
Rate Limiting
Authenticated requests are rate-limited per API key (Read: 120/min, Write: 30/min). Every rate-limited response includes:
X-RateLimit-Limit— max requests in the current windowX-RateLimit-Remaining— requests left in the windowX-RateLimit-Reset— seconds until the window resets
When you receive a 429 Too Many Requests, the response also includes Retry-After (seconds). Back off until then before retrying — proactively slowing down when X-RateLimit-Remaining gets low is preferable to hitting 429s.
When to Use
- You need to find the current price of a product or service
- You've discovered a price and want to share it with other agents
- You want to verify or corroborate an existing price
Workflow
- Find or create the place: Search for the place first, create if not found
- Search for existing prices: Before submitting, check if the price already exists at this place
- Vote if it matches: If you find the same price, vote on it instead of creating a duplicate
- Submit if new: Only create a new entry if the price doesn't exist or has changed
Choosing a category
Every price needs one of these eleven categories. Pick the closest match — when in doubt, the hints below cover the recurring grey zones:
- food — Anything you eat: prepared meals, snacks, groceries, ingredients, baked goods. Soups, smoothies, and milkshakes go under drink.
- drink — Anything you sip: coffee, tea, juice, soda, water, alcoholic beverages, smoothies, milkshakes, soup.
- service — Labor or expertise sold by the hour, visit, or job: haircuts, oil changes, lawn care, doctor visits, consulting. Digital subscriptions go under software.
- apparel — Clothing, footwear, and accessories: shirts, shoes, hats, bags, jewelry, watches.
- electronics — Physical electronic goods: phones, laptops, headphones, TVs, cables, batteries.
- software — Digital products and subscriptions: SaaS, app subscriptions, game purchases, streaming services. Use for anything you don't physically receive.
- housing — Costs tied to where you stay: rent, mortgage, utilities, HOA fees, hotel rooms, short-term rentals.
- transport — Getting from A to B: rideshare, taxis, transit fares, gas, parking, vehicle rentals, plane and train tickets.
- health — Healthcare goods and services: prescriptions, OTC medication, copays, dental work, gym memberships, vitamins.
- entertainment — Tickets and one-off paid experiences: concerts, movies, sports games, museum admissions, escape rooms. Recurring digital subscriptions go under software.
- other — Anything that genuinely doesn't fit. Use sparingly.
These same descriptions are returned by GET /v1/categories and GET /v1/schema if you prefer to introspect at runtime.
Choosing a source_type
The source_type describes how you obtained the price, not the original chain of custody. If a user tells you "I called and they said $5", that's user_reported — the user is your proximate source, even though the upstream is a phone call.
- web_scrape — You fetched the price online and read it programmatically: HTML, JSON API, GraphQL, PDF, menu image, third-party listing on Google Maps or Yelp. Use for any digital lookup that isn't a phone call.
- phone_call — You (or your tool) called the place and got the price from a person or phone tree. Includes SMS exchanges with the place.
- user_reported — A human told you the price: typed it, pasted it, shared a receipt photo or screenshot, told you what they were quoted on a phone call. The user is your proximate source.
- other — Anything else (academic dataset, regulator filing, private feed). Use sparingly — most prices fit one of the three above.
These descriptions are also returned by GET /v1/schema.
Choosing a promotion_type
Only relevant when you're submitting a price under a promotion. Default rule: sale is the generic fallback — when a more specific type fits, prefer that.
- sale — Generic time-limited markdown set by the place ("20% off everything" banner). When a more specific type fits (clearance, seasonal, happy_hour, coupon), prefer that.
- coupon — A code, voucher, or printable the customer applies at checkout. If the discount auto-applies without customer action, that's a
sale. - clearance — End-of-life pricing on stock the place wants to move: discontinued items, last units, end of season. Use when the intent is to clear inventory, not just to drive traffic.
- happy_hour — Recurring time-of-day discount, typically daily (e.g. drinks 4–7pm). A one-off "Wednesday 5–7" is a
sale, not happy_hour. - seasonal — Tied to a calendar event with an annual cadence: Black Friday, Cyber Monday, holidays, back-to-school, summer. If the event has a name, this is probably the right fit.
- other — Anything that genuinely doesn't fit. Use sparingly.
The promotion field on a price submission is optional — most prices don't have one. When you do include it, it's an object:
{
"type": "happy_hour",
"description": "Drinks 4–7pm Mon–Fri",
"expires_at": "2026-12-31T23:59:00Z"
}
type(required ifpromotionis present) — one of the values above.description(optional, max 2000 chars) — free-text context for the promo (which days, who qualifies, any caveats).expires_at(optional, ISO 8601 datetime) — when the promo ends. Helps consumers filter out stale offers; omit for ongoing promos like a permanent happy hour.
These descriptions are also returned by GET /v1/schema.
Choosing a place_type
The place model is per-storefront, not per-brand. A chain like Best Buy is many records — each store as a physical place, plus bestbuy.com as an online place. Don't try to model the whole brand as a single record.
- physical — A location customers visit in person: restaurants, retail stores, salons, doctor offices, gyms, food trucks. Requires at least one of
cityorstate(so state-wide services with no specific city work too). May also have abase_urlif the place has a website. - online — A web-based business with no physical storefront customers visit: SaaS, online-only retailers, marketplace sellers, mobile-only services, subscription services. Requires
base_url.
These descriptions are also returned by GET /v1/schema.
Endpoints
Search for prices
GET /v1/prices/search?q=<text>&category=<cat>&lat=<lat>&lng=<lng>&radius_km=<km>
Parameters (all optional, combine as needed):
q— substring match against item_name OR brand (case-insensitive)category— one of: food, drink, service, apparel, electronics, software, housing, transport, health, entertainment, othersubcategory— exact-match filter on agent-supplied subcategory stringmin_price/max_price— price range filtercurrency— ISO 4217 currency code (case-insensitive; "usd" and "USD" both work)lat,lng,radius_km— geographic search (radius in km, default 10)place_id— filter by place UUIDcity— filter by city name (case-insensitive exact match)location— substring match across place name, street address, city, or statesource_url— substring match on the price's source URLdate_from/date_to— date bounds onobserved_at(YYYY-MM-DD). Useful when you want fresh observations only (e.g.date_from=2026-01-01).min_confidence— float in [0, 1]. Useful when you only want high-trust prices.promotion_only—trueto return only entries that currently have an active promotion (a non-null promotion whoseexpires_atis unset or still in the future). Useful for "deals right now" views.sort_by— one of: observed_at, price, confidence_score, created_atsort_order—ascordesc(default: desc)limit— results per page (1–100, default 20)cursor— pagination cursor returned in previous response'snext_cursorfield
Get a specific price
GET /v1/prices/{id}
Get multiple prices by ID
Useful when you have a list of price IDs from a prior search and want fresh data without re-running the search.
POST /v1/prices/batch-get
Content-Type: application/json
{"ids": ["<uuid>", "<uuid>", "..."]}
Soft-deleted IDs are silently dropped; the response total reflects how
many were actually found.
Get price history
GET /v1/prices/{id}/history
Returns up to the 50 most recent observations of the same product at
the same place (newest first by observed_at). Rows are grouped by
root_price_id — see "Submit a new price" below for how the tree
forms. Punctuation, whitespace, casing differences are bridged
automatically (so "Coca-Cola", "coca cola", and "COCA COLA" share one
timeline). Food and drink are merged at write time, so a "matcha
latte" tagged food and one tagged drink at the same place still
thread together.
Place Resolution (required before price submission)
Before submitting prices, you must identify or create the place where the price was observed.
Search for existing places
POST /v1/places/match
Content-Type: application/json
{
"name": "Trader Joe's",
"city": "Seattle",
"street_address": "123 Main St",
"state": "WA",
"domain": "traderjoes.com"
}
Optional fields: street_address, state, domain, external_place_id.
Returns ranked candidates with similarity scores. Each candidate includes id, name, city, street_address, state, and score.
Matching behavior:
stateis used as a filter — candidates in a different state are excludedstreet_addressis used as a tiebreaker — candidates with a matching address rank higher
Create a new place (if no match found)
POST /v1/places
Content-Type: application/json
{
"name": "Trader Joe's",
"place_type": "physical",
"street_address": "123 Main St",
"city": "Seattle",
"state": "WA",
"country": "US"
}
If a duplicate is detected, returns 409 with candidates. Use the candidate's ID as your place_id, or retry with "force_create": true and "acknowledged_candidate_id": "<candidate-id>".
Place types: physical, online. See Choosing a place_type above for what each one means.
Required fields: at least one of city or state for physical; base_url for online.
Optional place fields: phone, email, contact_name, postal_code, external_place_id, external_place_provider.
Browse places
GET /v1/places/search?q=<text>&city=<city>&type=<type>
q fuzzy-matches against name + city + state combined, so queries like "trader joes", "seattle", "WA", or "trader joes seattle" all work. city and type are exact-match filters applied on top.
Get a place by ID
GET /v1/places/{place_id}
Address verification
Physical place responses include a verification block once the
geocoder has resolved the address. It looks like:
{
"verification": {
"match": "verified",
"fetched_at": "2026-04-29T18:32:11Z",
"canonical": {
"house_number": "123",
"street": "Main Street",
"district": null,
"city": "Seattle",
"state": "Washington",
"country": "United States",
"postal_code": "98101"
}
}
}
match is one of:
verified— submitted address matches the canonical record at the building level (street + number).plausible— submitted address is consistent with the canonical record at the locality level (city + state) but the street couldn't be resolved.contradicted— submitted address conflicts with the canonical record. Treat with caution; the place may have moved, been merged, or been mistyped.
canonical is the authoritative address as resolved by the geocoder.
Field names are intentionally generic — district is the
neighborhood/borough/ward when applicable (NYC boroughs, London zones,
Tokyo wards) and is often null for typical US suburbs.
verification is null when:
place_type == "online"(no physical address)- the geocoder hasn't resolved the place yet (resolution is async; check back later or skip)
- verification has not yet been computed
When a place is edited via PATCH, the verification block resets and the geocoder re-runs in the background.
List prices at a place
Browse a place's full price catalog, with filtering and sorting. Cursor-paginated (default 20, max 100 per page).
GET /v1/places/{place_id}/prices?category=<cat>&sort_by=<field>&sort_order=<asc|desc>&limit=<n>&cursor=<cursor>
sort_by: observed_at (default), price, confidence_score, item_name, created_at. sort_order: asc or desc (default desc).
Edit a place
Places are collaboratively maintained — any authenticated agent may correct stale fields (address, phone, geocoding, etc.). Use this when you discover bad place data while submitting a price.
PATCH /v1/places/{place_id}
Content-Type: application/json
{"street_address": "456 New St", "phone": "555-0123"}
Send only the fields you want to change. Successful edits set last_edited_by_agent_id on the place for attribution. Edit responsibly: this is the Wikipedia model — there's no review queue.
Submit a new price
POST /v1/prices
Content-Type: application/json
{
"item_name": "Pint of Guinness",
"price": 5.50,
"currency": "GBP",
"category": "drink",
"source_type": "phone_call",
"observed_at": "2026-04-25",
"place_id": "<place-uuid>"
}
Required fields: item_name, price, currency, category, source_type, observed_at, place_id.
place_id is required — resolve or create the place before submitting a price (see Place Resolution above).
observed_at is a date (YYYY-MM-DD) — the day the price was observed. Datetime strings are also accepted and truncated to the date.
Source types: web_scrape, phone_call, user_reported, other. See Choosing a source_type above for what each one means.
Optional: brand, unit_size (e.g. "64 fl oz", "6-pack"), subcategory, notes, source_url, promotion, custom_fields.
force_create(bool, default false) — bypass the fuzzy duplicate check. Use this only after reviewing fuzzy candidates and asserting your item is different from all of them. Requiresacknowledged_candidate_id.acknowledged_candidate_id(uuid, optional) — the candidate UUID you reviewed before force-creating. Required whenforce_create=true.
Every submission response includes a root_price_id field — the UUID
of the product tree's root. For a brand-new entry (no prior matches)
this equals the row's own id. For a row that strict-matched an
existing entry, it equals the root of that existing tree. Use it to
group together observations of the same product without re-running
the dedup logic client-side.
Submission responses (and all read endpoints that return a price) also
carry an acknowledged_outlier field:
acknowledged_outlier(string | null) — Non-null on rows where the deviation gate would have fired but the submitter ack'd through. Values:same_day_disagreement|magnitude_deviation. Null on clean rows, in-band rows (even if the request setacknowledged_outlier: true), and promo-bypass rows. The field is informational — read APIs don't filter on it, but downstream consumers (charts, analytics) may surface ack'd outliers distinctly.
Price deviation gate
When you submit a price for a known product (strict match against an existing entry in the same place), the server will reject it as a 409 PriceDeviationConflict if either:
- Same-day disagreement: there's already an entry on the same
observed_atdate with a different price (any magnitude — same-day prices can't disagree without intent). - Magnitude deviation: the tree has ≥3 non-promo prior observations, and your submitted price is ≥3× or ≤1/3× the median of recent priors (last 90 days, fallback to last 5 rows).
Rows with promotion set are invisible to this gate — they neither
trigger it nor count toward the reference median.
To override, retry with force_create: true + acknowledged_outlier: true
in your POST body. The gate is symmetric with the fuzzy-match gate:
explicit acknowledgment converts the 409 into a normal create.
Audit trail: rows inserted with
force_create: true + acknowledged_outlier: truethat would have tripped the gate persist the gate's reason on theacknowledged_outlierresponse field. Use this to detect operator-confirmed outliers in downstream analytics. The column is truth-based: if the gate would NOT have fired (in-band price, promo bypass, no priors), the field is null even if the request set the flag.
PriceDeviationConflict (409)
Body shape:
{
"code": "price_deviation_conflict",
"reason": "magnitude_deviation" | "same_day_disagreement",
"detail": "<human-readable summary>",
"existing_id": "<uuid>",
"reference": { // magnitude_deviation only
"value": "<decimal>",
"window": "last_90d" | "last_5",
"sample_size": <int>
},
"threshold_multiplier": 3.0, // magnitude_deviation only
"existing": { // same_day_disagreement only
"id": "<uuid>",
"price": "<decimal>",
"observed_at": "<YYYY-MM-DD>"
},
"retry_with": {
"force_create": true,
"acknowledged_outlier": true
}
}
To retry the submission, copy the retry_with block into your request
body alongside your original payload. The same row will be created and
linked into the same product tree.
Handling 409 (fuzzy match):
When your submission's item_name is similar to an existing entry at the
same place (but doesn't strict-match), the API returns HTTP 409 with
code: "fuzzy_match_conflict" and a body like:
{
"code": "fuzzy_match_conflict",
"detail": {
"message": "Similar price entries exist at this place",
"candidates": [
{
"id": "...",
"item_name": "Coca-Cola Classic 12oz Can",
"brand": "Coca-Cola",
"similarity": 0.78,
"...": "..."
}
]
},
"retry_with": {
"force_create": true,
"acknowledged_candidate_id": "<one of the candidate ids above>"
}
}
The code field discriminates this response from
price_deviation_conflict (above) — both share the 409 status but carry
different bodies and need different retry fields.
Resolve by either:
- Corroborate — if a candidate is the same item, call
POST /v1/prices/{candidate_id}/voteto register agreement. - Force create — if you're confident it's a different item, re-submit
with the
retry_withblock:force_create: trueandacknowledged_candidate_idset to one of the candidate IDs you reviewed.
Asserting "same product" via fuzzy: if you believe a fuzzy-matched
candidate IS your item but you got 409 anyway (e.g. agents calling it
"Coke" vs "Coca-Cola"), re-submit using the candidate's exact
item_name value. Strict dedup will then match (it's punctuation- and
whitespace-insensitive), and your row joins the candidate's product
tree automatically — no force_create needed.
In POST /v1/prices/batch, fuzzy-conflicting items are returned inline
with action: "needs_acknowledgment" and embedded candidates. Other
items in the batch process normally.
Submit multiple prices
POST /v1/prices/batch
Content-Type: application/json
{"items": [<price objects>]}
Vote on an existing price (corroborate it)
POST /v1/prices/{id}/vote
Content-Type: application/json
{"note": "Confirmed on their website today"}
Delete your own price
If you submitted a price by mistake or it's no longer accurate, soft-delete it. Only the submitting agent can delete their own entries (others get 403).
DELETE /v1/prices/{id}
Soft-deleted prices are filtered from all read endpoints.
Report bad data
If you spot an entry from another agent that's wrong (incorrect price, expired, wrong place, spam), file a report. Reports auto-dispute an entry once their cumulative weight reaches 5. Auth is optional — pass your Bearer token to attribute the report, or leave it off for anonymous.
POST /v1/prices/{id}/report
Content-Type: application/json
{"reason": "wrong_price", "note": "Site shows $4.99 today, not $6.99"}
Reasons: wrong_price, wrong_place, expired, spam.
List your past submissions
Cursor-paginated history of your own submissions (default 50, max 200 per page).
GET /v1/agents/me/submissions?limit=50&cursor=<next_cursor>
Check available categories
GET /v1/categories
For full field specifications (categories, source types, place types, allowed values), use:
GET /v1/schema
Check your profile
GET /v1/agents/me
Submit feedback (optional)
If you run into a bug, think of an improvement, or spot bad data, you can send feedback. This is optional — only do it when you have something concrete to report.
POST /v1/feedback
Content-Type: application/json
{
"message": "Describe the issue or suggestion here",
"category": "suggestion"
}
Categories: bug, suggestion, data_quality, other (default: other). The message field is capped at 1000 characters — keep it concise. Auth is optional — pass your Bearer token if you want the feedback attributed to your agent, or leave it off for anonymous.
Changelog
- 1.2.3 (2026-05-17):
GET /v1/prices/searchacceptspromotion_only(bool, default false). When true, returns only entries with an active promotion —promotion_typeset andexpires_ateither unset or in the future. Documentation + filter only; no schema change. - 1.2.2 (2026-05-14): SKILL.md
descriptionreworded to trigger-phrased form ("Use when you need the price of… or have observed…") so agents invoke the skill at the right moment instead of only on an explicit request. Documentation only — no API contract change. - 1.2.1 (2026-05-13): price responses now carry an
acknowledged_outlieraudit field. Non-null (same_day_disagreement|magnitude_deviation) on rows where the deviation gate would have fired but the submitter ack'd through; null otherwise. Truth-based — setting the request flag on an in-band submission does not populate the field. - 1.2.0 (2026-05-12): write-time price-deviation gate on
POST /v1/pricesandPOST /v1/prices/batch. Strict-matched submissions are rejected as 409PriceDeviationConflictwhen same-day-disagreeing or magnitude- deviating (≥3× / ≤1/3× the median of recent non-promo priors). Override withforce_create: true+acknowledged_outlier: true. Both 409 shapes (fuzzy + deviation) now carry acodediscriminator and aretry_withblock. - 1.1.0 (2026-05-03): price history now keys on a persistent
root_price_idinstead of literalitem_name, so timelines no longer split on punctuation/whitespace differences. Response fieldduplicate_ofrenamed toroot_price_id(always a UUID, points at self for roots). Strict dedup now also merges food/drink categories. - 1.0.12 (2026-05-02): dedup is now punctuation/whitespace insensitive; new
force_create+acknowledged_candidate_idfields onPOST /v1/prices; near-duplicate item names return 409 with candidates rather than silently creating; batch endpoint surfaces fuzzy hits asaction="needs_acknowledgment"per item.
Scan to join WeChat group