openapi: 3.1.0
info:
  title: GetPrice Customer Data API
  version: 1.0.0-pilot
  summary: Read competitor prices for your products and link your own SKUs to them.
  description: |
    Look up competitor prices for your own SKUs and feed them into your ERP, BI or pricing tools. You get the
    same prices you see in the GetPrice dashboard.

    - Look up competitor prices for up to 100 SKUs: `GET /v1/comparison-sets?sku=…`.
    - Sync every comparison set into your ERP: `GET /v1/comparison-sets`.
    - Link your SKUs to your products: `GET /v1/products`, then `PUT /v1/sku-mappings/{sku}`.

    New here? Start with the [quickstart](https://www.getprice.ai/developers/quickstart).

    **Conventions**

    - JSON over HTTPS. Property and parameter names are `snake_case`.
    - Times are UTC, for example `2026-11-02T04:12:09Z`.
    - Money is a decimal string with a currency: `{"amount": "18.99", "currency": "ZAR"}`. Don't parse it as a
      float.
    - Every documented field is always present. `null` means "no value", and lists are `[]`, never `null`.
    - Ids such as `prd_…`, `set_…` and `cmp_…` are opaque. Store them; don't parse them.
    - Ignore fields you don't recognise, and expect new enum values. Within v1 we only add things.
    - Every response has an `X-Request-Id`. Quote it when you contact support.

    **Acceptable use.** API data is for your organisation's internal use. Don't publish or publicly display
    competitor prices, including on in-store digital price tags, without GetPrice's written agreement.

    Support: support@getprice.ai
  contact:
    name: GetPrice support
    email: support@getprice.ai
    url: https://www.getprice.ai
  termsOfService: https://www.getprice.ai/terms
  license:
    name: Proprietary
    identifier: LicenseRef-GetPrice-Proprietary
servers:
  - url: https://api.getprice.ai/v1
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Account
    description: Check which API key you're using and what it can do.
  - name: Comparison sets
    description: |
      A comparison set is one of your products plus the same item at each competitor, with their latest prices.
      Use it for price checks and ERP syncs.
  - name: Products
    description: Your own products, as GetPrice collects them from your online shop.
  - name: Competitors
    description: The competitor websites GetPrice tracks for you.
  - name: SKU mappings
    description: |
      Links from your own SKUs to your products in GetPrice. Create, replace and remove them so you can look up
      prices by the codes your ERP already uses.

paths:
  /me:
    get:
      operationId: getMe
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            BASE=https://api.getprice.ai/v1
            curl "$BASE/me" \
              -H "Authorization: Bearer $GETPRICE_API_KEY"
        - lang: python
          label: Python
          source: |
            import os

            import requests

            BASE = "https://api.getprice.ai/v1"
            KEY = os.environ["GETPRICE_API_KEY"]

            resp = requests.get(
                f"{BASE}/me",
                headers={"Authorization": f"Bearer {KEY}"},
                timeout=30,
            )
            resp.raise_for_status()
            me = resp.json()
            print(me["company"]["name"], me["limits"]["per_minute"])
        - lang: typescript
          label: TypeScript
          source: |
            interface Me {
              company: { name: string };
              limits: { per_minute: number };
            }

            const BASE = "https://api.getprice.ai/v1";
            const KEY = process.env.GETPRICE_API_KEY;
            const url = `${BASE}/me`;
            const res = await fetch(url, {
              headers: { Authorization: `Bearer ${KEY}` },
            });
            if (!res.ok) {
              throw new Error(`GetPrice returned ${res.status}`);
            }
            const me = (await res.json()) as Me;
            console.log(me.company.name, me.limits.per_minute);
        - lang: php
          label: PHP
          source: |
            <?php
            $base = "https://api.getprice.ai/v1";
            $key = getenv("GETPRICE_API_KEY");
            $ch = curl_init("$base/me");
            curl_setopt_array($ch, [
                CURLOPT_HTTPHEADER => [
                    "Authorization: Bearer $key",
                ],
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 30,
            ]);
            $body = curl_exec($ch);
            $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            if ($body === false || $status >= 400) {
                throw new RuntimeException(
                    "GetPrice returned $status: $body"
                );
            }
            $data = json_decode(
                $body, true, flags: JSON_THROW_ON_ERROR
            );

            echo $data["company"]["name"], " ",
                $data["limits"]["per_minute"], "\n";
        - lang: csharp
          label: C#
          source: |
            using System.Net.Http.Headers;
            using System.Net.Http.Json;
            using System.Text.Json;
            using Naming = System.Text.Json.JsonNamingPolicy;

            var key = Environment.GetEnvironmentVariable(
                "GETPRICE_API_KEY");
            var api = new Uri("https://api.getprice.ai/v1/");
            var http = new HttpClient { BaseAddress = api };
            http.Timeout = TimeSpan.FromSeconds(30);
            http.DefaultRequestHeaders.Authorization =
                new AuthenticationHeaderValue("Bearer", key);
            var json = new JsonSerializerOptions
            {
                PropertyNamingPolicy = Naming.SnakeCaseLower,
            };

            var res = await http.GetAsync("me");
            res.EnsureSuccessStatusCode();
            var me = await res.Content
                .ReadFromJsonAsync<Me>(json);
            Console.WriteLine(
                $"{me!.Company.Name} {me.Limits.PerMinute}");

            record Me(Company Company, Limits Limits);
            record Company(string Name);
            record Limits(int PerMinute);
        - lang: odoo
          label: Odoo (Python)
          source: |
            import requests
            from odoo import models

            API = "https://api.getprice.ai/v1"


            class ResConfigSettings(models.TransientModel):
                _inherit = "res.config.settings"

                def action_getprice_test_key(self):
                    """Settings button: check the GetPrice key."""
                    icp = self.env["ir.config_parameter"].sudo()
                    key = icp.get_param("getprice.api_key")
                    resp = requests.get(
                        f"{API}/me",
                        headers={"Authorization": f"Bearer {key}"},
                        timeout=30,
                    )
                    resp.raise_for_status()
                    name = resp.json()["company"]["name"]
                    return {
                        "type": "ir.actions.client",
                        "tag": "display_notification",
                        "params": {
                            "message": f"Connected as {name}",
                            "type": "success",
                        },
                    }
      tags: [Account]
      summary: Retrieve your API key
      description: |
        Returns the API key you're calling with: its name and prefix, your company, its scopes and its rate limit.
        Any valid key can call it, whatever its scopes. Use it to check that your key works.
      parameters:
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: The calling key.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
            RateLimit: { $ref: '#/components/headers/RateLimit' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
            ETag: { $ref: '#/components/headers/ETag' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Me' }
              examples:
                example:
                  summary: A production key
                  value:
                    key:
                      name: Odoo production
                      prefix: k3Xa91Qz
                      environment: live
                    company:
                      name: Example Wholesale (Pty) Ltd
                    scopes: [catalog:read, matches:read, skus:write]
                    limits:
                      per_minute: 120
                      window_seconds: 60
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /comparison-sets:
    get:
      operationId: listComparisonSets
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            BASE=https://api.getprice.ai/v1
            curl -G "$BASE/comparison-sets" \
              -H "Authorization: Bearer $GETPRICE_API_KEY" \
              --data-urlencode "sku=10072L" \
              --data-urlencode "sku=11247P"
        - lang: python
          label: Python
          source: |
            import os

            import requests

            BASE = "https://api.getprice.ai/v1"
            KEY = os.environ["GETPRICE_API_KEY"]

            resp = requests.get(
                f"{BASE}/comparison-sets",
                params=[("sku", "10072L"), ("sku", "11247P")],
                headers={"Authorization": f"Bearer {KEY}"},
                timeout=30,
            )
            resp.raise_for_status()
            page = resp.json()
            for entry in page["skus"]:
                print(entry["sku"], entry["status"])
            for cset in page["data"]:
                for entry in cset["competitors"]:
                    price = entry["price"]
                    if price:
                        name = entry["competitor"]["name"]
                        print(cset["sku"], name, price["amount"])
        - lang: typescript
          label: TypeScript
          source: |
            interface Entry {
              competitor: { name: string };
              price: { amount: string } | null;
            }
            interface Page {
              data: { sku: string | null; competitors: Entry[] }[];
              skus: { sku: string; status: string }[];
            }

            const BASE = "https://api.getprice.ai/v1";
            const KEY = process.env.GETPRICE_API_KEY;
            const params = new URLSearchParams([
              ["sku", "10072L"],
              ["sku", "11247P"],
            ]);
            const url = `${BASE}/comparison-sets?${params}`;
            const res = await fetch(url, {
              headers: { Authorization: `Bearer ${KEY}` },
            });
            if (!res.ok) {
              throw new Error(`GetPrice returned ${res.status}`);
            }
            const page = (await res.json()) as Page;
            for (const { sku, status } of page.skus) {
              console.log(sku, status);
            }
            for (const set of page.data) {
              for (const { competitor, price } of set.competitors) {
                if (!price) continue;
                console.log(set.sku, competitor.name, price.amount);
              }
            }
        - lang: php
          label: PHP
          source: |
            <?php
            $base = "https://api.getprice.ai/v1";
            $key = getenv("GETPRICE_API_KEY");
            // Repeat sku once per SKU.
            $skus = ["10072L", "11247P"];
            $query = implode("&", array_map(
                fn ($sku) => "sku=" . rawurlencode($sku),
                $skus,
            ));
            $ch = curl_init("$base/comparison-sets?$query");
            curl_setopt_array($ch, [
                CURLOPT_HTTPHEADER => [
                    "Authorization: Bearer $key",
                ],
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 30,
            ]);
            $body = curl_exec($ch);
            $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            if ($body === false || $status >= 400) {
                throw new RuntimeException(
                    "GetPrice returned $status: $body"
                );
            }
            $data = json_decode(
                $body, true, flags: JSON_THROW_ON_ERROR
            );

            foreach ($data["skus"] as $entry) {
                echo $entry["sku"], " ", $entry["status"], "\n";
            }
            foreach ($data["data"] as $set) {
                foreach ($set["competitors"] as $entry) {
                    if ($entry["price"] === null) {
                        continue;
                    }
                    echo $set["sku"], " ",
                        $entry["competitor"]["name"], " ",
                        $entry["price"]["amount"], "\n";
                }
            }
        - lang: csharp
          label: C#
          source: |
            using System.Net.Http.Headers;
            using System.Net.Http.Json;
            using System.Text.Json;
            using Naming = System.Text.Json.JsonNamingPolicy;

            var key = Environment.GetEnvironmentVariable(
                "GETPRICE_API_KEY");
            var api = new Uri("https://api.getprice.ai/v1/");
            var http = new HttpClient { BaseAddress = api };
            http.Timeout = TimeSpan.FromSeconds(30);
            http.DefaultRequestHeaders.Authorization =
                new AuthenticationHeaderValue("Bearer", key);
            var json = new JsonSerializerOptions
            {
                PropertyNamingPolicy = Naming.SnakeCaseLower,
            };

            var res = await http.GetAsync(
                "comparison-sets?sku=10072L&sku=11247P");
            res.EnsureSuccessStatusCode();
            var page = await res.Content
                .ReadFromJsonAsync<Page>(json);
            foreach (var s in page!.Skus)
                Console.WriteLine($"{s.Sku} {s.Status}");
            foreach (var set in page.Data)
                foreach (var e in set.Competitors)
                    if (e.Price is { } price)
                        Console.WriteLine(
                            $"{set.Sku} {e.Competitor.Name} "
                            + price.Amount);

            record Page(List<CompSet> Data, List<SkuStatus> Skus);
            record SkuStatus(string Sku, string Status);
            record CompSet(string? Sku, List<Entry> Competitors);
            record Entry(Competitor Competitor, Price? Price);
            record Competitor(string Name);
            record Price(string Amount);
        - lang: odoo
          label: Odoo (Python)
          source: |
            from decimal import Decimal

            import requests
            from odoo import models

            API = "https://api.getprice.ai/v1"


            class ProductProduct(models.Model):
                _inherit = "product.product"

                def action_getprice_competitor_prices(self):
                    """Post the cheapest fresh competitor price.

                    Up to 100 products per call.
                    """
                    icp = self.env["ir.config_parameter"].sudo()
                    key = icp.get_param("getprice.api_key")
                    resp = requests.get(
                        f"{API}/comparison-sets",
                        params=[
                            ("sku", p.default_code) for p in self
                        ],
                        headers={"Authorization": f"Bearer {key}"},
                        timeout=30,
                    )
                    resp.raise_for_status()
                    sets = {
                        s["sku"].strip().upper(): s
                        for s in resp.json()["data"]
                    }
                    for product in self:
                        sku = product.default_code.strip().upper()
                        cset = sets.get(sku, {})
                        entries = cset.get("competitors", [])
                        offers = [
                            e
                            for e in entries
                            if e["price"]
                            and not e["stale"]
                            and e["match"]["review"] == "same"
                        ]
                        if not offers:
                            continue
                        best = min(
                            offers,
                            key=lambda e: Decimal(
                                e["price"]["amount"]
                            ),
                        )
                        name = best["competitor"]["name"]
                        price = best["price"]
                        line = f"{name}: R{price['amount']}"
                        if price["was"]:  # on promotion
                            line += f" (was R{price['was']})"
                        product.message_post(
                            body=f"Cheapest: {line}"
                        )
      tags: [Comparison sets]
      summary: List comparison sets
      description: |
        Returns your comparison sets: each of your products with every competitor's price and when it was last
        collected.

        Call it in one of two ways:

        - **Look up SKUs.** Repeat `sku` for up to 100 of your SKUs: `?sku=10072L&sku=11247P`. `data` holds a set
          for each SKU that has one, and `skus` gives each SKU's status: `found`, `no_comparison_set` or `unknown_sku`.
        - **Sync everything.** Leave out `sku` and follow `next_cursor` until it is `null`.

        Rate-limit cost: with `sku`, 1 unit per 25 SKUs (rounded up); without it, 1 unit per 100 of `limit`
        (rounded up). Every other endpoint costs 1 unit.

        Pack and case codes answer `unknown_sku` until you link them. GetPrice never converts unit prices into pack
        prices. Suppressed prices and rejected matches are never returned. Delisted competitor products appear only
        with `include_delisted=true`.
      security:
        - bearerAuth: [matches:read]
      parameters:
        - $ref: '#/components/parameters/SkuFilter'
        - $ref: '#/components/parameters/ReviewedBy'
        - $ref: '#/components/parameters/IncludeDelisted'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: A page of comparison sets.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            ETag: { $ref: '#/components/headers/ETag' }
            RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
            RateLimit: { $ref: '#/components/headers/RateLimit' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ComparisonSetList' }
              examples:
                bySku:
                  summary: Look up SKUs (?sku=10072L&sku=11893L&sku=11247P)
                  value:
                    data:
                      - &kooSet
                        id: set_x7Kp2Lm9
                        name: Baked Beans in Tomato Sauce 410g
                        sku: 10072L
                        own_product:
                          id: prd_8fK2mQa1
                          name: Baked Beans in Tomato Sauce 410g
                          url: https://shop.example.com/product/10072L
                          price: { amount: '18.99', was: null, currency: ZAR }
                          last_collected_at: '2026-11-02T04:12:09Z'
                          stale: false
                        competitors:
                          - competitor: { id: cmp_3LqV8nP2, name: Harbour Market, domain: harbourmarket.example.com }
                            product: { id: prd_Q2vN9cD4, name: Baked Beans 410g, url: 'https://harbourmarket.example.com/baked-beans-in-tomato-sauce-410g' }
                            price: { amount: '17.99', was: '19.99', currency: ZAR }
                            last_collected_at: '2026-11-02T03:05:11Z'
                            availability: unknown
                            status: active
                            stale: false
                            match: { review: same, reviewed_by: customer, confidence: null }
                          - competitor: { id: cmp_7RwK4tB9, name: Cape Fresh, domain: capefresh.example.com }
                            product: { id: prd_Hm3Wc8Kp, name: Baked Beans In Tomato Sauce 410g, url: 'https://capefresh.example.com/baked-beans-in-tomato-sauce-410g' }
                            price: { amount: '18.99', was: null, currency: ZAR }
                            last_collected_at: '2026-11-02T03:11:40Z'
                            availability: unknown
                            status: active
                            stale: false
                            match: { review: same, reviewed_by: getprice, confidence: 0.94 }
                          - competitor: { id: cmp_9TzM2cX5, name: Northwind Wholesale, domain: northwindwholesale.example.com }
                            product: { id: prd_Zt6Pa2Rn, name: Baked Beans In Tomato Sauce 410g, url: 'https://northwindwholesale.example.com/baked-beans-in-tomato-sauce-410g' }
                            price: { amount: '17.99', was: null, currency: ZAR }
                            last_collected_at: '2026-11-02T03:20:05Z'
                            availability: unknown
                            status: active
                            stale: false
                            match: { review: unreviewed, reviewed_by: null, confidence: 0.81 }
                          - competitor: { id: cmp_2HdR6wJ3, name: Metro Bulk, domain: metrobulk.example.com }
                            product: { id: prd_Yc1Ke7Fs, name: 'Baked Beans in Tomato Sauce 24 x 410g', url: 'https://metrobulk.example.com/baked-beans-24x410g' }
                            price: { amount: '429.00', was: null, currency: ZAR }
                            last_collected_at: '2026-10-24T04:31:58Z'
                            availability: unknown
                            status: active
                            stale: true
                            match: { review: with_differences, reviewed_by: customer, confidence: null }
                        integrity: ok
                        updated_at: '2026-11-02T04:14:00Z'
                    next_cursor: null
                    skus:
                      - { sku: 10072L, status: found, set_id: set_x7Kp2Lm9, product_id: prd_8fK2mQa1 }
                      - { sku: 11893L, status: no_comparison_set, set_id: null, product_id: prd_Zt41Hq6m }
                      - { sku: 11247P, status: unknown_sku, set_id: null, product_id: null }
                allSets:
                  summary: Sync everything (no sku filter)
                  value:
                    data:
                      - *kooSet
                      - id: set_R4m8wQe2
                        name: Pilchards in Tomato Sauce 400g
                        sku: null
                        own_product:
                          id: prd_Lp4sT1x9
                          name: Pilchards in Tomato Sauce 400g
                          url: https://shop.example.com/product/pilchards-in-tomato-sauce-400g
                          price: { amount: '31.99', was: '33.99', currency: ZAR }
                          last_collected_at: '2026-11-02T04:12:31Z'
                          stale: false
                        competitors: []
                        integrity: ok
                        updated_at: '2026-11-02T04:14:00Z'
                    next_cursor: null
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /comparison-sets/{set_id}:
    get:
      operationId: getComparisonSet
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            BASE=https://api.getprice.ai/v1
            curl "$BASE/comparison-sets/set_x7Kp2Lm9" \
              -H "Authorization: Bearer $GETPRICE_API_KEY"
        - lang: python
          label: Python
          source: |
            import os

            import requests

            BASE = "https://api.getprice.ai/v1"
            KEY = os.environ["GETPRICE_API_KEY"]

            resp = requests.get(
                f"{BASE}/comparison-sets/set_x7Kp2Lm9",
                headers={"Authorization": f"Bearer {KEY}"},
                timeout=30,
            )
            resp.raise_for_status()
            cset = resp.json()
            for entry in cset["competitors"]:
                price = entry["price"]
                amount = f"R{price['amount']}" if price else "-"
                name = entry["competitor"]["name"]
                print(name, amount, entry["last_collected_at"])
        - lang: typescript
          label: TypeScript
          source: |
            interface Entry {
              competitor: { name: string };
              price: { amount: string } | null;
              last_collected_at: string | null;
            }

            const BASE = "https://api.getprice.ai/v1";
            const KEY = process.env.GETPRICE_API_KEY;
            const url = `${BASE}/comparison-sets/set_x7Kp2Lm9`;
            const res = await fetch(url, {
              headers: { Authorization: `Bearer ${KEY}` },
            });
            if (!res.ok) {
              throw new Error(`GetPrice returned ${res.status}`);
            }
            const set = (await res.json()) as {
              competitors: Entry[];
            };
            for (const entry of set.competitors) {
              const { price } = entry;
              const amount = price ? `R${price.amount}` : "-";
              const name = entry.competitor.name;
              console.log(name, amount, entry.last_collected_at);
            }
        - lang: php
          label: PHP
          source: |
            <?php
            $base = "https://api.getprice.ai/v1";
            $key = getenv("GETPRICE_API_KEY");
            $ch = curl_init("$base/comparison-sets/set_x7Kp2Lm9");
            curl_setopt_array($ch, [
                CURLOPT_HTTPHEADER => [
                    "Authorization: Bearer $key",
                ],
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 30,
            ]);
            $body = curl_exec($ch);
            $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            if ($body === false || $status >= 400) {
                throw new RuntimeException(
                    "GetPrice returned $status: $body"
                );
            }
            $data = json_decode(
                $body, true, flags: JSON_THROW_ON_ERROR
            );

            foreach ($data["competitors"] as $entry) {
                $price = $entry["price"];
                $amount = $price ? "R" . $price["amount"] : "-";
                echo $entry["competitor"]["name"], " ", $amount,
                    " ", $entry["last_collected_at"], "\n";
            }
        - lang: csharp
          label: C#
          source: |
            using System.Net.Http.Headers;
            using System.Net.Http.Json;
            using System.Text.Json;
            using Naming = System.Text.Json.JsonNamingPolicy;

            var key = Environment.GetEnvironmentVariable(
                "GETPRICE_API_KEY");
            var api = new Uri("https://api.getprice.ai/v1/");
            var http = new HttpClient { BaseAddress = api };
            http.Timeout = TimeSpan.FromSeconds(30);
            http.DefaultRequestHeaders.Authorization =
                new AuthenticationHeaderValue("Bearer", key);
            var json = new JsonSerializerOptions
            {
                PropertyNamingPolicy = Naming.SnakeCaseLower,
            };

            var res = await http.GetAsync(
                "comparison-sets/set_x7Kp2Lm9");
            res.EnsureSuccessStatusCode();
            var set = await res.Content
                .ReadFromJsonAsync<CompSet>(json);
            foreach (var e in set!.Competitors)
            {
                var amount = e.Price is null
                    ? "-" : $"R{e.Price.Amount}";
                var name = e.Competitor.Name;
                Console.WriteLine(
                    $"{name} {amount} {e.LastCollectedAt}");
            }

            record CompSet(List<Entry> Competitors);
            record Entry(
                Competitor Competitor,
                Price? Price,
                string? LastCollectedAt);
            record Competitor(string Name);
            record Price(string Amount);
        - lang: odoo
          label: Odoo (Python)
          source: |
            import requests
            from odoo import models

            API = "https://api.getprice.ai/v1"


            class ProductProduct(models.Model):
                _inherit = "product.product"

                def getprice_post_comparison_set(self, set_id):
                    """Post every competitor price in one set."""
                    self.ensure_one()
                    icp = self.env["ir.config_parameter"].sudo()
                    key = icp.get_param("getprice.api_key")
                    resp = requests.get(
                        f"{API}/comparison-sets/{set_id}",
                        headers={"Authorization": f"Bearer {key}"},
                        timeout=30,
                    )
                    resp.raise_for_status()
                    lines = []
                    for e in resp.json()["competitors"]:
                        name = e["competitor"]["name"]
                        price = e["price"]
                        amount = "-"
                        if price:
                            amount = f"R{price['amount']}"
                        if price and price["was"]:  # on promotion
                            amount += f" (was R{price['was']})"
                        lines.append(f"{name}: {amount}")
                    self.message_post(body="; ".join(lines))
      tags: [Comparison sets]
      summary: Retrieve a comparison set
      description: |
        Returns the comparison set with the given id. If the set was merged into another one, you get the surviving
        set, and its `id` differs from the one you asked for.
      security:
        - bearerAuth: [matches:read]
      parameters:
        - name: set_id
          in: path
          required: true
          description: The set id, e.g. `set_x7Kp2Lm9`.
          schema: { $ref: '#/components/schemas/SetId' }
        - $ref: '#/components/parameters/ReviewedBy'
        - $ref: '#/components/parameters/IncludeDelisted'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: The comparison set.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            ETag: { $ref: '#/components/headers/ETag' }
            RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
            RateLimit: { $ref: '#/components/headers/RateLimit' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ComparisonSet' }
              examples:
                kooBeans: { $ref: '#/components/examples/ComparisonSetKooBeans' }
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: Not found for your company. Code `not_found`.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                unknownSet:
                  summary: Unknown id
                  value:
                    type: https://api.getprice.ai/errors/not-found
                    title: Not found
                    status: 404
                    detail: "No comparison set with this id."
                    code: not_found
                    request_id: req_01JBX4Q8ZJ7M2K
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /products:
    get:
      operationId: listProducts
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            BASE=https://api.getprice.ai/v1
            curl -G "$BASE/products" \
              -H "Authorization: Bearer $GETPRICE_API_KEY" \
              --data-urlencode "q=pilchards" \
              --data-urlencode "unmapped=true"
        - lang: python
          label: Python
          source: |
            import os

            import requests

            BASE = "https://api.getprice.ai/v1"
            KEY = os.environ["GETPRICE_API_KEY"]

            resp = requests.get(
                f"{BASE}/products",
                params=[("q", "pilchards"), ("unmapped", "true")],
                headers={"Authorization": f"Bearer {KEY}"},
                timeout=30,
            )
            resp.raise_for_status()
            for product in resp.json()["data"]:
                print(product["id"], product["name"])
        - lang: typescript
          label: TypeScript
          source: |
            interface Product {
              id: string;
              name: string;
            }

            const BASE = "https://api.getprice.ai/v1";
            const KEY = process.env.GETPRICE_API_KEY;
            const params = new URLSearchParams([
              ["q", "pilchards"],
              ["unmapped", "true"],
            ]);
            const url = `${BASE}/products?${params}`;
            const res = await fetch(url, {
              headers: { Authorization: `Bearer ${KEY}` },
            });
            if (!res.ok) {
              throw new Error(`GetPrice returned ${res.status}`);
            }
            const page = (await res.json()) as {
              data: Product[];
            };
            for (const product of page.data) {
              console.log(product.id, product.name);
            }
        - lang: php
          label: PHP
          source: |
            <?php
            $base = "https://api.getprice.ai/v1";
            $key = getenv("GETPRICE_API_KEY");
            $query = http_build_query([
                "q" => "pilchards",
                "unmapped" => "true",
            ]);
            $ch = curl_init("$base/products?$query");
            curl_setopt_array($ch, [
                CURLOPT_HTTPHEADER => [
                    "Authorization: Bearer $key",
                ],
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 30,
            ]);
            $body = curl_exec($ch);
            $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            if ($body === false || $status >= 400) {
                throw new RuntimeException(
                    "GetPrice returned $status: $body"
                );
            }
            $data = json_decode(
                $body, true, flags: JSON_THROW_ON_ERROR
            );

            foreach ($data["data"] as $product) {
                echo $product["id"], " ", $product["name"], "\n";
            }
        - lang: csharp
          label: C#
          source: |
            using System.Net.Http.Headers;
            using System.Net.Http.Json;
            using System.Text.Json;
            using Naming = System.Text.Json.JsonNamingPolicy;

            var key = Environment.GetEnvironmentVariable(
                "GETPRICE_API_KEY");
            var api = new Uri("https://api.getprice.ai/v1/");
            var http = new HttpClient { BaseAddress = api };
            http.Timeout = TimeSpan.FromSeconds(30);
            http.DefaultRequestHeaders.Authorization =
                new AuthenticationHeaderValue("Bearer", key);
            var json = new JsonSerializerOptions
            {
                PropertyNamingPolicy = Naming.SnakeCaseLower,
            };

            var res = await http.GetAsync(
                "products?q=pilchards&unmapped=true");
            res.EnsureSuccessStatusCode();
            var page = await res.Content
                .ReadFromJsonAsync<Page>(json);
            foreach (var p in page!.Data)
                Console.WriteLine($"{p.Id} {p.Name}");

            record Page(List<Product> Data);
            record Product(string Id, string Name);
        - lang: odoo
          label: Odoo (Python)
          source: |
            import requests
            from odoo import models

            API = "https://api.getprice.ai/v1"


            class ProductProduct(models.Model):
                _inherit = "product.product"

                def action_getprice_find_matches(self):
                    """Suggest unlinked products by name."""
                    self.ensure_one()
                    icp = self.env["ir.config_parameter"].sudo()
                    key = icp.get_param("getprice.api_key")
                    resp = requests.get(
                        f"{API}/products",
                        params={"q": self.name, "unmapped": "true"},
                        headers={"Authorization": f"Bearer {key}"},
                        timeout=30,
                    )
                    resp.raise_for_status()
                    found = [
                        f"{p['id']} {p['name']}"
                        for p in resp.json()["data"]
                    ]
                    text = "; ".join(found) or "none"
                    self.message_post(body=f"Candidates: {text}")
      tags: [Products]
      summary: List your products
      description: |
        Returns your own products, as GetPrice collects them from your online shop. Search by name with `q`, and add
        `unmapped=true` to find products with no SKU linked. That gives you the `prd_…` id to link with
        `PUT /v1/sku-mappings/{sku}`. Filters combine.
      security:
        - bearerAuth: [catalog:read]
      parameters:
        - $ref: '#/components/parameters/SkuFilter'
        - name: q
          in: query
          required: false
          description: Case-insensitive substring of the product name.
          schema: { type: string, minLength: 1, maxLength: 200 }
          example: pilchards
        - name: unmapped
          in: query
          required: false
          description: '`true` returns only products with no SKU linked.'
          schema: { type: boolean, default: false }
        - name: include_delisted
          in: query
          required: false
          description: '`true` also returns products no longer on your site (`status: delisted`).'
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: A page of products.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            ETag: { $ref: '#/components/headers/ETag' }
            RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
            RateLimit: { $ref: '#/components/headers/RateLimit' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductList' }
              examples:
                search:
                  summary: GET /v1/products?q=pilchards&unmapped=true
                  value:
                    data:
                      - &luckyStar
                        id: prd_Lp4sT1x9
                        name: Pilchards in Tomato Sauce 400g
                        url: https://shop.example.com/product/pilchards-in-tomato-sauce-400g
                        skus: []
                        comparison_set_id: set_R4m8wQe2
                        price: { amount: '31.99', was: '33.99', currency: ZAR }
                        last_collected_at: '2026-11-02T04:12:31Z'
                        status: active
                    next_cursor: null
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /products/{product_id}:
    get:
      operationId: getProduct
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            BASE=https://api.getprice.ai/v1
            curl "$BASE/products/prd_8fK2mQa1" \
              -H "Authorization: Bearer $GETPRICE_API_KEY"
        - lang: python
          label: Python
          source: |
            import os

            import requests

            BASE = "https://api.getprice.ai/v1"
            KEY = os.environ["GETPRICE_API_KEY"]

            resp = requests.get(
                f"{BASE}/products/prd_8fK2mQa1",
                headers={"Authorization": f"Bearer {KEY}"},
                timeout=30,
            )
            resp.raise_for_status()
            product = resp.json()
            price = product["price"]
            amount = f"R{price['amount']}" if price else "-"
            print(product["name"], amount)
        - lang: typescript
          label: TypeScript
          source: |
            interface Product {
              name: string;
              price: { amount: string } | null;
            }

            const BASE = "https://api.getprice.ai/v1";
            const KEY = process.env.GETPRICE_API_KEY;
            const url = `${BASE}/products/prd_8fK2mQa1`;
            const res = await fetch(url, {
              headers: { Authorization: `Bearer ${KEY}` },
            });
            if (!res.ok) {
              throw new Error(`GetPrice returned ${res.status}`);
            }
            const product = (await res.json()) as Product;
            const { price } = product;
            const amount = price ? `R${price.amount}` : "-";
            console.log(product.name, amount);
        - lang: php
          label: PHP
          source: |
            <?php
            $base = "https://api.getprice.ai/v1";
            $key = getenv("GETPRICE_API_KEY");
            $ch = curl_init("$base/products/prd_8fK2mQa1");
            curl_setopt_array($ch, [
                CURLOPT_HTTPHEADER => [
                    "Authorization: Bearer $key",
                ],
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 30,
            ]);
            $body = curl_exec($ch);
            $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            if ($body === false || $status >= 400) {
                throw new RuntimeException(
                    "GetPrice returned $status: $body"
                );
            }
            $data = json_decode(
                $body, true, flags: JSON_THROW_ON_ERROR
            );

            $price = $data["price"];
            $amount = $price ? "R" . $price["amount"] : "-";
            echo $data["name"], " ", $amount, "\n";
        - lang: csharp
          label: C#
          source: |
            using System.Net.Http.Headers;
            using System.Net.Http.Json;
            using System.Text.Json;
            using Naming = System.Text.Json.JsonNamingPolicy;

            var key = Environment.GetEnvironmentVariable(
                "GETPRICE_API_KEY");
            var api = new Uri("https://api.getprice.ai/v1/");
            var http = new HttpClient { BaseAddress = api };
            http.Timeout = TimeSpan.FromSeconds(30);
            http.DefaultRequestHeaders.Authorization =
                new AuthenticationHeaderValue("Bearer", key);
            var json = new JsonSerializerOptions
            {
                PropertyNamingPolicy = Naming.SnakeCaseLower,
            };

            var res = await http.GetAsync("products/prd_8fK2mQa1");
            res.EnsureSuccessStatusCode();
            var product = await res.Content
                .ReadFromJsonAsync<Product>(json);
            var amount = product!.Price is null
                ? "-" : $"R{product.Price.Amount}";
            Console.WriteLine($"{product.Name} {amount}");

            record Product(string Name, Price? Price);
            record Price(string Amount);
        - lang: odoo
          label: Odoo (Python)
          source: |
            import requests
            from odoo import models

            API = "https://api.getprice.ai/v1"


            class ProductProduct(models.Model):
                _inherit = "product.product"

                def getprice_post_shop_price(self, gp_product_id):
                    """Post the price collected from your shop."""
                    self.ensure_one()
                    icp = self.env["ir.config_parameter"].sudo()
                    key = icp.get_param("getprice.api_key")
                    resp = requests.get(
                        f"{API}/products/{gp_product_id}",
                        headers={"Authorization": f"Bearer {key}"},
                        timeout=30,
                    )
                    resp.raise_for_status()
                    price = resp.json()["price"]
                    if price:
                        self.message_post(
                            body=f"Shop price R{price['amount']}"
                        )
      tags: [Products]
      summary: Retrieve a product
      description: |
        Returns one of your own products. A competitor's product id, or an id from another company, returns 404.
      security:
        - bearerAuth: [catalog:read]
      parameters:
        - name: product_id
          in: path
          required: true
          description: The product id, e.g. `prd_8fK2mQa1`.
          schema: { $ref: '#/components/schemas/ProductId' }
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: The product.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            ETag: { $ref: '#/components/headers/ETag' }
            RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
            RateLimit: { $ref: '#/components/headers/RateLimit' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Product' }
              examples:
                kooBeans: { $ref: '#/components/examples/ProductKooBeans' }
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: Not found for your company. Code `not_found`.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                unknownProduct:
                  summary: Unknown id
                  value:
                    type: https://api.getprice.ai/errors/not-found
                    title: Not found
                    status: 404
                    detail: "No product of yours with this id."
                    code: not_found
                    request_id: req_01JBX4Q8ZJ7M2K
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /competitors:
    get:
      operationId: listCompetitors
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            BASE=https://api.getprice.ai/v1
            curl "$BASE/competitors" \
              -H "Authorization: Bearer $GETPRICE_API_KEY"
        - lang: python
          label: Python
          source: |
            import os

            import requests

            BASE = "https://api.getprice.ai/v1"
            KEY = os.environ["GETPRICE_API_KEY"]

            resp = requests.get(
                f"{BASE}/competitors",
                headers={"Authorization": f"Bearer {KEY}"},
                timeout=30,
            )
            resp.raise_for_status()
            for c in resp.json()["data"]:
                print(c["id"], c["name"], c["domain"])
        - lang: typescript
          label: TypeScript
          source: |
            interface Competitor {
              id: string;
              name: string;
              domain: string;
            }

            const BASE = "https://api.getprice.ai/v1";
            const KEY = process.env.GETPRICE_API_KEY;
            const url = `${BASE}/competitors`;
            const res = await fetch(url, {
              headers: { Authorization: `Bearer ${KEY}` },
            });
            if (!res.ok) {
              throw new Error(`GetPrice returned ${res.status}`);
            }
            const page = (await res.json()) as {
              data: Competitor[];
            };
            for (const { id, name, domain } of page.data) {
              console.log(id, name, domain);
            }
        - lang: php
          label: PHP
          source: |
            <?php
            $base = "https://api.getprice.ai/v1";
            $key = getenv("GETPRICE_API_KEY");
            $ch = curl_init("$base/competitors");
            curl_setopt_array($ch, [
                CURLOPT_HTTPHEADER => [
                    "Authorization: Bearer $key",
                ],
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 30,
            ]);
            $body = curl_exec($ch);
            $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            if ($body === false || $status >= 400) {
                throw new RuntimeException(
                    "GetPrice returned $status: $body"
                );
            }
            $data = json_decode(
                $body, true, flags: JSON_THROW_ON_ERROR
            );

            foreach ($data["data"] as $c) {
                echo $c["id"], " ", $c["name"], " ",
                    $c["domain"], "\n";
            }
        - lang: csharp
          label: C#
          source: |
            using System.Net.Http.Headers;
            using System.Net.Http.Json;
            using System.Text.Json;
            using Naming = System.Text.Json.JsonNamingPolicy;

            var key = Environment.GetEnvironmentVariable(
                "GETPRICE_API_KEY");
            var api = new Uri("https://api.getprice.ai/v1/");
            var http = new HttpClient { BaseAddress = api };
            http.Timeout = TimeSpan.FromSeconds(30);
            http.DefaultRequestHeaders.Authorization =
                new AuthenticationHeaderValue("Bearer", key);
            var json = new JsonSerializerOptions
            {
                PropertyNamingPolicy = Naming.SnakeCaseLower,
            };

            var res = await http.GetAsync("competitors");
            res.EnsureSuccessStatusCode();
            var page = await res.Content
                .ReadFromJsonAsync<Page>(json);
            foreach (var c in page!.Data)
                Console.WriteLine($"{c.Id} {c.Name} {c.Domain}");

            record Page(List<Competitor> Data);
            record Competitor(
                string Id,
                string Name,
                string Domain);
        - lang: odoo
          label: Odoo (Python)
          source: |
            import requests
            from odoo import api, models

            API = "https://api.getprice.ai/v1"


            class ResPartner(models.Model):
                _inherit = "res.partner"

                @api.model
                def getprice_sync_competitors(self):
                    """Create a contact per tracked competitor."""
                    icp = self.env["ir.config_parameter"].sudo()
                    key = icp.get_param("getprice.api_key")
                    resp = requests.get(
                        f"{API}/competitors",
                        headers={"Authorization": f"Bearer {key}"},
                        timeout=30,
                    )
                    resp.raise_for_status()
                    for c in resp.json()["data"]:
                        domain = [("website", "=", c["url"])]
                        if not self.search(domain, limit=1):
                            self.create({
                                "name": c["name"],
                                "website": c["url"],
                                "is_company": True,
                            })
      tags: [Competitors]
      summary: List competitors
      description: |
        Returns the competitor websites GetPrice collects prices from for you.
      security:
        - bearerAuth: [catalog:read]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: A page of competitors.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            ETag: { $ref: '#/components/headers/ETag' }
            RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
            RateLimit: { $ref: '#/components/headers/RateLimit' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CompetitorList' }
              examples:
                example:
                  summary: Retail competitors
                  value:
                    data:
                      - { id: cmp_3LqV8nP2, name: Harbour Market, domain: harbourmarket.example.com, url: 'https://harbourmarket.example.com' }
                      - { id: cmp_7RwK4tB9, name: Cape Fresh, domain: capefresh.example.com, url: 'https://capefresh.example.com' }
                      - { id: cmp_9TzM2cX5, name: Northwind Wholesale, domain: northwindwholesale.example.com, url: 'https://northwindwholesale.example.com' }
                      - { id: cmp_2HdR6wJ3, name: Metro Bulk, domain: metrobulk.example.com, url: 'https://metrobulk.example.com' }
                    next_cursor: null
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /sku-mappings:
    get:
      operationId: listSkuMappings
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            BASE=https://api.getprice.ai/v1
            curl -G "$BASE/sku-mappings" \
              -H "Authorization: Bearer $GETPRICE_API_KEY" \
              --data-urlencode "limit=100"
        - lang: python
          label: Python
          source: |
            import os

            import requests

            BASE = "https://api.getprice.ai/v1"
            KEY = os.environ["GETPRICE_API_KEY"]

            resp = requests.get(
                f"{BASE}/sku-mappings",
                params=[("limit", "100")],
                headers={"Authorization": f"Bearer {KEY}"},
                timeout=30,
            )
            resp.raise_for_status()
            page = resp.json()
            for m in page["data"]:
                target = m["product_id"] or m["comparison_set_id"]
                print(m["sku"], target)
            # More? Send page["next_cursor"] as cursor.
        - lang: typescript
          label: TypeScript
          source: |
            interface SkuMapping {
              sku: string;
              product_id: string | null;
              comparison_set_id: string | null;
            }

            const BASE = "https://api.getprice.ai/v1";
            const KEY = process.env.GETPRICE_API_KEY;
            const params = new URLSearchParams([
              ["limit", "100"],
            ]);
            const url = `${BASE}/sku-mappings?${params}`;
            const res = await fetch(url, {
              headers: { Authorization: `Bearer ${KEY}` },
            });
            if (!res.ok) {
              throw new Error(`GetPrice returned ${res.status}`);
            }
            const page = (await res.json()) as {
              data: SkuMapping[];
              next_cursor: string | null;
            };
            for (const m of page.data) {
              const target = m.product_id ?? m.comparison_set_id;
              console.log(m.sku, target);
            }
            // More? Send page.next_cursor as cursor.
        - lang: php
          label: PHP
          source: |
            <?php
            $base = "https://api.getprice.ai/v1";
            $key = getenv("GETPRICE_API_KEY");
            $query = http_build_query([
                "limit" => "100",
            ]);
            $ch = curl_init("$base/sku-mappings?$query");
            curl_setopt_array($ch, [
                CURLOPT_HTTPHEADER => [
                    "Authorization: Bearer $key",
                ],
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 30,
            ]);
            $body = curl_exec($ch);
            $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            if ($body === false || $status >= 400) {
                throw new RuntimeException(
                    "GetPrice returned $status: $body"
                );
            }
            $data = json_decode(
                $body, true, flags: JSON_THROW_ON_ERROR
            );

            foreach ($data["data"] as $m) {
                $target = $m["product_id"]
                    ?? $m["comparison_set_id"];
                echo $m["sku"], " ", $target, "\n";
            }
            // More? Send $data["next_cursor"] as cursor.
        - lang: csharp
          label: C#
          source: |
            using System.Net.Http.Headers;
            using System.Net.Http.Json;
            using System.Text.Json;
            using Naming = System.Text.Json.JsonNamingPolicy;

            var key = Environment.GetEnvironmentVariable(
                "GETPRICE_API_KEY");
            var api = new Uri("https://api.getprice.ai/v1/");
            var http = new HttpClient { BaseAddress = api };
            http.Timeout = TimeSpan.FromSeconds(30);
            http.DefaultRequestHeaders.Authorization =
                new AuthenticationHeaderValue("Bearer", key);
            var json = new JsonSerializerOptions
            {
                PropertyNamingPolicy = Naming.SnakeCaseLower,
            };

            var res = await http.GetAsync("sku-mappings?limit=100");
            res.EnsureSuccessStatusCode();
            var page = await res.Content
                .ReadFromJsonAsync<Page>(json);
            foreach (var m in page!.Data)
                Console.WriteLine(
                    $"{m.Sku} {m.ProductId ?? m.ComparisonSetId}");
            // More? Send page.NextCursor as cursor.

            record Page(List<SkuMapping> Data, string? NextCursor);
            record SkuMapping(
                string Sku,
                string? ProductId,
                string? ComparisonSetId);
        - lang: odoo
          label: Odoo (Python)
          source: |
            import logging

            import requests
            from odoo import api, models

            API = "https://api.getprice.ai/v1"
            _logger = logging.getLogger(__name__)


            class ProductProduct(models.Model):
                _inherit = "product.product"

                @api.model
                def getprice_unlinked_products(self):
                    """Products whose reference has no SKU link."""
                    icp = self.env["ir.config_parameter"].sudo()
                    key = icp.get_param("getprice.api_key")
                    linked, params = set(), {"limit": 100}
                    while True:
                        resp = requests.get(
                            f"{API}/sku-mappings",
                            params=params,
                            headers={
                                "Authorization": f"Bearer {key}"
                            },
                            timeout=30,
                        )
                        resp.raise_for_status()
                        page = resp.json()
                        for m in page["data"]:
                            linked.add(m["sku"].strip().upper())
                        if page["next_cursor"] is None:
                            break
                        params["cursor"] = page["next_cursor"]
                    products = self.search(
                        [("default_code", "!=", False)]
                    )
                    unlinked = products.filtered(
                        lambda p: p.default_code.strip().upper()
                        not in linked
                    )
                    _logger.info("%s unlinked", len(unlinked))
                    return unlinked
      tags: [SKU mappings]
      summary: List SKU mappings
      description: |
        Returns every SKU link you have, ordered by SKU.
      security:
        - bearerAuth: [catalog:read]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: A page of SKU mappings.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            ETag: { $ref: '#/components/headers/ETag' }
            RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
            RateLimit: { $ref: '#/components/headers/RateLimit' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SkuMappingList' }
              examples:
                example:
                  value:
                    data:
                      - &kooMap
                        sku: 10072L
                        product_id: prd_8fK2mQa1
                        comparison_set_id: null
                        source: list_sku
                        created_at: '2026-11-02T10:04:12Z'
                        updated_at: '2026-11-02T10:04:12Z'
                      - sku: 13817L
                        product_id: prd_Lp4sT1x9
                        comparison_set_id: null
                        source: api
                        created_at: '2026-11-02T10:05:00Z'
                        updated_at: '2026-11-02T10:05:00Z'
                      - sku: 15520L
                        product_id: null
                        comparison_set_id: set_Wq3NdT5v
                        source: list_sku
                        created_at: '2026-11-01T16:20:00Z'
                        updated_at: '2026-11-01T16:20:00Z'
                    next_cursor: null
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /sku-mappings/{sku}:
    parameters:
      - name: sku
        in: path
        required: true
        description: |
          Your SKU, percent-encoded. SKUs may contain `/`, spaces, `+`, `%` and non-ASCII characters; encode
          every reserved character (e.g. `AB/12 X` → `AB%2F12%20X`). Surrounding whitespace (space, tab, CR, LF,
          non-breaking space) is trimmed. A SKU can't contain line breaks or other control characters inside it.
        schema: { type: string, minLength: 1, maxLength: 100 }
        example: 10072L
    get:
      operationId: getSkuMapping
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            BASE=https://api.getprice.ai/v1
            curl "$BASE/sku-mappings/10072L" \
              -H "Authorization: Bearer $GETPRICE_API_KEY"
        - lang: python
          label: Python
          source: |
            import os

            import requests

            BASE = "https://api.getprice.ai/v1"
            KEY = os.environ["GETPRICE_API_KEY"]

            resp = requests.get(
                f"{BASE}/sku-mappings/10072L",
                headers={"Authorization": f"Bearer {KEY}"},
                timeout=30,
            )
            resp.raise_for_status()
            mapping = resp.json()
            print(mapping["sku"], mapping["product_id"])
            print("ETag:", resp.headers["ETag"])
        - lang: typescript
          label: TypeScript
          source: |
            interface SkuMapping {
              sku: string;
              product_id: string | null;
            }

            const BASE = "https://api.getprice.ai/v1";
            const KEY = process.env.GETPRICE_API_KEY;
            const url = `${BASE}/sku-mappings/10072L`;
            const res = await fetch(url, {
              headers: { Authorization: `Bearer ${KEY}` },
            });
            if (!res.ok) {
              throw new Error(`GetPrice returned ${res.status}`);
            }
            const mapping = (await res.json()) as SkuMapping;
            console.log(mapping.sku, mapping.product_id);
            console.log("ETag:", res.headers.get("etag"));
        - lang: php
          label: PHP
          source: |
            <?php
            $base = "https://api.getprice.ai/v1";
            $key = getenv("GETPRICE_API_KEY");
            $etag = null;
            $ch = curl_init("$base/sku-mappings/10072L");
            curl_setopt_array($ch, [
                CURLOPT_HTTPHEADER => [
                    "Authorization: Bearer $key",
                ],
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 30,
                CURLOPT_HEADERFUNCTION =>
                    function ($ch, $line) use (&$etag) {
                        if (stripos($line, "ETag:") === 0) {
                            $etag = trim(substr($line, 5));
                        }
                        return strlen($line);
                    },
            ]);
            $body = curl_exec($ch);
            $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            if ($body === false || $status >= 400) {
                throw new RuntimeException(
                    "GetPrice returned $status: $body"
                );
            }
            $data = json_decode(
                $body, true, flags: JSON_THROW_ON_ERROR
            );

            echo $data["sku"], " ", $data["product_id"], "\n";
            echo "ETag: ", $etag, "\n";
        - lang: csharp
          label: C#
          source: |
            using System.Net.Http.Headers;
            using System.Net.Http.Json;
            using System.Text.Json;
            using Naming = System.Text.Json.JsonNamingPolicy;

            var key = Environment.GetEnvironmentVariable(
                "GETPRICE_API_KEY");
            var api = new Uri("https://api.getprice.ai/v1/");
            var http = new HttpClient { BaseAddress = api };
            http.Timeout = TimeSpan.FromSeconds(30);
            http.DefaultRequestHeaders.Authorization =
                new AuthenticationHeaderValue("Bearer", key);
            var json = new JsonSerializerOptions
            {
                PropertyNamingPolicy = Naming.SnakeCaseLower,
            };

            var res = await http.GetAsync("sku-mappings/10072L");
            res.EnsureSuccessStatusCode();
            var mapping = await res.Content
                .ReadFromJsonAsync<SkuMapping>(json);
            Console.WriteLine(
                $"{mapping!.Sku} {mapping.ProductId}");
            Console.WriteLine($"ETag: {res.Headers.ETag?.Tag}");

            record SkuMapping(string Sku, string? ProductId);
        - lang: odoo
          label: Odoo (Python)
          source: |
            from urllib.parse import quote

            import requests
            from odoo import models

            API = "https://api.getprice.ai/v1"


            class ProductProduct(models.Model):
                _inherit = "product.product"

                def action_getprice_show_link(self):
                    """Post what this reference is linked to."""
                    self.ensure_one()
                    icp = self.env["ir.config_parameter"].sudo()
                    key = icp.get_param("getprice.api_key")
                    resp = requests.get(
                        f"{API}/sku-mappings/"
                        + quote(self.default_code, safe=""),
                        headers={"Authorization": f"Bearer {key}"},
                        timeout=30,
                    )
                    if resp.status_code == 404:
                        self.message_post(body="Not linked.")
                        return
                    resp.raise_for_status()
                    m = resp.json()
                    target = m["product_id"]
                    target = target or m["comparison_set_id"]
                    self.message_post(body=f"Linked to {target}")
      tags: [SKU mappings]
      summary: Retrieve a SKU mapping
      description: |
        Returns what one of your SKUs is linked to. The response's `ETag` is the link's version, such as `"v42"`.
        Send it as `If-Match` to replace or remove the link only if nobody has changed it since.
      security:
        - bearerAuth: [catalog:read]
      parameters:
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: The SKU mapping.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            ETag: { $ref: '#/components/headers/ETagStrong' }
            Cache-Control: { $ref: '#/components/headers/CacheControlNoCache' }
            RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
            RateLimit: { $ref: '#/components/headers/RateLimit' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SkuMapping' }
              examples:
                kooBeans: { $ref: '#/components/examples/SkuMappingKooBeans' }
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: Not found for your company. Code `not_found`.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                unlinkedSku:
                  summary: Unknown id
                  value:
                    type: https://api.getprice.ai/errors/not-found
                    title: Not found
                    status: 404
                    detail: "No mapping for this SKU."
                    code: not_found
                    request_id: req_01JBX4Q8ZJ7M2K
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }
    put:
      operationId: putSkuMapping
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            BASE=https://api.getprice.ai/v1
            curl -X PUT "$BASE/sku-mappings/13817L" \
              -H "Authorization: Bearer $GETPRICE_API_KEY" \
              -H "If-None-Match: *" \
              -H "Content-Type: application/json" \
              -d '{"product_id": "prd_Lp4sT1x9"}'
        - lang: python
          label: Python
          source: |
            import os

            import requests

            BASE = "https://api.getprice.ai/v1"
            KEY = os.environ["GETPRICE_API_KEY"]

            resp = requests.put(
                f"{BASE}/sku-mappings/13817L",
                json={"product_id": "prd_Lp4sT1x9"},
                headers={
                    "Authorization": f"Bearer {KEY}",
                    "If-None-Match": "*",
                },
                timeout=30,
            )
            resp.raise_for_status()
            created = resp.status_code == 201
            print("created" if created else "replaced")
        - lang: typescript
          label: TypeScript
          source: |
            const BASE = "https://api.getprice.ai/v1";
            const KEY = process.env.GETPRICE_API_KEY;
            const url = `${BASE}/sku-mappings/13817L`;
            const res = await fetch(url, {
              method: "PUT",
              headers: {
                Authorization: `Bearer ${KEY}`,
                "Content-Type": "application/json",
                "If-None-Match": "*",
              },
              body: JSON.stringify({
                product_id: "prd_Lp4sT1x9",
              }),
            });
            if (!res.ok) {
              throw new Error(`GetPrice returned ${res.status}`);
            }
            const created = res.status === 201;
            console.log(created ? "created" : "replaced");
        - lang: php
          label: PHP
          source: |
            <?php
            $base = "https://api.getprice.ai/v1";
            $key = getenv("GETPRICE_API_KEY");
            $ch = curl_init("$base/sku-mappings/13817L");
            curl_setopt_array($ch, [
                CURLOPT_CUSTOMREQUEST => "PUT",
                CURLOPT_HTTPHEADER => [
                    "Authorization: Bearer $key",
                    "Content-Type: application/json",
                    "If-None-Match: *",
                ],
                CURLOPT_POSTFIELDS => json_encode([
                    "product_id" => "prd_Lp4sT1x9",
                ]),
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 30,
            ]);
            $body = curl_exec($ch);
            $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            if ($body === false || $status >= 400) {
                throw new RuntimeException(
                    "GetPrice returned $status: $body"
                );
            }

            echo $status === 201 ? "created" : "replaced", "\n";
        - lang: csharp
          label: C#
          source: |
            using System.Net.Http.Headers;
            using System.Net.Http.Json;

            var key = Environment.GetEnvironmentVariable(
                "GETPRICE_API_KEY");
            var api = new Uri("https://api.getprice.ai/v1/");
            var http = new HttpClient { BaseAddress = api };
            http.Timeout = TimeSpan.FromSeconds(30);
            http.DefaultRequestHeaders.Authorization =
                new AuthenticationHeaderValue("Bearer", key);

            var req = new HttpRequestMessage(
                HttpMethod.Put, "sku-mappings/13817L")
            {
                Content = JsonContent.Create(
                    new { product_id = "prd_Lp4sT1x9" }),
            };
            // Create only: 412 if the SKU is already linked.
            req.Headers.IfNoneMatch.Add(EntityTagHeaderValue.Any);
            var res = await http.SendAsync(req);
            res.EnsureSuccessStatusCode();
            var created = (int)res.StatusCode == 201;
            Console.WriteLine(created ? "created" : "replaced");
        - lang: odoo
          label: Odoo (Python)
          source: |
            from urllib.parse import quote

            import requests
            from odoo import models
            from odoo.exceptions import UserError

            API = "https://api.getprice.ai/v1"


            class ProductProduct(models.Model):
                _inherit = "product.product"

                def getprice_link_sku(self, gp_product_id):
                    """Link this reference; never overwrite."""
                    self.ensure_one()
                    icp = self.env["ir.config_parameter"].sudo()
                    key = icp.get_param("getprice.api_key")
                    resp = requests.put(
                        f"{API}/sku-mappings/"
                        + quote(self.default_code, safe=""),
                        json={"product_id": gp_product_id},
                        headers={
                            "Authorization": f"Bearer {key}",
                            "If-None-Match": "*",
                        },
                        timeout=30,
                    )
                    if resp.status_code == 412:
                        raise UserError("Already linked.")
                    resp.raise_for_status()
                    self.message_post(
                        body=f"Linked to {gp_product_id}"
                    )
      tags: [SKU mappings]
      summary: Link a SKU to a product
      description: |
        Links your SKU to one of your own products, creating or replacing the link. Returns 201 with a `Location`
        header for a new link, and 200 when it replaced one.

        Sending the same request twice has the same effect as sending it once. Surrounding whitespace in the SKU is
        trimmed. A product id that isn't one of your own products returns 404 `product_not_found`.

        To avoid overwriting someone else's link:

        - `If-None-Match: *` creates only. If the SKU is already linked, you get 412 and nothing changes.
        - `If-Match: <ETag>` replaces only if the link hasn't changed since you read it. Otherwise you get 412.
      security:
        - bearerAuth: [skus:write]
      parameters:
        - name: If-None-Match
          in: header
          required: false
          description: '`*` to create only (412 if the SKU is already linked).'
          schema: { type: string, const: '*' }
        - name: If-Match
          in: header
          required: false
          description: The ETag from a previous `GET /v1/sku-mappings/{sku}`; replace only if unchanged.
          schema: { type: string }
          example: '"v42"'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SkuMappingWrite' }
            examples:
              link:
                value: { product_id: prd_8fK2mQa1 }
      responses:
        '200':
          description: An existing link was replaced (or re-sent unchanged).
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            ETag: { $ref: '#/components/headers/ETagStrong' }
            Cache-Control: { $ref: '#/components/headers/CacheControlNoCache' }
            RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
            RateLimit: { $ref: '#/components/headers/RateLimit' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SkuMapping' }
              examples:
                kooBeans: { $ref: '#/components/examples/SkuMappingKooBeans' }
        '201':
          description: A new link was created.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            ETag: { $ref: '#/components/headers/ETagStrong' }
            Cache-Control: { $ref: '#/components/headers/CacheControlNoCache' }
            Location:
              description: URL of the new mapping.
              schema: { type: string }
              example: /v1/sku-mappings/10072L
            RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
            RateLimit: { $ref: '#/components/headers/RateLimit' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SkuMapping' }
              examples:
                kooBeans: { $ref: '#/components/examples/SkuMappingKooBeans' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: |
            `product_id` is not one of your own products (for example a competitor's product, or an unknown id).
            Code: `product_not_found`.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                notOwnProduct:
                  value:
                    type: https://api.getprice.ai/errors/product-not-found
                    title: Not found
                    status: 404
                    detail: No product prd_Q2vN9cD4 among this company's own products.
                    code: product_not_found
                    request_id: req_01JBX4Q8ZJ7M2K
        '412': { $ref: '#/components/responses/PreconditionFailed' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/UnprocessableContent' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }
    delete:
      operationId: deleteSkuMapping
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            BASE=https://api.getprice.ai/v1
            curl -X DELETE "$BASE/sku-mappings/13817L" \
              -H "Authorization: Bearer $GETPRICE_API_KEY"
        - lang: python
          label: Python
          source: |
            import os

            import requests

            BASE = "https://api.getprice.ai/v1"
            KEY = os.environ["GETPRICE_API_KEY"]

            resp = requests.delete(
                f"{BASE}/sku-mappings/13817L",
                headers={"Authorization": f"Bearer {KEY}"},
                timeout=30,
            )
            resp.raise_for_status()
            print("unlinked")
        - lang: typescript
          label: TypeScript
          source: |
            const BASE = "https://api.getprice.ai/v1";
            const KEY = process.env.GETPRICE_API_KEY;
            const url = `${BASE}/sku-mappings/13817L`;
            const res = await fetch(url, {
              method: "DELETE",
              headers: { Authorization: `Bearer ${KEY}` },
            });
            if (!res.ok) {
              throw new Error(`GetPrice returned ${res.status}`);
            }
            console.log("unlinked");
        - lang: php
          label: PHP
          source: |
            <?php
            $base = "https://api.getprice.ai/v1";
            $key = getenv("GETPRICE_API_KEY");
            $ch = curl_init("$base/sku-mappings/13817L");
            curl_setopt_array($ch, [
                CURLOPT_CUSTOMREQUEST => "DELETE",
                CURLOPT_HTTPHEADER => [
                    "Authorization: Bearer $key",
                ],
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 30,
            ]);
            $body = curl_exec($ch);
            $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            if ($body === false || $status >= 400) {
                throw new RuntimeException(
                    "GetPrice returned $status: $body"
                );
            }

            echo "unlinked\n";
        - lang: csharp
          label: C#
          source: |
            using System.Net.Http.Headers;

            var key = Environment.GetEnvironmentVariable(
                "GETPRICE_API_KEY");
            var api = new Uri("https://api.getprice.ai/v1/");
            var http = new HttpClient { BaseAddress = api };
            http.Timeout = TimeSpan.FromSeconds(30);
            http.DefaultRequestHeaders.Authorization =
                new AuthenticationHeaderValue("Bearer", key);

            var res = await http.DeleteAsync(
                "sku-mappings/13817L");
            res.EnsureSuccessStatusCode();
            Console.WriteLine("unlinked");
        - lang: odoo
          label: Odoo (Python)
          source: |
            from urllib.parse import quote

            import requests
            from odoo import models

            API = "https://api.getprice.ai/v1"


            class ProductProduct(models.Model):
                _inherit = "product.product"

                def action_getprice_unlink_sku(self):
                    """Remove this reference's GetPrice link."""
                    self.ensure_one()
                    icp = self.env["ir.config_parameter"].sudo()
                    key = icp.get_param("getprice.api_key")
                    resp = requests.delete(
                        f"{API}/sku-mappings/"
                        + quote(self.default_code, safe=""),
                        headers={"Authorization": f"Bearer {key}"},
                        timeout=30,
                    )
                    # 404 means it wasn't linked, which is fine.
                    if resp.status_code != 404:
                        resp.raise_for_status()
                    self.message_post(body="Unlinked in GetPrice")
      tags: [SKU mappings]
      summary: Remove a SKU link
      description: |
        Removes the link for your SKU and returns 204. Your product and its comparison set stay as they are. Returns
        404 if the SKU isn't linked.
      security:
        - bearerAuth: [skus:write]
      parameters:
        - name: If-Match
          in: header
          required: false
          description: The ETag from a previous `GET`; delete only if unchanged.
          schema: { type: string }
      responses:
        '204':
          description: The link was removed.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
            RateLimit: { $ref: '#/components/headers/RateLimit' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '412': { $ref: '#/components/responses/PreconditionFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: GetPrice API key (gp_live_…)
      description: |
        Send your API key in the `Authorization` header: `Authorization: Bearer gp_live_…`.
        Never put the key in a URL. An admin creates your organisation's one key in Settings → API &
        Integrations; it is shown once, and the same page regenerates it. Scopes are listed in `GET /v1/me`.

        | Scope | Grants |
        |---|---|
        | `catalog:read` | your products, competitors, SKU links |
        | `matches:read` | comparison sets (competitor matches and prices) |
        | `skus:write` | create, replace and remove SKU links |

  parameters:
    Limit:
      name: limit
      in: query
      required: false
      description: Items per page. Default 100. Values above 500 are treated as 500.
      schema: { type: integer, minimum: 1, default: 100 }
    Cursor:
      name: cursor
      in: query
      required: false
      description: |
        The `next_cursor` from the previous page. Opaque — pass it back unchanged, together with the **same**
        filters, on the same endpoint, as the first request (otherwise 400 `cursor_mismatch`).
      schema: { type: string }
    SkuFilter:
      name: sku
      in: query
      required: false
      description: |
        Your SKU. Repeat the parameter for several SKUs (`?sku=A&sku=B`), up to 100. Commas are part of the
        SKU, not separators. Matching ignores case and surrounding spaces; every other character (including
        leading zeros) counts: `00981A` and `981A` are different SKUs.
      style: form
      explode: true
      schema:
        type: array
        maxItems: 100
        items: { type: string, minLength: 1, maxLength: 100 }
      example: [10072L, 13817L]
    ReviewedBy:
      name: reviewed_by
      in: query
      required: false
      description: '`customer` returns only competitors your own users have confirmed. Omit for every competitor in the set.'
      schema: { type: string, enum: [customer] }
    IncludeDelisted:
      name: include_delisted
      in: query
      required: false
      description: '`true` also returns competitor products that are no longer listed (`status: delisted`).'
      schema: { type: boolean, default: false }
    IfNoneMatch:
      name: If-None-Match
      in: header
      required: false
      description: An ETag from a previous response. If nothing changed you get `304 Not Modified` with no body.
      schema: { type: string }
      example: W/"9f3c1e"

  headers:
    X-Request-Id:
      description: Unique id for this request (`req_…`). Quote it when contacting support.
      schema: { type: string }
      example: req_01JBX4Q8ZJ7M2K
    ETag:
      description: |
        Weak validator for this representation. Send it back in `If-None-Match` to get a 304. These responses
        also carry `Cache-Control: private, max-age=60`.
      schema: { type: string }
      example: W/"9f3c1e"
    RateLimit-Policy:
      description: |
        Your limits (IETF draft-ietf-httpapi-ratelimit-headers). `q` = quota, `w` = window in seconds.
      schema: { type: string }
      example: '"sustained";q=120;w=60'
    RateLimit:
      description: What is left right now. `r` = units remaining, `t` = seconds until the budget is full again.
      schema: { type: string }
      example: '"sustained";r=117;t=12'
    X-RateLimit-Limit:
      description: Rate-limit units per minute. Most requests cost 1 unit.
      schema: { type: integer }
      example: 120
    X-RateLimit-Remaining:
      description: Units left in the current minute. Drops by each request's cost.
      schema: { type: integer }
      example: 117
    X-RateLimit-Reset:
      description: Seconds until the per-minute allowance resets (a duration, not a timestamp).
      schema: { type: integer }
      example: 12
    ETagStrong:
      description: |
        Strong validator: the SKU link's version, such as `"v42"`. Send it as `If-Match` to replace or remove the
        link only if it hasn't changed, or as `If-None-Match` to get a 304. Treat it as opaque.
      schema: { type: string }
      example: '"v42"'
    CacheControlNoCache:
      description: '`private, no-cache`: a SKU link you just changed is never served from a cache.'
      schema: { type: string }
      example: private, no-cache
    Retry-After:
      description: Seconds to wait before retrying.
      schema: { type: integer }
      example: 21
    WWW-Authenticate:
      description: Bearer challenge (RFC 6750).
      schema: { type: string }
      example: Bearer realm="api.getprice.ai", error="invalid_token"

  responses:
    NotModified:
      description: Not modified — your cached copy (matching `If-None-Match`) is current. No body.
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        ETag: { $ref: '#/components/headers/ETag' }
    BadRequest:
      description: |
        The request is malformed: an invalid or unknown query parameter, a bad cursor, more than 100 `sku`
        values, or an invalid SKU in the path (empty after trimming or over 100 characters). Codes:
        `validation_failed` (a value is invalid), `unknown_parameter` (a parameter this endpoint doesn't take),
        `invalid_parameter` (a query or path value contains a control character), `cursor_mismatch`
        (filters or endpoint changed while paging) and `invalid_cursor` (the cursor is malformed, altered or expired; start
        again without one).
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            unknownParameter:
              value:
                type: https://api.getprice.ai/errors/unknown-parameter
                title: Unknown query parameter
                status: 400
                detail: "Unknown query parameter 'update_since'."
                code: unknown_parameter
                request_id: req_01JBX4Q8ZJ7M2K
                errors:
                  - { parameter: update_since, detail: Not a parameter of this endpoint. }
            tooManySkus:
              value:
                type: https://api.getprice.ai/errors/validation-failed
                title: Validation failed
                status: 400
                detail: At most 100 sku values per request.
                code: validation_failed
                request_id: req_01JBX4Q8ZJ7M2K
                errors:
                  - { parameter: sku, detail: 'Got 140 values; the maximum is 100.' }
    Unauthorized:
      description: |
        No key (`unauthenticated`), or a key that is malformed, unknown, revoked or expired (`invalid_key`; for
        security the response doesn't say which).
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        WWW-Authenticate: { $ref: '#/components/headers/WWW-Authenticate' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            missing:
              value:
                type: https://api.getprice.ai/errors/unauthenticated
                title: Authentication required
                status: 401
                detail: "Send your API key as 'Authorization: Bearer gp_live_…'."
                code: unauthenticated
                request_id: req_01JBX4Q8ZJ7M2K
            invalidKey:
              value:
                type: https://api.getprice.ai/errors/invalid-key
                title: Invalid API key
                status: 401
                detail: The API key is not valid.
                code: invalid_key
                request_id: req_01JBX4Q8ZJ7M2K
    Forbidden:
      description: |
        The key is valid but lacks the scope this endpoint needs. Code: `insufficient_scope`.
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            scope:
              value:
                type: https://api.getprice.ai/errors/insufficient-scope
                title: Insufficient scope
                status: 403
                detail: This endpoint needs the 'skus:write' scope.
                code: insufficient_scope
                request_id: req_01JBX4Q8ZJ7M2K
    NotFound:
      description: |
        The resource does not exist **for your company**. Ids belonging to other companies also return 404.
        Code: `not_found`.
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            notFound:
              value:
                type: https://api.getprice.ai/errors/not-found
                title: Not found
                status: 404
                detail: No mapping for this SKU.
                code: not_found
                request_id: req_01JBX4Q8ZJ7M2K
    PreconditionFailed:
      description: '`If-Match` did not match, or `If-None-Match: *` was sent and the SKU is already linked. Code: `precondition_failed`.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            alreadyLinked:
              value:
                type: https://api.getprice.ai/errors/precondition-failed
                title: Precondition failed
                status: 412
                detail: "SKU '10072L' is already linked (If-None-Match: *)."
                code: precondition_failed
                request_id: req_01JBX4Q8ZJ7M2K
    PayloadTooLarge:
      description: 'Request body over 1 MB. Code: `payload_too_large`.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    UnsupportedMediaType:
      description: 'The body is not `application/json`. Code: `unsupported_media_type`.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    UnprocessableContent:
      description: |
        The body is not valid: not JSON, missing `product_id`, or extra fields. Code: `validation_failed`, with
        `errors[].pointer` naming the field.
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            missingField:
              value:
                type: https://api.getprice.ai/errors/validation-failed
                title: Validation failed
                status: 422
                detail: The request body is not valid.
                code: validation_failed
                request_id: req_01JBX4Q8ZJ7M2K
                errors:
                  - { pointer: /product_id, detail: Field required }
    TooManyRequests:
      description: |
        This request costs more rate-limit units than you have left. The refused request costs nothing. Wait
        `Retry-After` seconds, until enough units are back for it, then retry. Code: `rate_limited`.
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        Retry-After: { $ref: '#/components/headers/Retry-After' }
        RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
        RateLimit: { $ref: '#/components/headers/RateLimit' }
        X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            rateLimited:
              value:
                type: https://api.getprice.ai/errors/rate-limited
                title: Rate limit exceeded
                status: 429
                detail: Sustained limit of 120 requests per minute reached for this API key.
                code: rate_limited
                request_id: req_01JBX4Q8ZJ7M2K
                limit: sustained
                retry_after: 21
    InternalError:
      description: |
        Something went wrong on our side. **Not retryable**: retrying the same request is unlikely to help. Log the
        `request_id` and contact support if it persists. Code: `internal_error`.
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unavailable:
      description: |
        Temporarily unavailable: GetPrice can't guarantee correct match data right now, or the database is
        saturated. **Retryable**: wait `Retry-After` seconds, then retry. Code: `unavailable`.
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        Retry-After: { $ref: '#/components/headers/Retry-After' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:
    ProductId:
      type: string
      pattern: '^prd_[A-Za-z0-9]+$'
      maxLength: 32
      description: Opaque product id. Stable across URL changes and re-collection.
      examples: [prd_8fK2mQa1]
    SetId:
      type: string
      pattern: '^set_[A-Za-z0-9]+$'
      maxLength: 32
      description: Opaque comparison set id.
      examples: [set_x7Kp2Lm9]
    CompetitorId:
      type: string
      pattern: '^cmp_[A-Za-z0-9]+$'
      maxLength: 32
      description: Opaque competitor (website) id.
      examples: [cmp_3LqV8nP2]
    Money:
      type: string
      pattern: '^-?[0-9]+\.[0-9]{2}$'
      description: Decimal amount with exactly two decimals, as a string. Do not parse as a float.
      examples: ['18.99']
    Timestamp:
      type: string
      format: date-time
      description: RFC 3339, UTC, `Z` suffix.
      examples: ['2026-11-02T04:12:09Z']

    Price:
      type: object
      description: A price as read from a website. When it was read is the sibling `last_collected_at`.
      required: [amount, was, currency]
      properties:
        amount:
          $ref: '#/components/schemas/Money'
          description: |
            The price a shopper pays now. During a promotion, this is the discounted price. "Cheapest" and
            "position" in GetPrice always use `amount`.
        was:
          description: |
            The original (pre-discount) price the retailer shows during a promotion, from the same price reading
            as `amount`. Sent only when strictly greater than `amount`; otherwise `null` (not on promotion).
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: ISO 4217 code. Always present.
          examples: [ZAR]

    CompetitorRef:
      type: object
      required: [id, name, domain]
      properties:
        id: { $ref: '#/components/schemas/CompetitorId' }
        name: { type: string, examples: [Harbour Market] }
        domain: { type: string, examples: [harbourmarket.example.com] }

    Competitor:
      type: object
      required: [id, name, domain, url]
      properties:
        id: { $ref: '#/components/schemas/CompetitorId' }
        name: { type: string, examples: [Harbour Market] }
        domain: { type: string, examples: [harbourmarket.example.com] }
        url: { type: string, format: uri, examples: ['https://harbourmarket.example.com'] }

    ProductRef:
      type: object
      required: [id, name, url]
      properties:
        id: { $ref: '#/components/schemas/ProductId' }
        name: { type: string }
        url: { type: string, format: uri }

    OwnProductInSet:
      type: object
      required: [id, name, url, price, last_collected_at, stale]
      properties:
        id: { $ref: '#/components/schemas/ProductId' }
        name: { type: string }
        url: { type: string, format: uri }
        price:
          description: Your current price, or `null` if none has been collected.
          oneOf:
            - $ref: '#/components/schemas/Price'
            - type: 'null'
        last_collected_at:
          description: |
            The last time GetPrice **successfully** read this product's price, whether or not it changed. Failed
            reads, and readings rejected by our quality checks, don't move it; you keep the previous good price
            and its time. It is not "when the price last changed". `null` when `price` is `null`.
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        stale:
          type: boolean
          description: |
            `true` when `last_collected_at` is older than your refresh schedule's longest normal gap plus 24 hours
            (for a Monday/Wednesday/Friday schedule: 96 hours), or there is no price. The price is still returned;
            treat it with care.

    Match:
      type: object
      description: How this competitor product came to be in the set.
      required: [review, reviewed_by, confidence]
      properties:
        review:
          type: string
          x-extensible-enum: [same, with_differences, unreviewed]
          description: |
            `same` — confirmed as the same item. `with_differences` — kept on purpose despite a difference (for
            example pack size). `unreviewed` — added to the set but not yet confirmed; GetPrice leaves these out
            of "cheapest" and "position" in the dashboard. Open set.
        reviewed_by:
          description: |
            Who confirmed it: `customer` — one of your own users; `getprice` — GetPrice's matching team or review
            process; `null` — not confirmed by anyone yet. Open set.
          oneOf:
            - { type: string, x-extensible-enum: [customer, getprice] }
            - type: 'null'
        confidence:
          description: The matcher's confidence from 0 to 1 when a machine match backs this entry, otherwise `null`.
          oneOf:
            - { type: number, minimum: 0, maximum: 1 }
            - type: 'null'

    CompetitorOffer:
      type: object
      required: [competitor, product, price, last_collected_at, availability, status, stale, match]
      properties:
        competitor: { $ref: '#/components/schemas/CompetitorRef' }
        product: { $ref: '#/components/schemas/ProductRef' }
        price:
          description: The competitor's current price, or `null` if no valid price is available.
          oneOf:
            - $ref: '#/components/schemas/Price'
            - type: 'null'
        last_collected_at:
          description: |
            The last time GetPrice **successfully** read this product's price, whether or not it changed. Failed
            reads, and readings rejected by our quality checks, don't move it; you keep the previous good price
            and its time. It is not "when the price last changed". `null` when `price` is `null`.
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        availability:
          type: string
          x-extensible-enum: [unknown, in_stock, out_of_stock]
          description: |
            Stock status on the competitor's site. GetPrice does not collect stock yet, so this is `unknown`
            for every listing for now. Other values will appear without notice; treat this as an open set.
        status:
          type: string
          x-extensible-enum: [active, delisted]
          description: |
            `active` while the product is listed on the competitor's site. `delisted` products are only returned
            with `include_delisted=true`.
        stale:
          type: boolean
          description: |
            `true` when `last_collected_at` is older than your refresh schedule's longest normal gap plus 24 hours
            (for a Monday/Wednesday/Friday schedule: 96 hours), or there is no price. The price is still returned;
            treat it with care.
        match: { $ref: '#/components/schemas/Match' }

    ComparisonSet:
      type: object
      required: [id, name, sku, own_product, competitors, integrity, updated_at]
      properties:
        id: { $ref: '#/components/schemas/SetId' }
        name: { type: string }
        sku:
          description: Your SKU for this set, or `null` if none is linked.
          oneOf:
            - type: string
            - type: 'null'
        own_product:
          description: Your product in this set, or `null` if the set has no product of yours.
          oneOf:
            - $ref: '#/components/schemas/OwnProductInSet'
            - type: 'null'
        competitors:
          type: array
          description: Every competitor in the set, as the dashboard shows it. `[]` when there are none.
          items: { $ref: '#/components/schemas/CompetitorOffer' }
        integrity:
          type: string
          x-extensible-enum: [ok, retailer_conflict]
          description: |
            `ok` — at most one listing per competitor. `retailer_conflict` — the set holds more than one listing
            from the same retailer (for example two pack sizes). All of them are returned; treat the set with care,
            e.g. take the lowest price per retailer or flag the set for review. Open set.
        updated_at:
          $ref: '#/components/schemas/Timestamp'
          description: When the set's contents last changed (not on every price read).

    ComparisonSetList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/ComparisonSet' }
        next_cursor: { $ref: '#/components/schemas/NextCursor' }
        skus:
          type: array
          description: |
            Only when you filter by `sku`, and only on the first page: one entry per SKU you sent, in the order you
            sent them, saying what happened to it.
          items: { $ref: '#/components/schemas/SkuStatus' }

    SkuStatus:
      type: object
      required: [sku, status, set_id, product_id]
      properties:
        sku: { type: string, description: The SKU as you sent it (trimmed)., examples: [10072L] }
        status:
          type: string
          x-extensible-enum: [found, no_comparison_set, unknown_sku]
          description: |
            `found` — linked to your product, which has a comparison set (in `data`).
            `no_comparison_set` — linked to your product, but no comparison set has been built for it yet.
            `unknown_sku` — not linked to any of your products. The stock codes on your online shop are
            pre-loaded, so this is usually a pack, case or internal code; link it with
            `PUT /v1/sku-mappings/{sku}`. Open set.
        set_id:
          oneOf:
            - $ref: '#/components/schemas/SetId'
            - type: 'null'
        product_id:
          oneOf:
            - $ref: '#/components/schemas/ProductId'
            - type: 'null'

    Product:
      type: object
      required: [id, name, url, skus, comparison_set_id, price, last_collected_at, status]
      properties:
        id: { $ref: '#/components/schemas/ProductId' }
        name: { type: string }
        url: { type: string, format: uri }
        skus:
          type: array
          description: Every SKU you have linked to this product. `[]` when none.
          items: { type: string }
        comparison_set_id:
          description: The comparison set this product sits in, or `null` if it is not in one yet.
          oneOf:
            - $ref: '#/components/schemas/SetId'
            - type: 'null'
        price:
          oneOf:
            - $ref: '#/components/schemas/Price'
            - type: 'null'
        last_collected_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        status:
          type: string
          x-extensible-enum: [active, delisted]
          description: '`active`, or `delisted` when the product is no longer on your site (only with `include_delisted=true`). Open set.'

    ProductList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Product' }
        next_cursor: { $ref: '#/components/schemas/NextCursor' }

    CompetitorList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Competitor' }
        next_cursor: { $ref: '#/components/schemas/NextCursor' }

    SkuMapping:
      type: object
      description: |
        A link from your SKU to exactly one target: one of your products (`product_id`) or, for a comparison set
        with no product of yours, the set itself (`comparison_set_id`). Exactly one of the two is non-null. Links
        you make with `PUT` always target a product.
      required: [sku, product_id, comparison_set_id, source, created_at, updated_at]
      properties:
        sku: { type: string, examples: [10072L] }
        product_id:
          description: The linked product, or `null` when the SKU is linked to a comparison set instead.
          oneOf:
            - $ref: '#/components/schemas/ProductId'
            - type: 'null'
        comparison_set_id:
          description: |
            The linked comparison set when it has no product of yours (set up by GetPrice), otherwise `null`.
            A `PUT` to such a SKU replaces the link with a product link.
          oneOf:
            - $ref: '#/components/schemas/SetId'
            - type: 'null'
        source:
          type: string
          x-extensible-enum: [api, list_sku, site_sku, import, settings_upload]
          description: |
            How the link was made: `api` (through this API), `list_sku` (the SKU on a comparison set in the
            dashboard), `site_sku` (the code on your website), `import` / `settings_upload` (a file GetPrice
            loaded for you). Open set.
        created_at: { $ref: '#/components/schemas/Timestamp' }
        updated_at: { $ref: '#/components/schemas/Timestamp' }

    SkuMappingWrite:
      type: object
      required: [product_id]
      additionalProperties: false
      properties:
        product_id:
          $ref: '#/components/schemas/ProductId'
          description: One of your own products (from `GET /v1/products`).

    SkuMappingList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/SkuMapping' }
        next_cursor: { $ref: '#/components/schemas/NextCursor' }

    NextCursor:
      description: Pass as `cursor` to get the next page. `null` on the last page.
      oneOf:
        - type: string
        - type: 'null'

    Me:
      type: object
      required: [key, company, scopes, limits]
      properties:
        key:
          type: object
          required: [name, prefix, environment]
          properties:
            name: { type: string, description: The key's name as shown in Settings → API & Integrations. }
            prefix:
              type: string
              description: The public part of the key. Safe to quote to support; identifies the key in logs.
            environment:
              type: string
              x-extensible-enum: [live, test]
              description: '`live` for production keys. Open set.'
        company:
          type: object
          required: [name]
          properties:
            name: { type: string }
        scopes:
          type: array
          items:
            type: string
            x-extensible-enum: [catalog:read, matches:read, skus:write]
        limits:
          type: object
          required: [per_minute, window_seconds]
          properties:
            per_minute: { type: integer, description: 'Rate-limit units per window. Most requests cost 1 unit.' }
            window_seconds: { type: integer, description: Length of the rate-limit window in seconds. }

    Problem:
      type: object
      description: RFC 9457 problem details. Branch on `code`, not on `title` or `detail`.
      required: [type, title, status, detail, code, request_id]
      properties:
        type:
          type: string
          format: uri
          description: Documentation URL for this error; `code` is its slug.
        title: { type: string }
        status: { type: integer }
        detail: { type: string, description: Human-readable explanation for this occurrence. }
        code:
          type: string
          description: |
            Stable machine-readable error code. Open set: new codes may be added, so handle an unknown code by
            its HTTP status. `key_revoked`, `plan_required`, `conflict`, `cursor_expired`, `idempotency_key_reused`,
            `precondition_required`, `concurrency_limited` and `quota_exceeded` are reserved for later features.
          x-extensible-enum:
            - unauthenticated
            - invalid_key
            - key_revoked
            - insufficient_scope
            - plan_required
            - not_found
            - product_not_found
            - validation_failed
            - unknown_parameter
            - invalid_parameter
            - cursor_mismatch
            - invalid_cursor
            - precondition_failed
            - payload_too_large
            - unsupported_media_type
            - method_not_allowed
            - conflict
            - cursor_expired
            - precondition_required
            - idempotency_key_reused
            - rate_limited
            - concurrency_limited
            - quota_exceeded
            - internal_error
            - unavailable
        request_id: { type: string, examples: [req_01JBX4Q8ZJ7M2K] }
        errors:
          type: array
          description: Field-level details for validation errors.
          items:
            type: object
            required: [detail]
            properties:
              pointer: { type: string, description: 'JSON Pointer into the request body, e.g. `/product_id`.' }
              parameter: { type: string, description: Name of the offending query parameter. }
              detail: { type: string }
        limit:
          type: string
          description: On 429, which limit was hit (e.g. `sustained`).
        retry_after:
          type: integer
          description: On 429, seconds to wait (same as the `Retry-After` header).

  examples:
    ComparisonSetKooBeans:
      summary: Baked Beans 410g with four competitors
      value: *kooSet
    ProductKooBeans:
      summary: Baked Beans 410g (own product)
      value:
        id: prd_8fK2mQa1
        name: Baked Beans in Tomato Sauce 410g
        url: https://shop.example.com/product/10072L
        skus: [10072L]
        comparison_set_id: set_x7Kp2Lm9
        price: { amount: '18.99', was: null, currency: ZAR }
        last_collected_at: '2026-11-02T04:12:09Z'
        status: active
    SkuMappingKooBeans:
      summary: 10072L → Baked Beans 410g
      value: *kooMap
