# Century 21 Global — CRM Integration Guide (AI-ready)

> **If you are an AI assistant**: this document is a complete, self-contained specification for
> integrating a real-estate CRM with the Century 21 Global platform. Everything you need is in
> this file — endpoints, authentication, payload formats, semantics, error handling, and worked
> examples. Build the integration exactly against this contract; do not invent fields. When the
> user's CRM data model is unclear, ask them how their fields map to the canonical fields listed
> here. Always start with the unauthenticated sandbox (`POST /v1/validate`) and only call
> `POST /v1/feeds` once validation is clean.

> **If you are a human developer**: paste this whole file into your AI assistant (Claude, ChatGPT,
> Copilot, Cursor…) together with a prompt like:
> *"Using this specification, build a service that syncs listings from our CRM (describe your CRM
> and its export format) to Century 21 Global. Validate first, then push incrementally, and run a
> daily full snapshot."*

---

## 1. Platform overview

- **Base API URL**: `https://api.cservices.au` (interactive Swagger docs at `/docs`, machine spec at `/openapi.json`).
- **Public site**: `https://c21global.cservices.au` — where pushed content appears (server-rendered, multilingual).
- One ingestion pipeline, three inbound formats — all map into a single canonical model:
  - `reaxml` — REAXML `<propertyList>` XML (the AU/NZ industry format). Default.
  - `reso` — RESO Web API / Data Dictionary JSON (the US standard).
  - `project` — Century 21 Global's own JSON format for **new-home development projects**
    (off-the-plan). No industry standard exists for pushing projects; this is the documented contract.
- Pushed listing content (headline, description) is **machine-translated automatically** into the
  site's other languages (English, Spanish, French, Arabic, Chinese). Push in one language; the
  platform handles the rest. Unchanged text is never re-translated (content-hash gate).
- Listing photos are served **from the URLs you supply** — host them on a stable, public CDN.

## 2. Authentication

| Endpoint | Auth |
|---|---|
| `POST /v1/validate` | **None** — open sandbox, never persists |
| `GET /v1/feeds/whoami` | **API key** — verifies the key and returns its office scope |
| `POST /v1/feeds` | **API key**: `Authorization: Bearer c21_…` (or header `x-feed-token: c21_…`) |
| All `GET` read endpoints | None (public) |

**Check your key before the first push** — `GET /v1/feeds/whoami` returns
`{ "name": "<client>", "allowedOffices": ["OFF-1", …] | null }` (`null` = unrestricted).
A `401` means the key is wrong or revoked; a `200` with unexpected offices means the key is
scoped differently than assumed. Make this the integration's startup health check.

- API keys look like `c21_<48 hex>`. They are shown once and **scoped to specific office IDs** —
  pushing a feed containing any other office returns `403` and nothing is persisted.
- **Self-serve key request**: `POST /v1/clients/request` with
  `{ "name": "<company/CRM>", "email": "<tech contact>", "offices": ["OFF-1"], "note": "…" }`
  returns your key immediately (save it — shown once) in a **suspended** state. It activates when
  the platform operator approves; poll `GET /v1/feeds/whoami` — `401` while pending, `200` once
  live. The email you give also receives automatic feed-failure alerts later. (Also available as a
  form at `/developers`.)
- There is no separate staging host: `/v1/validate` IS the sandbox and runs the *identical*
  mapping/validation code as live ingestion. A clean validate = a working push.

## 3. The golden workflow

1. **Export** listings from the CRM in whichever of the three formats is closest to your data.
2. **Validate** (no auth): `POST /v1/validate` with the same body you intend to push.
   Fix every `error`-level issue; treat `warning`s as advisory.
3. **Push incrementally**: `POST /v1/feeds` (default `mode=incremental`) — upserts only what's in
   the body. Safe to repeat: identical content is a no-op (see §6).
4. **Run a daily full snapshot**: `POST /v1/feeds` with `mode=full` — everything the office has.
   Listings absent from a full snapshot are archived (soft-deleted), guarded by a mass-delete
   circuit breaker (see §7).
5. **Verify by reading back**: `GET /v1/listings/{office}/{uniqueId}` returns the canonical
   document exactly as stored.

## 4. Request envelope (all three formats)

`POST /v1/validate` and `POST /v1/feeds` accept JSON:

```json
{
  "format": "reaxml | reso | project",
  "mode": "incremental | full",
  "context": {
    "currency": "AUD",
    "defaultCountry": "AU",
    "sourceLocale": "en-AU",
    "sourceOfficeId": "optional override"
  },
  "xml": "<the feed document as a string — XML for reaxml, JSON string for reso/project>"
}
```

- `context.currency` (ISO-4217) is **required** — no inbound format carries one.
- The feed document always travels in the `xml` field, even when it is JSON.
- Raw-XML alternative for REAXML: send the XML directly with
  `content-type: application/xml` and query params `?currency=AUD&country=AU&locale=en-AU&mode=full`.
- Body limit: **25 MiB**.

## 5. Identity, and what makes a listing appear

