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
- The shape
- Flow 1: the change watchdog
- The prompt
- The ledger
- Flow 2: the volume monitor
- Gotchas that cost me real hours
- 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:
{
"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 [email protected] 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:
{
"occurredAt": "2026-07-23T21:45:11.000Z",
"actingUser": "[email protected]",
"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.
DecisionSourcesays 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
{
"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.
[email protected] · jimmypiraino.com · Stack & Signal