# Who Changed That? Build Notes

Flow structure and prompt for the HubSpot change watchdog from my MOPZA26 session.

Jimmy Piraino · jimmypiraino.com · jimmy@jimmypiraino.com

---

This is the actual shape of what I built, written so you can rebuild it rather than
copy it. It's two Power Automate flows that write to the same Teams channel. Nothing
here is HubSpot-specific except the first box. If your platform has an audit log and
an API, the pattern holds.

Names, keys, IDs and internal references have been genericized. Anywhere you see
`{{DOUBLE_BRACES}}`, that's yours to fill in.

**Contents**

1. [The shape](#1-the-shape)
2. [Flow 1: the change watchdog](#2-flow-1-the-change-watchdog)
3. [The prompt](#3-the-prompt)
4. [The ledger](#4-the-ledger)
5. [Flow 2: the volume monitor](#5-flow-2-the-volume-monitor)
6. [Gotchas that cost me real hours](#6-gotchas-that-cost-me-real-hours)
7. [Porting it to Salesforce](#7-porting-it-to-salesforce)

---

## 1. The shape

Three boxes.

```
  AUDIT LOG              REASONING LAYER            TRIAGE MESSAGE
  ---------              ---------------            --------------
  Platform already   ->  Summarize, rate,      ->   Post only the risky
  records the event      propose a next step        ones where people
                                                    already look
```

Everything below is detail on top of those three boxes.

Two flows sit under this, and the split is deliberate:

| | Flow 1: Change Watchdog | Flow 2: Volume Monitor |
|---|---|---|
| Catches | Causes | Symptoms |
| Clock | Every 30 min | Every 10 min |
| Source | Audit log endpoint | CRM Search API |
| AI | Yes | No |
| HubSpot tier | Enterprise | Any plan with Search API |
| Fails when | The model misjudges a change | Your baseline shifts |

Flow 1 depends on a model correctly judging that a change was risky. That's a
probabilistic call and sometimes it will be wrong. Flow 2 doesn't care what caused
the spike, it only knows what normal looks like. A deterministic backstop under a
probabilistic system.

**If you only build one, build Flow 2.** It's simpler, it needs no audit log, and it
would have caught the incident that started all this on its own.

---

## 2. Flow 1: the change watchdog

Scheduled cloud flow. Runs every 30 minutes.

### Actions, in order

```
Recurrence                    every 30 minutes
Initialize variable           HubSpotKey      (String, secure)
Initialize variable           AzureKey        (String, secure)
Initialize variable           WindowStart     (String)
Initialize variable           AfterCursor     (String, empty)
Scope: TRY
  Compose                     BuildURI
  Do until (AfterCursor empty)
    HTTP                      GET audit log page
    Parse JSON                audit log response
    Apply to each (results)
      Condition               is this a workflow change?
        > HTTP                GET workflow name by ID
        > Compose             TargetName
      HTTP                    POST Azure OpenAI
      Parse JSON              model response
      SharePoint Create item  ledger row
      Condition               risk == HIGH
        > Teams               post to channel
    Set variable              AfterCursor = next page cursor
  Set variable                WindowStart = now
Scope: CATCH  (configure run after: has failed, timed out, skipped)
  Teams                       post failure notice
```

### The window

The flow runs every 30 minutes and looks back **35**. Every run overlaps the
previous one by five minutes.

```
occurredAfter = addMinutes(utcNow(), -35)
```

Without the overlap you lose whatever lands in the seam between runs, and you lose it
silently, which is the worst way to lose something. Dedupe on the audit log's own
event ID if the overlap bothers you. It never bothered me.

### Building the URL

Do not build the URL inline in the HTTP action. Build it in a Compose first and
reference the output. Power Automate throws unhelpful URI validation errors on
inline expressions and will not tell you which character it disliked.

**Compose: BuildURI**

```
concat(
  'https://api.hubapi.com/account-info/v3/activity/audit-logs?occurredAfter=',
  variables('WindowStart'),
  if(empty(variables('AfterCursor')), '', concat('&after=', variables('AfterCursor')))
)
```

**HTTP action**

```
Method:  GET
URI:     @{outputs('BuildURI')}
Headers: Authorization: Bearer @{variables('HubSpotKey')}
```

Two things before you go try this:

- This endpoint is **Enterprise tier**. On Professional it isn't there. I'd rather
  tell you now than have you lose an afternoon.
- The scope you need is `account-info.security.read`.

Use a **Service Key**, not a legacy Private App token. Service keys rotate without
rebuilding the integration and they don't die when somebody offboards.

### Paging

Audit logs paginate. If you only take the first page you will miss changes, and
you'll miss them on exactly the busy days when you most needed the coverage.

The `Do until` condition is `empty(variables('AfterCursor'))`. At the bottom of the
loop, set `AfterCursor` from the response:

```
body('Parse_audit_log')?['paging']?['next']?['after']
```

If there's no next page that expression returns null, the variable goes empty, and
the loop exits. Set a loop limit (count 100, timeout PT1H) so a bad cursor can't run
forever.

**Test this specifically.** Force a window wide enough to produce more than one page
and confirm the cursor actually advances. A loop that silently processes page one and
exits looks identical to a loop that worked.

### What comes back

Per entry: `category`, `subCategory`, `action`, `objectId`, `objectType`,
`actingUser` (with email), `occurredAt`.

The categories I see in practice: `CRM_OBJECT`, `CONTENT`, `DATA_PRIVACY_REQUEST`,
`PROPERTY_VALUE`, `LIST`, `LOGIN`, `CRITICAL_ACTION`, `PROPERTY`, `APP_ACTIVITY`,
`WORKFLOWS`, `CRM_OBJECT_ASSOCIATION`.

### Resolve the ID before the model sees it

This is the most reusable decision in the whole build.

An audit entry tells you an object changed. It gives you a twelve digit object ID. A
twelve digit number is meaningless to a human and just as meaningless to a model. If
you hand it to the model, the model will either ignore it or make something up.

So before anything reaches the reasoning layer: if the change touched a workflow,
make a second call and resolve the ID into a real name.

```
Method:  GET
URI:     https://api.hubapi.com/automation/v3/workflows/@{items('Apply_to_each')?['objectId']}
Headers: Authorization: Bearer @{variables('HubSpotKey')}
```

Take `body?['name']` and pass that into the prompt. The model never sees a number.
It sees `Lead Scoring v3`.

**The principle: look up anything you can look up.** Every fact you hand a model
deterministically is a fact it can't get wrong. Most of the AI failures I've watched
in ops come down to somebody asking a model to guess at something the system already
knew.

### The model call

```
Method:  POST
URI:     https://{{YOUR_RESOURCE}}.openai.azure.com/openai/deployments/{{YOUR_DEPLOYMENT}}/chat/completions?api-version={{API_VERSION}}
Headers: Content-Type: application/json
         api-key: @{variables('AzureKey')}
```

Note the header is `api-key`, not `Authorization: Bearer`. Azure OpenAI differs from
the OpenAI API here and it will cost you twenty minutes if you don't know.

You do not need a frontier model for this. It reads one structured record and answers
three questions against a rubric you wrote. A small, cheap, fast deployment is the
right call, and because the whole thing is an HTTP request, swapping the model later
is a config change and not a rebuild. Worth doing on purpose. Your approved AI list
next year probably looks different from this year's.

Body:

```json
{
  "messages": [
    { "role": "system", "content": "{{SYSTEM_PROMPT, see section 3}}" },
    { "role": "user",   "content": "{{THE AUDIT ENTRY AS JSON}}" }
  ],
  "temperature": 0,
  "response_format": { "type": "json_object" }
}
```

`temperature: 0`. You want the same entry to get the same rating every time. This is
a classifier, not a brainstorm.

### Error handling

Wrap the whole thing in a **Scope** named TRY. Add a second Scope named CATCH,
configured to run after TRY has failed, timed out, or been skipped. CATCH posts to
the same Teams channel.

Azure goes down sometimes. Models return malformed JSON sometimes. When that happens
the Parse JSON step fails, the scope fails, and I get a message saying the watchdog
errored and may have missed changes.

A silent watchdog is worse than no watchdog. With no watchdog you know you're
exposed. With a silent one you think you're covered.

**Go break it on purpose once.** Put a bad key in, let the flow fail, and confirm the
message actually reaches you. Untested error handling is a comment that says it'll
probably be fine.

---

## 3. The prompt

This is the part people ask about, and it's the part that does the least magic. It's
a rubric. I decided what risk means. The model applies it at a volume I don't have
the bandwidth to read.

### System prompt

```
You are a change triage assistant for a HubSpot CRM instance. You read a single
audit log entry and return a structured assessment. You do not have access to any
information beyond the entry provided. Never infer facts that are not in the entry.

Return ONLY valid JSON matching this schema, with no markdown fences and no
commentary:

{
  "summary":   "string, one plain-English sentence, max 25 words",
  "risk":      "HIGH" | "LOW",
  "triage":    "string, one specific action, max 30 words"
}

SUMMARY
Write one sentence a busy person understands in two seconds. Name the actor by
email, name the thing that changed by its name if a name was provided, and say what
happened. Do not include raw object IDs. Do not restate the category enums.

RISK
Default to LOW. Rate HIGH only if the entry matches one of these conditions:

  1. A workflow was edited, deleted, or deactivated.
  2. A workflow was created by anyone other than {{ADMIN_EMAIL_1}} or
     {{ADMIN_EMAIL_2}}.
  3. Data was pointed at a new external endpoint (webhook target changed, new
     integration authorized, export to an unfamiliar destination).
  4. Lead scoring or lead routing logic was changed.
  5. A property definition or a pipeline structure was changed. Property VALUE
     changes on individual records are LOW. Property DEFINITION changes are HIGH.
  6. The acting user's email domain is not {{YOUR_DOMAIN}}.

If none of the above match, the answer is LOW. Do not rate something HIGH because
it feels unusual. Volume of similar changes is not a risk factor. An entry you
cannot classify is LOW.

TRIAGE
Write the next action, not a category of action. "Investigate" is not an answer.
Name who to ask, or what to go look at, or both.

Good:  "Confirm with sarah@example.com whether the Lead Scoring v3 change was
        approved, then check MQL volume for the last two hours."
Bad:   "Review this change."

For LOW entries, triage should be "No action needed."
```

### User message

Pass the entry as JSON with the resolved name already filled in:

```json
{
  "occurredAt":   "2026-07-23T21:45:11.000Z",
  "actingUser":   "contractor@partner-agency.example",
  "category":     "WORKFLOWS",
  "subCategory":  "WORKFLOW_UPDATED",
  "action":       "UPDATE",
  "objectType":   "WORKFLOW",
  "objectName":   "Lead Scoring v3"
}
```

### Why two risk levels and not five

Five levels means every rating becomes a debate with yourself and nobody trusts the
middle three. Two levels forces the rubric to be explicit, which is the actual work.
The question isn't "how risky is this," it's "does this get a message or not."

### Tuning

**The tuning surface is the rubric, not the model.** When it rates something wrong,
I don't reword the prompt's tone and I don't swap the model. I add a line to the
numbered list, or I add an explicit exclusion. Then the ledger tells me whether it
stopped.

That's the whole loop. It gets one wrong, I add a line, the list tells me if it
worked.

---

## 4. The ledger

If I were starting over I'd build this first, before the alerting.

Every entry gets written to a SharePoint list before it's gated. High and low, all
of it. One row per change.

| Column | Type | Notes |
|---|---|---|
| Title | Single line | Event ID from the audit log |
| OccurredAt | Date and time | From the entry, not the flow run time |
| ActorEmail | Single line | |
| Category | Single line | |
| SubCategory | Single line | |
| Action | Single line | CREATE / UPDATE / DELETE / EXPORT / ARCHIVE |
| ObjectId | Single line | Keep it even though the model never saw it |
| ObjectName | Single line | Resolved name, blank if not resolvable |
| Summary | Multiple lines | Model output |
| RiskRating | Choice | HIGH, LOW |
| TriageStep | Multiple lines | Model output |
| DecisionSource | Choice | AI, Rule, Human |
| Feedback | Choice | Useful, Noise |

Teams is for the moment. The list is for everything that comes after it.

Three things it buys you:

- **It's queryable.** You can answer who changed what and when without scrolling a
  channel.
- **It measures the model.** Every row carries the rating and the reasoning, so you
  can go count how often it got one wrong.
- **It holds the disagreements.** `DecisionSource` says AI on every row today.
  Sooner or later a person is going to overrule this thing, and I want that on the
  record.

A warning from experience: `Feedback` is the column that makes the second bullet
real, and it is blank on every row in my list. I built the instrument and never
picked it up. If you build this, put a recurring fifteen minutes on your calendar to
go mark rows Useful or Noise. Otherwise your false positive rate is a feeling, not a
number.

---

## 5. Flow 2: the volume monitor

Scheduled cloud flow. Every 10 minutes. No AI, no audit log, no Enterprise tier.

```
Recurrence                every 10 minutes
Initialize variable       Threshold (Integer) = {{YOUR_NUMBER}}
Compose                   WindowStartMs
HTTP                      POST contacts search
Parse JSON                response
Condition                 total > Threshold
  > Teams                 post to channel
```

**Compose: WindowStartMs**

The Search API wants epoch milliseconds.

```
div(sub(ticks(addMinutes(utcNow(), -10)), ticks('1970-01-01T00:00:00Z')), 10000)
```

**HTTP**

```
Method:  POST
URI:     https://api.hubapi.com/crm/v3/objects/contacts/search
Headers: Authorization: Bearer @{variables('HubSpotKey')}
         Content-Type: application/json
```

```json
{
  "filterGroups": [{
    "filters": [{
      "propertyName": "{{HS_DATE_ENTERED_MQL_PROPERTY}}",
      "operator": "GTE",
      "value": "@{outputs('WindowStartMs')}"
    }]
  }],
  "limit": 1
}
```

`limit: 1` on purpose. You want the `total` off the response, not the records. One
row back, and the count is free.

The property name is `hs_v2_date_entered_<lifecyclestage_id>` and **the ID is
specific to your portal.** Find yours by pulling the contact property list and
looking for the `hs_v2_date_entered_` prefix, or read it out of a contact record that
recently became an MQL.

**On the threshold:** mine is 200, and 200 isn't a magic number. It came from our
baseline, not from anywhere clever. Pull your own MQL volume for a few normal weeks,
find your ten minute peak, and set it comfortably above that. You'd rather start
quiet and tighten than start noisy and get ignored.

---

## 6. Gotchas that cost me real hours

**1. URI whitespace validation errors.** Power Automate rejects a URL it doesn't like
and won't tell you why. Build it in a Compose action first, then reference that
output. Fixed it every time.

**2. Parse JSON breaking on type mismatches.** Real audit data has nulls where your
sample data doesn't. Generate the schema from a sample, then go make every field
null-tolerant by hand: `"type": ["string", "null"]`.

**3. Conditions failing on type.** A number arriving as a string does not compare the
way you think it does. Wrap it: `int(body('Parse_JSON')?['total'])`.

**4. Teams actions corrupting after an upstream schema change.** There is no fix.
Delete the action and recreate it. I lost an hour repairing one before rebuilding it
in ninety seconds.

**5. The new designer removed "edit in advanced mode" for conditions.** If you find
an older tutorial that says otherwise, you're not losing your mind. Use the `fx`
expression tab on the operand instead.

**6. Export a copy of your flow before you touch it.** Power Automate will happily
let you save your way into something that no longer runs, and the undo story is worse
than you want it to be. It takes fifteen seconds and you'll need it exactly once,
which is enough.

**7. Premium connector licensing.** HTTP and the Azure OpenAI path need a premium
Power Automate license. If you already have one this adds no new line item. If you
don't, price it before you build it.

The tool changes but the failure class doesn't. Every low-code platform breaks the
same three ways: strings that should be numbers, schemas that assume your sample
data, and connectors that hold stale state. Build the URL in a variable first,
tolerate nulls, rebuild rather than repair.

---

## 7. Porting it to Salesforce

I haven't built this one. It's a map, not a finished thing.

| | HubSpot | Salesforce |
|---|---|---|
| Event source | `account-info/v3/activity/audit-logs` | `SetupAuditTrail` |
| Auth | Service Key, Bearer | Connected App, OAuth |
| Window | `occurredAfter` parameter | `CreatedDate` filter vs last run |
| Fields | category, action, objectId, actingUser | Section, Action, CreatedBy, Display |
| Reasoning layer | **No change** | **No change** |
| Ledger | **No change** | **No change** |
| Teams message | **No change** | **No change** |

Four rows change. Three don't. The three that don't are the ones that took longest
to get right.

Same story for a Marketo audit trail, for your CDP, for anything with an events
endpoint. If your platform records who changed what, you can do this.

The question worth asking: **which of our systems would hurt the most if somebody
changed it quietly?** Start there. Though if it's a system you don't know well, start
somewhere you do, because you need to be able to tell instantly whether the thing got
a rating right.

---

## A note on what this is and isn't

This detects. It does not remediate.

People ask why it doesn't just undo the change. It knows what changed, it knows who
did it, it could roll it back. Here's why not: the system has no idea what anybody
intended. Most of those changes are legitimate work by people doing their jobs. An
automation that reverts production config based on a probabilistic risk rating is a
far more dangerous thing than the problem I was trying to solve.

Detection that's wrong costs me two minutes. Remediation that's wrong costs me a
workflow somebody spent a week building.

So it tells me, and I decide.

---

If you build one of these, even a somewhat janky first version, I'd like to hear
about it. What you pointed it at, and whether it caught anything.

jimmy@jimmypiraino.com · [jimmypiraino.com](https://jimmypiraino.com) · *Stack & Signal*