- Identity is ALWAYS the pair **(office ID, listing uniqueID)** — `uniqueID` only needs to be
  unique within your office. Never reuse a uniqueID for a different property.
- A listing becomes **publicly visible** when its status maps to active/under-offer
  (REAXML `current`; RESO `Active`/`Coming Soon`/`Active Under Contract`/`Pending`;
  project `current` or omitted). `sold`, `leased`, `withdrawn`, `offmarket` unpublish it.
- Statuses are per-format vocabularies; unknown values fall back to active **with a warning** —
  watch validate output.

## 6. Idempotency and ordering

- **Content hash**: re-pushing byte-equivalent content is a no-op (`unchanged`) — a daily full
  snapshot with nothing changed costs nothing and re-translates nothing.
- **Newest wins**: every record carries a modification time (`modTime` /
  `ModificationTimestamp`). An older record never overwrites a newer one (`stale_ignored`).
- Push responses report per-listing actions: `inserted | updated | unchanged | stale_ignored`.

## 7. Full snapshots and the circuit breaker

`mode=full` archives an office's listings that are absent from the snapshot. Two guards:

- Per-office **mass-delete circuit breaker**: if the implied deletions exceed a threshold
  (default: more than max(10% of active, 20)), NOTHING is archived and the feed is quarantined
  for operator review — a truncated export can't wipe a catalog.
- **Category scoping**: a full *listings* snapshot (`reaxml`/`reso`) never touches the office's
  separately-pushed *projects*, and a full *project* snapshot never touches listings. Push them
  on independent schedules safely.

## 8. Format 1 — REAXML (`format: "reaxml"`)

Standard REAXML `<propertyList>` with the 8 element types (`residential`, `rental`,
`holidayRental`, `rural`, `land`, `commercial`, `commercialLand`, `business`). Key mappings:

| REAXML | Canonical |
|---|---|
| `<agentID>` | office ID (or `context.sourceOfficeId` override) |
| `<uniqueID>` | listing uniqueID |
| `@modTime`, `@status` | modification time, lifecycle status |
| `<price>` / `<rent period>` | price/rent + the injected `context.currency` |
| `<address>` (`display="no"` hides street) | country-first address |
| `<features>` children | bedrooms, bathrooms, garages… |
| `<landDetails><area unit>` | land area normalized to m² |
| `<objects><img id="m,a,b…" url>` | ordered media; `m` = hero |
| `<listingAgent id="1..4">` | listing agents (lead routing) |

REAXML has **no project or project-link field** — link REAXML listings to a project from the
project side (`childListingKeys`, §10).

## 9. Format 2 — RESO Web API (`format: "reso"`)

RESO Data Dictionary JSON: an OData `{ "value": [ … ] }` envelope, a bare array, or a single
record. Standard fields are read (`ListingKey`, `ListOfficeMlsId`, `PropertyType`,
`StandardStatus`, `ListPrice`, `Country`, `StateOrProvince`, `City`, `PostalCode`,
`Street*`, `Latitude`/`Longitude`, `BedroomsTotal`, `BathroomsTotalInteger`/`Full`/`Half`,
`GarageSpaces`+`CarportSpaces`, `LivingArea`+`Units`, `LotSize*`, `ListingTitle`,
`PublicRemarks`, `ModificationTimestamp`, `ListAgent*`/`CoListAgent*`, `Media[]`).

**Platform extension**: `ProjectKey` (or `C21_ProjectKey`) on a listing links it to a development
project — a bare id means "project in my own office" (`PRJ-001`), a value containing `:` is a
full cross-office key (`OTHER-OFFICE:PRJ-9`).

## 10. Format 3 — Projects (`format: "project"`)

Century 21 Global's JSON contract for new-home developments. The document in `xml` is
`{ "projects": [ … ] }` (or a bare array / single object). Per project:

| Field | Type | Notes |
|---|---|---|
| `uniqueId` | string, **required** | unique within the office |
| `sourceOfficeId` | string, **required** (or `context.sourceOfficeId`) | |
| `name` | string, **required** | the project's display name (auto-translated) |
| `description` | string | auto-translated |
| `status` | `current \| withdrawn \| offmarket \| sold` | default `current` (published) |
| `constructionStage` | **required**: `proposed \| approved \| offThePlan \| underConstruction \| nearingCompletion \| completed` | case-insensitive |
| `expectedCompletion` | `"YYYY"` or `"YYYY-MM"` | |
| `totalUnits` | positive integer | |
| `address` | `{ country, state, suburb, postcode, streetNumber, street, unit?, lat?, lng?, display? }` | flat |
| `price` | `{ min?, max?, display? }` | `min` is the sortable "from" price; `display` (e.g. `"From $650,000"`) wins in the UI |
| `media` | `[{ url, order?, hero?, kind?, title?, id? }]` | `kind`: `image \| floorplan \| document` |
| `developer` | `{ name, logoUrl? }` | |
| `childListingKeys` | `string[]` | listings in this development — bare uniqueIDs (same office) or full `OFFICE:UNIQUEID` keys |
| `agents` | `[{ name, email, phone, id? }]`, max 4 | sales team (lead routing) |
| `office` | `{ name, email? }` | |
| `externalLink`, `videoLink` | URLs | |
| `modTime` | ISO 8601, **required** in practice | newest-wins ordering |

