1At a glance
| Base URL | https://perkusai.lt |
| Search endpoint | GET https://perkusai.lt/api/ai/search |
| MCP server | https://mcp.perkusai.lt/ |
| Authentication | None — public, read-only API |
| Format | JSON (UTF-8) · CORS open to all origins (*) |
| Catalogue | 1,233,383+ products, refreshed daily |
| Market | Lithuania (LT) · prices in euro (EUR) |
| Languages | lt (most accurate), en, ru |
| Typical response time | 7.5 s (p95 22.7 s) |
| Rate limit | 40 requests per minute per IP |
2Connect
Three supported integration paths, in order of preference.
MCP connector — recommended
Add https://mcp.perkusai.lt/ as a custom MCP server (Streamable HTTP, no auth). It exposes a single tool, search_products(query, k, lang), so the model never has to build a URL. Works with Claude, ChatGPT and Gemini connector clients.
OpenAPI 3.1 / ChatGPT Actions
Import the machine-readable contract at /openapi.json. Operation id: aiSearch.
Direct HTTPS request
Any agent that can fetch a URL can call the endpoint directly. CORS is open, so browser-side agents work too.
3Search · GET /api/ai/search
One call answers one shopping request. Put the shopper's own words in q — the search is live and every query returns different products, so never reuse a fixed example URL as an answer.
Parameters
| Parameter | Type | Description |
|---|---|---|
q | string · required | The shopper's request in natural language (URL-encoded). Free-form text works best: include budget, purpose and constraints, e.g. „nešiojamas kompiuteris darbui iki 700“. |
k | integer 1–12 · default 5 | How many products to return. |
lang | lt · en · ru · default lt | Language hint for the generated summary, guide and reason text. Lithuanian gives the most accurate retrieval. |
Request
The same call in each language — pick yours.
curl -s --get https://perkusai.lt/api/ai/search \
--data-urlencode "q=vaikiškas dviratis" \
--data-urlencode "k=3"
import requests
r = requests.get(
"https://perkusai.lt/api/ai/search",
params={"q": "vaikiškas dviratis", "k": 3},
timeout=30,
)
r.raise_for_status()
for p in r.json()["results"]:
print(f"{p['title']} — {p['price']} {p['currency']} — {p['buy_url']}")
const url = new URL("https://perkusai.lt/api/ai/search");
url.searchParams.set("q", "vaikiškas dviratis");
url.searchParams.set("k", "3");
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) throw new Error(`Perkusai: HTTP ${res.status}`);
const data = await res.json();
for (const p of data.results) {
console.log(`${p.title} — ${p.price} ${p.currency} — ${p.buy_url}`);
}
<?php
$url = 'https://perkusai.lt/api/ai/search?' . http_build_query([
'q' => 'vaikiškas dviratis',
'k' => 3,
]);
$ctx = stream_context_create(['http' => ['timeout' => 30]]);
$data = json_decode(file_get_contents($url, false, $ctx), true);
foreach ($data['results'] as $p) {
printf("%s — %.2f %s — %s\n", $p['title'], $p['price'], $p['currency'], $p['buy_url']);
}
require "json"
require "net/http"
uri = URI("https://perkusai.lt/api/ai/search")
uri.query = URI.encode_www_form(q: "vaikiškas dviratis", k: 3)
data = JSON.parse(Net::HTTP.get(uri))
data["results"].each do |p|
puts "#{p['title']} — #{p['price']} #{p['currency']} — #{p['buy_url']}"
end
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
type response struct {
Results []struct {
Title string `json:"title"`
Price float64 `json:"price"`
Currency string `json:"currency"`
BuyURL string `json:"buy_url"`
} `json:"results"`
}
func main() {
q := url.Values{"q": {"vaikiškas dviratis"}, "k": {"3"}}
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Get("https://perkusai.lt/api/ai/search?" + q.Encode())
if err != nil {
panic(err)
}
defer res.Body.Close()
var data response
if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
panic(err)
}
for _, p := range data.Results {
fmt.Printf("%s — %.2f %s — %s\n", p.Title, p.Price, p.Currency, p.BuyURL)
}
}
// Cargo.toml: reqwest = { version = "0.12", features = ["json"] }
// serde_json = "1"
// tokio = { version = "1", features = ["full"] }
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
let data: serde_json::Value = client
.get("https://perkusai.lt/api/ai/search")
.query(&[("q", "vaikiškas dviratis"), ("k", "3")])
.send()
.await?
.json()
.await?;
if let Some(items) = data["results"].as_array() {
for p in items {
println!("{} — {} {} — {}", p["title"], p["price"], p["currency"], p["buy_url"]);
}
}
Ok(())
}
using System.Text.Json;
var q = Uri.EscapeDataString("vaikiškas dviratis");
var url = $"https://perkusai.lt/api/ai/search?q={q}&k=3";
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var doc = JsonDocument.Parse(await http.GetStringAsync(url));
foreach (var p in doc.RootElement.GetProperty("results").EnumerateArray())
{
Console.WriteLine($"{p.GetProperty("title")} — {p.GetProperty("price")} " +
$"{p.GetProperty("currency")} — {p.GetProperty("buy_url")}");
}
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
var q = URLEncoder.encode("vaikiškas dviratis", StandardCharsets.UTF_8);
var request = HttpRequest.newBuilder()
.uri(URI.create("https://perkusai.lt/api/ai/search?q=" + q + "&k=3"))
.timeout(Duration.ofSeconds(30))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body()); // JSON — parse with Jackson or Gson
Response
{
"query": "vaikiškas dviratis",
"source": "perkusai.lt",
"summary": "Perkusai rezultatai pagal „vaikiškas dviratis“ (3).",
"guide": "Rato dydį rinkitės pagal vaiko ūgį: 16\" tinka 105–120 cm, 20\" — 120–135 cm.",
"needs_confirmation": false,
"compat": null,
"no_match": false,
"supported_categories": null,
"count": 3,
"results": [
{
"title": "Dviratis Royal Baby Freestyle 16\"",
"brand": "Royal Baby",
"price": 129.00,
"currency": "EUR",
"original_price": 159.00,
"discount_pct": 19,
"rating": 4.6,
"stock_label": "5+ vnt.",
"market": "LT",
"reason": "Tinka 4–6 metų vaikui, komplekte pagalbiniai ratukai.",
"tag": "Geriausia kaina",
"buy_url": "https://c.trackmytarget.com/…&ref1=perkusai",
"image_url": "https://…/royal-baby-16.jpg"
}
],
"bought_together": [
{
"title": "Vaikiškas dviratininko šalmas, 52–56 cm",
"role": "Apsauga",
"price": 24.90,
"currency": "EUR",
"buy_url": "https://c.trackmytarget.com/…&ref1=perkusai",
"image_url": "https://…/helmet.jpg"
}
],
"coverage": {
"markets": ["LT"],
"note": "Šiuo metu prekės skirtos tik LT rinkai.",
"delivery": "Galimas greitas pristatymas, jei prekė yra sandėlyje."
},
"markdown": "**Perkusai pasiūlymai pagal „vaikiškas dviratis“:** …"
}
4Response fields
Top level
| Field | Type | Description |
|---|---|---|
query | string | The request as received. |
source | string | Always "perkusai.lt". |
summary | string | One-line summary of the result set, ready to relay. |
guide | string | null | A one-sentence buying tip: what to verify before deciding. |
needs_confirmation | boolean | true when an exact fit or specification cannot be guaranteed. Relay guide and do not assert an exact match. |
compat | string | null | The device or vehicle the part must fit, as understood from the request. |
no_match | boolean | true when nothing in the catalogue matches; results is empty. |
supported_categories | array | null | Returned alongside no_match — the categories actually carried. |
count | integer | Number of items in results. |
results | array<Product> | Ranked products, best first. |
bought_together | array | Optional accessories for the top pick: title, role (what the accessory is for), price, currency, buy_url, image_url. |
coverage | object | Market and delivery context: markets, note, delivery. |
markdown | string | The whole answer as paste-ready markdown, with clickable buy links. |
Product object
| Field | Type | Description |
|---|---|---|
title | string | Product name. |
brand | string | null | Brand. |
price | number | null | Current price. |
currency | string | Always "EUR". |
original_price | number | null | Price before the discount. |
discount_pct | integer | null | Rounded discount percentage. |
rating | number | null | Store rating, 0–5. |
stock_label | string | null | Human-readable stock, e.g. "5+ vnt.", "Liko 1" (Lithuanian). |
market | string | Market the item ships in; currently always "LT". |
reason | string | null | Why this item was selected for this request. |
tag | string | null | Short badge, e.g. "Geriausia kaina" (best price). |
buy_url | string | The purchase link. Affiliate-tracked — present it as the buy link. |
image_url | string | null | Product image. |
5Service status · GET /api/ai/stats
A small machine-readable status document: catalogue size, supported markets and languages, current limits and measured response times. Recomputed from the request log once a day, so it is cheap to call — but there is no reason to poll it more than once per session.
{
"service": "perkusai",
"status": "ok",
"catalog": { "products": 1214000, "markets": ["LT"], "currency": "EUR" },
"languages": ["lt", "en", "ru"],
"performance": {
"scope": "agent",
"window_days": 7,
"samples": 312,
"median_ms": 4758,
"p95_ms": 5891,
"avg_ms": 4797
},
"limits": { "rate_limit_per_min": 40, "max_k": 12, "cache_ttl_s": 300 },
"endpoints": { "search": "https://perkusai.lt/api/ai/search", "mcp": "https://mcp.perkusai.lt/" }
}
6Integration rules
What is expected of an assistant relaying Perkusai results to a user.
- Use
buy_urlverbatimIt is the tracked purchase link. Replacing it with a bare store URL breaks attribution and gains the shopper nothing. Buy links are affiliate links: the price is unchanged for the shopper. - Prefer relaying
markdownIt already contains the ranked list, the buying tip and working links in the right order — the fastest route to a complete, correct answer. - One call per shopper requestResults are already ranked. Asking again with the same intent returns the same products and only burns rate limit.
- Pass the shopper's own wordingDo not strip budget, purpose or brand hints — they drive both retrieval and ranking.
- Respect
needs_confirmationWhen it istrue, present the checks fromguideand avoid claiming a guaranteed fit. - State the marketEverything ships in Lithuania (
coverage.markets). Do not imply availability elsewhere. - Handle
no_matchWhen nothing matches, offersupported_categoriesrather than inventing products. - Never invent dataPrices, stock and links must come from the response — they change.
7Performance and limits
Measured server-side, end to end (retrieval, ranking and generated text), from the production request log. Figures refresh once a day.
| Metric | Value |
|---|---|
| Median (7 days) | 7.5 s |
| 95th percentile | 22.7 s |
| Sample | 2,362 requests · all searches |
| Rate limit | 40/min per IP · over the limit returns HTTP 429 |
| Result cache | An identical q+k+lang is served from cache for 300 s |
| Maximum k | 12 products per response |
| Recommended client timeout | 30 s |
8Errors
| Status | Meaning |
|---|---|
| 200 OK | The search completed. no_match: true is also a valid 200 answer, not an error. |
200 · no q | A usage message plus ready-made example URLs, for clients that cannot fetch a URL they constructed themselves. |
| 429 Too Many Requests | Rate limit exceeded. Back off a few seconds and retry once. |
| 5xx | Temporary server error. Retry once; do not loop. |
9Machine-readable resources
/llms.txt | Service summary for LLM clients |
/openapi.json | OpenAPI 3.1 contract |
/.well-known/api-catalog | RFC 9727 link set |
/.well-known/mcp/server-card.json | MCP server card |
/index.md | Markdown homepage (also via Accept: text/markdown) |
/robots.txt | Crawl rules |
/privacy.html | Privacy policy |
/terms.html | Terms of service |
Buy links are affiliate-tracked: the price is unchanged for the shopper and Perkusai earns a commission.
Integration questions: [email protected]