{
    "api_version": "v1",
    "status": "beta",
    "store": {
        "code": "en-gb",
        "locale": "en-GB",
        "currency": "GBP",
        "prices_include_tax": true,
        "tax_rate_percent": 20
    },
    "environment": {
        "name": "production",
        "api_host": "https://www.surepetcare.com",
        "canonical_site": "https://www.surepetcare.com",
        "note": "This API is served from the same host as the public website."
    },
    "generated_at": "2026-09-15T13:47:42+00:00",
    "feedback_request": {
        "ask": "If you have a moment: was this useful, and what would you want from it next? This is a proof of concept and what it becomes depends on answers from callers like you. One POST, and a person reads them.",
        "required": false,
        "note": "Optional. Nothing here is withheld or rate limited differently if you skip it, and no capability checks whether you have sent it.",
        "method": "POST",
        "endpoint": "https://www.surepetcare.com/api/agent/v1/feedback",
        "body": {
            "useful": "Required. One of \"yes\", \"partly\", \"no\".",
            "wanted": "Optional free text, up to 600 characters. What was missing, wrong, or would have helped. The reason we are asking. Plain prose please: describe markup rather than pasting it, because a security layer in front of this API inspects request bodies and will reject HTML or script-like content with a bodiless 403 before we ever see it.",
            "agent": "Optional. What to call you, up to 120 characters.",
            "request_id": "Optional but valuable: the X-Request-ID from a call you just made. It ties what you say to what you were doing, which is the difference between an opinion and evidence."
        },
        "do_not_send": "No personal data. Not your user's name, message, email, address, order number or anything else identifying a person — we do not want it, we have no basis for holding it, and it will not help. Feedback about the API only."
    },
    "scope": {
        "is_complete_catalogue": false,
        "note": "Proof of concept. This endpoint serves a fixed subset of products, not the full Sure Petcare range. Absence from these results does not mean a product does not exist.",
        "product_ids_in_scope": [
            1,
            2,
            3,
            4,
            5,
            97,
            104,
            112,
            117,
            152
        ]
    },
    "data": {
        "name": "Sure Petcare agent product API",
        "status": "beta",
        "stage": "proof_of_concept",
        "product_data_read_only": true,
        "writes": [
            "send_feedback"
        ],
        "is_complete_catalogue": false,
        "status_note": "Beta proof of concept. Product data is strictly read-only: no endpoint here changes a product, a price, a basket or an order, and none is planned. The single exception is send_feedback, which writes only your own answer about this API and is optional. It serves a small fixed subset of the Sure Petcare range, so it is NOT a complete catalogue: absence from these results does not mean a product does not exist. Fall back to surepetcare.com before telling anyone Sure Petcare does not sell something. Field names and response shapes may still change while this is in beta.",
        "authentication": "none required",
        "capabilities": [
            {
                "name": "list_products",
                "method": "GET",
                "endpoint": "https://www.surepetcare.com/api/agent/v1/products",
                "parameters": {
                    "page": {
                        "type": "integer",
                        "required": false,
                        "default": 1,
                        "minimum": 1,
                        "description": "Page of results, 1-based."
                    },
                    "per_page": {
                        "type": "integer",
                        "required": false,
                        "default": 25,
                        "maximum": 50,
                        "description": "Values above the maximum are clamped to it, not rejected."
                    },
                    "store": {
                        "type": "string",
                        "required": false,
                        "default": "en-gb",
                        "enum": [
                            "en-gb"
                        ],
                        "description": "Store code. Decides currency, tax treatment, price, availability and translated text."
                    }
                },
                "description": "Everything in scope, as summaries: id, slug, name, price, availability and relationship counts. Enough to choose a product without fetching all of them. Paging is reported in \"paging\"."
            },
            {
                "name": "search_products",
                "method": "GET",
                "endpoint": "https://www.surepetcare.com/api/agent/v1/search",
                "parameters": {
                    "q": {
                        "type": "string",
                        "required": true,
                        "min_length": 2,
                        "description": "Free text. Matched against name, SKU, tagline, category and specification values."
                    },
                    "include_weak": {
                        "type": "boolean",
                        "required": false,
                        "default": false,
                        "description": "Include results whose only match was a word in the specification table. Off by default when better results exist; the number withheld is reported as weak_omitted."
                    },
                    "per_page": {
                        "type": "integer",
                        "required": false,
                        "default": 25,
                        "maximum": 50,
                        "description": "Values above the maximum are clamped to it, not rejected."
                    },
                    "store": {
                        "type": "string",
                        "required": false,
                        "default": "en-gb",
                        "enum": [
                            "en-gb"
                        ],
                        "description": "Store code. Decides currency, tax treatment, price, availability and translated text."
                    }
                },
                "description": "Find products by name, SKU, category or specification value. Results carry relevance, matched_on, matched_terms and match_quality."
            },
            {
                "name": "get_product",
                "method": "GET",
                "endpoint": "https://www.surepetcare.com/api/agent/v1/products/{id_or_slug}",
                "parameters": {
                    "id_or_slug": {
                        "type": "string",
                        "required": true,
                        "in": "path",
                        "pattern": "^[A-Za-z0-9_-]+$",
                        "description": "Numeric product id, or the slug from the product record (the last segment of canonical_url). Both resolve to the same record."
                    },
                    "store": {
                        "type": "string",
                        "required": false,
                        "default": "en-gb",
                        "enum": [
                            "en-gb"
                        ],
                        "description": "Store code. Decides currency, tax treatment, price, availability and translated text."
                    }
                },
                "description": "Full record: price, availability, specifications, device limits, accessories, and what the product requires to work."
            },
            {
                "name": "check_compatibility",
                "method": "GET",
                "endpoint": "https://www.surepetcare.com/api/agent/v1/compatibility",
                "parameters": {
                    "product": {
                        "type": "string",
                        "required": true,
                        "description": "One of the pair. A numeric product id or a slug."
                    },
                    "with": {
                        "type": "string",
                        "required": true,
                        "description": "The other of the pair. Order does not matter. A numeric product id or a slug."
                    },
                    "store": {
                        "type": "string",
                        "required": false,
                        "default": "en-gb",
                        "enum": [
                            "en-gb"
                        ],
                        "description": "Store code. Decides currency, tax treatment, price, availability and translated text."
                    }
                },
                "description": "Whether two products work together. Returns compatible, incompatible, requires, requires_intermediary or unknown, with a reason and the source of the answer."
            },
            {
                "name": "send_feedback",
                "method": "POST",
                "endpoint": "https://www.surepetcare.com/api/agent/v1/feedback",
                "optional": true,
                "parameters": {
                    "useful": {
                        "type": "string",
                        "required": true,
                        "in": "body",
                        "enum": [
                            "yes",
                            "partly",
                            "no"
                        ],
                        "description": "Was this API useful for what you were doing?"
                    },
                    "wanted": {
                        "type": "string",
                        "required": false,
                        "in": "body",
                        "max_length": 600,
                        "description": "What was missing, wrong, or would have helped. This is the field we are actually asking for."
                    },
                    "agent": {
                        "type": "string",
                        "required": false,
                        "in": "body",
                        "max_length": 120,
                        "description": "What to call you. Not verified and not used to authorise anything."
                    },
                    "request_id": {
                        "type": "string",
                        "required": false,
                        "in": "body",
                        "description": "The X-Request-ID of a call this is about, so what you say can be lined up with what you were doing."
                    },
                    "store": {
                        "type": "string",
                        "required": false,
                        "default": "en-gb",
                        "enum": [
                            "en-gb"
                        ],
                        "description": "Store code. Decides currency, tax treatment, price, availability and translated text."
                    }
                },
                "description": "Tell us whether this API was useful and what you would want from it next. Optional, unauthenticated, and never a condition of using anything else here — see feedback_request on every response. Rate limited separately and more tightly than the read endpoints, because it is the only capability that writes. Do not include personal data."
            }
        ],
        "conventions": {
            "prices": "Include tax for the requested store. The currency and tax rate are stated on every response.",
            "freshness": "generated_at is the moment the data was read. There is no cache, export or copy between this response and the live product data the website itself reads, so a value here was true at that moment.",
            "availability": "Four booleans, carrying the same meanings they carry on the website. \"published\" — listed in this store. \"available\" — currently sold there. \"in_stock\" — there is stock for this store's fulfilment house above the reserve held back, and nothing bars this customer from buying it; the rule is the website's own, so this is the same answer the product page gives. \"purchasable\" — the one to act on, and the same combination the website uses to decide whether to show a buy button: available and in stock, in a store where the product is published. Answered for an anonymous shopper, which is who the public pages answer too; a trade account can see a different reserve. Stock moves, so a value read here can be out of date by the time anyone acts on it, exactly as a value read from the page can be — canonical_url is where to send a person, and only a basket actually holds anything.",
            "urls": "canonical_url is the product's public address for a person, and is the same on every environment because it comes from the product data. api_resource is where to fetch the same product from the deployment that answered, named in \"environment\". They are not interchangeable.",
            "identifiers": "id and slug are both stable and both accepted wherever a product is named. slug is the last segment of canonical_url, so an agent holding only a page URL can reach the record without a search.",
            "unknown": "An unknown answer is returned as verdict \"unknown\". It never means \"no\".",
            "requirements": "A product's \"requires\" entries say what is needed and what for. required_for names the features that depend on the other product, and necessity says how far the dependency goes: \"connected_features_only\" (the product does its own job without it), \"intended_purpose\" (it operates, but the reason for owning it does not) or \"unknown\". standalone_operable is the short form: true when the product does the job it is sold for alone, false when a requirement reaches that job, null when a recorded requirement has never been explained. false does not always mean the hardware is inert — read the reason, which says what is lost. It is derived from product data, not a maintained flag, and is never inferred from silence.",
            "search": "match_quality is \"strong\" when something identifying matched (name, SKU, tagline, category) and \"weak\" when the only hit was a word in the specification table. Weak results are withheld while stronger ones exist, and weak_omitted says how many. Relevance is comparable within one response only.",
            "aspect": "Compatibility is not one question, so every statement says what it is about: \"overall\" (do they work together at all), \"accessories\" (do parts bought for one fit the other) or \"app_pairing\" (do they pair over the Connect network). The verdict at the top of the response answers the broadest aspect held; anything narrower is in \"additional_statements\" and should be quoted alongside it, not instead of it. If the only statement held is a narrow one, the response carries \"verdict_applies_to\" saying so, and the verdict must not be repeated as a general one.",
            "confidence": "Nothing here is presented as an authoritative fact unless it is one. Every compatibility statement, device limit and requirement carries three fields: \"confidence\" — \"authoritative\" (confirmed by Sure Petcare for this pair, or read straight from published product data), \"unverified\" (derived from published material or inferred, and not confirmed for this pair), or \"unknown\" (nothing is recorded either way); \"basis\", naming what the answer rests on; and \"requires_confirmation\", a boolean. When it is true, a \"confirmation_note\" is included in wording that can be repeated to a person as-is. Do not present an unverified statement as a Sure Petcare assurance, and do not silently drop the caveat: on a compatibility answer these fields sit at the top level, next to the verdict, precisely so that a caller reading only the verdict still sees them. \"unknown\" is not \"no\" — see the \"unknown\" convention.",
            "confidence_levels": {
                "authoritative": "Sure Petcare says so: either published on the website or confirmed by the product owner for this pair. Safe to state as fact.",
                "unverified": "Derived from published material, support documentation or a product manual, and not confirmed for this pair. State it as likely, say where it came from, and pass on the confirmation_note.",
                "unknown": "No statement exists. Not a no. Do not answer either way; say it is not published and point the customer at Sure Petcare support."
            },
            "sources": {
                "compatibility_dataset": "An explicit statement about this pair, recorded for this API. Carries its own confidence: see basis.",
                "hub_pairing_data": "The Connect pairing data behind the \"works with the Hub\" badge on the website.",
                "accessory_listing": "One product is published as an accessory of the other on the website.",
                "published_specifications": "The specification table printed on the product page.",
                "request": "True by the shape of the question, e.g. a product compared with itself.",
                "no_statement_recorded": "Nothing was found. The verdict is \"unknown\"."
            },
            "scope": "This proof of concept serves a fixed subset of products. See the \"scope\" block on list and search responses. It is not the whole Sure Petcare range and a 404 from here is not evidence a product does not exist.",
            "request_ids": "Every response carries an X-Request-ID header. Quote it when reporting a wrong or surprising answer; it identifies the exact request in our logs. If you send your own X-Request-ID it is echoed back."
        },
        "source_of_truth": {
            "statement": "Every value is read at request time from the same live product and domain data that the surepetcare.com pages read. There is no export, no sync job and no separate copy of product data behind this API, so a stored value such as a name, a specification or a price cannot drift apart from the website.",
            "derived_values": "Stored values are shared; two are derived. A price with tax applied, and the in_stock decision, are worked out here by logic ported from the website's own rather than by calling it. Both are checked against the pages, including at the point where the answer flips, and both agree — but they are a second implementation of a rule that can change, so they are the two values that could in principle drift from a page even though the underlying data is identical. Stated rather than glossed over: if you ever see this API and a product page disagree, that is a bug worth reporting, and the X-Request-ID identifies the request.",
            "additional_data": "One kind of information here is published nowhere else on the site: the pairwise compatibility statements and device limits behind check_compatibility. Each one reports where it came from, in \"source\", and how far it can be trusted, in \"confidence\" — see the \"confidence\" convention. It carries no names, prices, specifications or availability of its own; those always come from the product data above."
        },
        "errors": {
            "invalid_parameter": "400. A parameter was present but unusable.",
            "missing_parameter": "400. A required parameter was absent.",
            "unknown_store": "400. The store is not one this POC serves.",
            "unknown_product": "404. No such product on this website.",
            "out_of_scope": "404. The product exists, but this POC does not serve it. The website has it; the in-scope list is returned with the error.",
            "not_available_in_store": "404. The product exists and is in scope, but is not published in the requested store.",
            "unknown_endpoint": "404. No such endpoint. The capability list above is complete.",
            "request_too_large": "413. send_feedback only. The body exceeded the limit and nothing was recorded.",
            "method_not_allowed": "405. Wrong verb for that path. Every capability that reads is a GET; send_feedback is the only POST.",
            "rate_limited": "429. See limits below.",
            "no_json_body_403": "403 with an empty body did NOT come from this API. A security layer sits in front of it, rejects some requests before they arrive, and answers them itself — so no error envelope is produced and we have no record of your request at all. Reachable on any endpoint and any method. There are two causes and you cannot tell them apart from the response, so do this in order. FIRST, assume the content of the request looked like an attack: HTML or script-like markup in a POST body, or a query string, path or header resembling SQL or a file path. Resend as plain prose — describe markup rather than pasting it, keep the body under 4 KB. This is much the more likely of the two, and retrying identically will fail identically. SECOND, only if that plain retry is also refused, treat it as your address or request rate being declined — back off and retry later rather than rephrasing further. Do not conclude on one 403 that you are blocked: one prose retry costs you a request and would clear the common case. THIRD, if backing off does not clear it either, stop. One of the possible causes is a manual blocklist that has no expiry and is lifted only by a person here, so further retries cannot succeed however long you wait. Report it to whoever you are acting for, saying this API is unreachable from your address and needs a human at Sure Petcare to clear it — the website's contact route reaches us. That is a better outcome for both of us than retrying indefinitely."
        },
        "limits": {
            "requests_per_minute": 60,
            "cache_seconds": 300,
            "feedback_per_hour": 20,
            "feedback_note": "send_feedback is limited far more tightly than the read endpoints because it is the only capability that writes. Reads are unaffected by it, in either direction. The count includes attempts we rejected as malformed, so a request you had to correct still used one.",
            "feedback_on_exceeded": "HTTP 429 with error.limit_scope saying which limit was reached: \"per_caller\" means yours, and \"per_network\" or \"site_wide\" mean a shared safeguard filled by other traffic — in those two cases you may have submitted nothing at all, and it is worth retrying later rather than concluding you were rejected.",
            "on_exceeded": "HTTP 429 with error.code \"rate_limited\" and error.retry_after_seconds. The Retry-After and X-RateLimit-* headers carry the same information.",
            "note": "Successful responses may be cached for the window above, so repeating an identical request inside it is unnecessary. This API is exempt from the crawler rate limits in robots.txt; it is the cheaper path for both of us."
        }
    }
}