Skip to content
Expedify
Actions — making things happen

Module · Working with the CRM

CRM Manager

Lesson 1 of 21 · 5 min

Most of what an automation does to your CRM is small and repetitive. Mark the contact qualified. Open a deal. Attach a note. File a task against the record someone just touched. None of that is interesting work, and all of it has to happen before anything downstream is worth doing.

The CRM Manager is the one node that does all of it. It is not a contact node with a company node beside it and a deal node beside that — it is a single node where you pick which object you are working on and what you want done to it. Once that is wired, a workflow can move data through your CRM the same way a person would, and considerably faster.

Learn three fields and you have the node: which entity, which operation, and the data. Everything else in the panel is one of those three getting more specific.

Entity, operation, data

Five fields out of 26. The rest are batch options and per-entity shortcuts.

entity_type

What it holds
Type of CRM entity or relationship to manage One of: contact · company · deal · note · task · product · activity · deal_contact · deal_company · contact_company · deal_product · deal_payment · deal_payment_schedule Defaults to contact.

operation

What it holds
Operation to perform. Use batch_* for multiple items at once. One of: create · update · get · search · delete · link · unlink · update_relationship · batch_create · batch_update · batch_delete Defaults to get.

json_data

What it holds
JSON string of entity data for create/update (preferred write path). On update: "" blanks a field, null leaves it unchanged, custom_fields merge.

entity_id

What it holds
Entity ID or email (for contacts) for operations that require it

create_if_not_found

What it holds
For contact updates: if contact not found by email, create it (upsert behavior) Defaults to true.

Two things in that table are worth stopping on, because they are where this node stops behaving like a form.

Writes go in json_data, as a string. There is a data object in the schema and it looks like the obvious place to put a record — it is not the reliable one. Put your fields in json_data as JSON text. Some fields can only be set there: a deal's stage has no native config field at all, so {"stage_name": "Qualification"} is the only way to set it.

Update targets a record by ID — or, for contacts, by email. Put either in entity_id. And because create_if_not_found defaults to on, an update against an email that does not exist creates the contact. That is an upsert, and it is the reason lead-capture workflows do not need a create branch and an update branch. It is also a surprise the first time you meant to update strictly.

A worked example

A contact is created in the CRM. Three CRM Managers follow: one marks the contact qualified, one opens a deal, one links the deal back to the contact.

Three operations, one node type

Update, create, link — the same node, configured differently each time.

Scroll for all 4 steps →

Read the three nodes as one shape repeated. Each one names an entity, names an operation, and carries its payload as JSON text. Nothing else changes.

  • crmmanager_1 updates a contact and targets it by email from the trigger. create_if_not_found is off here on purpose: the contact certainly exists, because its creation is what started the run.
  • crmmanager_2 creates a deal, and sets its stage through stage_name — the field with no form control.
  • crmmanager_3 links the two. deal_contact is a junction entity: it is not a record you look at, it is the relationship between two records. Linking is an operation like any other.

The last node is also where the outputs earn their keep. It reads {{crmmanager_2.entity.id}} and {{crmmanager_1.entity.id}} — the ids of records that did not exist when the workflow was saved. That is the normal way to chain CRM work: create something, then use what came back.

What comes out

One record is entity. Many records is results. They are never both populated.

{{alias.entity.id}}

What you get
The record's id, after a create, update or get. The field you pass into the next node.

{{alias.entity_id}}

What you get
The same id, at the top level.

{{alias.results}}

What you get
The array a search returns. Feed it to a Loop.

{{alias.total}}

What you get
How many a search matched.

{{alias.operation}} · {{alias.entity_type}}

What you get
What this node just did, echoed back.

Older material says {{crmmanager_1.result.id}}. It does not resolve. The single-record output is entity, and the search output is results. Screenshots and scripts from earlier product versions still show result, and a template that references it fails quietly — the downstream node receives an empty string rather than an error. If a value arrives blank and you cannot see why, check this first.

Which operation

get

Reach for it when
You know exactly which record you want, and you want one.

search

Reach for it when
You want every record matching a filter. Returns an array plus a count; pair it with a Loop.

create

Reach for it when
The record definitely does not exist yet.

update

Reach for it when
The record exists — or you want it created if it does not. This is the default choice for anything keyed on an email.

link / unlink

Reach for it when
You are relating two records rather than changing either one.

batch_create / batch_update / batch_delete

Reach for it when
You have many records and would otherwise wrap this node in a Loop. One node, one set of outputs, kinder to rate limits.

The batch row is the one people find late. A Loop around a CRM Manager works, and for twenty records it is fine. For hundreds it is slow and it makes the execution log unreadable. If the list is already in hand, batch it.

What breaks

Watch out: WRITES go in `json_data` (a JSON STRING), not a `data` object — the object is dropped on write.

A deal's stage has no native field — set it only via json_data: '{"stage_name": "..."}'.

custom_fields update MERGES: "" blanks a value, JSON null leaves it unchanged, omitted = unchanged.

SEARCH cannot filter by a custom field (it's ignored) — use a database_query SELECT instead.

Read results as {{alias.results}} / {{alias.results[0].id}}; a single entity as {{alias.entity.id}}.

The one that costs the most time is the search filter. filters covers standard columns only, and a custom-field filter is not rejected — it is ignored. So the search runs, returns records, and returns the wrong ones: everything that matched the rest of the filter, unfiltered by the condition you actually cared about. Nothing in the run looks failed. When you need to filter on a custom field, use a Database Query instead.

The update semantics are the second. On an update, an empty string blanks the field, JSON null leaves it alone, and a key you leave out is untouched. Building the payload from upstream variables that might be empty is therefore how a workflow quietly erases the phone numbers it was supposed to be enriching. Send null, not "", for anything you might not have.

Try it

  1. Add a CRM Manager, set the entity to contact and the operation to search, and give it a filter you know matches a couple of records. Run it and read {{alias.total}}.
  2. Change the operation to update, put a real contact's email in entity_id, and set json_data to {"lead_status": "qualified"}. Run it and check the record.
  3. Now put an email that does not exist in your CRM in the same node and run it again. A new contact appears — that is create_if_not_found. Turn it off and run it a third time to see the difference.
  4. Finally, add a second field to json_data with the value "" and watch it blank that field on the record. This is the failure worth having once, on purpose, in a workflow you do not care about.

Next: Contact Summary — one node that reads a contact's whole history and writes the brief a rep would have had to assemble by hand.