# Dokicasa Market Scanner — Integration instructions for AI assistants

You are an assistant integrating **Dokicasa's Market Scanner APIs**: cadastral surveys,
mortgage inspections, transcription notes, maps, property searches from cadastral data,
fiscal code, name or address.

Human-readable documentation: <https://docs.dokicasa.it/en/market-scanner> ·
Swagger (complete schema of every endpoint): <https://api.dokicasa.it/api/documentation#/Scanner>

---

## 1. The two environments

Between sandbox and production **only the base URL** changes (and the socket parameters):
endpoints, parameters, response format, webhooks and signature are **identical**. Develop and
test in sandbox first, then switch a single constant.

| | **Sandbox (test)** | **Production** |
|---|---|---|
| API base URL | `https://api-dev.dokicasa.it` | `https://api.dokicasa.it` |
| Socket `key` | `staging` | `ra9jcihuz0sx7vtw8yut` |
| Socket `wsHost` | `api-dev.dokicasa.it` | `socket.dokicasa.it` |
| Socket `authEndpoint` | `https://api-dev.dokicasa.it/broadcasting/auth` | `https://api.dokicasa.it/broadcasting/auth` |
| Data | sample, fixed, **no credit consumed** | real, **metered** |
| Origin whitelist | disabled (can also be called from `localhost`) | active on the partner's domains |

> Don't mix environments: with the production socket and sandbox calls the channel
> subscribes but you **never receive events** (they are two different servers).

### Authentication

Every call carries the header:

```http
Authorization: Bearer <api_token>
Content-Type: application/json
```

**Sandbox token** (public, test user with every call enabled and free):

```
HfBfYShns3E02no3kkwCRWQugkVNcJCtrRN2BPoI5NxhqsiXaxxnSPpIHY2O
```

**Production token**: it is **personal to the partner** and does not appear in this file or in
any documentation. Dokicasa provides it to you. In your code **never write it in plain text**:
read it from an environment variable (`DOKICASA_API_TOKEN`) or from a secret manager.

Recommended configuration structure:

```js
const ENV = process.env.DOKICASA_ENV === 'production'
  ? { base: 'https://api.dokicasa.it',     key: 'ra9jcihuz0sx7vtw8yut', wsHost: 'socket.dokicasa.it' }
  : { base: 'https://api-dev.dokicasa.it', key: 'staging',              wsHost: 'api-dev.dokicasa.it' }
const TOKEN = process.env.DOKICASA_API_TOKEN
```

---

## 2. Constraints (non-negotiable)

1. The token **never** ends up in the frontend, in logs, or in repositories.
2. The APIs are **asynchronous**: the POST does not contain the result. You receive the outcome
   via **webhook** (backend) or **WebSocket** (frontend). **Polling is the last resort**:
   if you really need it, minimum interval 3s, backoff, stop on `SUCCESS`/`ERROR`, 5-minute timeout.
3. Transient errors are retried by Dokicasa (~every 5 min): **do not resend the request**,
   you would cause a double charge. Credit is only deducted on the first successful outcome.
4. PDFs are not in the JSON: there is a **link** `result.pdf_path`, to be downloaded with a
   separate **authenticated** GET.
5. Provinces **TN** and **BZ** cannot be queried (tavolare land registry): the request returns 422.

---

## 3. Endpoints

Base URL = the one for the chosen environment (§1).

| Method | Path | Use |
|---|---|---|
| `POST` | `/api/scanner/{type}` | Creates the request → `{ scanner_request_id, status: "ENQUEUED" }` |
| `GET` | `/api/scanner-request/{id}` | Status + final `result` (**PDFs are here**, and `list-addresses`) |
| `GET` | `/api/scanner-request/{id}/status` | Status only (lightweight polling) |
| `GET` | `/api/scanner-request/{id}/results` | Paginated structured results (e.g. properties) |
| WS | `private-scanner_request.{id}` | Private channel, event `ScannerRequestNotification` |
| `GET` | `/api/scanner/health` | Sister's status: `status` = `ok` \| `slow` \| `down` \| `unknown`; with `down`, `accepting_requests=false` → switch to manual. `maintenance` = Sister maintenance in progress or announced (`starts_at`/`ends_at`). `unknown` is not an outage. `types.<type>.status` = status per scan type from the last hour's outcomes (`ok` \| `degraded` \| `down` \| `unknown`), informational only: `down` = ≥3 different searches failed on ≥2 servers since the last success. Cached 60 s: call it every `recommended_poll_seconds` (300) |
| `POST` | `/api/monitoring/from-catasto` | Creates a property monitoring (§8) |
| `POST` | `/api/user-monitorings/{id}/simulate-webhook` | **Sandbox only**: immediately sends a sample event |

