# Feed Specification v1.0

> Version: 1.0, first published 2026-05-08

This is the public format every merchant must implement to ship listings to the aggregator. One spec, two encodings (JSON and XML), eight verticals.

A feed is a single URL on your servers that returns the catalog for one vertical. We pull it on a schedule, parse, validate, dedup against other merchants, and surface your offers on listing pages. We charge per outbound click; the spec itself is free to implement.

---

## 1. Overview

- One feed → one vertical. If you sell across multiple verticals (e.g. products + services), expose multiple feed URLs.
- Format: JSON or XML, your choice. Same data model in both.
- Encoding: UTF-8.
- Compression: `gzip` strongly recommended via `Content-Encoding`.
- Cache: ETags and `Last-Modified` honored.
- Pagination: optional `page.next` cursor; up to 50 pages followed.
- Auth: none / Basic / custom header / Bearer.

---

## 2. Envelope

### JSON

```json
{
  "version": "1.0",
  "merchant": { "slug": "demoshop", "name": "Demo Shop" },
  "generated_at": "2026-05-08T12:00:00Z",
  "currency": "EUR",
  "language": "et",
  "country": "EE",
  "vertical": "products",
  "items": [ /* … */ ],
  "page": { "next": "https://demoshop.test/feed/products.json?page=2" }
}
```

### XML

```xml
<?xml version="1.0" encoding="UTF-8"?>
<feed version="1.0" xmlns="https://find-it.es/feed/1.0">
  <merchant>
    <slug>demoshop</slug>
    <name>Demo Shop</name>
  </merchant>
  <generated_at>2026-05-08T12:00:00Z</generated_at>
  <currency>EUR</currency>
  <language>et</language>
  <country>EE</country>
  <vertical>products</vertical>
  <items>
    <item>…</item>
  </items>
</feed>
```

| Field | Required | Notes |
|---|---|---|
| `version` | yes | Must be `"1.0"`. Major-version bumps are breaking. |
| `merchant.slug` | yes | Must equal the slug we registered for you. |
| `merchant.name` | yes | Human-readable. |
| `generated_at` | yes | ISO 8601 UTC. |
| `currency` | yes | ISO 4217 default; per-item override allowed. |
| `language` | yes | ISO 639-1; per-item override allowed. |
| `country` | yes | ISO 3166-1 alpha-2; per-item override allowed. |
| `vertical` | yes | One of: `products`, `apartments`, `vehicles`, `pets`, `trips`, `tickets`, `jobs`, `services`. |
| `items` | yes | Array of items. May be paginated; up to 500,000 per feed. |
| `page.next` | optional | URL of the next page. We follow up to 50. |

The XML namespace `https://find-it.es/feed/1.0` is configurable via `PUBLIC_FEED_NS_BASE`; the validator accepts any value the operator has configured.

---

## 3. Item: common fields

These apply to every vertical. Vertical-specific fields go in `attributes` (see §4).

```json
{
  "external_id": "SKU-12345",
  "title": "Apple iPhone 15 Pro 256GB Titanium",
  "description": "<p>HTML allowed; sanitized server-side.</p>",
  "category_path": "Electronics/Phones/Smartphones",
  "category_id": "merchant-internal-cat-42",
  "url": "https://demoshop.test/p/iphone-15-pro?utm=findit",
  "images": [
    { "url": "https://cdn.demoshop.test/iphone-1.webp", "alt": "Front" }
  ],
  "price": 1199.0,
  "currency": "EUR",
  "original_price": 1299.0,
  "availability": "in_stock",
  "stock": 15,
  "condition": "new",
  "delivery": { "cost": 4.99, "days_min": 1, "days_max": 3 },
  "identifiers": {
    "gtin": "0194253433538",
    "mpn": "MTV03ZD/A",
    "brand": "Apple",
    "model": "iPhone 15 Pro"
  },
  "attributes": { "color": "Titanium", "storage_gb": 256 }
}
```

