PriceClaw

Data & APIs

Use when you need the price of a product or service, or have observed a price worth recording. Searches crowdsourced price data, submits new price observations, and votes on existing entries in PriceClaw.

Install

openclaw skills install priceclaw

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/.env or openclaw.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.

  1. 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, or discord.

  2. Ask the user to authenticate. Show them the auth_url with 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.

  3. 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}.

  4. Persist the API key. Ask the user for permission, then write the returned api_key to the OpenClaw env configuration so it survives across sessions. Typical locations:

    • ~/.openclaw/.env — append PRICECLAW_API_KEY=pc_...
    • or the env block in their project's openclaw.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 window
  • X-RateLimit-Remaining — requests left in the window
  • X-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

  1. Find or create the place: Search for the place first, create if not found
  2. Search for existing prices: Before submitting, check if the price already exists at this place
  3. Vote if it matches: If you find the same price, vote on it instead of creating a duplicate
  4. 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 if promotion is 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 city or state (so state-wide services with no specific city work too). May also have a base_url if 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, other
  • subcategory — exact-match filter on agent-supplied subcategory string
  • min_price / max_price — price range filter
  • currency — 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 UUID
  • city — filter by city name (case-insensitive exact match)
  • location — substring match across place name, street address, city, or state
  • source_url — substring match on the price's source URL
  • date_from / date_to — date bounds on observed_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_onlytrue to return only entries that currently have an active promotion (a non-null promotion whose expires_at is unset or still in the future). Useful for "deals right now" views.
  • sort_by — one of: observed_at, price, confidence_score, created_at
  • sort_orderasc or desc (default: desc)
  • limit — results per page (1–100, default 20)
  • cursor — pagination cursor returned in previous response's next_cursor field

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:

  • state is used as a filter — candidates in a different state are excluded
  • street_address is 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. Requires acknowledged_candidate_id.
  • acknowledged_candidate_id (uuid, optional) — the candidate UUID you reviewed before force-creating. Required when force_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 set acknowledged_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:

  1. Same-day disagreement: there's already an entry on the same observed_at date with a different price (any magnitude — same-day prices can't disagree without intent).
  2. 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: true that would have tripped the gate persist the gate's reason on the acknowledged_outlier response 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:

  1. Corroborate — if a candidate is the same item, call POST /v1/prices/{candidate_id}/vote to register agreement.
  2. Force create — if you're confident it's a different item, re-submit with the retry_with block: force_create: true and acknowledged_candidate_id set 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/search accepts promotion_only (bool, default false). When true, returns only entries with an active promotion — promotion_type set and expires_at either unset or in the future. Documentation + filter only; no schema change.
  • 1.2.2 (2026-05-14): SKILL.md description reworded 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_outlier audit 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/prices and POST /v1/prices/batch. Strict-matched submissions are rejected as 409 PriceDeviationConflict when same-day-disagreeing or magnitude- deviating (≥3× / ≤1/3× the median of recent non-promo priors). Override with force_create: true + acknowledged_outlier: true. Both 409 shapes (fuzzy + deviation) now carry a code discriminator and a retry_with block.
  • 1.1.0 (2026-05-03): price history now keys on a persistent root_price_id instead of literal item_name, so timelines no longer split on punctuation/whitespace differences. Response field duplicate_of renamed to root_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_id fields on POST /v1/prices; near-duplicate item names return 409 with candidates rather than silently creating; batch endpoint surfaces fuzzy hits as action="needs_acknowledgment" per item.