How to use MyCRM.
This page describes the shipped product, screen by screen and button by button. Where a number is compiled in — a rate limit, a page size, a session length — it is written down here rather than left for you to discover. If something is not on this page, it is probably not built; the honest limits list says which parts.
Start here: three moves
Get in
There are no passwords. An admin creates your user, you go to /login, ask for a link and click it. Brand-new install? The first admin is made from the server's command line. See Getting started.
Connect your systems
Contacts, purchases, licences and tickets arrive through connectors, not by typing. Add the commerce backend first — nothing in the renewal engine happens until it is running. See Connectors.
Work the day
Open a contact to see everything about them, save the filters you keep retyping as views, clear the inbox, move deals. See Your customer record and Working the day.
Getting in
MyCRM has no passwords and no sign-up screen. Everyone signs in by asking for a one-time link sent to their work address, and can add a 6-digit authenticator code on top.
First run: create the first admin #
A brand-new database has zero users, so nobody can sign in until you make the first one from the server's shell. Start the server before that and it logs one warning, and nothing else:
no users yet — create the first admin with: mycrm user add you@example.com -role admin
mycrm user add you@example.com -name "You" -role admin
It prints one line: user <email> (<name>) role=admin id=<id>. Then start the server and open the base URL, which lands you on /login:
mycrm serve -addr :8090 -base-url https://crm.example.com -mail sendgrid
mycrm user list prints one line per user as <email> <role> totp=false disabled=false, which is the quickest way to confirm the account exists and is an admin.
-role defaults to member. Forget the flag and you have created a member, not an admin, and there is still nobody who can open Settings. mycrm user add is idempotent on the email address: running it again returns the existing user unchanged — it does not promote them.
Defaults if you pass no flags: listen address :8090, base URL http://localhost:8090, mail mode log (sign-in links are written to the server log, never sent), tenant default. The whole CLI is serve, user add, user list, apikey create, connector add|list|run|reset|kinds, seed and version. Self-hosting covers the install around it.
Sign in with a magic link #
- Go to
/login. The card is headed Sign in: “Enter your work email and we'll send you a one-time sign-in link.” - Type your address in the Email field and press Send sign-in link.
- The card changes to Check your email — “If that address belongs to a team member, a sign-in link is on its way.” Use a different email takes you back.
- Open the message, subject Your MyCRM sign-in link, and click Sign in to MyCRM.
- The browser lands on
/login?token=…, shows “Signing you in…”, and drops you on Home — or on whatever page you were trying to reach.
A link is valid for 20 minutes and works once. There is no “resend” button: go back, submit the address again, and use the newest email — a link you have already clicked stays dead. Link requests are capped at 6 per 15 minutes per client IP — if a whole office shares one outbound address, it shares one budget of six.
The “Check your email” message appears whether or not the address belongs to a user. That is deliberate: the endpoint cannot be used to work out who is on your team. It also means a typo, a disabled account and an address that was never added all look identical from the sign-in screen.
Two-factor sign-in #
With TOTP enabled, clicking the link lands you on a card headed Two-factor code instead of on Home. Type the 6 digits — the field takes digits only and Verify code stays disabled until there are exactly six — and press Verify code. Start over abandons the attempt and returns you to the email form.
Between clicking the link and entering the code you are in a half-open session that lasts 10 minutes. Overrun it and the code is rejected with the same words as a wrong code, “That code was not accepted. Try again.”, because both come back as the same refusal. If a code you know is right keeps failing, press Start over and request a fresh link rather than retyping. Codes are accepted one 30-second step either side of now, so a slightly wrong device clock still works and a badly wrong one does not.
Turn on two-factor for yourself #
- Click your name in the top right — the avatar and name button, titled Account menu.
- Choose Profile & security (TOTP). This is the only route in; there is no sidebar link to it.
- On the right, under Two-factor authentication (TOTP), press Set up authenticator app.
- Scan the QR code, or press Copy secret and type the secret in by hand.
- Enter the 6-digit code in the 6-digit code field and press Enable two-factor. A toast confirms “Two-factor authentication enabled” and the grey off pill turns green: enabled.
To turn it off later, the same page has Disable two-factor…; it asks for a current code first. The standard applies as you would expect: SHA1, 6 digits, a 30-second period.
Opening that page while authenticated with an API key rather than as a person shows “Signed in with an API key — no profile to show.” Nothing is wrong: profiles and TOTP belong to human accounts.
Sessions, and being bounced to the login screen #
A completed session lasts 21 days from sign-in and the clock does not slide forward as you use the app. Three weeks after signing in you will be sent back to the login screen mid-task, with no warning beforehand. There is no session list and no “sign out everywhere”; Sign out in the account menu ends the session in that browser only.
When a session ends — by ageing out, or because an admin switched the account off, which kills its sessions instantly — the next request fails, the app clears your credentials and sends you to /login?next=<the page you were on>. Sign in again and you are returned to that exact page.
Neither the link lifetime nor the session lifetime is configurable. They are compiled-in constants, as are the rate limits.
The page you land on
Signing in drops you on Home, the snapshot of the whole tenant. It is read-only: everything on it is a link into a list, a board or a record. Refresh in the top right reloads it, and the subtitle says what moment the snapshot was taken at.
The tiles #
A row of four counts across the top — Contacts (with “N new in 7 days” under it), Customers (customer plus renewal due, with the renewal-due count under it), Open deals and Open tasks. Each one is clickable and lands on the list already filtered to what it counted.
Under them, three work tiles: Unassigned — open deals and open tasks with no owner, broken out underneath — which opens the deal list filtered to unassigned; My open tasks, which opens the Inbox; and My open deals, which opens the Pipeline filtered to you. A work tile showing — means the server could not answer that count, not that the answer is zero.
The panels #
Contacts by lifecycle stage is a single proportional bar with a legend beneath it: all eight stages, each with its count and each a link to the contacts at that stage. Then two contact panels, Expiring in 30 days and At-risk customers (health score below 40), each with a View all link, and two work panels, Open tasks and Open deals. Rows in all four open the record.
At the bottom, Activity, last 7 days is a bar per event type — raw type names, biggest count first — and Recent activity is a feed of the last 30 events across the whole tenant, the same rows the timeline draws.
A fresh install shows the empty states rather than zeros: “No contacts yet — connect a source under Settings → Connectors, or add one from the Contacts list.” That is the cue to go and add a connector.
Contact 360, the timeline, and the numbers that compute themselves
Contacts get a dedicated page at /c/<id>. Every other object, companies included, uses the generic record page at /r/<id>. Open a contact at /r/<id> and the app redirects you to the 360.
Find a contact #
The box in the top bar reads “Search contacts by name or email… (Enter)”. Type an email address and press Enter and you jump straight to that person's 360 page; if there is no match you get the toast “No contact with email <what you typed>” and land on the contact list pre-filtered by that text. Type anything else and you land on the contact list with the search applied.
That top-bar box only ever searches contacts — there is no cross-object search. Inside the contact list, the search box searches name, email and company_name, case-insensitively, as “contains”.
Read the header #
Line one is the name, the lifecycle-stage pill, the health-score chip, and — when set — a red DO NOT CONTACT badge and a marketing: <value> badge. Line two is email, phone, title and company, then the product chips. The facts strip underneath shows Last seen, Next expiry, Total spent, Open tickets, Lead score, Source and Created; dates are relative (“3d ago”, “in 2mo”) and hovering shows the exact timestamp. Then Identities: and one pill per identity, written as kind: value.
On the right sit the Owner picker and the buttons: Log activity, Note, Task, Deal, then Refresh, Recompute, Merge into this contact and Delete.
— on every contact. Health score is the number that is actually calculated.
The eleven tabs #
In order: Timeline, About, Entitlements, Activations, Tickets, Deals, Tasks, Notes, Activities, Consents, Feature requests. Every tab except Timeline and About carries a count badge. Each related tab has an Open in list link, and Deals, Tasks, Notes, Activities and Consents also have a New button. An empty tab says so plainly, e.g. “No tickets for this contact.”
The Timeline tab puts the event list on the left and, on the right, the Consent & deliverability panel, the Lifecycle and Signals field sections, and an Open tasks panel when a task is still open. The About tab is every field section: About, Lifecycle, Signals, Consent, Attribution, Address.
The count badges are capped at 100. The 360 fetches at most 100 related records per object, so a contact with 300 tickets shows “100”. Use Open in list for the real total. (The Open tickets figure in the header is separate; it is derived from up to 500 ticket records.)
The timeline #
Events are newest first, grouped under a day heading. Each row shows the title, the relative time, the raw event type in a chip — purchase.renewal, activation.created, ticket.message.in, email.bounced, form.submitted and so on — the actor if one is known, and “via <source>” when the source is not the UI.
A contact's timeline loads the newest 50 events and Load older events pulls 50 more per click. On a non-contact record the panel loads 30 at a time. A contact's timeline is keyed on the contact, so it shows everything MyCRM knows about the person — purchases, heartbeats, tickets, web visits, email bounces — not only what was done inside the CRM. Events carry a dedupe key, so a connector replaying the same data does not duplicate rows.
Actions taken in the CRM write fixed titles: “Logged <type>: <name>”, “Note: <name>”, “Task created: <name>”, “Task done: <name>”, “Deal opened: <name>”, “Deal <name> moved to <stage>”, “Lifecycle stage → <stage>”. The actor is the user's name, or api-key:<key name>, or system.
Which numbers are derived, and when they refresh #
Several header figures are calculated, not typed. Knowing the rules stops the “that number looks wrong” conversation.
- lifecycle_stage
- A purchase plus at least one still-active entitlement makes a customer, and renewal_due if the earliest upcoming expiry is 60 days or less away. A purchase with nothing active is churned. No purchase but at least one activation is activated. Otherwise lead. An entitlement with no expiry counts as active.
- health_score
- 0–100. +40 seen in the last 7 days, +30 within 30, +15 within 90; +30 if any entitlement is active; −10 if the next expiry is 30 days or less away; −20 if there was a purchase but nothing is active now; −10 per open ticket, capped at −30; +2 per event in the last 30 days, capped at +20; +10 for an activation with no purchase. The chip is green at 70 and above, amber at 40, red below.
- products
- The sorted, de-duplicated set of product codes across the contact's entitlements and activations.
- next_expiry_at
- The earliest entitlement expiry that has not yet passed. Already-expired entitlements do not count.
- total_spent_cents
- The plain sum of entitlement amounts. Subscriptions are never added in — a subscription's recurring amount is informational only.
- open_tickets
- Tickets whose status is set and is not closed, resolved or done.
- first/last seen
- The oldest and newest of: entitlement purchase dates, activation last-seen and verified dates, ticket opened and last-message dates, and the most recent event.
Entitlements flagged as Stripe test mode are skipped entirely: they contribute nothing to products, money, expiry, last purchase or last seen.
Lifecycle stage is editable by hand, but recompute may overwrite it. It will overwrite an empty stage, lead, trial, activated, customer, renewal_due and churned. It leaves visitor and won_back alone — those two you can only set by hand — except that a real purchase moves visitor on, while won_back sticks even then. A contact on trial is never demoted back to bare lead.
Edit a field #
Click any value. An editable field says “Click to edit”, an empty one “Click to set”. Type, then press Enter or click away to save; Escape cancels. Long text, JSON, multi-picklist and lookup fields get explicit Save and Cancel buttons instead. Read-only fields carry a small ro marker and cannot be clicked; formula fields carry ƒ. A refusal raises the toast “Could not save <Field label>: <reason>” and shows the reason in red under the field.
Read-only on a contact means: products, first seen, last seen, last purchase, next expiry, total spent, lead score, health score, open tickets, email suppressed, suppressed because, suppressed at.
Merge two duplicate contacts #
- Open the contact you want to keep. The record you have open always wins.
- Click Merge into this contact.
- Under “Contact to merge in (will be removed)”, search by name, email or company and pick the duplicate. A preview line appears.
- Click Merge and delete the other contact. A toast confirms “Contacts merged”.
What happens: the winner's non-empty values are kept and its empty fields are filled from the loser; the winner's name and email are never overwritten; tags and products are unioned; every identity, every event and every lookup pointing at the loser is repointed at the winner; the loser's email becomes an extra identity on the winner; the loser is soft-deleted; a contact.merged event is written; and the aggregates are recomputed.
Identities #
The identity pills are display-only. Identities are created by the system, never by hand: an email identity whenever a contact is created or updated with an address, machine ids by the commerce-backend connector, Stripe customer ids by the Stripe connector. Email identities are trimmed and lowercased; other kinds are only trimmed. In practice you will see three kinds: email, machine_id and stripe_customer.
Companies #
Companies live at /o/company and open on the generic record page. New company takes Name (required), Domain, Website, Industry, Employees, Phone, Tags and Notes. The right-hand Related area lists one collapsible group per object that points at companies — Contacts, Deals, Tasks, Notes, Activities — each showing up to 50 rows with a View all link when there are more, and a New button that pre-fills the company.
To attach a contact to a company, edit the contact's Company field in the About section and pick the company.
company_name) and never touches the company lookup — so a contact can show a company name and still have no company record behind it. Companies also have no field sections, so every field appears under one heading called Details.
Two more things worth knowing everywhere: contact email is unique, so two live contacts cannot share an address; and money is always formatted as USD, whatever a field's currency setting says. Deleting a contact is a soft delete — “the contact is soft-deleted and disappears from lists”.
Lists, filters, saved views and ownership
Every object gets the same list page at /o/<object>. Everything the list is showing lives in the page URL, so a filtered list is a link you can paste to a colleague.
Read a list #
The heading is the object's plural name — or, while a saved view is selected, that view's name — with the count beside it: “1,234 total” normally, or “1,234 matching” as soon as a search, a filter or a view is active. Columns come from the object's configured list fields — for contacts that is Name, Email, Lifecycle stage, Products, Health score, Last seen. An Owner column is appended automatically unless the active view already asked for it, and Updated is always the last column. Rows open the record.
Pages are 50 rows. Previous and Next move by 50 and the counter reads “51–100 of 1,234”. Click a column heading to sort, click it again to flip direction. With no sort chosen, a list is ordered most-recently-updated first, and rows with an empty value in the sorted column always sort last. Computed columns cannot be sorted: the heading does not react and its tooltip reads “<Label> (computed — not sortable)”.
There is no column picker. The only way to change the columns is to open a saved view that names different ones — and a view's columns are whatever the list happened to be showing when it was saved.
Search matches the record name plus the object's configured search fields, case-insensitively, as “contains”. The extras are: Contact name, email, company_name · Company name, domain · Deal name · Ticket name, ticket_ref · Suppression name, email · Form name, slug · Feedback name, comment. Everything else searches the name only.
Build a filter #
Click Filters (it shows a count once filters exist, e.g. “Filters (2)”), then Add filter, then pick a field, an operator and a value. Add as many rows as you need and press Apply filters. The helper line reads: “All filters must match. Filters are kept in the page URL.” Clear filters empties the builder; Clear all also clears the search box and the chips.
There is no OR and no bracketing. Every row must match.
Which operators you get depends on the field type:
- number, currency, date, datetime
- is, >, ≥, <, ≤, is empty, is set
- picklist
- is, is not, is any of, is empty, is set — “is any of” gives tick-boxes for the values
- yes/no
- is, with a —/yes/no dropdown
- tags, multi-picklist
- contains, is empty, is set — one tag per box
- lookup
- is, is not, is empty, is set — the value box is a type-ahead over the target object
- text, long text, email, phone, url
- is, is not, contains, starts with, is empty, is set
Two behaviours catch people out: is not and is none of also match records where the field is empty, and is empty treats a blank string as empty. “contains” and “starts with” are case-insensitive.
The Field dropdown offers computed fields — Contact Days since seen and Days to expiry, Deal Days to next step — but the server refuses them and answers with a red banner. Filter on the stored field instead: Last seen rather than Days since seen, Next expiry rather than Days to expiry.
The three owner chips — Anyone (default), Mine, Unassigned — sit at the top left; contacts get a second strip of lifecycle chips on the right: All, then visitor, lead, trial, activated, customer, renewal due, churned, won back. Click an active chip again to turn it off. All of these are greyed out while a saved view is selected, because the view supplies the filtering.
Filters also understand tokens: @me and @unassigned on the owner field, and now, now-7d, now+60d (also h and m) on dates. @unassigned is an emptiness test rather than an equality test. You cannot type a relative token into the filter builder — date fields render a browser date picker — so tokens reach a list only through a shipped or saved view, a hand-edited filter= URL, or the API.
Saved views #
The Saved view dropdown sits above the toolbar. The first entry is “All <objects>”, then three groups: Shipped, Shared and Private (only you). An object's views also appear indented under it in the sidebar while you are looking at that object. When a view is active you see a badge — shipped, shared or private, plus edited if someone has changed a shipped one — and a grey strip listing the view's filters as chips, ending “· search and sort still apply on top.”
Eighteen views ship with the product:
- Contacts: New leads (7 days) · Trials that never purchased · Renewals due (60 days) · At risk — customers whose health score has fallen away · Marketable — consented, contactable and deliverable · Site leads (7 days) · Mail is bouncing.
- Deals: My open pipeline · Team pipeline · Renewals closing (90 days) · Win-back deals.
- Tasks: My open tasks · Overdue tasks · Unassigned tasks.
- Subscriptions: Cancellation scheduled · Payment failing. Suppressions: Active suppressions. Feedback: Detractors to call.
To make your own: set the list up the way you want it, click Save as view, give it a Name (up to 120 characters) and optionally a Description, and tick Share with the whole team if it should be visible to everyone. The dialog summarises what is being stored, e.g. “Saving 2 filters, the search term, sorted by close_date.” If the Mine chip was on it is stored as the token @me, not as your user id — so a shared view called “My open deals” means “mine” to whoever opens it.
Names must be unique per object within their scope: only one shared view can be called “Renewals”, and you may only have one private view of that name. A private view and a shared view can both be called “Renewals” — they are different scopes — and so can two colleagues' private views.
A shipped view can be renamed and updated like any other, but doing so locks your copy: MyCRM will never refresh it again when a newer definition ships. The toast says so — “<name> updated — it is now locked against upgrades” — and the view gains the edited badge. An admin can select it and click Reset (“Any local changes to its filters and columns are lost.”) to put it back. Reset appears for admins only, and only on shipped views; shipped views can never be deleted.
A private view belonging to someone else is reported as missing rather than forbidden, so the API cannot be used to probe what colleagues have saved. A view is either private to you or shared with everyone — there are no folders, favourites or per-view permissions.
Ownership and assignment #
Ownership is how work is divided up, and it is a label rather than a permission — everyone who can read can read everything. Open a record, find the Owner field and pick a person. The first option is Unassigned, which clears the owner. Toasts confirm: “Assigned to <name>”, “Unassigned”, or “Owner unchanged” if you re-picked the person who already owned it. Every real change writes one record.owner_changed event; re-picking the current owner writes nothing.
Only enabled, non-read-only users can be given records. Others still appear in the dropdown, greyed out and labelled “<name> — disabled” or “<name> — read-only”, so a former colleague's records still show a name rather than a bare id. Disabling somebody reassigns nothing.
To move many at once, tick the row checkboxes — the header checkbox is labelled “Select every row on this page” and covers that page only — then use Assign owner… in the bar that appears. The cap is 200 records per action: select more and the bar warns that only the first 200 will be sent. Partial success is shown rather than hidden: “<N> records re-assigned, <M> refused” plus a Record/Reason table. Changing page, view, search or a chip clears the selection.
Assigning an owner is the only bulk action on a list. There is no bulk edit, no bulk delete and no bulk tag, and no select-all-across-pages.
Export to CSV #
Set the list up — a saved view, or search, filters and chips — and click Export CSV. The file downloads immediately, with a toast “Downloaded <filename>”. The name is the view name, or the object name, slugged and dated: renewals-due-60-days-20260824.csv.
The export honours the active view or filters, the search term and the columns you are looking at. It does not honour your sort — rows come out in internal id order — and it ignores paging, exporting the whole matching set up to 100,000 rows. Computed columns are dropped. Any unique field is pulled to the front of the file as its key, so the file can be re-imported — but only four fields are unique anywhere in MyCRM (contact email, product code, form slug, suppression email), so every other object exports a keyless file the importer will refuse until you tell it how to match rows. Every file carries a UTF-8 byte-order mark so Excel opens accented names correctly.
Export is a one-off download. There are no scheduled or emailed exports and no format other than CSV. The button only appears when the CSV lane is enabled on your server.
The pipeline board and the task inbox
Two pages sit outside the generic list: a board of deals by stage, and an inbox of tasks. Both have their own owner filters, and both are hidden from read-only users as far as editing goes.
Pipeline #
The Pipeline page renders deals as a board grouped by stage. Drag a card to move a deal between stages; each card also carries a stage box, which does the same thing from the keyboard. A read-only user sees the board but cannot drag and has no stage box.
A deal's stage is not a field you type over on the record page — it moves through the board, and the record endpoint guards it. Every move writes “Deal <name> moved to <stage>” to the timeline.
The two guards on a move
Two moves are refused until you supply something the deal is missing:
- Into a won stage, the deal needs an amount above zero. The server answers
a won deal needs an amount above zero. - Into a lost stage, the deal needs a lost reason. The server answers
a lost deal needs a reason.
Neither is a dead end. The board catches the refusal and opens a dialog headed Move “<deal>” to <stage> that says “The server refused this move until one more thing is recorded. Nothing has changed yet.” and asks only for what is missing — an Amount box, or a Lost reason picker with an optional Notes field. Fill it in, press Move to <stage>, and the move is retried with that value. Cancel leaves the card where it was. The same two rules apply from the API, where they come back as a 422 naming amount_cents or lost_reason.
The four controls above the board
Get these wrong and the board looks like it is missing deals:
- Closed within — 30 days, 90 days (the default), a year, or no limit. This is the usual answer to “where did our old won deals go?”: won and lost deals that closed more than 90 days ago are simply outside the window. Open deals are never hidden by it.
- View — applies a saved deal view to the board, so the board can be narrowed by the same filters a list uses. The default is “no saved view”.
- The owner controls: Everyone / Mine / Unassigned chips, plus an Owner dropdown for one named person.
- A Search deals box, which narrows the whole board at once.
Two more things about what a column shows. Six stages ship — new, qualified, proposal, negotiation, won, lost — and a deal whose stage is empty, or is a value no longer in the picklist, lands in an extra (none) column. You cannot drop a card into (none); move it to a real stage to empty that column. And each column shows a page of cards — 50 by default — not all of them: the count and the money total in the column header are for everything that matched, and a column with more says “N more not shown” with a link that opens that stage as a list. A column too large to total exactly reads “≥ $X” rather than under-reporting.
Note that a saved view declared as a board — the shipped Team pipeline is one — still renders as an ordinary table when you open it from the deals list. The board is the Pipeline page.
Task inbox #
The Inbox is your open work in five buckets, one panel each, each with a count and the product's own one-line description of what falls into it:
- Overdue
- “Due before now — a 9am call at 2pm is late”
- Today
- “Due between now and midnight UTC”
- This week
- “The following six days”
- Later
- “Beyond this week, plus tasks with no due date”
- Unassigned
- “Open tasks nobody owns — the pile this inbox exists to drain”
The four dated buckets each hold a task exactly once. Unassigned deliberately overlaps them — an unowned overdue task is in both — which is why it is not added into the count in the page heading. Buckets are cut against UTC, and the subtitle names the moment they were computed. A bucket with more tasks than the page shows says so at the bottom.
Which inbox you are reading is set by the Mine / Everyone chips and the Or a teammate dropdown.
Quick-add speaks exactly two tokens
The box at the top of the page carries a worked example as its placeholder: Call Ada about the renewal due:tomorrow !high. Everything you type is the task name except two kinds of token:
- A due date:
due:today,due:tomorrow,due:+3d,due:2026-09-01. - A priority:
!low,!normal,!high,!urgent.
due:.
The new task goes to whoever the chips are pointing at: yourself on Mine, the named person from the dropdown, or nobody at all on Everyone, which leaves it in the unassigned pile on purpose.
Done, Snooze, Reopen
Every row carries Done and Snooze; a task already done carries Reopen, which puts it back on the queue. Completing a task writes “Task done: <name>” to the timeline; like deal stage, a task's status and completion time are guarded on the record endpoint rather than edited as plain fields. Row checkboxes plus Complete selected tick off a page of them at once — the one bulk action in the product that is not owner assignment.
Snooze offers tomorrow, +3d, +7d, +14d or a date you pick, and it is capped at five. A sixth is refused outright: past the cap the task has to be re-dated, cancelled or done. A snoozed task carries a snoozed N× badge, and the first snooze records what the task was originally due, so the badge answers “how long has this been pushed” rather than only “when was it last pushed”. A completed or cancelled task cannot be snoozed at all — reopen it first.
Three shipped task views cover the same ground from the list side: My open tasks, Overdue tasks and Unassigned tasks. On a contact's Timeline tab, an Open tasks panel appears whenever that person has a task still open.
Quick-create buttons on a contact — Log activity, Note, Task, Deal — are the fastest way to put work on the board without leaving the record.
Renewals that run themselves: the four playbooks
Playbooks are scheduled sweeps that open renewal deals and raise follow-up tasks so nobody has to remember. They live at Settings → Playbooks, admins only, and every one of them ships switched off and in dry-run — so a fresh install writes nothing until an admin deliberately flips two separate switches.
What the four plays do #
The page lists four cards in a fixed order — renewal_open, deal_close_won, winback, dunning_followup — headed by their machine names. There are no prettier display names.
- renewal_open
- Opens one renewal deal and one follow-up task for every entitlement expiring inside the lead time (default 60 days), skipping cancelled, expired and test entitlements and anything that already has a deal anchored to it at any stage — including one closed lost. The deal lands at stage
new, probability 10, type renewal, named “Renewal — <entitlement name>”, next step “Contact about the renewal”. The task is due in 3 days by default. - deal_close_won
- A backstop. It sweeps entitlements that arrived since the last complete live run and closes the renewal deals they fulfil. When a purchase comes through the commerce-backend connector the matching deal is already closed won within seconds, so this only catches what that path missed. Lookback default 7 days.
- winback
- Raises one task “Win back: <entitlement name>” for each entitlement that lapsed
grace_daysago (default 30, looking back 60) with no renewal in flight. An open or won renewal deal blocks it; a deal closed lost does not — that is exactly when a win-back is wanted. Due in 2 days. It never sends an email. - dunning_followup
- Raises one high-priority task “Payment failing: <subscription name>” per subscription per payment-failure episode, triggered by the Payment failing flag the Stripe connector sets. Due the same day. Because the key includes the failure timestamp, a card that fails again months later gets a fresh task while one failing repeatedly today does not.
Two rules cut across all four. Playbook writes are create-only: a play never rewrites a record it made earlier, so a deal you closed lost or a task you ticked off is never resurrected. And a contact flagged do not contact suppresses the tasks — outreach — but not the renewal deal; the money still gets managed. Those show up in the run stats as “blocked by consent”.
Preview before anything can write #
Click Preview on a card. It works even while the play is switched off, runs the real candidate scan, and writes nothing. The window is headed Preview · <playbook name> and the green box reads “Nothing was written. This is the exact list of actions a live run would carry out right now…”, followed by count chips and a table of Action, Subject, Detail.
The Subject column shows the record id of the entitlement or subscription, not its name. The readable sentence — deal name, amount, close date, owner, due date — is in Detail. With nothing to do you get “Nothing to do — no candidate matched.”
A preview writes a row into that playbook's run history, badged dry run, so History fills with previews as well as real runs.
Going live: two switches, in this order #
- Preview. Read the intent list. Satisfy yourself the numbers are sane.
- Flip Enabled — “(the scheduler picks it up)”. The play now runs on schedule but still writes nothing, because it is in dry run. Run now becomes clickable.
- Flip Dry run — “(reports intents, writes nothing)” — to off. The toast reads “<name> will now write records” and the card turns green: “This play is live: the next scheduled tick will create and update records.”
The badges track those two switches: enabled/off and dry run/writing. The page heading counts only plays that are both, e.g. “Playbooks 0 of 4 writing”. Turning Enabled off does not merely pause the scheduler — a disabled play does nothing at all and does not even record a run row, so its History simply stops growing.
Run now is greyed out while a play is switched off (“A disabled play does nothing at all — enable it first”); use Preview instead. A manual run honours the stored dry-run setting and never promotes itself to live. If the play is live you get a confirmation first: “This play is enabled and out of dry-run, so this run writes records — up to 200 actions.” A run started from the browser survives the tab being closed.
Knobs #
Edit knobs opens Knobs · <playbook name>: the two switches, Max actions per run, and only the day-knobs that play actually uses — Lead time (days), Grace (days), Lookback (days), Task due in (days). The grey chips on each card show the values in force, always including “max actions per run: 200”.
Values are clamped when you save and you are not told: max actions to 1–1000, lead time / grace / lookback to 1–365, task-due to 0–90. Entering 0 in any day field except task-due does not mean zero days — it resets that knob to the shipped default.
dunning_followup shows no day-knob chip at all. Its task-due default is 0, and a zero knob is omitted from the data the page reads, so neither the chip nor the field appears. That is correct: its tasks are due the same day.
Run history, and the two partial-sweep warnings #
History expands the 20 most recent runs, newest first, previews included. Each row shows a status pill (ok, error, running, disabled), the relative start time, the duration, and the non-zero stat chips: candidates, planned, created, updated, suppressed, skipped, errors, blocked by consent. “Suppressed” means the play tried to create a record whose slot is held by a soft-deleted twin somebody deleted on purpose; the run is still green.
If a run left work on the table, a red box appears beginning “This run did not sweep everything.” There are two versions:
- It hit its max_actions cap. The play planned more work than the cap allowed. Raise Max actions per run, or let the next tick pick up the rest.
- The candidate scan stopped early. The scan hit its internal bound of 10,000 rows per run. The note says which rows were read and where the next run resumes; the sweep rotates through the whole set over several runs rather than re-reading the same head.
Both end “Treat the numbers below as a partial pass, not a clean sweep.” They also appear inside the Preview window, so a cap problem is visible before you go live. Worth fixing rather than ignoring: deal_close_won's watermark refuses to advance past an incomplete run, so its window silently widens until a run finishes cleanly.
The sweep runs every 15 minutes. That cadence is compiled in — there is no schedule editor, no “next run at” countdown, and no way to run all four plays at once from the UI. Playbook run history is never pruned.
Hosted forms
A form is an ordinary record. You create it at Settings → Forms, copy a paste-ready snippet, and your site posts to /f/<slug>. The server creates the contact, the consent record and the follow-up task.
Create one #
New form asks for: Name, Slug, Kind, Enabled, Allowed origins, Lead source recorded, Consent checkbox text, Consent required to submit, Create a follow-up task, Success message, Redirect after submit. Kind is pre-set to lead; Enabled and Create a follow-up task are pre-ticked. The slug takes lowercase letters, digits, - and _, and becomes the public URL.
Everything else — field mapping, Required fields, Tags applied, Notify the owner, Notify this address, Submissions per hour per IP, Require Turnstile, Product — is edited afterwards by clicking the form's name, which opens its record page.
Get it onto your site #
Embed opens automatically after you create a form, and is on every row. Three tiles show Submissions, Slug (locked or still changeable) and Allowed origins. Under “Paste this into the page”, press Copy snippet and paste the block into your HTML — it is a plain <form> plus a <script src=…> include. The URLs block lists the submit URL, the script include, the hosted page and the thanks page.
The snippet works with JavaScript switched off: the browser posts the plain form and lands on the thanks page. The script only removes that page navigation. If you would rather not embed anything, link people to the hosted page URL instead — it is the same address as the submit URL, rendering the form on GET and accepting it on POST.
“Fields the form posts” lists exactly what the snippet renders. For a lead form: Email (required), Name, Company, “How can we help?”. For a feedback form: the 0–10 recommendation score, “What could we do better?”, and an optional email.
Allowed origins — the setting that loses leads #
Add one entry per site that hosts the form, matching scheme and host exactly: https://www.example.com, no path, no trailing slash. Empty means same-origin only. * allows everything.
form <slug>: dropped submission (<reason>).
Before you trust a form, use Embed → Check configuration. “No problems found.” means it is clean; otherwise each line is labelled error or warning against the field it concerns. Fix every error — those are the ones that lose data. Common warnings are worth reading too: “the form is disabled: submissions are accepted and silently discarded”, “Turnstile is requested but no secret is configured on this server, so it is not enforced”, and “notifications are configured but no mailer is wired on this server; submissions are still captured”.
What the consent checkbox records #
Put your wording in Consent checkbox text — it is recorded verbatim as evidence — and tick Consent required to submit if the form must not proceed without it. On a ticked submission MyCRM writes a consent record named “Marketing consent — <email>”, purpose marketing, status granted, source form:<slug>, with an evidence block holding your wording, the form name and slug, the page URL, the time, and a keyed hash of the client (the address itself is not stored). Only then is the contact's own Marketing consent set to granted.
Values that count as ticked: on, yes, y, true, 1, granted, checked. Anything else leaves consent unknown, which the send gate treats as a no.
A ticked box cannot un-revoke. If that contact previously revoked marketing consent they stay revoked; the audit record is still written, named “Marketing consent NOT APPLIED (previously revoked)” with a line explaining that a public form may not grant it again. A public form can also never write marketing consent directly, consent date, do-not-contact, lifecycle stage, lead score, owner, tags, lead source or the suppression fields, whatever the field mapping says.
What the visitor sees #
On success with JavaScript, the form is replaced by your Success message; the defaults are “Thanks — we have your details and will be in touch.” for a lead form and “Thanks — your feedback is with the team.” for feedback. Without JavaScript it is a redirect to your Redirect after submit, or to the thanks page.
Only two kinds of thing are ever shown as a problem. Something the visitor must fix appears by the form: “An email address is required.”, “That email address does not look right.”, “<Field> is required.”, “Please tick the box to continue.”, “Please give a score between 0 and 10.” And a genuine server fault says “we could not record that just now — please try again”. A refused submission consumes nothing: fixing it and pressing Send again counts as a first attempt.
Telling somebody a lead arrived #
Two settings on the form record can trigger it:
- Notify this address — one fixed address, typed on the form. Useful for a shared sales inbox.
- Notify the owner — a tick-box. The server resolves the form record's own Owner to that person's email address, so re-assigning the form re-points the notification with it. A form with the box ticked and no owner — or an owner whose account has been switched off — notifies nobody.
One submission produces at most one notification, so if you set both, the fixed address wins and the owner is not written to as well. Both routes really do send, and both go through the same send gate as every other message MyCRM produces. If the destination address is suppressed the notification is skipped and the reason goes to the server log — a notification is never the thing that mails a dead address. Nothing about that touches the submission itself: the contact, the consent record and the follow-up task are written either way.
One case where nothing is delivered: a server built without a mail path at all. Embed → Check configuration says so as a warning — “notifications are configured but no mailer is wired on this server; submissions are still captured” — and a bad address in Notify this address is reported there as an error rather than being discovered when a lead goes missing.
The limits that are built in #
- Body capped at 8 KiB. Longer is “That submission is too long. Please shorten it and send it again.” A body that cannot be read at all — a malformed or truncated post — is “We could not read that submission. Please try again.”
- 20 submissions per hour per IP per form by default, overridable per form; and 5 per hour for one email address across every IP it uses. Both are stored as keyed hashes, so no address or IP is kept in the rate table.
- A submission faster than 2 seconds after the form was rendered is dropped. A fill token expires 24 hours after render.
- An identical payload from the same client inside 10 minutes is written once — that is the double-clicked Send. Two different people submitting the same answers are still two submissions.
- The honeypot input is named
website_urland is rendered off-screen with the label “Leave this field empty”. Any value in it drops the submission. - First-touch attribution (utm parameters, landing page, referrer, first-touch time) is recorded once and only ever fills a blank field — it never overwrites something a human typed.
A lead form with a follow-up task opens “Follow up: <name or email>” due in 24 hours.
Feedback forms #
Set Kind to feedback. The posted fields are score, comment and an optional email, with sensible aliases understood without configuration. Each submission creates a feedback record with channel form. Scores are range-checked — NPS 0–10, CSAT 1–5 — and out of range is a visible error, not a silent drop. NPS bands are 0–6 detractor, 7–8 passive, 9–10 promoter; a detractor or unhappy score opens a high-priority “Call back” task due in 24 hours, anything else a “Review feedback” task due in 72.
A feedback form never creates a contact. If the address matches someone you already have, the response is linked to them; otherwise it is stored anonymously. That is why consent wording on a feedback form raises a warning — there is nothing for it to attach to.
CSV import, in four steps
Import CSV sits in the sidebar under Data. The step strip reads 1 · File, 2 · Columns, 3 · Dry run, 4 · Commit. The commit has to echo two hashes the dry run produced, and an admin can undo the whole thing afterwards.
1. The file #
Choose Import into (Contacts by default) and pick the file: up to 10 MiB and 100,000 data rows, UTF-8 or UTF-16 with a byte-order mark, delimiter sniffed from the header line (comma, semicolon, tab or pipe). Set When a row matches an existing record — update it (default), skip the row, or treat it as an error — and optionally an owner for records the import creates (never applied to records it updates) and a batch tag such as tradeshow-2026. One left off gets a generated tag like import-20260824-a1b2c3.
Three tick-boxes: create records for rows that match nothing (on), an empty cell clears the field (off — empty cells are left alone), write the batch tag onto each record's tags.
2. Columns #
For each CSV column choose a destination, or leave “— ignore this column —”. The Why column explains itself: auto (matched by header name), chosen, or the reason it was skipped — “no matching field”, “read-only: maintained by MyCRM, not by the file”, “computed: this field is derived, not stored”, “another column already maps to this field”, “the column has no header”. Press Save mapping if you changed anything.
Header matching ignores case, spaces and underscores, so “First Name”, “first_name” and “FIRSTNAME” are the same column — an export, edit and re-import round trip usually needs no mapping at all.
Read the footer line: “Matching on <field> to decide create vs update.” For contacts with an email column, matching goes through the identity graph, so a merged or secondary address still finds the right person. Otherwise it uses the first mapped unique field — and only four fields are unique: contact email, product code, form slug and suppression email. Two rows sharing a key, or resolving to the same existing record, are both errors rather than last-wins.
Affirming a consent column #
If your file maps a consent column — marketing consent, consent date, do-not-contact, a consent record's status or purpose, or a suppression status — a panel appears headed Consent columns need an affirmation. Tick the confirmation, describe where the consent came from (“double opt-in export from…”), and press Record the affirmation. Only an admin signed in with a session can do this; an API key cannot.
Every row that then moves one of those fields gets its own consent audit record naming the file, the row number, the job, the change, who affirmed it and what they wrote.
Affirm before the dry run. A dry run refused for a missing affirmation parks the job in an error state, and an errored job that failed the consent check cannot be re-mapped — you start another import.
3. Dry run, then 4. Commit #
Run the dry run writes nothing: “It validates every row and produces the report the commit has to echo.” You get four tiles — Create, Update, Skip, Errors — an expandable per-row table, and, if there are errors, a list of rows that failed with a Download errors.csv button.
Then Commit N changes. Beside the button you can see the two hashes being echoed. The dry run is not an estimate; it is the same code the commit runs. Before writing, the commit re-runs the whole plan against the database as it is right then, and if anything moved — a colleague, a connector or a form created a contact your file was going to create — it refuses: “The preview went stale… Run the dry run again and commit the fresh result.” Re-mapping a column invalidates the previous dry run for the same reason.
On success the panel is headed Committed. Start another import clears the wizard.
Fixing rejected rows #
Download errors.csv is available after a dry run as well as after a commit. The file is UTF-8 with a BOM, named <your-filename>-errors-YYYYMMDD.csv, and its columns are row_num, every column of your original header, then error. Row numbers are the real line numbers from your file, so they match the spreadsheet. Only the first 200 error rows appear on screen; errors.csv has all of them. Fix and upload the corrected file as a new import.
Undoing an import #
Undo this import is on the Committed panel, admins only. It deletes what the import created and reverts what it updated, field by field — but only where the imported value is still the current value. A field somebody has edited since is left alone and reported. The banner reads “Rolled back: N deleted, N reverted, N skipped…”.
Consent audit records are never removed: an audit trail of what was asserted, by whom and on what evidence survives a rollback of the data it justified.
A committed import cannot be deleted or re-run — its journal is the only exact record of what changed. If an undo could not put everything back, the job stays partial so the journal survives and you can undo again. Undo journals are kept 90 days; abandoned jobs and their rows are cleaned up after 7.
Export is covered under Finding things: the same lane, in the other direction, and the two are designed to round-trip.
Bringing the outside systems in
Connectors are the scheduled pulls that mirror outside data into MyCRM. They live at Settings → Connectors, admins only. Unlike playbooks, a connector starts enabled the moment you add it and will run within about 30 seconds.
Add one #
Add connector asks for a Kind — the three are “iOwn commerce backend (api.iownsoftware.com)”, “SendGrid (email deliverability)” and “Stripe (read-only retention state)” — an optional Name, an optional sync interval in minutes, and then the kind's configuration fields. Required fields carry a red asterisk; secrets are password inputs. The Streams chips list what the kind pulls; they are informational and you cannot pick a subset.
There is no enabled checkbox in the Add form and no “Test connection” button. If you are not ready for it to sync, add it and immediately turn the Enabled toggle off in the table row. To check credentials, save and press Run now.
iOwn commerce backend #
Needs a Base URL (pre-filled) and a CRM partner key — a key configured on the commerce backend, not something MyCRM generates. Page size defaults to 500; it syncs every 5 minutes.
This is the connector that owns money. It mirrors ten streams — products, customers, purchases, activations, trials, captures, tickets, web events, blocked and feature requests — into Contacts, Products, Entitlements, Activations, Tickets, Feature requests and the timeline. Entitlements are what the renewal plays work from, so nothing in the renewal engine happens until this connector is running.
Stripe #
Needs a Restricted API key starting rk_live_ or rk_test_, and a dated Stripe-Version such as 2025-03-31.basil — there is no default and blank fails. Create the key in Stripe under Developers → API keys → Restricted keys, with read scopes for Customers, Charges, Refunds, Disputes, Subscriptions, Invoices, Products, Prices and Checkout Sessions, and nothing else. Include test-mode objects is off by default. Page size defaults to 100, which is also Stripe's maximum; it syncs every 15 minutes.
Two things people get wrong. A normal secret key (sk_…) is refused outright, before any network call — the connector is read-only by construction and will not accept full-access credentials. And it creates zero entitlements and never touches revenue; purchases and money come from the commerce backend. What it writes is the Subscription object — status, current period end, cancel-at-period-end, trial end, Payment failing, last payment failure — plus refunds and disputes on the timeline. Payment failing is exactly what the dunning_followup play triggers on.
Configure an rk_test_ key while test-mode objects are off and the run succeeds while mirroring nothing.
SendGrid #
None of its fields are required, because a webhook-only setup is supported. The Event webhook verification key is the base64 public key from SendGrid's Settings → Mail Settings → Signed Event Webhook; without it the webhook refuses every batch. Comma-separate two keys to rotate without an outage. The API key is optional and only needed for the suppression pull — read access to Suppressions is enough. Pull existing suppression lists is on by default; it polls bounces, blocks, spam reports and unsubscribes, which is both how a fresh install inherits SendGrid's history and how a missed webhook batch gets repaired. It polls every 15 minutes.
Then point SendGrid at MyCRM. The webhook URL is your origin plus /webhooks/sendgrid/<connector id>.
mycrm connector list on the server. This is the single most likely place to get stuck. Second trap: with the pull switched off or no API key set, every scheduled run finishes ok with no stats, so the connector looks perfectly healthy while only the webhook is doing anything.
Run, edit, reset, delete #
Click a connector's name, or Details on its row. The window shows four tiles — Enabled, Interval, Last run, Last status — then Configuration, Run history and Cursors. The Enabled tile is read-only; the actual switch is the toggle in the table row, and that is how you pause a connector without deleting it.
- Run now syncs immediately. It is synchronous and can take up to 4 minutes; long backfills continue on the next scheduled tick because cursors are saved per page.
- Edit in the Configuration panel changes the name, the interval or any config field. Masked secrets (
••••••••) are kept unless you type a new value; clearing a secret field deletes it. - Reset cursors clears every stream's position, so the next run backfills from the beginning. It is all-or-nothing in the UI; resetting a single stream is API-only.
- Delete connector stops the syncs and keeps the synced records.
Is it healthy? #
The table's Status column shows a green ok or a red error pill with the message truncated underneath; Last run shows a relative time or a grey never. Details gives the whole picture: the Last status tile, then Run history — the 20 most recent runs, each with start time, status, duration and stat chips — then Cursors, which shows how far each stream has got as raw JSON. Runs older than 30 days are deleted automatically.
For SendGrid, the Cursors panel is also the webhook's health report. A stream named webhook_last carries the time and counts of the last accepted batch. A stream named webhook_error appears when batches are being refused for a reason you must fix — a missing signing key, or a host clock more than five minutes out.
webhook_* cursors are the only place that failure is visible. There is also no email, Slack or in-app alert when any connector run fails — failures are visible on this page only.
Consent and deliverability are two different questions
Consent asks whether the person agreed to be marketed to. Suppression asks whether the address is deliverable at all. MyCRM keeps them apart everywhere, and the difference is the source of most of the confusion in this area.
Ask whether an address can be emailed #
Settings → Email health opens with Can we email this address? — “the same gate every send path asks”. Type the address, choose a purpose (marketing by default, or product updates, or transactional) and press Check. The answer is a green Sendable or a red Not sendable, a plain-English reason, and a code in brackets: ok, suppressed, no_consent, revoked, do_not_contact.
The order matters. Suppression is checked first and applies to every purpose, transactional included — a hard-bounced address is not mailed at all. Do-not-contact is next. Consent is checked after that, for marketing and product updates; only transactional mail is allowed without recorded consent. Unknown consent is a no, never a maybe.
The panel on a contact #
On the Timeline tab of a contact, Consent & deliverability shows three badges — the marketing consent value, a do-not-contact flag if set, and either deliverable or suppressed: <reason> — then a live verdict from the same gate with its reason and code. With no email on the record it says so: nothing can be sent to it.
The contact's own Marketing consent field is a picklist in the About tab and can be edited by hand, like any other picklist. The consent records are the audit truth — who agreed, when, on what evidence — and the panel links to them. Treat a hand-edit as an assertion you should be able to defend, because the consent records are what you will be asked for.
Read and release a suppression #
Settings → Email health lists the addresses deliverability has taken out of service. Filter with the Active / Released / All chips; Open as a list goes to the full Suppressions object. The columns are Address, Reason, Status, Recorded by, Suppressed, Contact. Reason is one of bounce, block, spam report, unsubscribe, invalid or manual; Recorded by is sendgrid, manual or import. The page loads up to 100 rows and does not page.
To lift one, an admin clicks Release on the row and confirms: “Make <address> deliverable again? This restores sending only — it does not grant marketing consent, and if the address bounces again it will be suppressed once more.” The row stays, with status released and the time.
Email health is not the only route. When a contact's own address is suppressed, the Consent & deliverability panel on their Timeline tab carries a Release the suppression button for admins, with the same confirmation and the same reminder that consent is untouched. That is usually the faster path, because you are already looking at the person.
Suppression rows are never deleted. Releasing keeps the history so a later bounce for the same address can still be recorded, and the release time is the high-water mark that stops the scheduled pull re-suppressing the address from an entry SendGrid still holds. Only a bounce newer than the release does that.
What SendGrid events do: a bounce becomes a bounce (or a block when tagged that way), a blocked event a block, a dropped event an invalid or a block depending on the reason, a spam report a spam report, an unsubscribe an unsubscribe. Deliveries, opens, clicks, processed and deferred change nothing.
Objects and fields
Settings → Objects is headed “Objects & fields”: “The schema behind every list, record and form. Standard definitions ship with MyCRM; custom fields and objects are yours.” Admins only.
Add a custom field #
Pick an object on the left, click Add field, then fill in: API name (lowercase letters, digits and underscores, starting with a letter), Label, Type, Section, plus whatever the type needs — “Values (comma separated)” for a picklist, “Target object” for a lookup, “Formula” and “Result type” for a formula. Optionally tick Required and Indexed (faster filters). The field appears immediately in filters, the create modal and the record page.
The types on offer are text, long text, number, currency, bool, date, datetime, picklist, multi-picklist, lookup, email, phone, URL, JSON, formula and tags. Currency is stored as integer cents. Text fields are limited to 4,000 characters — use long text for prose. Dates are YYYY-MM-DD and datetimes are RFC3339 in UTC.
Formula fields are computed when the record is read, are never stored, and cannot be sorted or filtered on. The two that ship on a contact are Days since seen and Days to expiry.
An API name cannot collide with the record's own columns: id, object, created_at, updated_at, deleted_at, owner_id, source, external_id, tenant_id.
Delete a custom field #
The trash button at the end of the row (“Delete custom field”) — standard rows have no button. The confirmation warns that stored values become inaccessible: only the definition is deleted, the values stay in the records and simply become unreachable. Saved views that named the field quietly drop the column rather than breaking.
Add a custom object #
Add object takes an API name in snake_case, a Label, a Plural and a Description. “A required name text field is created automatically. Add more fields afterwards.” The object appears in the sidebar under Objects, lists at /o/<api_name>, and its records open at /r/<id>. New objects are created with the timeline enabled and a single list column.
The Add-field form also cannot set unique, hidden, read-only or a default value; those exist in the data model but have no UI.
Users, roles and API keys
The whole Settings block only renders in the sidebar when you are an admin. The one settings page a non-admin can reach is their own Profile & security, from the account menu.
Where everything is #
The admin list, in sidebar order: Playbooks, Forms, Email health, Connectors, Users, API keys, Objects. Three of those are conditional and simply will not appear otherwise — Playbooks needs the playbooks service mounted, Forms needs the form object, Email health needs the suppression object. A separate Data heading above Settings holds Import CSV when that lane is mounted.
There is no Settings landing page: /settings on its own gives “Page not found.” A non-admin who follows a direct link to an admin page gets a body reading “Admin access required.” with no redirect. And if the server was built without a feature's service, its page says “<name> is not enabled on this server.” followed by “Its endpoints are not mounted, so there is nothing to show here. Nothing is broken.” That is a deployment choice, not a fault.
The three roles #
There are exactly three: admin, member and readonly. Records are readable by everyone signed in and writable by admins and members. The admin-only areas are object and field changes, connectors, users, API keys and playbooks. There are no per-user, per-object or per-field permissions and no record-sharing rules — access is these three roles, applied uniformly.
A read-only user signs in the same way and sees Home, every list, every record, the 360, the Pipeline and the Inbox. What disappears: the whole Settings block; every New <thing> button; click-to-edit on field values; Delete, Merge into this contact and Recompute on a contact; row checkboxes and the bulk bar; dragging on the Pipeline. The Owner control becomes a plain label. Their own profile shows “Permissions: read-only”.
A read-only user can never own a record. They still appear in owner dropdowns, greyed out as “<name> — read-only”. So moving somebody to read-only does not strip the records they already own — those keep showing their name — but nothing new can be assigned to them.
Add a user, change a role, switch someone off #
Settings → Users. The columns are Email, Name, Role, 2FA, Last sign-in, Active, and your own row is tagged with a grey you pill. Add user takes an Email, a Name and a Role. Change a role with the dropdown in the Role cell — it saves immediately and toasts “<email> updated”. Switch someone off with the toggle in the Active column; their row dims.
/login and request one. Tell new people that, or they will wait for a message that is never coming.
Two more things that will catch you out. Adding an email that already exists silently returns the existing user — same toast, no error, no change to their role. And you cannot change your own role or switch yourself off; both controls are disabled on your own row (“You cannot change your own role”), so a deployment can never end up with zero admins.
Users can never be deleted, only disabled. Disabling kills their live sessions immediately, and they keep owning any records already assigned to them. There is no login-attempt lockout and no audit log of sign-ins — the only per-user trace is the Last sign-in column.
Create and revoke an API key #
Settings → API keys → Create API key. Give it a Name and pick a Scope: read (read records and events), write (read plus create and update), admin (everything including settings). Scopes nest — admin grants everything, write also grants read.
The dialog then shows a green bar: “Copy this key now — it will not be shown again.” That is literal. Only the first 12 characters, shown as the Prefix, are kept; the rest is stored as a hash. Use the key as a header:
Authorization: Bearer mycrm_…
The table shows Name, Prefix, Scopes, Created, Last used, Status. Revoke kills a key immediately — “Integrations using it stop working immediately.” — and revoked keys stay in the list, dimmed, with a red pill. There is no way to rename a key or change its scope after creation: make a new one and revoke the old. Keys can also be minted from the shell with mycrm apikey create <name> -scopes read|write|admin.
api-key:<name> rather than a name. Person-scoped shortcuts do not work for keys: a filter or view using @me is refused, because a key has no user identity.
One practical note for integrations: writes authenticated by the session cookie must also carry X-Requested-With: MyCRM. That requirement does not apply to Authorization: Bearer calls, which is another reason integrations should use a key rather than borrowing a browser session.
What the message means, and what to do
These are the messages MyCRM actually produces, word for word. Search this page for the text you are looking at.
Signing in #
too many requests; try again later- More than 6 sign-in links requested from one IP in 15 minutes.
- Do: wait for the window to pass. If a whole office shares one outbound address, it shares one budget of six.
this sign-in link is invalid or has expired- A link older than 20 minutes, a link already used once, or a link from a different deployment.
- Do: request a new one and use the newest email. Older ones stay dead.
could not send the sign-in email; ask an admin to check the mail settings- The mail provider rejected the message.
- Do: an operator checks the SendGrid key and the from-address, and looks for the log line
magic link mail to … failed: …. That code was not accepted. Try again.- A wrong 6-digit code — or the 10-minute window between clicking the link and entering the code ran out, which produces exactly the same message.
- Do: retype the current code once. If it fails again, press Start over and request a fresh link.
Code not accepted — check your authenticator app and try again.- Enabling two-factor with a code that does not match the freshly generated secret.
- Do: confirm the app scanned the QR on this page rather than an older entry, and that the phone's clock is set automatically.
unauthorized- No credentials, an expired session, a revoked API key, or a disabled account. In the browser you are simply sent to the login screen.
- Do: sign in again. If it repeats instantly, an admin has switched the account off.
totp required- A half-finished session — link redeemed, code never entered — used against an endpoint.
- Do: finish the two-factor step, or start over from
/login. Signed in with an API key — no profile to show.- The Profile page opened by a key rather than a person. Nothing is wrong.
Permissions #
admin access required·Admin access required.- A member or read-only user, or a read/write key, reaching a Settings area: users, API keys, connectors, playbooks, object or field changes. The page version replaces the whole body and does not redirect.
- Do: have an admin do it, or have them change your role on the Users page and reload. For integrations, mint a key with the admin scope.
write access required- A read-only user or a read-scoped key attempting a create, update or delete. The UI hides these controls from read-only users, so in practice this reaches integrations.
- Do: use a member or admin account, or a write-scoped key.
missing X-Requested-With header- A cookie-authenticated write without
X-Requested-With: MyCRM— something other than the MyCRM UI reusing the browser's session cookie. - Do: authenticate the integration with
Authorization: Bearer mycrm_…instead. The header requirement does not apply to Bearer calls. you cannot disable or demote yourself- An admin trying to switch off their own account or drop their own role. The UI disables both controls on your own row, so this is reachable only through the API.
- Do: ask another admin. This exists so a deployment can never end up with zero admins.
views: @me needs a signed-in user (API keys must filter by an explicit owner_id)- An integration ran a view or filter using
@me. Keys have no user identity. - Do: filter by an explicit owner, or use a view that does not use the token.
Records and fields #
a record with the same unique value already exists- Another live contact already has that email address.
- Do: search for the address in the top bar — it jumps straight to the contact — and edit or merge into that record instead.
field is read-only- Writing a system-maintained field: products, first and last seen, last purchase, next expiry, total spent, health score, open tickets, lead score, the suppression fields.
- Do: change the underlying entitlement, activation or ticket data, then press Recompute on the contact.
computed field is read-only- Supplying a value for a formula field when creating a record.
- Do: leave it out. It is calculated when the record is read.
a won deal needs an amount above zero- Moving a deal into a won stage while its amount is empty or zero. A won deal with no money in it is a number nobody can forecast from, so the move is refused.
- Do: on the Pipeline board this opens a dialog by itself — type the amount and press Move to won. From the API, re-send the stage move with
amount_cents. a lost deal needs a reason- Moving a deal into a lost stage with no lost reason recorded.
- Do: the board asks for it: pick a Lost reason, add optional notes, press Move to lost. From the API, re-send the move with
lost_reason. must be one of unknown, granted, denied, revoked- A picklist value outside its list — the same shape of message appears for lifecycle stage and every other picklist.
invalid email- An address that will not parse, or one containing a space or angle brackets.
- Do: enter a bare address. No display name, no brackets.
too long (max 4000)- A text field over 4,000 characters.
- Do: shorten it, or use a long-text field for prose.
this record is mirrored from <source> and cannot be deleted here — deleting it would stop that connector from ever syncing it again. Remove it at the source, or disable the connector.- Deleting an entitlement, activation, ticket, subscription, product or feature request that a connector created.
- Do: delete it in the system of record and let the connector sync, or turn the connector off. Contacts are not mirrored this way and can be deleted.
contacts: cannot merge a contact into itself·contacts: merge only supports contacts- Both sides of a merge are the same record, or one of them is not a contact.
- Do: pick a different duplicate. There is no merge for companies or any other object — re-point the related records by hand and delete the duplicate.
meta: invalid field api_name "<name>"- An API name with uppercase, a leading digit, spaces, or one that collides with a reserved column such as
idorowner_id. - Do: use snake_case starting with a letter.
meta: not found: field <object>.<field> (or it is standard)- Deleting a field that does not exist, or one that ships as standard.
- Do: standard fields cannot be removed. Leave the value empty, or add your own field alongside.
Lists, views and assignment #
records: cannot filter on computed field "days_since_seen"- You picked a computed field in the filter builder. The builder offers them; the server refuses them.
- Do: remove that row and filter on the stored field instead — Last seen, not Days since seen.
records: unknown order field "<field>"- A shared or bookmarked URL sorts by a field the object no longer has.
- Do: click any column heading to re-sort, or strip
order_by=from the URL. no such view- A
?view=link pointing at a deleted view, or at somebody else's private view — those are reported as missing, never as forbidden. - Do: pick a view from the dropdown. If a colleague meant to share it, ask them to open it, click Rename and tick Share with the whole team.
views: a view with that name already exists for this object- The name is taken in the same scope — another shared view of that name, or another private view of your own. A private view and a shared view can happily share a name; the uniqueness rule is per object and per owner, with shared views all counting as one ownerless scope.
- Do: qualify the name, e.g. “Renewals — BJ”.
owner_id is not a user of this tenant who can be assigned records- The person has since been disabled or moved to read-only, and the picker was loaded before that.
- Do: reload so the roster refreshes. In a bulk assign this refuses the whole batch, because the owner is wrong for every row.
not found·record is a <object>, not a <object>- In the Record/Reason table after a bulk assign: rows deleted by someone else while you had them selected, or an id from a different object.
- Do: close the dialog, let the list reload, re-select and assign again. The rows that succeeded have already moved.
Unknown object “<name>”.- A stale bookmark to an object this server does not have.
- Do: pick the object from the sidebar. Admins can check what exists at Settings → Objects.
Forms #
Sorry, that did not go through.- The embedded script's request failed — commonly because the page's origin is not in Allowed origins.
- Do: add the exact origin (scheme and host, no path) to the form record, then re-run Embed → Check configuration.
no such form- The slug in the posted action URL does not exist.
- Do: copy the submit URL again from Settings → Forms → Embed.
this form has already received submissions, so its slug is part of a public URL that other sites are using — create a new form instead of renaming this one- Editing the slug on a form with submissions.
- Do: leave it alone and create a new form with the slug you want, then swap the snippet on your site.
we could not record that just now — please try again- A genuine server fault while writing a submission. This is the only failure not dressed up as success, precisely so the lead is not silently lost.
- Do: retry, and check the server log.
Hosted forms are not enabled on this server — the /f/ endpoints are not mounted, so there is no embed snippet or public URL. The form record is still here and will work as soon as the service is wired.- The build never mounted the public forms surface.
- Do: nothing in the UI — the deployment has to wire the service. The form record is safe.
And the failure with no message at all: leads that stop arriving. Check Allowed origins first, then whether the form is enabled, then the per-IP and per-email rate limits, then the server log for dropped submission.
CSV import #
imports: this file maps a consent column, which needs an explicit affirmation from an admin session: <column names>- A dry run on a file that maps a consent column, without the affirmation.
- Do: use the affirmation panel — tick the confirmation, describe where the consent came from, press Record the affirmation — or set that column to “ignore this column” and import the rest.
This import already failed the consent check, and a failed job cannot be re-mapped. Start another import and affirm before the dry run.- You tried the dry run first and the job is now in error.
- Do: Start another import, re-upload, affirm, then dry-run.
The preview went stale — the job or its mapping changed since that dry run. Run the dry run again and commit the fresh result.- The mapping changed, or the database moved under the approved preview.
- Do: the wizard puts you back on Columns. Run the dry run again, read the new numbers, commit that.
imports: not valid UTF-8 — … re-save the file as UTF-8 (or UTF-16 with a byte-order mark)- A legacy encoding — Windows-1252 curly quotes are the usual culprit.
- Do: in Excel, Save As → “CSV UTF-8”, then upload again. The offset in the message tells you where to look.
imports: 100001 data rows exceeds the 100000-row limit — split the file- Too many rows, or too many bytes for the 10 MiB cap.
- Do: split the spreadsheet. Each part gets its own batch tag and its own undo.
consent for this contact was revoked; an import may not grant it again (the person has to)- A row would set marketing consent back to granted on somebody who revoked it. A hard row error, in the dry run as well as the commit.
- Do: remove the row or the column. If they genuinely re-opted in, record it from a form with a ticked box.
this contact is marked do not contact; an import may not clear that flag- A row would clear do-not-contact.
- Do: clear the flag on the contact by hand if that is really what you mean. The importer will not.
duplicate email "a@b.c" — row 12 in this file has the same value- Two rows share the matching value, or resolve to the same existing record.
- Do: download errors.csv, merge the rows in the spreadsheet, re-upload.
imports: invalid request: this file has no column that can match a row to an existing record…- No unique column is mapped, so every row would create a new record and re-importing an export would duplicate it. Only four fields are unique anywhere in MyCRM — contact email, product code, form slug, suppression email — so every other object hits this unless you name a dedupe field yourself.
- Do: map a column that identifies the record. The wizard has no control for choosing one, so otherwise it is an API call.
imports: undoing an import needs an admin session- A non-admin, or a key, called undo.
- Do: ask an admin to open the import and press Undo this import.
the commit failed partway and did not finish: <error>- A commit died with records already written. The job is marked partial and the records it wrote are live.
- Do: do not re-upload. Have an admin undo the import — the journal names exactly which records landed.
Connectors and playbooks #
connectors: config api_key cannot be unsealed — the master key does not match the one that sealed it; re-enter the secret- The instance's master key changed — a data directory restored from a different install, or a lost key file. Every run now fails before it starts.
- Do: Details → Configuration → Edit, retype the secret (do not leave the mask), Save changes. And read the backup note under Self-hosting: running it after the install.
stripe: api_key is a secret key (sk_…) — this connector is read-only and refuses full-access credentials…- A Stripe secret key was pasted into the restricted-key field. It is refused locally, before anything reaches Stripe.
- Do: create a restricted key with the nine read-only scopes and paste the
rk_value. stripe: api_version is required — pin a dated Stripe-Version (e.g. 2025-03-31.basil)- The version field is blank. There is no default.
stripe: forbidden on /v1/disputes — the restricted key is missing a READ scope for this resource- The restricted key was created without one of the required read scopes.
- Do: edit the key in Stripe, add read access for the named resource, re-run.
apihub: unauthorized (check partner_key)- The commerce backend rejected the key on the connector's first call, so no stream ran.
- Do: re-enter the partner key; it must match what is configured on the commerce backend.
apihub: export not configured on the api (CRM_PARTNER_KEY unset)- The CRM export is not switched on at the other end.
- Do: the fix is on the commerce backend, not in MyCRM.
sendgrid: API key rejected (needs read access to Suppressions)- The suppression pull was refused.
- Do: issue a key with read access to Suppressions, or clear the field to run webhook-only.
signature verification is not configured- SendGrid is posting events but the verification key is empty or unusable, so every batch is refused. The Cursors panel gains a
webhook_errorentry. - Do: paste the base64 verification key from SendGrid into the connector and save.
timestamp outside the accepted window- The signature verified but this host's clock is more than five minutes out.
- Do: fix time sync on the server. The suppression pull backfills whatever was rejected meanwhile.
invalid signature- A post the configured key cannot verify — a stranger, or a key rotation done without overlap.
- Do: if it is your own traffic, put both the old and new keys in the field, comma-separated, until SendGrid has switched over.
unknown connector- The connector id in the webhook URL does not exist, or belongs to a connector of another kind.
- Do: get the real id from
mycrm connector listand rebuild the URL. a run is already in progress- A scheduled tick beat you to Run now, or two admins clicked at once.
- Do: watch Run history and try again when it finishes.
playbooks: deal guard on renewal_of did not complete (more than 10000 rows for 500 subjects); refusing to plan against a partial guard- A play's safety check could not read all the existing deals or tasks it needed to check against, so it refused to plan anything rather than write over records it could not see.
- Do: narrow the window — a lower lead time or lookback — so fewer candidates are in play. The next run retries.
Email health #
no suppression for that address- Release was called for an address with no suppression record.
- Do: check the spelling, and check the Released / All filters — it may already have been released.
This server has no form object, so there are no hosted forms to configure.·This server has no suppression object.- The instance's metadata does not include those standard objects.
- Do: nothing on those pages will work until the standard objects are seeded on that tenant.
network error — is the server running?- The browser could not reach the server at all. Nothing was sent.
- Do: click Retry, or Refresh in the page header, once the connection is back.
The words the product uses
These are the ones new users misread most often.
Lifecycle stage
Where a contact sits: visitor, lead, trial, activated, customer, renewal due, churned, won back. Mostly derived from purchases and entitlements, not typed. Recompute leaves only visitor and won back alone.
Entitlement
One thing a customer has bought and may hold until a date — the record renewals are built on. Its expiry drives next expiry, lifecycle stage and the renewal play. Total spent is the sum of entitlement amounts.
Activation
Evidence that a machine is running your product. Activations feed products, last seen and health score, and an activation with no purchase is what makes a contact activated.
Playbook
One of four shipped, scheduled sweeps that create renewal deals and follow-up tasks. Not a rules builder: each has a handful of numeric knobs and two switches, and it ships off and in dry-run.
Dry run
Used twice in the product, meaning the same thing both times: run the real logic, write nothing. A playbook in dry run reports intents; a CSV dry run validates every row and produces the report the commit must echo.
View
A saved filter, search, sort and column set for one object. Either private to you or shared with everyone. Eighteen ship with the product; editing one of those locks your copy against future upgrades.
Suppression
A record that an address is out of service — bounce, block, spam report, unsubscribe, invalid or manual. It answers “can this be delivered”, not “may we market to them”. Releasing one restores sending and nothing else.
Consent
A record of a person agreeing to be marketed to, with the wording they saw and when. The contact's Marketing consent field is a cache of that; the consent records are the audit trail.
Identity
An alternative way of recognising the same person: an email address, a machine id, a Stripe customer id. Identities are created by the system, and they are what makes a merge and an email-keyed CSV import find the right contact.
Cursor
How far a connector's stream has read. Cursors are saved as it goes, so a restart resumes rather than replays. Resetting them makes the next run backfill from the beginning.
Batch tag
The label written on everything one CSV import touched. It is how you find those records afterwards, and it appears in the undo banner.
Scope
What an API key may do: read, write or admin, nesting in that order. Distinct from a role, which is what a person may do. A key is not a person and cannot use @me.
Tell us what you were doing.
The most useful message names the page you were on, the exact words on screen, and what you expected instead. If it is a self-hosted instance, add the version and whether it is SQLite or PostgreSQL. Our support address is support@iownsoftware.com, and the form on the contact page reaches the same place.