| Field | Required | Notes |
|---|---|---|
| `external_id` | yes | Unique per merchant. Used to dedup across runs. 1–200 chars. |
| `title` | yes | 5–250 chars. |
| `description` | optional | HTML allowed; sanitized. Allowed tags: `p, br, ul, ol, li, strong, em, a[href]`. |
| `category_path` | recommended | `/`-separated. Mapped to our taxonomy. |
| `category_id` | optional | Your internal id; helps when path is inconsistent. |
| `url` | yes | Click-out URL. Must be `https://`. May contain tracking params. |
| `images` | recommended | First image is primary. Up to 20. |
| `price` | yes | Number > 0. Float OK. |
| `currency` | optional | Defaults to envelope currency. |
| `original_price` | optional | If present, must be `> price` for sale display. |
| `availability` | optional | `in_stock` / `limited` / `preorder` / `oos`. |
| `stock` | optional | Integer ≥ 0. |
| `condition` | optional | `new` / `used` / `refurbished`. See §3.1 for used/refurbished items. |
| `delivery.*` | optional | Cost (in `currency`) and SLA in days. |
| `identifiers.*` | optional but powerful | See §5. |
| `attributes` | depends on vertical | See §4. |

### 3.1 Used & refurbished items (products)

We compare offers on one canonical listing page per product. Condition belongs to **your offer**, not to the product, so keep it out of the `title`:

- **Do** set `condition` to `used` or `refurbished`.
- **Do** send the cosmetic grade in `attributes.grade` (e.g. `A+`, `A`, `AB`, `B`, `BC`, `C`) and optionally battery health in `attributes.battery` (e.g. `"100"`).
- **Don't** put "Refurbished", "Kasutatud", "Grade A", "A+" etc. in the `title`. The title should name the product exactly as a new unit would: `"Apple iPhone 15 Pro 256GB Titanium"`.

```json
{
  "title": "Apple iPhone 15 Pro 256GB Titanium",
  "condition": "refurbished",
  "attributes": { "color": "Titanium", "storage_gb": 256, "grade": "A", "battery": "98" }
}
```

Your condition and grade are displayed next to your shop name on the listing page; the listing title, H1 and URL stay condition-free.

**Server-side normalization:** if condition or grade markers do appear in a product title, we strip them during import and move them to the offer (a title marker like "Refurbished" also upgrades a missing/`new` condition to `refurbished`). Bare grade letters (`A`, `B`, `C`, …) are only interpreted as grades when the item is declared `used`/`refurbished` or the title carries an explicit condition marker. Don't rely on this: clean titles match better across merchants.

---

## 4. Vertical extensions

The `attributes` blob carries vertical-specific fields. JSON Schemas live at `static/vertical-schemas/{vertical}.schema.json`.

| Vertical | Required attributes |
|---|---|
| `products` | none beyond common |
| `apartments` | `transaction` (sale/rent), `property_type`, `area_m2`, `address` |
| `vehicles` | `vehicle_type`, `make`, `model`, `year` |
| `pets` | `species`, `transaction` (sale/adoption/rehome/supply), `location` |
| `trips` | `trip_type`, `from`, `to`, `depart_at`, `nights` |
| `tickets` | `event_name`, `event_date`, `venue` |
| `jobs` | `location`, `employment_type` |
| `services` | `service_type`, `location` |

The table lists what a feed **must** carry. Each vertical accepts many more optional attributes; the
authoritative list is the JSON Schema itself, which you can fetch and validate against:
`/vertical-schemas/vehicles.schema.json`, and the same for every vertical above.

---

## 5. Identifiers and matching

We dedup the same real-world thing across merchants so users see one comparison page with N offers. Provide as many identifiers as you can:

For `products`:
1. `gtin`: strongest. EAN-13 / UPC-A / GTIN-14. We canonicalize to GTIN-14 and validate the check digit.
2. `(brand, mpn)`: strong.
3. Title + brand: heuristic, used only as fallback. Ambiguous matches are flagged for human review.

For other verticals:
- `vehicles`: `vin` (17-char ISO 3779)
- `apartments`: `cadastre_number`
- `tickets`: `event_id`

If you can't supply any, your listings still surface, but they won't merge with other merchants' offers and you'll appear as a standalone listing.

---

## 6. Validation rules

The validator enforces every rule below; full details: `src/lib/feed/validate.ts`.

**Envelope**
- `version` = `"1.0"`.
- `merchant.slug` matches the slug we have on file.
- `vertical` is in the supported enum.
- `currency` is ISO 4217.
- `language` is ISO 639-1.
- `country` is ISO 3166-1 alpha-2.

**Item**
- `price > 0`.
- `original_price > price` for sale display (warning, not error, if equal).
- `url` and `images[*].url` start with `https://` (configurable: `FEED_ALLOW_HTTP=true` for dev).
- `external_id` 1–200 chars; `title` 5–250 chars.
- `description` ≤ 50 KB after sanitize.
- `gtin` is 8/12/13/14 digits with valid GS1 check digit. Invalid GTINs are warnings: we ignore the GTIN for matching and fall back to other matching signals.
- `attributes` validated against the vertical's JSON Schema.