States: `ENQUEUED` → `SCANNING` → `SUCCESS` \| `ERROR` (the first two transient, the last two final).

---

## 4. Request types and parameters

All under `POST /api/scanner/{type}`. Parameters are **required** unless noted otherwise.

**Data lookups** — the result is JSON in `result`:

| `type` | Parameters | Field in `result` |
|---|---|---|
| `search-fiscal-code` | `first_name`, `last_name`, `provincia` (opt.: omitted = all of Italy), `birth_date` (opt.) — **free** | `anagrafiche` |
| `search-company` | `company_name`, `provincia` (opt.: omitted = all of Italy) — **price-list item** | `anagrafiche` |
| `tax-data` | `tax_code`, `search_catasto_type` (opt.: `E` land and buildings, default \| `T` land only \| `F` buildings only), `provincia` (opt., code: only properties in that province), `comune` (opt., with `provincia` if a name; alone if ISTAT/cadastral code) | `immobili` |
| `catasto` | `comune`, `provincia`, `foglio`, `particella`, `search_catasto_type` (`F`\|`T`) | `immobili` |
| `intestatari-catasto` | same as `catasto` + `subalterno` (opt.) | `intestatari` |
| `list-addresses` | `indirizzo`, `comune`, `provincia` | `addresses` |
| `immobili-by-name` | `first_name`, `last_name`, then `selected_value` (§5) | `immobili` |
| `elenco-immobili` | same as `catasto` | — |

**Documents** — the result is a link in `result.pdf_path`:

| `type` | Parameters |
|---|---|
| `visura-catasto` | `comune`, `provincia`, `foglio`, `particella`, `tipo_visura` (`ANALITICA`\|`SINTETICA`\|`COMPLETA`) |
| `visura-tax-data` | `tax_code`, `provincia`, `tipo_visura`, `storica` (opt., `true` = **historical survey by subject**, see below), `situazione_al` (opt., `YYYY-MM-DD`, see below) |
| `ispezione-catasto` | `foglio`, `particella`, `comune`, `provincia` |
| `ispezione-tax-data` | `tax_code`, `comune` (used to resolve the land registry office) |
| `nota-catasto` | `comune`, `provincia`, `foglio`, `particella`, `search_catasto_type`, `numero_registro_particolare`, `anno_registro` (4 digits), `tipo_nota` |
| `nota-tax-data` | `tax_data`, `comune`, `provincia`, `numero_registro_particolare`, `anno_registro`, `tipo_nota` |
| `mappa-catasto` | `comune`, `provincia`, `foglio`, `particella`, `tipo_richiesta` (`ATTUALITA`\|`STORICA`), `tipo_foglio` (`INTERO`\|`MAPPE_LIMITROFE`), `formato` (`A3`\|`A4`\|`SCALA_ORIGINARIA`) |
| `elaborato-planimetrico` | `comune`, `provincia`, `foglio`, `particella` |

`tipo_nota`: `TRASCRIZIONI` \| `ISCRIZIONI` \| `ANNOTAZIONI` \| `PRIVILEGI_AGRARI` \|
`PRIVILEGI_SPECIALI` \| `PRIVILEGI_MINERARI`.

**Current or historical survey.** The two surveys behave differently:

| Call | `tipo_visura` | Survey |
|---|---|---|
| `visura-catasto` | `ANALITICA` / `SINTETICA` | **historical** |
| `visura-catasto` | `COMPLETA` | **current** |
| `visura-tax-data` | any | **current**, by subject |
| `visura-tax-data` + `"storica": true` | `ANALITICA` / `SINTETICA` | **historical** by subject |

