> ## Documentation Index
> Fetch the complete documentation index at: https://alpha.developer.tomorro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Full cycle example

> Sync your counterparties: a working example with a Notion database as the ERP and four Make scenarios

In this example, we build the whole integration with [Make](https://www.make.com) as the middleware, and a Notion database playing the role of the ERP, the counterparty base. Each of the four flows is one Make scenario: you can watch it run end to end in the video, then import the same scenarios from the [blueprints](#make-blueprints).

<Frame>
  <iframe src="https://www.loom.com/embed/19dcfc6b90a142788f0bdd6073c7c0ca" title="Counterparty sync demo: Notion and Make" width="100%" height="420" frameBorder="0" allowFullScreen />
</Frame>

## Notion ERP with Make

[Make](https://www.make.com) is a no-code automation platform. The four Make scenarios below run every flow of this doc with a Notion database playing the ERP (*Mock Counterparties*: `Counterparty Name`, `SIRET Number`, `Industry`, `Address`, `City`, `ID tomorro`). Use them as a **head start** if you run Make, or as a reference implementation for n8n, custom code or another ERP: only the ERP modules change.

<Accordion title="Notes">
  The scenarios are a working example, not a ready-made integration. They make choices you may want to change:

  * **Connections**: the Tomorro calls use an *API Key Auth* keychain (header `x-api-key`), the Notion calls a Notion connection invited on the counterparty database.
  * **Notion calls** go through *Make an API Call* (Notion API version `2022-06-28`), so the raw Notion JSON is parsed in code modules. With your ERP, replace these modules with its own connector.
  * **Webhooks**: Flows 1 and 2 start from a Notion webhook (*Watch Events*, event *Page: Properties updated*), within a minute or two of the change: Notion groups its events before sending them. No polling. The webhook receives events from the whole Notion workspace: pages the connection cannot read are skipped by an *Ignore* error handler, and a filter keeps only the counterparty database.
  * **Retries**: the Tomorro calls that write (create, update) retry automatically, 3 times, 1 minute apart. A bundle that still fails is kept as an incomplete execution, to review in Make.
  * **Sync state**: Flow 3 saves the last `updatedAt` it processed in a Make data store, and only reads what changed since.
  * **Pages**: Flow 0 reads up to 100 counterparties per run and can simply be re-run. Flow 3 reads the 50 latest updates per run; paginate with `pagination.next_cursor` above that.
  * **Industry**: labels are matched case-insensitively on the Tomorro options. A Notion value missing from Tomorro is left out and reported in the `warning` output of the code module.
</Accordion>

### About the code below

The scenarios are mostly standard modules. The logic lives in a few short **JavaScript** snippets, run by Make *Code* modules: read the ERP row, translate the Industry label into its option id, and compare both sides before writing. They are already inside the [blueprints](#make-blueprints): you only need to copy them if you build the flows yourself.

| You use | How to reuse the snippets |
| - | - |
| **Make** | Nothing to copy: import the [blueprints](#make-blueprints). Open a Code module to adapt a field. |
| **n8n** | Paste them into a *Code* node. Replace `input` with the node input (`$json`) and return the result. |
| **Custom code** | Wrap each snippet in a function: `input` is its argument, the returned object is its result. |
| **Another ERP** | Only the reading part changes: replace the Notion property names (`p['Counterparty Name']`, `p['SIRET Number']`…) with your ERP fields. |

In every case, replace the smart field ids in `FIELDS` with yours (see [1.3](/guides/sync-your-counterparties/technical-recipes#1-3-counterparty-smart-fields-in-tomorro)).

### Flow 0 - \[Notion → Tomorro → Notion] Initialisation

Make scenario *Flow 0 - Initialisation*, scheduling **on demand** (Run once).

<Steps>
  <Step title="Step 0 - List the counterparties not linked to Tomorro">
    *Make modules 1, 3*

    Query the database for rows with an empty `ID tomorro`, then iterate over `body.results`:

    ```http theme={null}
    POST https://api.notion.com/v1/databases/{databaseId}/query
    Notion-Version: 2022-06-28

    { "filter": { "property": "ID tomorro", "rich_text": { "is_empty": true } }, "page_size": 100 }
    ```
  </Step>

  <Step title="Step 1 - Get the Industry option ids">
    *Make module 2*

    ```http theme={null}
    GET https://api.tomorro.com/v2/smart-fields/b25c969a-2a1c-4903-be86-10269aeb5e84/options?limit=50
    x-api-key: <your api key>
    ```
  </Step>

  <Step title="Step 2.1 - Build the counterparty body">
    *Make module 4*

    Inputs: the row `id` and `properties`, and `data` of step 1. Skips rows already linked or without a name, and translates the Industry label into its option id.

    ```javascript theme={null}
    const { pageId, properties, industryOptions } = input;

    const p = properties || {};
    const text = (prop) => ((prop && (prop.title || prop.rich_text)) || []).map((t) => t.plain_text).join('').trim();

    const counterparty = {
      tomorroId: text(p['ID tomorro']),
      name: text(p['Counterparty Name']),
      siret: text(p['SIRET Number']),
      industry: (p['Industry'] && p['Industry'].select && p['Industry'].select.name) || '',
      address: text(p['Address']),
      city: text(p['City']),
    };

    // Already linked, or row created empty in the ERP: nothing to create yet
    if (counterparty.tomorroId) return { skip: true, reason: 'already linked to Tomorro', pageId };
    if (!counterparty.name) return { skip: true, reason: 'missing counterparty name', pageId };

    // Tomorro smart field ids: Dynamic components > Smart fields, last segment of the URL
    const FIELDS = {
      siret: '9d6175af-86a5-497a-aa42-facfb2b8dafb',
      industry: 'b25c969a-2a1c-4903-be86-10269aeb5e84',
      address: '0abf8d86-02ab-4583-aea3-b730ad802c37',
      city: 'a6690cab-baaa-4848-8866-a03d800049e3',
    };

    // Tomorro stores a picklist value as the option id, not its label: translate both ways
    const optionIdByLabel = {};
    const labelByOptionId = {};
    for (const option of industryOptions || []) {
      for (const translation of option.translations || []) {
        const label = String(translation.value || '').trim();
        if (!label) continue;
        optionIdByLabel[label.toLowerCase()] = option.id;
        if (!labelByOptionId[option.id]) labelByOptionId[option.id] = label;
      }
    }

    const values = { ...counterparty, industry: counterparty.industry && optionIdByLabel[counterparty.industry.toLowerCase()] };
    const warning = counterparty.industry && !values.industry ? `Industry "${counterparty.industry}" is not a Tomorro option` : null;

    const fields = {};
    for (const [key, fieldId] of Object.entries(FIELDS)) {
      if (values[key]) fields[fieldId] = values[key];
    }

    return { skip: false, pageId, warning, body: JSON.stringify({ name: counterparty.name, fields }) };
    ```
  </Step>

  <Step title="Step 2.2 - Create the counterparty in Tomorro">
    *Make module 5*, filtered on `skip` not `true`, with a *Retry* error handler.

    ```http theme={null}
    POST https://api.tomorro.com/v2/counterparties
    x-api-key: <your api key>
    Content-Type: application/json

    <body from step 2.1>
    ```
  </Step>

  <Step title="Step 3 - Write ID tomorro back to Notion">
    *Make module 6*

    ```http theme={null}
    PATCH https://api.notion.com/v1/pages/{pageId}
    Notion-Version: 2022-06-28

    { "properties": { "ID tomorro": { "rich_text": [ { "text": { "content": "<data.id from step 2.2>" } } ] } } }
    ```
  </Step>
</Steps>

### Flow 1 - \[Notion → Tomorro → Notion] Counterparty created in the ERP

Make scenario *Flow 1 - Creation*, triggered by a Notion webhook.

<Steps>
  <Step title="Step 0 - Notion webhook: a counterparty row is edited">
    *Make modules 1, 2*

    *Watch Events*, event **Page: Properties updated**, then read the raw row with `GET /v1/pages/{entity.id}`, filtered on the counterparty database.

    A row added in Notion is often empty when it is created: the counterparty is ready once its fields are filled. So Flow 1 listens to property updates, and only creates a counterparty for a row with a name and no `ID tomorro`.
  </Step>

  <Step title="Step 1 - Get the Industry option ids">
    *Make module 3*

    Same call as Flow 0 step 1.
  </Step>

  <Step title="Step 2.1 - Build the counterparty body">
    *Make module 4*

    Same code as Flow 0 step 2.1, inputs `body.id` and `body.properties` of module 2.
  </Step>

  <Step title="Step 2.2 - Create the counterparty in Tomorro">
    *Make module 5*

    Same call as Flow 0 step 2.2, filtered on `skip` not `true`, with a *Retry* error handler.
  </Step>

  <Step title="Step 3 - Write ID tomorro back to Notion">
    *Make module 6*

    Same call as Flow 0 step 3.
  </Step>
</Steps>

### Flow 2 - \[Notion → Tomorro] Counterparty updated in the ERP

Make scenario *Flow 2 - Update from ERP*, triggered by a Notion webhook.

<Steps>
  <Step title="Step 0 - Notion webhook: a counterparty row is edited, read it">
    *Make modules 1, 2, 3*

    *Watch Events*, event **Page: Properties updated**, `GET /v1/pages/{entity.id}` filtered on the counterparty database, then read the values. Rows without `ID tomorro` are left to Flow 1.

    ```javascript theme={null}
    const { pageId, properties } = input;

    const p = properties || {};
    const text = (prop) => ((prop && (prop.title || prop.rich_text)) || []).map((t) => t.plain_text).join('').trim();

    const counterparty = {
      tomorroId: text(p['ID tomorro']),
      name: text(p['Counterparty Name']),
      siret: text(p['SIRET Number']),
      industry: (p['Industry'] && p['Industry'].select && p['Industry'].select.name) || '',
      address: text(p['Address']),
      city: text(p['City']),
    };

    return { pageId, ...counterparty, skip: !counterparty.tomorroId || !counterparty.name };
    ```
  </Step>

  <Step title="Step 1.1 - Read the current counterparty">
    *Make module 4*, filtered on `skip` not `true` (row linked to Tomorro and named).

    ```http theme={null}
    GET https://api.tomorro.com/v2/counterparties/{tomorroId}
    x-api-key: <your api key>
    ```
  </Step>

  <Step title="Step 1.2 - Get the Industry option ids">
    *Make module 5*

    Same call as Flow 0 step 1.
  </Step>

  <Step title="Step 2.1 - Compare Notion and Tomorro">
    *Make module 6*

    Inputs: the values of step 0, `data` of steps 1.1 and 1.2. Builds a `PATCH` body with the differences only. A Tomorro Industry that is not a valid option id counts as empty, so it is repaired.

    ```javascript theme={null}
    const { name, siret, industry, address, city, counterparty, industryOptions } = input;

    // Tomorro smart field ids: Dynamic components > Smart fields, last segment of the URL
    const FIELDS = {
      siret: '9d6175af-86a5-497a-aa42-facfb2b8dafb',
      industry: 'b25c969a-2a1c-4903-be86-10269aeb5e84',
      address: '0abf8d86-02ab-4583-aea3-b730ad802c37',
      city: 'a6690cab-baaa-4848-8866-a03d800049e3',
    };

    // Tomorro stores a picklist value as the option id, not its label: translate both ways
    const optionIdByLabel = {};
    const labelByOptionId = {};
    for (const option of industryOptions || []) {
      for (const translation of option.translations || []) {
        const label = String(translation.value || '').trim();
        if (!label) continue;
        optionIdByLabel[label.toLowerCase()] = option.id;
        if (!labelByOptionId[option.id]) labelByOptionId[option.id] = label;
      }
    }

    const current = counterparty || {};
    const currentFields = current.fields || {};
    const clean = (value) => (value === null || value === undefined ? '' : String(value).trim());
    const tomorroValue = (fieldId) => {
      const field = currentFields[fieldId];
      return clean(field && typeof field === 'object' && 'value' in field ? field.value : field);
    };

    // Industry is compared on the option id; a Tomorro value that is not an option id is treated as empty
    const erpIndustryId = clean(industry) ? optionIdByLabel[clean(industry).toLowerCase()] : '';
    const tomorroIndustryId = labelByOptionId[tomorroValue(FIELDS.industry)] ? tomorroValue(FIELDS.industry) : '';
    const erp = { siret: clean(siret), industry: erpIndustryId, address: clean(address), city: clean(city) };
    const tomorro = {
      siret: tomorroValue(FIELDS.siret),
      industry: tomorroIndustryId,
      address: tomorroValue(FIELDS.address),
      city: tomorroValue(FIELDS.city),
    };

    const body = {};
    if (clean(name) && clean(name) !== clean(current.name)) body.name = clean(name);

    const fields = {};
    for (const [key, fieldId] of Object.entries(FIELDS)) {
      // ERP label missing from the Tomorro picklist: leave the Tomorro value alone
      if (key === 'industry' && erpIndustryId === undefined) continue;
      if (erp[key] !== tomorro[key]) fields[fieldId] = erp[key] || null;
    }
    if (Object.keys(fields).length) body.fields = fields;

    // Nothing differs: usually the echo of Flow 1 (ID written back) or Flow 3 (Tomorro values written back)
    if (!Object.keys(body).length) return { changed: false };

    return { changed: true, body: JSON.stringify(body) };
    ```
  </Step>

  <Step title="Step 2.2 - Update the counterparty in Tomorro">
    *Make module 7*, filtered on `changed` = `true`, with a *Retry* error handler.

    ```http theme={null}
    PATCH https://api.tomorro.com/v2/counterparties/{tomorroId}
    x-api-key: <your api key>
    Content-Type: application/json

    <body from step 2.1>
    ```
  </Step>
</Steps>

### Flow 3 - \[Tomorro → Notion] Counterparty updated in Tomorro

Make scenario *Flow 3 - Update from Tomorro*, every day at 8:00.

<Steps>
  <Step title="Step 0 - Get the last updatedAt processed">
    *Make module 1*

    *Data store > Get a record*, key `flow3_last_updated_at`. Empty on the first run.
  </Step>

  <Step title="Step 1.1 - List the counterparties, latest updates first">
    *Make module 2*

    ```http theme={null}
    GET https://api.tomorro.com/v2/counterparties?sort=-updatedAt&limit=50
    x-api-key: <your api key>
    ```
  </Step>

  <Step title="Step 1.2 - Keep the counterparties updated since the last run">
    *Make module 3*

    Inputs: the saved value of step 0, `data` and `pagination.has_next` of step 1.1.

    ```javascript theme={null}
    const { lastUpdatedAt, counterparties, hasNext } = input;

    // First run: look back 24 hours
    const since = lastUpdatedAt ? new Date(lastUpdatedAt) : new Date(Date.now() - 24 * 60 * 60 * 1000);

    // The list is sorted by updatedAt, newest first: keep what changed since the last run
    const changed = (counterparties || []).filter((c) => new Date(c.updatedAt) > since);

    const newest = changed.length ? changed[0].updatedAt : since.toISOString();
    const warning = hasNext && changed.length === (counterparties || []).length
      ? 'More counterparties changed than one page holds: paginate with pagination.next_cursor'
      : null;

    return { counterparties: changed, newLastUpdatedAt: newest, warning };
    ```
  </Step>

  <Step title="Step 1.3 - Remember the latest updatedAt">
    *Make module 4*

    *Data store > Add/replace a record*, key `flow3_last_updated_at`, value `newLastUpdatedAt` of step 1.2.
  </Step>

  <Step title="Step 1.4 - Get the Industry option ids">
    *Make module 5*

    Same call as Flow 0 step 1, once per run.
  </Step>

  <Step title="Step 2.1 - Find the ERP counterparty">
    *Make modules 6, 7*

    Iterate over the counterparties of step 1.2, and look up the Notion row whose `ID tomorro` is the counterparty `id`:

    ```http theme={null}
    POST https://api.notion.com/v1/databases/{databaseId}/query
    Notion-Version: 2022-06-28

    { "filter": { "property": "ID tomorro", "rich_text": { "equals": "<counterparty id>" } }, "page_size": 1 }
    ```
  </Step>

  <Step title="Step 2.2 - Compare Tomorro and Notion">
    *Make module 8*

    Inputs: the rows of step 2.1, the counterparty `name` and `fields`, the options of step 1.4. Skips a Tomorro counterparty when no ERP row has its id, translates the Industry option id back to its label, and builds the Notion properties that differ.

    ```javascript theme={null}
    const { rows, counterpartyName, counterpartyFields, industryOptions } = input;

    // Counterparty not linked to any ERP counterparty: created in Tomorro only, ignored
    const row = (rows || [])[0];
    if (!row) return { changed: false, reason: 'no ERP counterparty with this ID tomorro' };

    const p = row.properties || {};
    const text = (prop) => ((prop && (prop.title || prop.rich_text)) || []).map((t) => t.plain_text).join('').trim();
    const erp = {
      name: text(p['Counterparty Name']),
      siret: text(p['SIRET Number']),
      industry: (p['Industry'] && p['Industry'].select && p['Industry'].select.name) || '',
      address: text(p['Address']),
      city: text(p['City']),
    };

    // Tomorro smart field ids: Dynamic components > Smart fields, last segment of the URL
    const FIELDS = {
      siret: '9d6175af-86a5-497a-aa42-facfb2b8dafb',
      industry: 'b25c969a-2a1c-4903-be86-10269aeb5e84',
      address: '0abf8d86-02ab-4583-aea3-b730ad802c37',
      city: 'a6690cab-baaa-4848-8866-a03d800049e3',
    };

    // Tomorro stores a picklist value as the option id, not its label: translate it back
    const labelByOptionId = {};
    for (const option of industryOptions || []) {
      for (const translation of option.translations || []) {
        const label = String(translation.value || '').trim();
        if (label && !labelByOptionId[option.id]) labelByOptionId[option.id] = label;
      }
    }

    const currentFields = counterpartyFields || {};
    const clean = (value) => (value === null || value === undefined ? '' : String(value).trim());
    const tomorroValue = (fieldId) => {
      const field = currentFields[fieldId];
      return clean(field && typeof field === 'object' && 'value' in field ? field.value : field);
    };

    const rawIndustry = tomorroValue(FIELDS.industry);
    const tomorro = {
      name: clean(counterpartyName),
      siret: tomorroValue(FIELDS.siret),
      // undefined when the stored value is not a known option: leave the ERP alone
      industry: rawIndustry ? labelByOptionId[rawIndustry] : '',
      address: tomorroValue(FIELDS.address),
      city: tomorroValue(FIELDS.city),
    };

    const richText = (value) => ({ rich_text: value ? [{ text: { content: value } }] : [] });
    const toNotion = {
      name: (value) => ['Counterparty Name', { title: [{ text: { content: value } }] }],
      siret: (value) => ['SIRET Number', richText(value)],
      industry: (value) => ['Industry', { select: value ? { name: value } : null }],
      address: (value) => ['Address', richText(value)],
      city: (value) => ['City', richText(value)],
    };

    const properties = {};
    const changedKeys = [];
    for (const key of Object.keys(toNotion)) {
      if (tomorro[key] === undefined) continue;
      if (clean(erp[key]).toLowerCase() === tomorro[key].toLowerCase()) continue;
      // Counterparty Name is the Notion title: never blank it
      if (key === 'name' && !tomorro.name) continue;
      const [propertyName, value] = toNotion[key](tomorro[key]);
      properties[propertyName] = value;
      changedKeys.push(key);
    }

    if (!changedKeys.length) return { changed: false, pageId: row.id };

    return { changed: true, pageId: row.id, changedKeys: changedKeys.join(','), body: JSON.stringify({ properties }) };
    ```
  </Step>

  <Step title="Step 2.3 - Update the counterparty in Notion">
    *Make module 9*, filtered on `changed` = `true`.

    ```http theme={null}
    PATCH https://api.notion.com/v1/pages/{pageId}
    Notion-Version: 2022-06-28

    <body from step 2.2>
    ```
  </Step>
</Steps>

### Make blueprints

A blueprint is a Make scenario exported as JSON. Import it and every module comes back configured, code included.

<Card title="Get the 4 Make blueprints" icon="google-drive" href="https://drive.google.com/drive/folders/1YXxdcyQ7cBKlkSu5FGwl0nZx5Vk5-t0U">
  The *Sync counterparties - Flow 0 to 3* files: Flow 0 - Initialisation, Flow 1 - Creation, Flow 2 - Update from the ERP and Flow 3 - Update from Tomorro, one JSON file each.
</Card>

<Steps>
  <Step title="Import">
    In Make, create a scenario, open the **⋯** menu, choose **Import blueprint** and select one of the downloaded JSON files. Repeat for each flow.
  </Step>

  <Step title="Connect">
    * **Tomorro HTTP modules**: create an *API Key Auth* keychain: key name `x-api-key`, value your API key, placed in the header.
    * **Notion modules**: select a Notion connection that has access to your counterparty database.
    * **Flows 1 and 2**: on the *Watch Events* trigger, create a webhook with the event **Page: Properties updated**, one per scenario.
    * **Flow 3**: on both *Data store* modules, create a data store with a `key` and a text `value`.
  </Step>

  <Step title="Replace the ids">
    Replace `YOUR_NOTION_DATABASE_ID` and `YOUR_NOTION_DATA_SOURCE_ID` with your database (Notion API calls and the webhook filters), and the smart field ids (URLs and `FIELDS` in the Code modules) with yours.
  </Step>

  <Step title="Run">
    Run Flow 0 once, check a few counterparties in Tomorro, then turn on Flows 1, 2 and 3.
  </Step>
</Steps>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.