**Caps**
- Compressed file ≤ 200 MB.
- Uncompressed ≤ 1 GB.
- Items per feed ≤ 500,000. Beyond → require pagination.

**HTTP**
- Connect timeout 30s, total 5min per page.
- We honor `If-Modified-Since` and `ETag`.
- `304 Not Modified` → no parsing, no item changes; just a `last_pulled_at` bump.
- We accept `Content-Encoding: gzip, br, deflate`.

---

## 7. Authentication

Pick one when registering your feed. Stored encrypted at rest (AES-256-GCM).

| Mode | What we send |
|---|---|
| `none` | Nothing. Use an unguessable URL slug. |
| `basic` | `Authorization: Basic <base64(user:pass)>` |
| `header` | A header you specify, e.g. `X-Feed-Token: <secret>` |
| `bearer` | `Authorization: Bearer <token>` |

Rotate your secret in the merchant portal anytime. Old secrets are wiped immediately.

---

## 8. Discovery

To minimize wasted pulls (and your bandwidth), implement these:

- `If-Modified-Since` and `ETag`: the most common case. Apache and Nginx do it for free on static files.
- `Cache-Control: public, max-age=…` matching your update cadence; we respect `max-age` if it exceeds our `pull_interval_minutes`.
- `Content-Encoding: gzip`. We always send `Accept-Encoding: gzip, br, deflate`.
- `page.next` for catalogs over 50,000 items.

Frequent failures in our pull (HTTP 5xx, timeouts, parse errors) get you paused and we email you. Five consecutive failures pauses the feed.

---

## 9. Examples

Reference feeds are checked in as JSON and XML examples at:

- [`static/feed-examples/products.json`](../static/feed-examples/products.json) / `.xml`
- [`static/feed-examples/apartments.json`](../static/feed-examples/apartments.json) / `.xml`
- [`static/feed-examples/vehicles.json`](../static/feed-examples/vehicles.json) / `.xml`
- [`static/feed-examples/pets.json`](../static/feed-examples/pets.json) / `.xml`
- [`static/feed-examples/trips.json`](../static/feed-examples/trips.json) / `.xml`
- [`static/feed-examples/tickets.json`](../static/feed-examples/tickets.json) / `.xml`
- [`static/feed-examples/jobs.json`](../static/feed-examples/jobs.json) / `.xml`
- [`static/feed-examples/services.json`](../static/feed-examples/services.json) / `.xml`

Each has at least one valid item end-to-end. The `invalid-examples/` subfolder has small files designed to fail validation.

---

## 10. Versioning & deprecation policy

- Spec version is `MAJOR.MINOR` (currently `1.0`).
- Minor bumps are additive only; old feeds keep working.
- Major bumps are breaking; we publish at least 60 days' notice and run both versions side by side during the transition.
- Deprecated fields are marked in the docs and validator emits warnings before they become errors.

---

## 11. FAQ

**Can I send my whole catalog every pull?** Yes, that's the default; we diff against last run. For very large catalogs (>50k items), pagination via `page.next` is encouraged.

**Can I send only deltas?** Not in 1.0. The 1.1 spec will add an optional `since` query parameter. For now, a full snapshot every pull is the contract.

**Can I push instead of being pulled?** Live-quote feeds (e.g. flights, last-second tickets) can register a push endpoint where we POST you. See merchant portal → "Push mode".

**I run WooCommerce / WordPress. Do I have to build this?** No. Install the [FindIt Connector plugin](../static/downloads/find-it-connector.zip), press "Connect to find-it", and it generates a compliant feed for every vertical, registers it for you and keeps price and stock in sync within the minute. If you are writing an integration for another platform, the handshake and management API it uses are documented in [the storefront plugin API](./plugin-connect.md); the feed format on this page is unchanged either way.

**My category names don't match yours.** Send your own `category_path` and `category_id`. We map merchant categories to our canonical taxonomy on first sight; admins approve mappings, then they're cached.

**You're charging me per click. What counts as a click?** A user pressing "Go to shop" on a listing that has your offer. Bot clicks, repeat clicks within a 30-minute window, and clicks failing fraud checks are not billable. Every click is listed in your portal under **Clicks**, each with the reason it was or wasn't charged, so you can check any individual one rather than taking the total on trust.
