← Resources

SaaS CRM Data Model

The 12 custom fields a SaaS CRM actually needs - MRR, plan, seats, trial end, renewal date, health score, churn risk, source, ICP fit, persona, use case, loss reason - each with a data type, exactly one writer, and a copy-paste field kit.

Who this is for

This model fits subscription SaaS with a sales-assisted motion: trials or demos, monthly or annual plans, accounts worth tracking individually. That covers founder-led sales, a first RevOps hire, and anyone wiring an AI agent into customer data. If you run an agency instead, the account shape is different - use the agency CRM data model.

Use it on day one, or as the reset after the usual failure mode: forty ad-hoc properties, three of them spelled MRR, mrr_usd and monthly_revenue. A data model is not the fields you could track - it is the shortest list where every field has a type, one writer, and at least one report that needs it. Everything else trains the team to ignore the CRM.

The rule that makes this work: one writer per field

Every field below has exactly one of three writers. Two writers means drift; zero means rot.

The 12 fields

FieldObjectTypeWriterWhat it answers
mrrcompanycurrency (cents)webhookWhat is this account worth per month, right now?
plancompanyselect: trial / starter / growth / enterprisewebhookWhich tier - and which upgrade path?
seatscompanynumberwebhookHow wide is adoption? Near plan cap = expansion signal.
trial_endcompanydatewebhookWhen does the conversion window close?
renewal_datecompanydatewebhookWhen is revenue actually at stake?
health_scorecompanynumber (0-100)agentComposite of usage, support tone, engagement - the input.
churn_riskcompanyselect: low / medium / highagentThe call made from the input - what reports filter on.
sourcecontactselect: inbound / outbound / referral / product / partner / eventagentWhich channel produced this person?
icp_fitcontactselect: strong / medium / weakagentShould anyone spend time here at all?
personacontactselect: champion / economic_buyer / user / technical_evaluatorhumanWho are we actually talking to?
use_casedealtextagentWhy they are buying, in their words.
loss_reasondealselect: price / competitor / timing / no_budget / went_darkhumanWhy deals die - the only field that improves the others.

Three placement decisions worth defending. First, commercial state lives on the company - subscriptions attach to accounts, and the moment an account has a second contact, person-level MRR becomes double-counted nonsense. Second, health_score and churn_risk are two fields, not one: the score is a number an agent recomputes weekly and can explain; the risk is a three-value label that filters, triggers, and reports cleanly. Collapse them and you end up filtering on health_score < 47, a threshold nobody remembers choosing. Third, money is integer cents - $2,499.00/mo is 249900, matching Relm's value_cents convention and Stripe's amounts, so webhook code copies the number through untouched.

Selects beat free text everywhere a field feeds a filter: a select rejects "Enterprize" at write time, a text field stores it forever. use_case is the one deliberate exception - it is qualitative evidence, and a picklist destroys exactly what makes it useful. Naming discipline for the rest - snake_case keys, no synonyms, no orphans - is covered in the CRM hygiene checklist.

The reports these 12 fields unlock

Every field earns its place by powering at least one of these; cut a field and you give up its question.

QuestionFieldsHow
How much MRR is at risk this quarter?mrr, churn_risk, renewal_dateSum mrr where churn_risk = high and renewal inside 90 days. Opens every Monday meeting.
Which trials need a call this week?trial_end, health_scoretrial_end within 14 days, health_score < 50. Low-health trials convert on intervention, not drips.
Does our ICP definition predict revenue?icp_fit, deal stageWin rate of strong vs weak. If they match, your ICP is fiction - tighten the definition.
Which channel produces customers, not leads?source, mrrMRR by source, not signup count. Exposes channels that flood the top and never convert.
Who is ready to expand?seats, plan, health_scoreSeats near plan cap, health above 70 = the warm expansion list.
Why are we losing?loss_reason, planLoss distribution by tier: price clusters at starter, competitor at enterprise - two different fixes.

In an API-first CRM these are queries an agent runs on demand against list endpoints and custom_fields, not dashboard screenshots. Contact and deal fields also work as automation conditions - a rule or sequence filter like {"field": "icp_fit", "op": "eq", "value": "strong"} reads custom fields directly, so icp_fit can gate who enters your outbound cadence. A daily pipeline review agent turns the first two rows into a standing morning report.

The artifact: field kit JSON

Two arrays. Each element of enum_options is one valid POST /v1/enums body; each element of fields is one valid POST /v1/fields body. Apply enums first, then fields - 37 calls, both endpoints idempotent on name, so re-running is safe. Values then go on records under custom_fields, validated against these definitions at write time.