- `storica: true` (only `visura-tax-data`, optional) = Sister's "Visura storica per soggetto":
  every property the subject **has owned** in the province, even if they no longer own it
  (sellers, deceased persons). Without it, a subject who owns nothing in that province today
  produces no survey.
- Constraints: **fiscal code** or **VAT number** (companies), `ANALITICA`/`SINTETICA` only
  (`COMPLETA` → 422), one province per call. Same price as a regular survey.
- For the survey **by property** `COMPLETA` does **not** mean "more data": it is the only one
  without history.
- `tax-data` and `immobili-by-name` only return **current** properties (JSON list). Sister
  provides no historical list: the past exists only as a PDF with `storica: true`.
- `situazione_al` (only `visura-tax-data`, optional, `YYYY-MM-DD`) = Sister's "Situazione al"
  field: the survey shows the situation at that date instead of today. Without it, the date is
  today. Future date or other format → 422. Typically combined with `storica: true` (who owned
  property at that date). Same price, a few extra seconds (security code solved
  automatically). Date too far back → `Nessun documento trovato.` In the subject's document list it comes back as
  `situazione_al` (`null` = date of the request).
- `search_catasto_type` in searches **by subject** (`tax-data`, `visura-tax-data`,
  `immobili-by-name`, optional) = Sister's "Catasto" menu: `E` land and buildings (default),
  `T` land only, `F` buildings only. Same price, same rows. In searches by property the same
  parameter is the property's registry (`F`\|`T`).
- Only `mappa-catasto` has a `tipo_richiesta` parameter (`ATTUALITA`\|`STORICA`).

Watch out for two recurring pitfalls:

- **`catasto` ≠ survey.** `catasto` is the property *lookup* by sheet/parcel (JSON);
  the cadastral survey PDF is `visura-catasto` / `visura-tax-data`.
- **Municipalities split into sections** (Rome, Naples, Genoa): add `sezione_comune`, otherwise
  the search may return the wrong property. The field is named exactly that: a `sezione` field
  is ignored without error.
- `particella` and `subalterno` accept **digits only**.

---

## 4-bis. `search-fiscal-code` — list of matching people (free)

- Returns every person with that first and last name in the province (or across all of Italy
  if `provincia` is omitted): `anagrafiche[]` with `cognome`, `nome`, `data_di_nascita`,
  `luogo_di_nascita`, `sesso`, `codice_fiscale`.
- Does not charge the wallet and needs no price-list entry.
- `provincia` is optional; TN and BZ are rejected (tavolare land registry, excluded from the
  nationwide search too). With common surnames the nationwide search can exceed the result
  limit: repeat with the province.
- `birth_date` optional (`YYYY-MM-DD`): narrows down the matches (people with no date of birth
  in the Land Registry stay in). Above 400 people Sister gives no answer: immediate `ERROR`, no
  retries, with `details: "TOO_MANY_RESULTS"` → repeat with `birth_date` and/or `provincia`.
- Matching on the surname is **exact** (no fuzzy search): a surname misspelled in the
  Land Registry will not come up. For the first name one matching word is enough
  (`VERONICA` finds `MARIA VERONICA`). `tax-data` only compares the fiscal code, so it finds
  the subject even with the wrong name. Flow: `search-fiscal-code` → pick by date/place of
  birth → `tax-data` with the fiscal code.

## 4-ter. `search-company` — companies by company name (price-list item)

- The `search-fiscal-code` twin for legal entities. Body:
  `{ "company_name": "ESEMPIO IMMOBILIARE", "provincia": "MI" }` (`provincia` optional,
  no TN/BZ; `company_name` 2-150 characters).
- `result.anagrafiche[]` with `denominazione`, `sede`, `codice_fiscale`. `sede` and
  `codice_fiscale` can be empty strings (older records).
- **Paid**: requires the `SEARCH-COMPANY` entry in the partner's price list, otherwise
  `403 PARTNER_PRICING_REQUIRED`.
