# LISA.CRM — intake protocol `v1`

> The contract every project uses to send leads, clients and orders into LISA.CRM.
> Live at `https://crm-in.gilung.net` since 2026-10-31; `GET /` on that host serves this text.
> Only compatible changes inside `v1`; anything breaking becomes `v2` next to it.

## Three ways in

| Who | How | Can send |
|---|---|---|
| A project with a backend (site, shop, bot, script, internal system) | `POST /v1/intake` with its **source key** | leads, clients, orders |
| A landing page without a backend | a plain HTML `<form>` posting to `/v1/form/<formId>` | leads only |
| A project that wants to connect | `POST /v1/register` | gets a key that works after the owner approves it |

Every project is a **source**. The owner creates sources in the CRM (Sources screen), or a
project registers itself and waits for approval.

---

## 1. `POST /v1/intake`

```http
POST https://crm-in.gilung.net/v1/intake
Authorization: Bearer <source key>
Content-Type: application/json
```

```jsonc
{
  "kind": "lead",                 // lead | client | order
  "externalId": "form-1842",      // the record's id in your system — required, up to 200 chars
  "occurredAt": "2026-10-31T12:00:00Z", // when it happened at your side; default: time received

  "contact": {                    // the person; at least one of name / email / phone
    "name": "Jane Doe",           // required for kind=lead and kind=client, optional for orders
    "email": "jane@example.com",
    "phone": "+48 600 000 000",
    "company": "Example Sp. z o.o.",
    "lang": "pl",
    "country": "PL"
  },

  "lead": {                       // kind=lead, all optional
    "title": "Website redesign",  // default: first line of message
    "message": "Text from the form",
    "budget": { "amount": 2000, "currency": "EUR" },
    "url": "https://example.com/contact"
  },

  "order": {                      // required for kind=order
    "type": "goods",              // goods | service (default goods)
    "number": "WC-10233",
    "status": "paid",             // free text, your own vocabulary
    "total": { "amount": 149.90, "currency": "PLN" },
    "items": [ { "name": "…", "sku": "…", "qty": 1, "price": 149.90 } ]
  },

  "tags": ["landing-a", "spring-promo"],
  "utm": { "source": "google", "medium": "cpc", "campaign": "…" },
  "raw": { }                      // anything else; stored as is
}
```

### Responses

| Code | When | Body |
|---|---|---|
| `201` | new record | `{ "id": "ld_…", "type": "lead", "status": "created" }` |
| `200` | this `externalId` from this source was seen before | `{ "id": <same id>, "status": "duplicate" }` — or `"updated"` for orders |
| `400` | body does not match | `{ "error": "…", "field": "contact.email" }` |
| `401` | no key, unknown or revoked key | `{ "error": "…" }` |
| `403` | key is pending owner approval | `{ "error": "…" }` |
| `413` | body over 256 KB | |
| `429` | over 60 requests a minute for this key | |
| `5xx` | our failure | **retry the same request** — safe, see rule 2 |

Unknown top-level fields do not fail the request; they are listed in the
`X-CRM-Ignored-Fields` response header.

### Rules

1. **The key is the source.** A `source` field in the body is ignored. Revoking a key
   disconnects the project.
2. **Idempotency is `<source>:<externalId>`.** Sending the same record twice never creates a
   second one. Retry freely.
3. **Orders are upserted.** Resend an order with the same `externalId` when its status, total or
   items change; the CRM updates it and answers `"updated"`. Leads and clients are not
   overwritten by a resend — their later life happens in the CRM.
4. **People and companies are deduplicated by the CRM**, not by you: by email, then phone,
   then company name. Existing values win; a resend only fills empty fields.
5. An order or `kind: client` makes the contact a **client**.
6. Keys are secrets. Never put a key into a web page or a browser extension shipped to others —
   use the form endpoint there.

### Example

```bash
curl -X POST https://crm-in.gilung.net/v1/intake \
  -H "Authorization: Bearer crm_…" \
  -H "Content-Type: application/json" \
  -d '{"kind":"lead","externalId":"test-1","contact":{"name":"Test","email":"test@example.com"}}'
```

---

## 2. `POST /v1/form/<formId>` — landings without a backend

Each source has a public `formId` (Sources → Form). It is not a secret and accepts **leads
only**, so it can sit in any HTML page:

```html
<form action="https://crm-in.gilung.net/v1/form/f_…" method="post">
  <input name="name" placeholder="Name" required>
  <input name="email" type="email" placeholder="Email">
  <input name="phone" placeholder="Phone">
  <textarea name="message" placeholder="Message"></textarea>
  <!-- anti-spam: keep hidden and empty -->
  <input name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">
  <!-- where to go after sending; must be one of the source's form hosts -->
  <input type="hidden" name="_redirect" value="https://your-landing.example/thanks">
  <button type="submit">Send</button>
</form>
```

- Accepts `application/x-www-form-urlencoded`, `multipart/form-data` or JSON.
- Recognised names: `name`, `email`, `phone`/`tel`, `company`, `message`/`comment`/`text`,
  `subject`/`title`, `utm_*` (several common aliases too). **Every other field** is kept and
  added to the lead's message as `field: value`.
- `_redirect` (or `_next`) works only for the source's **form hosts**; otherwise the visitor gets
  a plain thank-you page with a link back. No open redirect.
- `_gotcha` filled in → treated as a bot: same thank-you, nothing stored.
- The same submission repeated within 10 minutes is one lead.
- `fetch()` with `Accept: application/json` gets `{ "ok": true, "message": "…" }`, with CORS for
  the source's form hosts only.
- 60 posts a minute per visitor IP and form.

---

## 3. `POST /v1/register` — a project connects itself

```bash
curl -X POST https://crm-in.gilung.net/v1/register \
  -H "Content-Type: application/json" \
  -d '{"name":"My Project","url":"https://my-project.example","contact":"who to ask"}'
```

Answer `201`: `{ "id": "src_…", "status": "pending", "key": "crm_…", "formId": "f_…" }`.
The key is shown once. Until the owner approves the source in the CRM, `/v1/intake` answers
`403` and the form answers `404`. After approval both work without any change on your side.

---

## Still open

- ❓ Batch intake (`POST /v1/intake/batch`) for imports.
- ❓ Webhook back to the project (e.g. "lead qualified").
- ❓ Turnstile on forms, if the honeypot and rate limit stop being enough.