{
  "enum_options": [
    { "group": "company.plan", "value": "trial", "label": "Trial" },
    { "group": "company.plan", "value": "starter", "label": "Starter" },
    { "group": "company.plan", "value": "growth", "label": "Growth" },
    { "group": "company.plan", "value": "enterprise", "label": "Enterprise" },
    { "group": "company.churn_risk", "value": "low", "label": "Low" },
    { "group": "company.churn_risk", "value": "medium", "label": "Medium" },
    { "group": "company.churn_risk", "value": "high", "label": "High" },
    { "group": "contact.source", "value": "inbound", "label": "Inbound" },
    { "group": "contact.source", "value": "outbound", "label": "Outbound" },
    { "group": "contact.source", "value": "referral", "label": "Referral" },
    { "group": "contact.source", "value": "product", "label": "Product signup" },
    { "group": "contact.source", "value": "partner", "label": "Partner" },
    { "group": "contact.source", "value": "event", "label": "Event" },
    { "group": "contact.icp_fit", "value": "strong", "label": "Strong" },
    { "group": "contact.icp_fit", "value": "medium", "label": "Medium" },
    { "group": "contact.icp_fit", "value": "weak", "label": "Weak" },
    { "group": "contact.persona", "value": "champion", "label": "Champion" },
    { "group": "contact.persona", "value": "economic_buyer", "label": "Economic buyer" },
    { "group": "contact.persona", "value": "user", "label": "User" },
    { "group": "contact.persona", "value": "technical_evaluator", "label": "Technical evaluator" },
    { "group": "deal.loss_reason", "value": "price", "label": "Price" },
    { "group": "deal.loss_reason", "value": "competitor", "label": "Competitor" },
    { "group": "deal.loss_reason", "value": "timing", "label": "Timing" },
    { "group": "deal.loss_reason", "value": "no_budget", "label": "No budget" },
    { "group": "deal.loss_reason", "value": "went_dark", "label": "Went dark" }
  ],
  "fields": [
    { "object": "company", "key": "mrr", "label": "MRR", "data_type": "currency" },
    { "object": "company", "key": "plan", "label": "Plan", "data_type": "select", "enum_group": "company.plan" },
    { "object": "company", "key": "seats", "label": "Seats", "data_type": "number" },
    { "object": "company", "key": "trial_end", "label": "Trial end", "data_type": "date" },
    { "object": "company", "key": "renewal_date", "label": "Renewal date", "data_type": "date" },
    { "object": "company", "key": "health_score", "label": "Health score", "data_type": "number" },
    { "object": "company", "key": "churn_risk", "label": "Churn risk", "data_type": "select", "enum_group": "company.churn_risk" },
    { "object": "contact", "key": "source", "label": "Source", "data_type": "select", "enum_group": "contact.source" },
    { "object": "contact", "key": "icp_fit", "label": "ICP fit", "data_type": "select", "enum_group": "contact.icp_fit" },
    { "object": "contact", "key": "persona", "label": "Persona", "data_type": "select", "enum_group": "contact.persona" },
    { "object": "deal", "key": "use_case", "label": "Use case", "data_type": "text" },
    { "object": "deal", "key": "loss_reason", "label": "Loss reason", "data_type": "select", "enum_group": "deal.loss_reason" }
  ]
}

Once registered, a webhook handler writes commercial state like this - unknown keys and invalid enum values are rejected with the valid options in the error body:

PATCH /v1/companies/cmp_x8f3k2m9q2
{ "custom_fields": { "mrr": 249900, "plan": "growth", "seats": 14, "churn_risk": "low" } }

Apply it with your agent

If your agent speaks MCP, point it at https://api.relmcrm.com/mcp with a bearer key and paste:

Connect to the Relm MCP server. Then:
1. Call relm_describe_schema and show me any custom fields that already
   exist on contact, company, and deal - flag key collisions with this kit.
2. Register the SaaS data model: create every enum option in the kit's
   enum_options array, then every field in the fields array
   (relm_create_enum_value and relm_create_field respectively; both
   are idempotent, so just run all 37).
3. Re-read the schema and confirm all 12 fields report the right
   data_type and enum_group.
Use my test key first; only repeat on live after I confirm.

Over REST, loop the kit with two commands. Rehearse with a relm_test_ key - test mode is free, unlimited, and invisible to live data - then re-run with relm_live_:

jq -c '.enum_options[]' saas-field-kit.json | while read -r row; do
  curl -s https://api.relmcrm.com/v1/enums \
    -H "Authorization: Bearer relm_test_..." \
    -H "Content-Type: application/json" -d "$row"; done
jq -c '.fields[]' saas-field-kit.json | while read -r row; do
  curl -s https://api.relmcrm.com/v1/fields \
    -H "Authorization: Bearer relm_test_..." \
    -H "Content-Type: application/json" -d "$row"; done

Once registered, the fields stay discoverable live at GET /v1/schema; the schema-first workflow is covered in the docs. If this is your agent's first CRM, start with how to give your AI agent a CRM. The kit pairs with the B2B SaaS sales pipeline template - stages carry the deal, these fields carry the account.

FAQ

Why store MRR in the CRM when the billing system already has it?

Because the questions that matter join revenue to relationship data: MRR at risk by churn signal, win rate by ICP fit, expansion by seat usage. Billing knows the number but not the context. Mirror MRR in via webhook so it is never hand-typed; billing stays the source of truth the CRM caches.

Should custom fields live on the contact or the company?

Commercial state (MRR, plan, seats, trial end, renewal, health, churn risk) belongs on the company - subscriptions attach to accounts, not people. Person-level judgment (source, ICP fit, persona) belongs on the contact; deal-specific facts (use case, loss reason) on the deal. MRR on a contact breaks the moment an account has two contacts.

Who should write the churn_risk field - a human or an AI agent?

An agent, on a schedule. Churn risk synthesizes usage, support tone, and payment history - cross-referencing an LLM does well and a human does inconsistently. Keep it a three-value select (low, medium, high) so it works in filters and reports, with the numeric health_score separate as the agent's input.

What data type should MRR use?

Currency, stored as integer minor units (cents), never a float or formatted string. $2,499.00/mo is 249900, matching Relm's value_cents and Stripe's amounts, so webhook code copies the number through without rounding bugs.

Can I use this data model without Relm?

Yes. The 12 fields, their types, and the one-owner-per-field rule are CRM-agnostic - recreate them as HubSpot properties or Attio attributes. The JSON kit maps to any CRM API with custom field definitions. Relm is simply the fastest way to apply it: 37 idempotent API calls, or one sentence to an MCP-connected agent, free on 1,000 requests a month.

Ship this data model in one prompt

1,000 requests a month on the Free plan, no card. See pricing.

Start free →