- The same company shows up multiple times with the name spelled differently: **group by
  `codice_fiscale`** before displaying them.
- A single generic word (`IMMOBILIARE`) → `ERROR` "troppo generica" (too generic): add a word
  from the company name. Beyond the result limit → `ERROR`: add a word or the province.
- Flow: `search-company` → pick the company → `tax-data` with its fiscal code.

## 5. `immobili-by-name` — two phases

1. POST with only `first_name` + `last_name` → the `result` contains `needs_selection: true`
   and the `omonimi` (namesakes) list.
2. Repeat the **same** POST adding `selected_value` = the `value` field of the chosen
   namesake, copied verbatim.

With a single namesake the selection is automatic. Both phases are regular async calls.
Phase 1 (`needs_selection`) is free: only the response with the properties is billed. Namesakes without a fiscal code also have a selectable `value`.

---

## 6. Receiving data — webhook (backend)

This is the recommended approach for a server-to-server integration: no polling, no
panel configuration. Put the URL **in the body of the initial POST**.

```json
{
  "comune": "ROMA", "provincia": "RM",
  "foglio": "123", "particella": "45", "tipo_visura": "ANALITICA",
  "callback": "https://your-server.com/dokicasa/scanner-callback",
  "webhook":  "https://your-server.com/dokicasa/scanner-webhook"
}
```

Two fields, same mechanism (signed POST with retries), different payload — you can use one,
the other, or both (in that case you receive two separate POSTs):

| | `callback` (compact) | `webhook` (full) |
|---|---|---|
| Payload | `{ id, type, status, percentage, result, created_at, updated_at, finished_at }` | `{ scanner_request: { id, user_id, type, params, result, status } }` |
| Includes input `params` | no | **yes** |
| When to use it | you just need to react to the outcome (most common case) | you want to reconcile without keeping your own state |

```json
{
  "id": 2615190,
  "type": "VISURA-CATASTO",
  "status": "SUCCESS",
  "percentage": 98,
  "result": { "pdf_path": "https://.../visura.pdf", "is_empty": false },
  "finished_at": "2026-07-02T23:23:02+02:00"
}
```

### Signature verification — mandatory

Every POST carries the header **`Signature`**: HMAC-SHA256 of the **raw body**, signed with
**your `api_token`** (the same one used as Bearer). No new key to manage.

```js
const crypto = require('crypto')
// the RAW body: in Express, app.use(express.json({ verify: (req,_,buf) => req.rawBody = buf }))
const expected = crypto.createHmac('sha256', TOKEN).update(req.rawBody).digest('hex')
if (expected !== req.get('Signature')) return res.sendStatus(401)
```

```php
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, $MY_API_TOKEN);
if (!hash_equals($expected, $_SERVER['HTTP_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
```

Compute the HMAC **before** parsing the JSON: re-serializing changes whitespace and key
order and the signature will no longer match.

**Idempotency**: on network errors delivery is retried. Identify the request by its `id`,
handle duplicates, and always respond `2xx` (even on duplicates), so you don't trigger
unnecessary retries. Work in the background: respond immediately, process afterwards.

---

## 7. Receiving data — WebSocket (frontend) and polling

The socket (Pusher/Reverb) is the right choice for web and mobile: progress bars and
instant results, without exposing a public endpoint.

```js
import Pusher from 'pusher-js'

const pusher = new Pusher(ENV.key, {
  wsHost: ENV.wsHost, wsPort: 443, wssPort: 443, forceTLS: false,
  cluster: 'mt1', disableStats: true, enabledTransports: ['ws', 'wss'],
  authEndpoint: `${ENV.base}/broadcasting/auth`,
  auth: { headers: { Authorization: 'Bearer ' + TOKEN } },
})

const { scanner_request_id: id } = await fetch(`${ENV.base}/api/scanner/intestatari-catasto`, {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json' },
  body: JSON.stringify({ comune: 'Roma', provincia: 'RM', foglio: '123', particella: '45', search_catasto_type: 'F' }),
}).then(r => r.json())

pusher.subscribe(`private-scanner_request.${id}`)
  .bind('ScannerRequestNotification', ({ message }) => {
    if (message.status === 'SCANNING') setProgress(message.percentage)
    if (message.status === 'SUCCESS') fetchResult(id)
    if (message.status === 'ERROR') handleError(message)
  })
```

