{
  "name": "IntakeSync: JetFormBuilder intake to Airtable client database (dedupe upsert)",
  "flow": [
    {
      "id": 1,
      "module": "gateway:CustomWebHook",
      "version": 1,
      "parameters": {
        "maxResults": 1
      },
      "mapper": {},
      "metadata": {
        "designer": {
          "x": 0,
          "y": 0,
          "name": "JetFormBuilder intake webhook"
        },
        "restore": {
          "parameters": {
            "hook": {
              "data": {
                "editable": "true"
              },
              "label": "JetFormBuilder intake"
            }
          }
        },
        "parameters": [
          {
            "name": "hook",
            "type": "hook:gateway-webhook",
            "label": "Webhook",
            "required": true
          },
          {
            "name": "maxResults",
            "type": "number",
            "label": "Maximum number of results"
          }
        ],
        "notes": "In JetFormBuilder, change the form action to Call Webhook and paste this webhook address. On the first real submission run Determine Data Structure once, so Make maps each form field to a top level key by its field name. Fields are then addressable as {{1.full_name}}, {{1.email}}, and so on. JetFormBuilder posts the submission as form fields; Make parses them into one bundle regardless of encoding."
      }
    },
    {
      "id": 2,
      "module": "util:SetVariables",
      "version": 1,
      "parameters": {},
      "filter": {
        "name": "Valid intake (a well-formed email is present)",
        "conditions": [
          [
            {
              "a": "{{1.email}}",
              "o": "exist"
            },
            {
              "a": "{{trim(1.email)}}",
              "b": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$",
              "o": "text:pattern"
            }
          ]
        ]
      },
      "mapper": {
        "variables": [
          {
            "name": "email_norm",
            "value": "{{lower(trim(1.email))}}"
          },
          {
            "name": "full_name",
            "value": "{{trim(1.full_name)}}"
          }
        ]
      },
      "metadata": {
        "designer": {
          "x": 300,
          "y": 0,
          "name": "Validate + normalize email"
        },
        "notes": "A blank or malformed email would break the dedupe match and let a junk record through, so the filter here requires a well-formed email before anything touches Airtable. The email is lowercased and trimmed into email_norm so the search and the write use one consistent key. Invalid submissions stop here and never create a record."
      }
    },
    {
      "id": 3,
      "module": "airtable:ActionSearchRecords",
      "version": 3,
      "parameters": {
        "__IMTCONN__": "<AIRTABLE_CONNECTION_ID>"
      },
      "mapper": {
        "base": "appMATCHMKR000000",
        "table": "tblClients00000000",
        "formula": "AND({Email}!=\"\", LOWER({Email})=LOWER(\"{{2.email_norm}}\"))",
        "maxRecords": "1",
        "useColumnId": false
      },
      "metadata": {
        "designer": {
          "x": 600,
          "y": 0,
          "name": "Airtable: search by email (dedupe)"
        },
        "notes": "Airtable does not enforce a unique constraint on a field, so a plain Create a Record would make a duplicate every time someone resubmits. This searches the Clients table for the normalized email first. The result count is read downstream as {{3.__IMTLENGTH__}}: 0 means new, 1 means the client already exists. maxRecords 1 keeps it to a single match. If the base already contains duplicates, this returns the first and you decide the tie-break (update the oldest, or flag for review)."
      }
    },
    {
      "id": 4,
      "module": "builtin:BasicRouter",
      "version": 1,
      "parameters": {},
      "mapper": null,
      "metadata": {
        "designer": {
          "x": 900,
          "y": 0,
          "name": "Route: create if new, update if exists"
        }
      },
      "routes": [
        {
          "flow": [
            {
              "id": 5,
              "module": "airtable:ActionCreateRecord",
              "version": 3,
              "parameters": {
                "__IMTCONN__": "<AIRTABLE_CONNECTION_ID>"
              },
              "filter": {
                "name": "New client (search found nothing)",
                "conditions": [
                  [
                    {
                      "a": "{{3.__IMTLENGTH__}}",
                      "b": "0",
                      "o": "number:equal"
                    }
                  ]
                ]
              },
              "mapper": {
                "base": "appMATCHMKR000000",
                "table": "tblClients00000000",
                "record": {
                  "Name": "{{1.full_name}}",
                  "Phone": "{{1.phone}}",
                  "Age": "{{1.age}}",
                  "City": "{{1.city}}",
                  "Seeking": "{{1.seeking}}",
                  "Preferred Age Range": "{{1.age_range_preference}}",
                  "Source": "{{1.how_heard}}",
                  "Consent": "{{1.consent}}",
                  "Last Updated": "{{now}}",
                  "Email": "{{2.email_norm}}",
                  "Status": "New",
                  "First Seen": "{{now}}",
                  "Intake Count": "1"
                },
                "typecast": true,
                "useColumnId": false
              },
              "onerror": [
                {
                  "id": 100,
                  "module": "builtin:Break",
                  "version": 1,
                  "mapper": {
                    "count": "3",
                    "interval": "5"
                  },
                  "metadata": {
                    "designer": {
                      "x": 0,
                      "y": 0,
                      "name": "Retry, then park as incomplete"
                    },
                    "notes": "On an Airtable error (rate limit or a 5xx), retry 3 times at 5 minute intervals, then store the run as an incomplete execution to resume later. No submission is dropped. Add a notify step before this directive to alert on repeated failures, or a dead-letter table to capture the raw payload."
                  }
                }
              ],
              "metadata": {
                "designer": {
                  "x": 1200,
                  "y": -150,
                  "name": "Airtable: create client record"
                },
                "notes": "Runs only when the search returned zero records. Creates a new client with Status New, First Seen set to now, and Intake Count 1. typecast true lets Airtable coerce the single select and number fields from the text the form sends. Writes by field name (useColumnId false) so the mapping reads clearly."
              }
            }
          ]
        },
        {
          "flow": [
            {
              "id": 6,
              "module": "airtable:ActionUpdateRecord",
              "version": 3,
              "parameters": {
                "__IMTCONN__": "<AIRTABLE_CONNECTION_ID>"
              },
              "filter": {
                "name": "Returning client (search found a match)",
                "conditions": [
                  [
                    {
                      "a": "{{3.__IMTLENGTH__}}",
                      "b": "0",
                      "o": "number:greater"
                    }
                  ]
                ]
              },
              "mapper": {
                "base": "appMATCHMKR000000",
                "table": "tblClients00000000",
                "id": "{{3.id}}",
                "record": {
                  "Name": "{{1.full_name}}",
                  "Phone": "{{1.phone}}",
                  "Age": "{{1.age}}",
                  "City": "{{1.city}}",
                  "Seeking": "{{1.seeking}}",
                  "Preferred Age Range": "{{1.age_range_preference}}",
                  "Source": "{{1.how_heard}}",
                  "Consent": "{{1.consent}}",
                  "Last Updated": "{{now}}",
                  "Intake Count": "{{add(3.Intake Count; 1)}}"
                },
                "typecast": true,
                "useColumnId": false
              },
              "onerror": [
                {
                  "id": 101,
                  "module": "builtin:Break",
                  "version": 1,
                  "mapper": {
                    "count": "3",
                    "interval": "5"
                  },
                  "metadata": {
                    "designer": {
                      "x": 0,
                      "y": 0,
                      "name": "Retry, then park as incomplete"
                    },
                    "notes": "On an Airtable error (rate limit or a 5xx), retry 3 times at 5 minute intervals, then store the run as an incomplete execution to resume later. No submission is dropped. Add a notify step before this directive to alert on repeated failures, or a dead-letter table to capture the raw payload."
                  }
                }
              ],
              "metadata": {
                "designer": {
                  "x": 1200,
                  "y": 150,
                  "name": "Airtable: update existing record"
                },
                "notes": "Runs only when the search found a match. Updates the found record ({{3.id}}) in place: refreshes the mutable fields, stamps Last Updated, and bumps Intake Count. First Seen and the email key are left untouched, so the history is kept and no duplicate is created."
              }
            }
          ]
        }
      ]
    }
  ],
  "metadata": {
    "instant": true,
    "version": 1,
    "scenario": {
      "roundtrips": 1,
      "maxErrors": 3,
      "autoCommit": true,
      "autoCommitTriggerLast": true,
      "sequential": false,
      "slots": null,
      "confidential": false,
      "dataloss": false,
      "dlq": false,
      "freshVariables": false
    },
    "designer": {
      "orphans": []
    },
    "zone": "eu2.make.com"
  }
}