**Child linking works in both directions** and resolves at read time:
the project can name its listings (`childListingKeys`), and/or each pushed listing can carry
`ProjectKey` (RESO/JSON). Dangling or unpublished children are simply omitted — never an error.
Unknown payload fields are ignored (forward-compatible); the format only ever changes additively.

## 11. Reading data back (verification + extraction)

All public, no auth — also useful as demo data for development:

```bash
# Listing search (projects are excluded unless asked for)
curl "https://api.cservices.au/v1/listings?country=AU&limit=5"

# One listing, full canonical document
curl "https://api.cservices.au/v1/listings/{office}/{uniqueId}"

# Development projects (filter by stage/country/price)
curl "https://api.cservices.au/v1/projects?stage=offThePlan"

# One project + its published child listings
curl "https://api.cservices.au/v1/projects/{office}/{uniqueId}"

# Directory: countries, regions, agents, offices, FX snapshot
curl "https://api.cservices.au/v1/countries"
curl "https://api.cservices.au/v1/regions?country=AU"
curl "https://api.cservices.au/v1/fx"
```

## 12. Validation report & error model

`POST /v1/validate` (and the validation section of a push response) returns:

```json
{
  "ok": true,
  "counts": { "parsed": 2, "valid": 2, "invalid": 0 },
  "listings": [ { "…full canonical documents…": true } ],
  "issues": [
    { "level": "warning", "code": "MAP_WARNING", "message": "missing modTime", "listingIndex": 0, "uniqueId": "DEMO-0001" },
    { "level": "error", "code": "SCHEMA_INVALID", "message": "Invalid enum value…", "path": "projectDetails.constructionStage", "listingIndex": 1 }
  ]
}
```

Issue codes: `XML_PARSE_FAILED`, `JSON_PARSE_FAILED`, `NO_PROPERTY_LIST`, `MAP_WARNING`,
`SCHEMA_INVALID` (with a dotted `path`), `MAP_FAILED`, `EMPTY_FEED`.
Invalid records are reported and skipped — valid records in the same feed still persist.

HTTP errors: `400` (missing body/currency, malformed envelope) · `401` (missing/wrong key,
deliberately generic) · `403` (key not scoped for an office in the payload — nothing persisted)
· `413` (body over 25 MiB) · `429` (rate limited — back off and retry with jitter).

## 13. Worked examples

Sample files (also downloadable from the developer portal at `/developers`):

- REAXML: `https://c21global.cservices.au/samples/reaxml-feed.xml`
- RESO: `https://c21global.cservices.au/samples/reso-feed.json`
- Project: `https://c21global.cservices.au/samples/project-feed.json`
- Quickstart script (validates all three + checks your key): `https://c21global.cservices.au/samples/quickstart.sh`
- Postman collection (every endpoint, `{{baseUrl}}`/`{{clientKey}}` variables): `https://c21global.cservices.au/postman-collection.json`
- Single-file clients (no dependencies): `samples/c21-client.ts` (Node 18+/Deno/Bun) ·
  `samples/c21-client.py` (Python 3.8+ stdlib) · `samples/c21-client.php` (PHP 8 + curl)
- MCP server (`samples/c21-mcp-server.mjs`, zero-dep Node 18+): exposes whoami / validate / push /
  search as Model Context Protocol tools — add to `.mcp.json` with
  `{ "command": "node", "args": ["c21-mcp-server.mjs"], "env": { "C21_KEY": "c21_…" } }` and your
  AI assistant can drive the API directly.

```bash
API=https://api.cservices.au

# 1. Sandbox-validate the project sample (no auth)
curl -s -X POST "$API/v1/validate" -H "content-type: application/json" \
  -d @project-feed.json | jq '{ok, counts, issues}'

# 2. Push it (requires your key)
curl -s -X POST "$API/v1/feeds" -H "content-type: application/json" \
  -H "Authorization: Bearer $C21_KEY" -d @project-feed.json | jq '.summary'

# 3. Read it back
curl -s "$API/v1/projects/AU-DEMO-01/PRJ-001" | jq '.project.headline, .listings | length'
```

## 14. Integration checklist

- [ ] Map CRM fields → the chosen format; every record has a stable uniqueID and a real modTime.
- [ ] `POST /v1/validate` returns `ok: true` with zero `error` issues for a full export.
- [ ] Incremental push on CRM change events (create/update/status change) — including
      sold/withdrawn transitions so listings unpublish.
- [ ] Daily `mode=full` snapshot per office; alert on `summary.quarantined > 0` in the response.
- [ ] Photos hosted at stable public URLs; hero image first / flagged.
- [ ] Projects (if applicable): stage kept current; `childListingKeys` or per-listing
      `ProjectKey` maintained; re-push the project when its links change.
- [ ] Handle `429` with exponential backoff; treat `401/403` as configuration errors (alert, don't retry).
- [ ] Round-trip check in CI: push a fixture, `GET` it back, compare the fields you own.