Authentication of the private channel uses the same Bearer token: if the token only lives
server-side, proxy `/broadcasting/auth` through your backend instead of exposing it in the
browser.

**Polling** — only if webhook and socket are impractical:
`GET /api/scanner-request/{id}/status` every ≥3s with backoff, stop on a final state,
5-minute timeout.

---

## 8. Reading the result

- **PDF** (surveys, inspections, notes, maps, floor-plan documents): `GET /api/scanner-request/{id}` →
  `result.pdf_path`. The link is **permanent** and must be downloaded with an authenticated GET:
  ```bash
  curl -o visura.pdf "<pdf_path>" -H "Authorization: Bearer $DOKICASA_API_TOKEN"
  ```
  With `curl` and no `-o` you see nothing on screen: the response is binary, it's not a bug.
- **Structured searches** (properties, owners): `GET /api/scanner-request/{id}/results`,
  paginated. For `list-addresses` the data is already in `result`.
- `result.is_empty: true` = scan succeeded but **no data found**: not an error,
  handle it as a valid outcome.

---

## 9. Property monitoring (optional)

Beyond one-off searches you can **monitor a property**: Dokicasa periodically re-checks the
owners at the Land Registry and notifies you via webhook when they change.

- `POST /api/monitoring/from-catasto` with the cadastral data + `webhook_url`.
- Event sent: `immobile.owners_changed`, signed like all the others (§6).
- In **sandbox** a sample one is sent immediately on creation, and you can re-trigger it with
  `POST /api/user-monitorings/{id}/simulate-webhook` (in production it returns 404).

---

## 10. Service status (Sister) — blocking requests when it's down

