# Connecting a CRM to Liney (contract v1) This file is the whole contract. Read it once, then fetch the JSON Schemas before you write code: they are the exact field names. https://www.liney.ai/developers/schema/schema-push.json, https://www.liney.ai/developers/schema/records-push.json, https://www.liney.ai/developers/schema/write-call.json, https://www.liney.ai/developers/schema/undo-call.json, https://www.liney.ai/developers/schema/reply.json, https://www.liney.ai/developers/schema/outbox-item.json, https://www.liney.ai/developers/schema/sso-token.json, https://www.liney.ai/developers/schema/line-request.json, https://www.liney.ai/developers/schema/line-question.json, https://www.liney.ai/developers/schema/line-reply.json, https://www.liney.ai/developers/schema/answer-request.json, https://www.liney.ai/developers/schema/write-request.json, https://www.liney.ai/developers/schema/headless-line.json, https://www.liney.ai/developers/schema/write-reply.json, https://www.liney.ai/developers/schema/undo-request.json, https://www.liney.ai/developers/schema/undo-reply.json, https://www.liney.ai/developers/schema/error.json, https://www.liney.ai/developers/schema/check-request.json, https://www.liney.ai/developers/schema/check-result.json. The same is served as OpenAPI at https://www.liney.ai/developers/openapi.json. Starters in Node, Python and Go are listed in section 17. Run `npx liney-check` (section 13) until every case is green; its lines say what to change. Four things that cost most first attempts a run: push the records a call changed before you reply to it, including a deal whose contacts or companies list changed (section 4); never mint a new record under an id you removed, even after a restart (section 4); render every value in `previous` as a string, numbers without a trailing .0 (section 5); and a records push carries deals, contacts and companies only, with their links as lists of ids, never notes or tasks (section 4). ## 1. What you are building [#what-you-are-building] Liney turns one sentence a sales rep types after a call into field changes, notes and tasks in your CRM. Your side is small: push your schema and records to Liney, and take two calls from it (`/liney/write` and `/liney/undo`), or poll for them if your server can take no inbound traffic. Liney never reads your CRM directly: everything it knows about your records is what you pushed. What Liney can and cannot do to your data: - a write carries field values on named records and items to create (note, task, contact, link) - undo carries restore values and a remove list that may only name ids Liney created in that line - there is no delete, bulk operation or query in the contract - only fields declared in the schema push are ever sent - every change names one record and one rep - their handler may refuse anything, and a refusal is a question for the rep, not an error The loop, in order: (1) push schema, (2) push records, (3) take writes (push mode) or poll for them (pull mode), (4) take undos the same way, (5) push back the records a write changed, (6) sign reps in from your CRM with a link. Steps 1, 2 and 5 are calls you make to Liney with your key; 3, 4 and 6 are calls Liney makes to you, signed with your secret. ## 2. Keys and secrets [#keys] A connection has two keys and one secret. The test key (`lk_test_…`) and the live key (`lk_live_…`) each name a target: where Liney sends writes when a line is written with that key's data. Send a key as `Authorization: Bearer ` on every call to Liney. Before go-live either key pushes schema and records into the one copy Liney keeps; after go-live the test key can no longer push (`test_key_after_go_live`) and test data belongs on a test team. The secret (`ls_…`) signs every call Liney makes to you and the sign-in tokens you mint; it is shown once when the connection is made, and it is deliberately not in the prompt that card hands you. Read it from `LINEY_SECRET` in your server's environment, and keep it out of prompts, files and logs: it is the one credential that can sign a rep in. The checker's first case (`secret-set`) sends one correctly signed dry-run request and tells you if your server hasn't got it. Rotation (from the connection card) mints a new secret and keeps the previous one active for 7 days: during that window every call from Liney carries two signatures, one per secret, and a token signed with either verifies. Keep the secret out of the browser and out of logs. ## 3. Push schema [#push-schema] `POST https://www.liney.ai/api/crm/custom/v1/schema` with the JSON in schema `schema-push`. The minimum: deal fields with the roles name, stage, amount, closeDate and owner, at least one open and one closed stage, and owners with emails; contact fields with a name and an email role are needed for contacts. Send it whenever your fields, stages, owners or rules change; each push replaces what the last one said, and the reply counts what it read: `{ fields: { deals, contacts, companies }, stages, owners: { created, known }, rules: { declared, removed } }` (`known`: an owner id Liney already had, from an earlier push or the test team's seed, which uses `u-sam` and `u-lee`). Field types: string, number, date, datetime, enum, bool. Roles tell Liney which of your fields is the deal's name, stage, amount, closeDate, nextStep, owner (name, stage, owner are required; one field per role), a contact's name, firstName, lastName, email, phone, title, company, a company's name, domain. An enum field lists its options; the stage field's options, if given, must be the stage ids. Stages carry `order` and, for closed ones, `closed: "won"` or `"lost"`. Owners are your users with the email they sign in with: one owner per email (a second owner with the same address is refused), and an owner's id is what the sign-in token's `sub` names. Keys start with a letter and use letters, digits, `_`, `.` and `-`. The checker acts as the first owner you push (and uses the second, if any, for the owner move); any owners work, and the key's own team members need not appear among them. The deal field with role `owner` holds an owner id: a write to it carries an id, never a name or email, and a records push carries the same id both as the record's top-level `owner` and inside `fields`. A contact's `role` in a create (section 5) is stored in the contact field whose role is `title`. ```json { "name": "Northwind CRM", "timezone": "America/Toronto", "currency": "CAD", "fields": { "deals": [ { "key": "title", "label": "Title", "type": "string", "role": "name" }, { "key": "stage", "label": "Stage", "type": "enum", "role": "stage", "options": [ { "value": "new", "label": "New" }, { "value": "quote", "label": "Quote sent" }, { "value": "signed", "label": "Signed" } ] }, { "key": "value", "label": "Value", "type": "number", "role": "amount" }, { "key": "close_date", "label": "Close date", "type": "date", "role": "closeDate" }, { "key": "next_step", "label": "Next step", "type": "string", "role": "nextStep" }, { "key": "owner", "label": "Owner", "type": "string", "role": "owner" }, { "key": "po_number", "label": "PO number", "type": "string" } ], "contacts": [ { "key": "full_name", "label": "Name", "type": "string", "role": "name" }, { "key": "email", "label": "Email", "type": "string", "role": "email" } ] }, "stages": [ { "id": "new", "label": "New", "order": 0 }, { "id": "quote", "label": "Quote sent", "order": 1 }, { "id": "signed", "label": "Signed", "order": 2, "closed": "won" } ], "owners": [ { "id": "u-sam", "name": "Sam Rivera", "email": "sam@northwind.example" } ], "rules": [ { "field": "po_number", "requiredAt": [ "signed" ] } ], "capabilities": { "activities": [ "note", "call", "task", "meeting" ], "links": "create" } } ``` ### 3a. Rules [#push-schema-rules] `rules` declares what your CRM enforces, so Liney asks the rep before writing instead of being refused: `required` (on every deal), `requiredAt: [stage ids]`, `requiredWhenClosed: "won" | "lost" | "both"`, or `mustChange` (the value must differ on every update: Liney asks the rep when a line would leave it as it is). A rule names a deal field from `fields.deals` and stage ids from `stages`. A push without `rules` removes the rules an earlier push declared and leaves the ones the team's admin set in Liney alone. Your handler still enforces its own rules: a `required_field` refusal (section 5) is turned into a rule as well. `requiredAt` and `requiredWhenClosed` are checked by your handler on the call that changes the stage to that stage (entering it; a write that sets the stage to the value it already holds is not entering it), `required` on a create and on any write; a write that leaves the stage alone is not refused for a rule about that stage. The order inside a handler: validate everything in the call first (rules, ids, fields), then apply everything, so a refusal writes nothing. The push reply counts `declared` and `removed`: each push replaces the rules the last one declared (a test team starts with one rule from its seed, so a first push may show it removed, or as known when yours is the same). A team admin edits rules in Liney too (the Rules editor: which fields are required to enter which stage, closed won or lost, or must change on every update) and can export that grid in exactly this `rules` shape to keep it in code and push it back; the checker's `rules-match-push` case dry-runs each `requiredAt` rule you declared and expects your handler to refuse for it. ### 3b. Capabilities [#push-schema-capabilities] `capabilities` says what Liney may create beyond field values: `activities` (any of note, call, task, meeting; default note only), `companies` (full, readonly or none), `createCompany`, `links` (create or none; full is read as create, because Liney never removes a link), `deleteRestores`. Leave it out for the defaults. ## 4. Push records [#push-records] `POST https://www.liney.ai/api/crm/custom/v1/records` with schema `records-push`: deals, contacts and companies, each with its owner (an owner id from the schema push) and its field values keyed by your field keys. Two modes. `upsert` changes what you send and leaves the rest; a record with `deleted: true` is a tombstone that Liney keeps for 30 days, so a later stale upsert that omits the flag can't bring it back (an explicit `deleted: false`, or a replace push that lists it, can). `replace` makes each mentioned owner's set exactly what you send: anything of theirs you leave out is dropped (not tombstoned). A deal moved to another owner is one record with its new owner. Push at least daily, and push the records a write or undo changed **before you reply to it** (or before you answer the outbox item in pull mode): Liney reads its copy the moment your reply lands, and so does the checker, so a push that comes after the reply loses the race and every read-back is empty. Include a deal whose `contacts` or `companies` list changed (a contact or company created with `dealId`, a link added): the lists are part of the deal record. Never mint a new record under an id you removed, even across restarts of a test server: a removed id is a tombstone for 30 days, and a plain upsert of it is ignored (the push reply's `ignored` count says how many). Stable seed ids that survive a restart are fine, and a replace push on start that lists them is the clean way to begin; ids for records created during a run must be fresh across restarts (a clock or run stamp in the id). `deleted: false` on a record, or a replace push that lists it, clears its tombstone. At most 5000 records per push; one push may carry several owners' records, and in replace mode an owner's whole set must be in one push (on a test team, a replace push for a seeded owner drops that owner's seed records it does not list, counted as dropped in the reply; the other owner's records stay). Values are scalars: strings, numbers, booleans or null; dates as YYYY-MM-DD, datetimes as ISO 8601, enums as the option value. A push carries deals, contacts and companies only, with their links as lists of ids (a deal's `contacts` and `companies`, a contact's `companies`); notes, calls, tasks and meetings are never pushed: they are yours, and Liney names them only by the ids your replies gave. The reply counts per object: `{ mode, deals: { upserted, deleted, dropped, ignored }, contacts, companies, likelyRules }`, where `likelyRules` is how many fields Liney now suggests as required from the values it saw (an admin accepts or dismisses those in Liney). ## 5. Receive writes [#receive-writes] Liney sends `POST /liney/write` with schema `write-call`. It names the rep (`rep`, an owner id) and carries any of: field values by object type and record id (`deals`, `contacts`, `companies`), things to `create` (a deal, a contact, a company, or a note, call, task or meeting on a `target` record, with `also` records that should show it too), `links` to add, and `tasks.complete`. Apply it as one transaction if you can. Reply with schema `reply`: ```json { "ok": true, "previous": { "deals": { "D-1042": { "stage": "proposal", "po_number": null } }, "contacts": { "C-77": { "title": "Ops lead" } } }, "created": [ { "type": "note", "id": "N-9931" } ] } ``` `previous` must name every field the call set, keyed by object type then id, with the value the record held before the write (null when it was empty): that is what makes undo possible. Every value in `previous` (and in `expect`/`restore`, section 6) is a string or null: numbers as plain decimals with no trailing `.0` (`"12000"`), booleans as `"true"`/`"false"`, dates as `YYYY-MM-DD`, datetimes as ISO 8601, an empty field as null; a string that happens to be `""` is the same as null. `created` has one `{ type, id }` per item in `create`, in the same order, with your own ids; Liney uses them in later calls and in undo's `remove`. A refusal is `{ ok: false, error: { code, message, field } }` with a code from this closed list: `required_field`, `invalid_value`, `not_found`, `conflict`, `rate_limited`. A refusal becomes a question for the rep, not an error: `required_field` with the `field` named makes Liney ask for it (and remember the rule), `invalid_value` shows your message next to the field, `not_found` tells the rep the record is gone, `conflict` shows both values, `rate_limited` makes Liney wait and retry. Refuse before you write anything: a refused call must leave the record as it was. A refusal is a normal reply: HTTP 200 with `ok: false` (Liney reads the body, not the status). A 5xx, a 429 or a body that is not JSON means the call itself failed and Liney retries it later under the same key; a 401 or 403 is read as your server refusing Liney's signature, and the line is refused to the rep until the connection is fixed. A deal create whose `fields` name no stage goes to your first open stage, and one that names no owner belongs to `rep`; one with no name is `required_field`. An owner id written to the owner field that you never pushed is accepted as an id, like an unknown `rep`. A contact or company created with `dealId` is linked to that deal (push the deal with the new id in its list); whether the contact also inherits the deal's companies is your CRM's own rule, and Liney reads whatever links your push carries. A line may arrive as several calls, each with its own `Idempotency-Key`, in any order; don't assume one line is one call. One call sets fields, another creates the note, another the task, another links a contact; a retry re-sends only the item that failed. Any `HTTP 5xx`, a body that is not JSON, or a reply outside the schema makes Liney queue the line and retry it later under the same key; `429` makes it wait. Who and what: `rep` is an owner id from your schema push (the rep whose line it is); if one arrives that you do not know, accept it and file the changes under it as an owner id string rather than refuse the call. An activity item's `ownerId` is who the note, call, task or meeting belongs to, normally the same rep; accept an id you do not know and file the item under `rep` rather than refuse. A deal created with `companyId` belongs to `rep` unless its `fields` name the owner field. `tasks.complete` naming a task you do not have is `not_found`, and so is a `links` item, an activity's `target`, or a create's `dealId` or `companyId` that names a record you do not have (only `also` entries are skipped). A write never carries null: a field is cleared with `""`; null appears only in undo's `restore`. A body you cannot parse as JSON is not from Liney: answer 400 with any body; Liney treats it as a failed call and retries later. A contact create's `role` goes into your contact field with role `title`; `name` is the full name; `properties` are extra contact fields by your keys. A create type or activity kind your capabilities did not declare, a field marked readOnly, or a value you cannot store: refuse with `invalid_value` and say which. `tasks.complete` marks tasks done that Liney itself created in an earlier call; the matching undo sends `remove` with type `task_done` and `previous: "open"` to reopen them, so nothing about a task's earlier status needs to be in your reply. Links are only ever added: a `links` item has no entry in `created`, and Liney never sends a `remove` of type `link`. If your push of the changed records fails after the write is applied, still reply `ok` and push again as soon as you can (a retry a minute later, or at worst your daily push): Liney's copy catches up on your next push, and a 5xx would make Liney retry a write that already happened. ## 6. Receive undos [#receive-undos] Liney sends `POST /liney/undo` with schema `undo-call` when a rep undoes a line, within 30 days of it. It is compare-and-set: `expect` holds, for every field to restore, the value Liney last read; `restore` holds the value to put back (null clears). Compare as values, not bytes: a number field numerically (`"12000"` equals `12000.0`), null equals `""`, anything else as trimmed strings. If any expected value differs from what the record holds now, reply `{ ok: false, error: { code: "conflict", field } }` and change nothing; Liney shows the rep both values instead of stomping a colleague's edit. Otherwise set the restore values and reply `{ ok: true }` (`previous` and `created` may be left out or empty). A forced undo, after the rep chose to overwrite, arrives with no `expect` for that record (or an empty one): restore without comparing. A record named in `expect` or `restore` that no longer exists is `not_found`. `remove` lists ids Liney created in that line (from your earlier `created` replies): delete them, and push the tombstone plus every deal whose `contacts` or `companies` list lost the id (Liney reads links only from your pushes); `task_done` means reopen the task (its `previous` is the status to put back). A second remove of something already gone is not an error. An undo may also arrive as several calls, each with its own key, and its refusals are remembered by key like a write's (section 8). Push the records the undo changed before you reply, as for a write. ## 7. Signing [#signing] Every call Liney makes to you carries three headers: `X-Liney-Timestamp` (unix seconds), `X-Liney-Nonce` (random, unique per call) and `X-Liney-Signature`. Verify before you parse or act on the body (the hash covers its raw bytes): ``` canonical = timestamp + "\n" + nonce + "\n" + METHOD + "\n" + path + "\n" + sha256_hex(raw_body) expected = "v1=" + hmac_sha256_hex(secret, canonical) ``` `METHOD` is upper-case (`POST`); `path` is the request path without host or query (`/liney/write`, or `/hooks/liney/write` if your endpoint URL has a path); the body hash is over the exact bytes received, not a re-serialisation. The header holds one or more comma-separated `v1=` values (two during a secret rotation): accept the call if any of them equals the HMAC with a secret you hold, compared in constant time. Refuse a timestamp more than 300 seconds from your clock, and refuse a nonce you have seen (keep them for at least that window). Answer refusals with 401 and `{ ok: false, error: { code: "bad_signature" } }`, before any write. Our egress IPs are not fixed; verify the signature, not the address. Liney follows no redirects and gives up after 10 seconds, so reply directly and quickly. ## 8. Idempotency [#idempotency] Every call carries `Idempotency-Key`, one per item: `:` for a write and `:undo:` for an undo, where the item names the record and what was written (so a second write of other values to the same field in one line is another key, and a retry of the same write is the same key). Remember each reply by its key for at least 24 hours, refusals included, and return it unchanged for a repeat; never create a second note, deal or contact for a key you have seen. A retry under the same key means Liney never got your first reply; a corrected write (other values, or a filled-in required field) always comes with a new key, so a remembered refusal never blocks it. A dry run (section 12) is not remembered. Two writes with different keys to the same field are two changes, in the order they arrive. ## 9. Pull mode [#pull-mode] If your server can take no inbound traffic (on-prem, a desktop app, a database with a cron job), expose nothing and poll instead. `GET https://www.liney.ai/api/crm/custom/v1/outbox` with your key is held open up to 25 s and answers with schema `outbox-item` as soon as there is a call for the key's target (else 204): the same body and `Idempotency-Key` a push call would carry, plus an id, a lease and `X-Liney-Dry-Run` as `dryRun`. Run your handler and answer at `POST https://www.liney.ai/api/crm/custom/v1/outbox/` with the lease in `X-Liney-Lease` and the same reply body a push endpoint would send (schema `reply`). A lease lasts 60 s; an item nobody answers goes back to the outbox for the next poll, an answer against an expired lease is refused (`lease_expired`; keep your remembered reply, the item comes back under the same key and your handler answers it from memory), and an item unanswered for 10 minutes is dropped (the rep's line waits as queued and is sent again later). In pull mode the checker's `secret-set` and the four signature cases are skipped (nothing reaches your server), so the sign-in link (section 10) is your proof that the server holds the right secret: if it opens the team, it does; and self-test your signed endpoints if you may ever switch to push mode; a pull-mode server need not listen on any port for Liney's sake. The loop, in three lines: `GET …/outbox` (client timeout 40 s or more; a 204 comes after the full 25 s hold when nothing is queued, so poll again at once, no back-off needed); on 200 read `kind`, `idempotencyKey`, `dryRun` and `body` from the item (they are body fields, not headers) and run your handler; `POST …/outbox/` with `X-Liney-Lease: ` and your reply. Poll with one worker; there is no signature to verify (nothing reaches your server), the key is the authentication, and a pull-mode integration needs no `/liney/write` or `/liney/undo` endpoint at all (keep them only if you may switch to push mode). Push the records an item changed before you answer it (section 4). Pull mode is chosen per target on the connection card, and `npx liney-check --pull` sets the test key's target to pull mode by itself. A poller that dies with a poll open leaves the item it was handed leased for the full 60 s (Liney does not see the dropped connection), so after restarting a poller wait a minute before running the checker, whose calls wait 30 s each. A pull target has no URL. ## 10. Sign reps in [#sign-in] From a button in your CRM, open `https://www.liney.ai/?sso=`, where `` is the team's address on Liney and the token is `base64url(json) + "." + base64url(HMAC-SHA256(secret, base64url(json)))` over schema `sso-token`: `sub` (the owner id from your schema push), `email`, `name`, `slug`, a `nonce` and `exp` (unix seconds, at most 300 s ahead; Liney refuses a longer one). A rep arriving this way is approved on first click (your CRM decided who is a rep) and is a billable seat from then on. What the secret can reach: only a `sub` your schema push listed may sign in, who they are is that owner's pushed email (the token's `email` is informational), the session it opens reaches that team and nothing else on Liney, whatever other teams the person belongs to, and a token for one slug never opens another team. So keep owner emails unique and correct: they are the identity. Liney never reuses a nonce; mint a fresh token per click. base64url is unpadded. Success looks like this: the team page answers the link with a redirect to `/api/sso/signin`, which sets the session cookies and redirects to `/`, the team's app, signed in as that rep; a non-browser test needs a cookie jar and to follow redirects; a rep's first sign-in takes a seat on a live team (a test team bills nothing), and testing the link on your test team is expected. ### 10a. The embedded line box [#embed] The same token opens Liney's line box inside your own page, in an iframe, so a rep never leaves your CRM. Load `https://www.liney.ai/embed.js` and mount it: ```html
``` `token` is a function on your page that gets a fresh sso token for the signed-in rep from your server (the same token as above, minted with the secret, which stays on your server; here the endpoint answers `{ "token": "…" }`). Or with no script of your own: `
` (the URL answers GET with the same JSON). `deal` presets the deal the page is on, so the rep just types what happened; they can still say "not this deal". The box is Liney's own app in its phone layout, `560px` tall by default (`height`), and needs nothing else from your page. How it signs in: the iframe cannot use Liney's session cookie on your domain (a third-party cookie: Safari blocks it, Chrome partitions it), so the script puts the token in the iframe URL's fragment (`https://www.liney.ai/embed/#sso=`, never the query), the box exchanges it at `POST https://www.liney.ai/api/sso/exchange` with `{ "sso": "", "slug": "" }` for a bearer token (one hour, kept in memory, sent as `Authorization: Bearer` on every request, refreshed by the box while it is open), and the team is decided by the token: a token for another team gets nothing there. After an hour idle the bearer is dead and the box cannot mint a new one, so it sends your page `{ type: "liney:needs-token" }` (a postMessage) and the script calls `token` again and answers `{ type: "liney:token", token }`; nothing else on your page has to change. Tokens are single use and live 300 s: mint one per call, never cache one. Messages the box sends your page: `liney:ready` (signed in, showing), `liney:needs-token`, `liney:refused` (the token was refused: a wrong team, a replay, an owner your push never listed); messages it takes: `liney:token` and `liney:deal` (`{ type: "liney:deal", deal: "" }`; the script's `setDeal` sends it). The script only listens to the iframe it made, and the box only to the page that framed it. A rep's first line from the box signs them in exactly as the link does (approved on arrival, a seat on a live team). The Node starter's page (section 17) has the box and the link wired, behind `LINEY_TEAM=`. The details an agent needs. The script mounts the frame bare (`https://www.liney.ai/embed/`, an iframe: a host page with a Content-Security-Policy needs `frame-src https://www.liney.ai`; the box's own requests are the iframe's, so the host's `connect-src` is not involved) and answers its first `liney:needs-token` with a token, so no token ever sits in your page's DOM; a page that builds the iframe itself may instead put the token in the fragment, `#sso=`. `token` may return a string or a promise of one. Messages up carry `{ type, team }`: `liney:ready` (signed in, showing), `liney:needs-token`, `liney:refused`. `Liney.embed` returns `{ iframe, setDeal(id), remove() }` and takes `onReady()` and `onRefused()` callbacks. The exchange (`POST https://www.liney.ai/api/sso/exchange`, JSON) answers `200 { "token", "expiresAt", "team": { "slug", "name" }, "rep": { "name", "email" } }`, `400 { "error": "bad_request" }` (no `sso` or `slug`), `401 { "error": "sso_refused" }` (a bad, replayed, expired or wrong-team token, an owner your push never listed, or a dead bearer; the reason is in the team's event ledger only) or `429 { "error": "rate_limited" }` (a cap per address an hour). A bearer refreshes for up to twelve hours from the token that started it, then the box asks your page for a fresh one; a bearer is taken by the API routes only, never by a page. If no token arrives within 30 s of a request, or the exchange refuses one, the box shows "Liney couldn't sign you in from your CRM. Reload this page, or sign in to your CRM again and come back." and nothing else. ## 11. Headless API [#headless] For a CRM whose own UI shows the line box, the question and the answer chips: four calls, server to server, with your key (`Authorization: Bearer`) and `X-Liney-Rep: ` (the rep whose line it is, from your schema push) on each. No browser session is involved. `POST https://www.liney.ai/api/crm/custom/v1/lines` with schema `line-request` (`text`, the rep's words; `dealId` to pin the deal the page is on, else Liney finds it; `today` as YYYY-MM-DD in the rep's day, else the connection's timezone decides) is one model read and answers schema `line-reply`: `lineId`, `deal`, the `proposal` (`fields` with `property`, `value` and `previous`; `engagements` to make; `contacts` and `newCompanies` to create; `recordUpdates`; `links`; `createDeal` when the line is about a deal you don't have yet, with `match.alternatives` and `existingDeals` to offer instead; `recordChoices` and `taskChoices` when a name fits more than one) and `questions` (`property`, `why`, `options`, `blocking`; a `record` when the field is a contact's or company's). Show the changes and the questions; the proposal carries no reasons and no scores, by design (what Liney sends anyone is results, never reasoning). `POST https://www.liney.ai/api/crm/custom/v1/lines/{id}/answer` with schema `answer-request` keeps the rep's answers on the line (`answers` keyed by the question's `property`; `dealId` to pick a deal from the proposal, or `createDeal: { name }` to make the one it offered, or `dropStage: true` to keep the stage and log the rest) and answers the questions still open. `POST https://www.liney.ai/api/crm/custom/v1/lines/{id}/write` sends the line to your endpoint (or the outbox in pull mode) keyed to the line (section 8) and answers schema `write-reply`: `status` is `written` (with `line.written`, `line.previous`, `line.engagements` and `line.records`), `asking` (a required field is still empty, or your handler refused with `required_field`: answer the `questions` and write again), `rejected` (`message` in the rep's words, from your handler or Liney), or `queued` (`cause`: `relay` when nothing polled in time, `network`, `429`, `5xx`, `paused`; Liney retries a queued line by itself), plus `calls`, the calls it made to you in order (kind, idempotency key, ok, ms; the bodies are in the request log). A line written twice is answered as it is. `POST https://www.liney.ai/api/crm/custom/v1/lines/{id}/undo` (schema `undo-request`, `force` to write over a field a person changed since) is the compare-and-set undo of section 6 and answers schema `undo-reply`: `reverted` with `failed` naming what your handler refused as `conflict`, `conflict` with both values when Liney's own read differs, or `not_undoable`. Rules: the key names the target (section 2): the test key's lines go to the test target, for real before go-live and as dry runs once the connection is live (nothing recorded; `dryRun: true` in the reply), the live key's lines go to the live target and are refused as `not_live` before go-live; `X-Liney-Dry-Run: 1` on a write is a dry run either way. A rep's first headless line approves them on this connection the way the sign-in link does (a seat on a live team). Refusals are the shape of section 15: `rep_required`, `unknown_rep` (push the owner first), `unknown_deal`, `empty`, `too_short`, `too_long`, `rate_limited` (429 with `Retry-After`: the rep's model budget, or a test team's 200 reads a day), `refused` (the line could not be read; say it another way), `ai_down` (503), `not_found` (no such line for that rep), `not_answerable`, `not_live`, `no_target`. Every line here shows in the rep's own log in Liney too (a line read and never written shows as ready there). One model read costs the same as a line typed in the box. The rules a UI has to follow. A question with `blocking: true` blocks the write: `write` on a line with such a question unanswered sends nothing and answers `asking` with `cause: "blocking"`; a non-blocking question is optional. Pseudo keys in `answers`: `__date` (YYYY-MM-DD) moves a task's due date or a meeting to that day when the line was vague about it; `__company` names a company id from `createDeal.companyChoices` (or `"new"`); `__link:` is `"yes"` or `"no"` for a link with `ask: true` (its `key`). `recordChoices` (a name that fits more than one contact or company) and `taskChoices` (a finished thing that fits more than one open task) are shown to the rep in Liney's own box; the headless API has no answer key for them yet, so a write leaves those changes out (say so on your side, or open Liney for that line). `answer.dealId` may name a deal from `match.alternatives` or `existingDeals` (a colleague's deal is adopted for the rep, as the box does); another id must be one of the rep's own. A line written twice is answered as it is; a line that was undone is closed (`not_writable`: send a new line). `GET https://www.liney.ai/api/crm/custom/v1/lines/{id}` answers the line as it stands (a queued line that landed since, the blocking questions still open) without writing anything. The shapes of `proposal`: `fields[]` `{ property, value, previous, required }`; `engagements[]` one of `{ type: "note", body }`, `{ type: "call", body, disposition, title }`, `{ type: "task", subject, body, dueDate, priority?, invite? }`, `{ type: "meeting", title, startTime, endTime, body }`; `contacts[]` `{ name, role, email, phone, new: true, properties? }`; `recordUpdates[]` `{ objectType, recordId, recordName, recordDetail, property, value, previous }`; `links[]` `{ key, kind, from: { type, id, name }, to: { type, id, name }, primary?, ask?, exists? }`; `createDeal` `{ name, company?, companyId?, companyDomain?, companyChoices? }`; `existingDeals[]` `{ dealId, name, mine, closed? }`; `recordChoices[]` `{ objectType, name, options: [{ id, name }], unsure?, unnamed? }`; `taskChoices[]` `{ phrase, options: [{ id, subject, dueDate }], unsure? }`; `completeTasks[]` the open tasks the write marks done; `alsoOn[]` the other timelines the note goes on; `doneWork[]` what the line says was finished, in its words; `noFact` when the line said nothing to write; `split` `{ deals: [{ dealId, name }] }` when the line is about more than one deal (send one line per deal instead), else null; and `questions`, the same list as the reply's, since it is the proposal's. ## 12. Local development [#local-development] Send `X-Liney-Dry-Run: 1` from the playground (or by header on `POST https://www.liney.ai/api/lines/write` as a signed-in rep) and Liney sends the write with that header set (in pull mode, the item's `dryRun` is true): validate it and reply exactly as you would, with `previous` and an empty `created`, but write nothing; `dryRun: true` in the reply is allowed and not required. An undo can be a dry run too: compare and reply, restore nothing. Liney records nothing either. For a laptop with no public URL there are two ways. Pull mode (section 9), with your own poller. Or `liney dev`: keep your server in push mode on localhost and run `LINEY_KEY=lk_test_… LINEY_SECRET=ls_… npx --yes --package=liney-check liney dev http://localhost:` next to it. It is pull mode with the poller on your laptop: it long-polls the outbox with your test key, POSTs each call to `/liney/write` or `/liney/undo` signed with the secret exactly as Liney would (section 7), and posts your reply back with the lease, so your signature check, your handlers and your pushes run unchanged. Set the test target to pull mode first (`npx liney-check --pull` does, or the connection card); a write in the playground then shows "waiting for liney dev…" until your server answers, and goes to queued (reason relay) if nothing answers in 30 s; a line whose only change is a note or a task, with no field value, ends written with that item marked as not saved; the rep's Retry on the line (or Send it again in the playground) sends the missing item once your server answers. A call your server does not answer within 25 s is answered to Liney as `rate_limited`, so the line waits and is retried rather than lost. The playground and the request log at `https://www.liney.ai//developers` show every call and reply, with a replay. ## 13. Passing the checker [#checker] `GET https://www.liney.ai/api/crm/custom/v1/check` lists the cases your key may run; `POST https://www.liney.ai/api/crm/custom/v1/check` with `{ "case": "" }` runs one (schema `check-request`, `check-result`); the client below does this for every case and prints a line per case with what to change on a failure and the section it is about. With the test key the whole list runs against the key's test target, creating and removing deals, notes, contacts and companies as it goes: use a test team or a test target, never a live CRM. With the live key only the non-mutating subset runs, against the live target, and changes nothing. A test target whose URL equals any live target's is refused for the mutating cases. In push mode pass the endpoint URL once (`npx liney-check https://crm.example.com`) and the test target is set to it; in pull mode run your poller and pass `--pull`. The live key sets nothing: its target is set on the connection card by an admin, and `npx liney-check` with a live key runs the subset against it. ``` LINEY_KEY=lk_test_… npx liney-check https://crm.example.com # push mode LINEY_KEY=lk_test_… npx liney-check --pull # pull mode (your poller running) ``` `secret-set` runs first: a correctly signed dry-run request at your endpoint, to prove your server holds this connection's secret before anything else is judged. In pull mode it is a stated skip, since Liney sends you nothing. Four cases of the live subset are dry runs against a deal the rep already has: `dry-write-accepted` (a write that passes validation is answered `ok` with `previous`, so refusing every dry run is not passing), `dry-required-refusal`, `dry-refusal-replay` and `dry-undo-conflict`. They need your handlers to validate a dry run exactly as a write (section 12) and refuse it the same way; the refusal ones also need a `requiredAt` rule in your schema push and a deal of the first owner that is not at that stage with the field empty, and are stated skips without those. Each case records the target it ran against, and Go live (section 14) needs every case of the live subset at pass or a stated skip in its latest run with the live key, against the live target the connection goes live with. As a GitHub Action: `https://www.liney.ai/developers/github-action.yml` is a workflow that runs the checker on every push against your staging endpoint in push mode (the test key as the repository secret `LINEY_KEY`, the endpoint as the variable `LINEY_ENDPOINT`), and `![liney check](https://www.liney.ai/developers/badge/.svg)` is a README badge that shows the latest run of every case the test key may run, from Liney's own record of the runs (the connection id is on the connection card). The client reads the key from `LINEY_KEY` (or `--key`) and Liney's address from `LINEY_URL` (default https://www.liney.ai); `--case ` reruns one case in seconds while you fix it (the same as `POST https://www.liney.ai/api/crm/custom/v1/check` with `{ "case": "" }`), `--json` prints the result as JSON. The checker acts as the first owner you pushed (section 3). A full run takes about a minute in push mode and three to four in pull mode. In pull mode, after restarting your poller wait a minute before running: its old poll still holds a lease (section 9). The test key runs every case below, 34 in all; the live key runs only the 18 marked "live subset". Each case says what it needs from your side. `required-refusal` takes the stage and field from the first `requiredAt` rule you pushed; `stage-move` moves a deal between your first two open stages, so a rule on an open stage after those, or on a closed one, is the usual choice. Cases that write (test key only, 16): - `create-deal`: create a deal, read it back, undo it. Needs: create: [{ type: "deal", fields }] makes a deal and the reply names its id in created; an undo remove deletes it and pushes a tombstone. - `update-previous`: update() returns what the fields held before, and takes the caller's read when given one. Needs: the reply's previous must carry, for every field the call set, the value it held before the write (null when empty), keyed by object type then id. - `undo-restores`: writing previous back puts the field back (undo). Needs: setting a field to its previous value must take, and the changed record must be pushed back. - `undo-after-human-edit`: a value a person changed after the write is seen before undo, and a forced undo still restores. Needs: push the changed record after every write so Liney's copy shows the value a person set. - `stage-move`: move a deal to the next stage and back. Needs: a write to the stage field moves the deal and previous carries the stage before. - `required-refusal`: a stage move the CRM refuses for a missing field comes back as validation with the field named (Liney turns it into a rule). Needs: refuse a stage move that lacks a field your rule needs with { code: "required_field", field } and write nothing; with the field present, apply it. - `activities`: every activity kind the module declares can be attached to a deal and undone; an undeclared kind is refused. Needs: every activity kind capabilities lists is created from create: [{ type, target, … }] with an id in created; an undo remove deletes it. - `update-contact`: create a contact on a deal, update a field with previous, restore it, undo the contact. Needs: create: [{ type: "contact", dealId, name, email, role }] makes a contact with the title field set from role, linked to the deal; a write to contacts[id] answers previous. - `task-done`: mark an open task done, then undo puts it back to open. Needs: tasks.complete marks the task done; an undo remove of type task_done reopens it. - `retry-half-saved`: retry a half-saved line: only the item that failed is sent again, so nothing is made twice. Needs: a retried call with the same Idempotency-Key answers the first reply; a new key makes a new item. - `tombstone`: a record deleted in the CRM reads as nothing, a write to it is gone, a second remove is nothing. Needs: after a remove, push the record with deleted: true so Liney forgets it. - `owner-move`: move a deal to another owner and back, with previous. Needs: a write to the owner field moves the deal to that owner; push it back under the new owner. - `undo-restores-cas`: an undo whose expect matches restores the value through /liney/undo. Needs: in /liney/undo, when every field in expect equals what the record holds, set the restore values and reply { ok: true }. - `undo-conflict`: an undo whose expect no longer matches is refused with conflict and changes nothing. Needs: in /liney/undo, compare every field in expect with what the record holds now; if any differs reply { ok: false, error: { code: "conflict", field } } and write nothing. - `idempotent-replay`: the same call twice, same Idempotency-Key, makes one thing and answers the same reply. Needs: remember each reply by its Idempotency-Key and return it again for a repeat; never create a second note or deal for a key you have seen. - `dry-run`: a write with X-Liney-Dry-Run: 1 is answered like a write and changes nothing. Needs: when the X-Liney-Dry-Run header is 1, validate the call and reply { ok: true, previous, created: [] } without writing anything. Cases that change nothing (the live subset, 18; the test key runs them too): - `secret-set`: your server holds this connection’s secret. Needs: the connection card shows the secret once: put it in LINEY_SECRET on your server and verify X-Liney-Signature with it. - `same-value`: sameValue: two spellings of one value are equal, and nothing else is. Needs: values are compared as Liney stores them; push numbers and dates as the schema types say. - `capabilities`: capabilities are consistent with each other and with what the module does. Needs: capabilities in the schema push must name only note, call, task, meeting; links create or none. - `schema-roles`: the schema names a field for every role Liney needs, stages open and closed, the rep among the owners. Needs: the schema push needs deal fields with the roles name, stage, amount, closeDate and owner, at least one open and one closed stage, and every owner with an email. - `identity`: identity.user finds the rep by their id in the CRM. Needs: every owner in the schema push has an id and an email. - `unknown-ids`: an id the CRM never had reads as nothing and cannot be written. Needs: a write to an id you do not have is refused with not_found. - `search`: a deal the CRM has is found by name, listed for its owner and readable. Needs: push the records the rep owns, with their owner id and the name field, before running the checker. - `rate-limit`: a rate limit surfaces as CrmError rate_limited with a wait, never as a crash or a silent nothing. - `rules-match-push`: every rule declared in the schema push is one the CRM refuses for (dry runs). Needs: every requiredAt rule in the schema push is dry-run against a deal not at that stage and must come back required_field: declare only what your handler refuses. - `dry-write-accepted`: a dry-run write of a field to its own value is accepted and answered with previous. Needs: a dry run that passes validation is answered like a write, { ok: true, previous } with the value each field holds, and nothing is applied; refusing every dry run is not passing. - `dry-required-refusal`: a dry-run stage move without the field a rule needs is refused with required_field, nothing written. Needs: validate a dry run exactly as a write (rules, ids, fields) and refuse it the same way; only the apply step is skipped. - `dry-refusal-replay`: the same refused dry-run call sent again, same Idempotency-Key, gets the same refusal. Needs: a repeat of a refused call answers the same refusal, whether from memory (a write) or by validating again (a dry run). - `dry-undo-conflict`: a dry-run undo whose expect does not match the record is refused with conflict, nothing changed. Needs: on a dry-run undo compare every field in expect with what the record holds and reply conflict when one differs, restoring nothing either way. - `refusal-codes`: a write to an id the CRM never had is refused with not_found. Needs: reply { ok: false, error: { code: "not_found", message } } for a record id you do not have; the codes are required_field, invalid_value, not_found, conflict and rate_limited only. - `signature-bad`: a call with a wrong signature is refused before anything is written. Needs: verify X-Liney-Signature (HMAC-SHA256 of the canonical string with your secret) before reading the body, and answer 401 with { ok: false, error: { code: "bad_signature" } }. - `signature-stale`: a call whose timestamp is outside the 300 s window is refused. Needs: refuse a request whose X-Liney-Timestamp is more than 300 seconds from now, before reading the body. - `signature-replay`: a call whose nonce was seen before is refused. Needs: remember every X-Liney-Nonce you accept (for at least the 300 s window) and refuse a second request that carries one of them. - `unsigned`: a call with no signature headers is refused. Needs: a request without X-Liney-Timestamp, X-Liney-Nonce and X-Liney-Signature is not from Liney: refuse it. A case Liney cannot run on your CRM is skipped with its reason and does not stop green: `rate-limit` (no fault to inject on a real CRM), `rules-match-push` when you declared no `requiredAt` rule, `task-done` (Liney does not read your open tasks, so it has nothing of yours to mark done), and in pull mode the four wire cases (nothing reaches your server). ## 14. Go live [#go-live] The connection card holds both targets. When the checker is green against the test target, save the live target on the card (its URL, or pull mode; the live key never sets it), run the live subset against it with the live key (`npx liney-check` with `lk_live_…`; in pull mode your own poller must be running with the live key, since `liney dev` is for the test key only, section 9), and press Go live: it is refused until every case of the live subset stands at pass or a stated skip in its latest run, and the card says which case is failing or has not run. From then on writes go to the live target and the test key keeps its own. The card also pauses writes (lines wait as queued), caps lines per rep per day, loads deals, contacts, companies or owners from a CSV (the same push as section 4, by column name), holds a signature debugger (paste a request your server received and the secret: it shows the canonical string line by line and names the usual slip; the secret stays in the browser), resets a test team to its seed in one click, and after go-live shows a health line: the last write that landed, the last failure with its request-log reference, the endpoint's p95, and when records were last pushed. Its admins are emailed when three writes in a row fail and when records are more than a day old. ## 15. Errors from Liney [#errors] Every refusal from a Liney API route is `{ ok: false, error: { code, message, hint, docs } }` (schema `error`): `hint` says what to do next, `docs` is the section of this file it is about. A failure inside Liney is `server_error` with a reference to quote. There is no other error shape on these routes. ## 16. Versioning [#versioning] The path carries the version (`/v1/`) and every reply carries `Liney-Contract: v1`. A breaking change gets a new version with 12 months of overlap; additions (a new optional field, a new create type your capabilities didn't declare) arrive within v1 and are safe to ignore. ## 17. Starters [#starters] Three servers you can start from, each with the four handlers (write, undo, and the two pushes to Liney), the pull-mode worker loop and the signature check already written, holding a few records in memory. Node is the reference CRM: run it and open its page to watch records change as lines are written. Files: `https://www.liney.ai/developers/starters/node/server.js`, `https://www.liney.ai/developers/starters/python/server.py`, `https://www.liney.ai/developers/starters/go/main.go`; each starts with the same variables: `LINEY_URL`, `LINEY_KEY`, `LINEY_SECRET`, `MODE` (push or pull), `PORT`. ## 18. Not in this file [#not-in-this-file] How Liney reads a line, decides what to ask, chooses what to write or learns a rule is not part of the contract and is not described anywhere you can reach. Your integration needs none of it: it takes field values and gives back previous values.