Cadastral queries go through **Sister** (the Italian Revenue Agency's portal), which
sometimes slows down, stops, or goes into **scheduled maintenance** ("unavailable from
5:00 pm to about 6:00 pm"). Dokicasa checks it every 10 minutes based on real scan times
and reads Sister's maintenance notices.

```http
GET /api/scanner/health
Authorization: Bearer <token>
```

Full response (maintenance in progress):

```json
{
  "status": "down",
  "accepting_requests": false,
  "message": "Sister (Agenzia delle Entrate) e' in manutenzione programmata fino alle 18:00: le scansioni riprenderanno al termine.",
  "since": "2026-09-17T17:00:00+02:00",
  "sister_checked_at": "2026-09-17T17:12:00+02:00",
  "maintenance": {
    "in_progress": true,
    "starts_at": "2026-09-17T17:00:00+02:00",
    "ends_at": "2026-09-17T18:00:00+02:00",
    "scanner_blocked": true,
    "message": "Sister (Agenzia delle Entrate) e' in manutenzione programmata fino alle 18:00: le scansioni riprenderanno al termine.",
    "sister_message": "A causa di un intervento programmato, il servizio e' indisponibile dalle 17.00 alle 18:00 circa"
  },
  "queue": { "enqueued": 0, "scanning": 0, "max_wait_seconds": 0 },
  "recommended_poll_seconds": 300,
  "generated_at": "2026-09-17T17:12:00+02:00"
}
```

Maintenance only announced: `status` stays whatever it currently is (e.g. `ok`), `accepting_requests: true`,
`maintenance.in_progress: false`, `scanner_blocked: false`, `message` something like "will be under
scheduled maintenance from 5:00 pm to 6:00 pm.".
No maintenance: `maintenance: null`.

| `status` | Meaning | What to do |
|---|---|---|
| `ok` | operational | nothing; if `maintenance` is not null, warn about the upcoming maintenance |
| `slow` | working but slower, scans still arrive | show a "longer than usual" warning |
| `down` | Sister is not responding, is under maintenance, or the service is suspended; `accepting_requests: false` | block new requests and route them to manual handling; with `maintenance.in_progress` show the restart time (`ends_at`) |
| `unknown` | status not available (check outdated or too few scans, e.g. at night) | **treat it as `ok`**: not an outage |

Implementation rules:

1. Decide **only on `accepting_requests`** (or `status === "down"`), never on the `message` text (it is in Italian and may change).
2. Query it **from the backend**, with a job every `recommended_poll_seconds` (300 s), and save the last outcome:
   user requests read the saved value, they don't call the endpoint every time. The response is cached 60 s.
3. If the call fails (network, 5xx), **do not** consider the service down: keep the last known status.
4. The `slow`/`down` status based on timing only changes after **two checks in a row** (10-20 minutes) and goes back to `ok` after two normal checks: you don't need your own hysteresis. Maintenance, on the other hand, applies immediately, and at `ends_at` the scanner restarts by itself.
5. `maintenance` = maintenance in progress or the next announced one (`null` if none). ISO 8601 dates in Italian time. It can appear with little notice or already be in progress.
6. During maintenance (or a manual block) `POST /api/scanner/{type}` responds **400** `{"error": "SISTER_FORCE_BLOCK", "maintenance": {...} | null}`, without creating the request or charging credits.
7. A request already being processed when maintenance starts ends up in `ERROR` with `result.code` = `SISTER_MAINTENANCE` (with `result.maintenance`) or `SISTER_UNAVAILABLE` (notice without times). PDF requests are relaunched by Dokicasa after it ends; others must be resent after `ends_at`.
8. With `down` caused by Sister not responding (no maintenance), requests you send are still accepted and stay queued until Sister is back: blocking them is a choice for the partner to make.
9. `since` = since when the problem has been going on (maintenance start time if in progress, `null` with `ok`); `queue` covers the last 2 hours.
10. In sandbox the status is normally `unknown` and `maintenance` is `null`.

---

## 11. Most common errors

| Symptom | Cause | Fix |
|---|---|---|
| `403 PARTNER_PRICING_REQUIRED` | the scan type isn't enabled on your price list | ask Dokicasa to enable it |
| `422` with `data: { … }` | missing/invalid parameters (see §4) | read the field-by-field detail |
| `422` province TN/BZ | tavolare land registry, not queryable | — |
| Silent socket | mixed environment (§1) or wrong channel | align `key`/`wsHost`/`authEndpoint` with the base URL |
| Webhook never received | URL not public or response not `2xx` | test with webhook.site, then your endpoint |
| Invalid signature | HMAC computed on the re-serialized JSON | use the raw body |

---

## 12. Prove the integration is alive (sandbox)

```bash
curl -X POST https://api-dev.dokicasa.it/api/scanner/list-addresses \
  -H "Authorization: Bearer HfBfYShns3E02no3kkwCRWQugkVNcJCtrRN2BPoI5NxhqsiXaxxnSPpIHY2O" \
  -H "Content-Type: application/json" \
  -d '{"indirizzo":"via roma","comune":"Milano","provincia":"MI"}'
# -> { "scanner_request_id": 12345, "status": "ENQUEUED" }
# then: GET /api/scanner-request/12345   until status = SUCCESS
```

In sandbox the data is **representative and fixed**: it does not vary with the parameters, it
serves to show the *shape* of `result`. There is also an interactive playground with socket
and polling side by side: <https://api-dev.dokicasa.it/playground.html>.

---

## Final checklist

- [ ] Base URL and socket parameters taken from **a single constant per environment**.
- [ ] Token in an environment variable, never in the client or in logs.
- [ ] Webhook (backend) **or** WebSocket (frontend) implemented; polling only as a fallback.
- [ ] `Signature` header verified on the raw body, always responding `2xx`, duplicates handled.
- [ ] `ERROR` state and `result.is_empty` handled explicitly.
- [ ] No automatic resend of the same request (avoids double charges).
- [ ] PDFs downloaded from the `result.pdf_path` link with an authenticated GET.
- [ ] Service status read from the backend every 5 min (`GET /api/scanner/health`), blocking only with `accepting_requests: false`; user warning if `maintenance` is not null.
- [ ] Tested in sandbox, then only the environment changed.
