Who this is for. Operations leaders can focus on the business impact, licensing, and exception handling. Power Automate developers can use the remaining sections as the implementation reference, including the data model, expressions, flow actions, error handling, and production considerations.
What this removes
A coordinator takes a service request, works out which engineer covers the postcode, opens several calendars to find a gap, emails the customer options, waits, chases, books it, confirms, and repeats the loop on any reschedule.
Two of those steps require judgment: whether the job is standard, and handling the exceptions. The rest are lookups against data the business already holds. This build automates the lookups and leaves the judgment with the coordinator, who receives a queue of genuine exceptions rather than every booking.
Which operations this fits
The pattern assumes a mobile engineer: someone certified who drives to a customer address for a job whose length you can predict. Three choices in the data model give that away. PostcodePrefixes on the Engineers list only matters if work is assigned by geography. TravelBufferMins added to every duration only matters if there is travel between jobs. And availability is read from each engineer’s own calendar, which means the scarce resource is the person’s time rather than a building or a machine.
Any trade with that shape can use this directly, because they all run on the same three variables: who covers this area, who holds this certification, and when are they free. The table below maps the common ones onto the data model, since the equipment being serviced is what your JobTypes list actually contains and the certification is what RequiredCert matches against.
Certification names here are UK examples. Substitute your own jurisdiction’s schemes, and treat the durations as placeholders to be replaced with figures from your closed job history, per the note on DurationMins below.
| Category | Engineer type | Equipment serviced | Certification gate |
|---|---|---|---|
| HVAC | Air conditioning and ventilation | Split and ducted systems, chillers, heat pumps, air handling units | F-Gas, for refrigerant handling |
| Heating | Boiler and heating engineers | Gas boilers, unvented cylinders, radiators, heat pumps | Gas Safe registration |
| Electrical | Electricians and test engineers | Consumer units, EV chargers, lighting circuits, periodic inspection | Part P, NICEIC or equivalent |
| Plumbing | Plumbers | Bathrooms, leaks, pumps, water heaters | Water regulations competence |
| Appliances | Domestic appliance technicians | Washing machines, dishwashers, ovens, refrigeration | Manufacturer authorisation per brand |
| Renewables | Solar and battery installers | Panels, inverters, battery storage, EV integration | MCS, plus grid connection competence |
| Telecom | Broadband and fibre engineers | Fibre terminations, ONTs, routers, business lines | Provider accreditation |
| Security | CCTV and alarm engineers | Cameras, recorders, intruder panels, door entry | SSAIB or NSI |
| Access | Lift and automatic door engineers | Passenger lifts, roller shutters, automatic doors | LOLER inspection competence |
| Fire safety | Fire systems technicians | Alarm panels, extinguishers, emergency lighting, sprinklers | BAFE scheme registration |
| Medical | Biomedical service engineers | Imaging, sterilisers, analysers, dialysis units | Manufacturer training per device |
| IT | Field support engineers | Switches, access points, printers, POS terminals | Vendor certification |
| Industrial | Maintenance technicians | Pumps, conveyors, compressors, control panels | Site and equipment specific |
| Pest control | Pest technicians | Treatments, bait stations, monitoring visits | RSPH or equivalent |
The reason certification belongs in the data rather than in the flow logic is visible in the last column. A brand authorisation for appliance repair and an F-Gas category for air conditioning behave identically as far as matching is concerned: both are a string on the engineer record that must be present before that engineer can be offered. Adding a new scheme means adding a row, not editing a flow.
The category column is worth carrying into the build as well. If you run more than one service line, add a ServiceLine choice column to both Engineers and JobTypes using these categories, and include it in the Filter array condition alongside postcode and certification. A multi-discipline contractor doing both HVAC and electrical work needs that separation, because certification strings alone will eventually let a job reach an engineer who is qualified on paper and wrong in practice.
It works best when three things are true. Engineers have defined service areas. Jobs carry a known skill or certification requirement. And engineers keep their calendars current, which is the prerequisite people underestimate, because a calendar that is not maintained produces slots that are not real.
Where the customer comes to you, the availability logic changes.
A garage, workshop, or service centre books a bay or a ramp rather than a person, so there is no postcode routing and the constraint is capacity per time slot rather than gaps in an individual calendar. Everything around it survives: the three flows, the token and expiry, the GET and POST split, the error handling. What gets rewritten is the availability computation in section 1.5, where each bay becomes a resource mailbox or its own list and certification matching becomes a question of whether the equipment suits the job. Mobile variants of the same trades, mobile mechanics and mobile tyre fitting for instance, fit the original pattern without changes.
Three cases stay manual regardless of trade: emergency and same-day dispatch, work needing more than one engineer or more than one day, and jobs whose duration is unknown until somebody diagnoses them on site. These are handled through the exception routing described later rather than by the matching logic.
Architecture: three flows
FLOW 1: Intake and Offer (SharePoint trigger)
------------------------------------------------
New Requests item
-> guard: AutoScheduleAllowed
-> query Engineers (OData)
-> filter: postcode + certification
-> read each calendar, compute gaps
-> rank slots, keep top 3
-> write token + slots to request
-> email customer 3 links
|
v
FLOW 2: Slot Selection (HTTP trigger, Any)
------------------------------------------------
GET (customer clicks the emailed link)
-> validate token + status + expiry
-> DISPLAY the slot, no writes
-> render a Confirm button
POST (customer clicks Confirm)
-> validate again, independently
-> re-check the slot is still free
-> create Outlook event
-> store EventId
-> confirm to customer
|
v
FLOW 3: Reminders (Recurrence, daily)
------------------------------------------------
Query Booked requests in window
-> send reminder + reschedule link
Design principle: SharePoint holds request state, Outlook holds availability truth, and neither is cached. Availability is computed at offer time and re-verified at booking time, because those are different moments.
Flow actions at a glance
The complete action inventory before the detail. Action names follow the current connectors at time of writing; confirm each in your own environment.
| Flow | Action | Purpose |
|---|---|---|
| 1 | When an item is created (SharePoint) | Trigger on a new request |
| 1 | Initialize variable (x5) | Postcode prefix, duration, slots, token, timestamp |
| 1 | Get item (SharePoint) | Read the job type and its duration |
| 1 | Condition | Guard on AutoScheduleAllowed, exit early |
| 1 | Get items (SharePoint) | Query active engineers, OData filtered |
| 1 | Filter array | Match postcode prefix and certification |
| 1 | Condition | Exit to coordinator if no candidates |
| 1 | Apply to each (concurrency set) | Iterate candidate engineers |
| 1 | Get calendar view of events (Outlook) | Read live availability, ordered by start |
| 1 | Inline Code or Office Script (optional) | Only if gap logic exceeds connector ordering |
| 1 | Append to array variable | Collect qualifying slots |
| 1 | Update item (SharePoint) | Write offered slots, token, expiry |
| 1 | Send an email (V2) | Send the three slot links |
| 2 | When an HTTP request is received (Any) | Receive both the GET and the POST |
| 2 | Switch on triggerOutputs()['method'] | Branch GET (display) from POST (book) |
| 2 | Get item (SharePoint) | Load the request for validation, both branches |
| 2 | Condition | Validate token, status, and expiry together |
| 2 | Response (GET branch) | Render the slot plus a Confirm button, no writes |
| 2 | Get calendar view of events (POST branch) | Re-check the slot is still free |
| 2 | Create event (V4) (Outlook) | Book the engineer's calendar |
| 2 | Update item (SharePoint) | Write status, engineer, slot, EventId |
| 2 | Response (POST branch) | Return a confirmation page |
| 3 | Recurrence | Daily reminder run |
| 3 | Get items (SharePoint) | Booked jobs inside the reminder window |
| 3 | Send an email (V2) | Reminder plus reschedule link |
| All | Scope (Try / Catch) | Error boundary with configured run-after |
| All | Create item (SharePoint) | Write failures to FlowLog |
Prerequisites
- A service account with a mailbox, used as the flow connection owner. Do not build these on a named employee’s account; the flows break when they leave.
- Calendar read access to engineer calendars for that service account. Either delegated permissions per engineer or an application policy, depending on your tenant’s approach.
- Engineers keeping their Outlook calendars current. This is a genuine prerequisite, not a nicety. If engineers block time inconsistently, the gap computation returns slots that are not real.
Environment variables
Build in a solution and parameterise anything an operations lead might change. Retrofitting solution awareness later is significant rework.
| Variable | Type | Example | Purpose |
|---|---|---|---|
| env_WorkDayStart | Text | 09:00 | Working window start, local time |
| env_WorkDayEnd | Text | 17:00 | Working window end, local time |
| env_TravelBufferMins | Number | 30 | Added to every job duration |
| env_SearchDaysAhead | Number | 10 | How far forward to look |
| env_OfferExpiryHours | Number | 48 | Token lifetime |
| env_CoordinatorChannel | Text | Teams channel id | Exception routing |
| env_TimeZone | Text | GMT Standard Time | Windows timezone id, not IANA |
| env_Flow2Url | Text | HTTP trigger URL | Injected into offer links |
Note env_TimeZone takes a Windows timezone identifier (GMT Standard Time), not IANA (Europe/London). The convertFromUtc expression rejects IANA names.
Data model
Four SharePoint lists. Dataverse works identically if you already have it and prefer proper relational behaviour.
| Column | Type | Notes |
|---|---|---|
| Title | Single line | Name |
| UPN | Single line | Login, used for calendar calls |
| PostcodePrefixes | Multiple lines | Semicolon-delimited: SW;SE;BR |
| Certifications | Multiple lines | Semicolon-delimited, must match JobTypes strings exactly |
| Region | Choice | Reporting and buffer lookup |
| Active | Yes/No | Filter on this; never delete engineers |
Use multiple lines of text with semicolon delimiters rather than multi-choice columns. Multi-choice columns return arrays that require an extra Apply to each to test, which multiplies action consumption. A contains() against a delimited string is one expression.
JobTypes
| Column | Type | Notes |
|---|---|---|
| Title | Single line | e.g. "Annual boiler service" |
| DurationMins | Number | From closed job history, not from estimate |
| RequiredCert | Single line | Exact string match against Engineers.Certifications |
| AutoScheduleAllowed | Yes/No | The exception list, as data |
Requests
| Column | Type | Notes |
|---|---|---|
| Title | Single line | Reference |
| CustomerEmail | Single line | |
| SitePostcode | Single line | |
| JobType | Lookup | To JobTypes |
| Status | Choice | New, AwaitingCustomer, Booked, Coordinator, Failed |
| AssignedEngineer | Lookup | Written at booking |
| OfferedSlots | Multiple lines | JSON array of the three offered slots |
| OfferToken | Single line | GUID, validates the customer link |
| OfferExpiry | Date and Time | UTC |
| SlotStart / SlotEnd | Date and Time | UTC, written at booking |
| EventId | Single line | Required for reschedule and cancel |
| FailureReason | Multiple lines | Written by the catch scope |
FlowLog
| Column | Type | Notes |
|---|---|---|
| Title | Single line | Flow name |
| RequestRef | Single line | |
| Severity | Choice | Info, Warning, Error |
| Detail | Multiple lines | Serialized error output |
Three schema decisions worth copying. AutoScheduleAllowed as data means the operations lead withdraws a job type from automation without editing a flow. EventId is what makes reschedule possible later, and teams routinely omit it then find they cannot update the event they created. OfferToken plus OfferExpiry is what stops an emailed booking link being reusable forever.
On DurationMins: derive it from closed job records. In most operations at least one job type runs materially longer than the assumed figure, and an optimistic duration degrades the whole afternoon silently.
Flow 1: Intake and offer
Trigger: SharePoint When an item is created on Requests.
1.1 Initialise variables
Five, all before any branching.
PostcodePrefix (String):
toUpper(substring(concat(triggerOutputs()?['body/SitePostcode'],' '), 0, 2))
RequiredMins (Integer):
0 (set after the JobTypes lookup)
CandidateSlots (Array):
[]
OfferToken (String):
guid()
NowUtc (String):
utcNow()
The concat(..., ' ') padding on the postcode is deliberate. substring throws a runtime error if the source string is shorter than the requested length, and a customer will eventually submit a one-character postcode. Padding makes it safe without a separate condition.
Capture utcNow() into a variable once. Calling utcNow() repeatedly through a long-running flow gives you slightly different values at each call, which produces off-by-seconds bugs in expiry comparisons.
1.2 Job type lookup and guard
Get item on JobTypes using triggerOutputs()?['body/JobTypeLookupId'], then set the duration and guard immediately:
Set variable RequiredMins:
add(
int(outputs('Get_item_JobType')?['body/DurationMins']),
int(parameters('env_TravelBufferMins'))
)
Condition:
outputs('Get_item_JobType')?['body/AutoScheduleAllowed'] is equal to false
-> Update item: Status = 'Coordinator'
-> Post card to env_CoordinatorChannel
-> Terminate (Succeeded)
Exit before doing any expensive work. Terminate as Succeeded, not Failed: routing to a human is a business outcome, not a system error, and marking it Failed makes genuine failures impossible to find in run history.
1.3 Query candidate engineers
Get items on Engineers, filtering server-side as far as SharePoint allows:
Filter Query: Active eq 1
Top Count: 500
SharePoint OData cannot substring-match the delimited prefix column, so postcode and certification filtering happens next, in the flow.
1.4 Narrow the candidates
Filter array over outputs('Get_items_Engineers')?['body/value'], condition in advanced mode:
@and(
contains(item()?['PostcodePrefixes'], variables('PostcodePrefix')),
contains(item()?['Certifications'], outputs('Get_item_JobType')?['body/RequiredCert'])
)
Then check before continuing:
Condition: length(body('Filter_array')) is equal to 0
-> Status = 'Coordinator'
-> FailureReason = 'No engineer matched area and certification'
-> Post to coordinator channel
-> Terminate (Succeeded)
Substring matching has a trap worth knowing: a prefix of S would match SW, SE, and SL. If your postcode areas include single-letter prefixes, store them with the delimiter attached (;SW;SE;) and match on contains(field, concat(';', prefix, ';')) instead.
1.5 Compute real availability
Two approaches. Find meeting times (V2) is fewer actions but its parameter shape differs between connector versions and it applies its own confidence weighting you do not control. Computing gaps yourself from the calendar view is more actions but deterministic and version-stable. For a scheduling system where you must be able to explain why a slot was or was not offered, compute them yourself.
Inside an Apply to each over body('Filter_array'), call Get calendar view of events (V3):
Calendar id: item()?['UPN']
Start time: variables('NowUtc')
End time: addDays(variables('NowUtc'), int(parameters('env_SearchDaysAhead')))
Set concurrency explicitly on the loop: open its Settings, enable Concurrency Control, and set a degree of parallelism around 4 or 5. The default runs sequentially and is slow across twenty engineers; a high value triggers Graph throttling. Tune against your real candidate counts.
GAP FINDING (per engineer, per day)
-----------------------------------
Working window: 09:00 ---------------- 17:00
Existing events: [10:00-11:00] [14:00-15:30]
Build the busy list, sort by start, then walk it:
cursor = 09:00
for each event (sorted):
gap = event.start - cursor
if gap >= requiredMins: record slot at cursor
cursor = max(cursor, event.end)
finally: gap = 17:00 - cursor
Required = DurationMins + TravelBufferMins
Figure 2. The gap walk. Sorting first is essential; unsorted events produce overlapping or negative gaps.
Get calendar view of events (V3) exposes an Order By parameter, so let the connector return events already sorted rather than sorting them yourself:
Order By: start/dateTime asc
That covers the ordering the gap walk needs. If your logic requires transformations the action cannot provide, sorting a merged array across several engineers, for example, or grouping by day, use Inline Code (JavaScript), an Office Script, or a Graph call through an HTTP action. Reach for those only when the connector genuinely cannot do the job; each adds a licensing or maintenance cost that plain connector parameters do not.
To compare two datetimes in minutes:
div(
sub(
ticks(item()?['start']['dateTime']),
ticks(variables('Cursor'))
),
600000000
)
ticks() returns 100-nanosecond intervals, so dividing by 600,000,000 gives minutes. This is the standard way to do datetime arithmetic in Power Automate, since there is no dateDiff function.
Append qualifying slots to CandidateSlots as objects:
{
"engineerId": @{item()?['ID']},
"engineerName": "@{item()?['Title']}",
"startUtc": "@{variables('Cursor')}",
"endUtc": "@{addMinutes(variables('Cursor'), variables('RequiredMins'))}"
}
1.6 Rank and take three
Earliest first is the usual rule. With events returned in order and engineers iterated in a stable sequence, take(variables('CandidateSlots'), 3) is sufficient. If you need a strict global ordering across engineers, sort the merged array before taking the top three.
Consider whether earliest-first is right for your operation. It concentrates work on whichever engineer happens to have the earliest gap. If you need load balancing, rank by the engineer's assigned job count for that week instead, which requires a Get items on Requests filtered by engineer and date range, and is a meaningfully different flow.
1.7 Write the offer and send the links
Update item on Requests:
Status: AwaitingCustomer
OfferedSlots: string(take(variables('CandidateSlots'), 3))
OfferToken: variables('OfferToken')
OfferExpiry: addHours(variables('NowUtc'), int(parameters('env_OfferExpiryHours')))
Then Send an email (V2) with one link per slot:
@{parameters('env_Flow2Url')}&ref=@{triggerOutputs()?['body/ID']}
&token=@{variables('OfferToken')}&slot=0
Display each time in the customer's local terms, not UTC:
formatDateTime(
convertFromUtc(variables('slotStartUtc'), parameters('env_TimeZone')),
'dddd d MMMM, HH:mm'
)
Flow 2: Slot selection
Trigger: When an HTTP request is received, method set to Any so the flow can handle both a GET and a POST, branching on triggerOutputs()[‘method’].
2.0 Why GET must not book
A GET request must only display. The booking happens on POST. This is not REST pedantry; getting it wrong produces bookings nobody made.
An emailed link is fetched by things that are not your customer. Mail security scanners follow links to check them for malware. Outlook Safe Links and equivalent products in other suites prefetch destinations. Chat clients unfurl link previews. Some mobile mail apps prefetch to speed up rendering. If your GET endpoint creates a calendar event, every one of those becomes a phantom booking, and the customer’s first knowledge of it is an engineer arriving.
GET /flow?ref=..&token=..&slot=1
-> validate token, status, expiry
-> RENDER a page showing the chosen slot
and a "Confirm this appointment" button
-> NO write of any kind
POST /flow (from that button)
-> validate again, independently
-> re-check availability
-> create event, update record
-> render confirmation
The GET branch is read-only: it may query SharePoint and Outlook, but it writes nothing and creates nothing. Every state change lives in the POST branch.
Two implementation notes. The confirmation form must post back to the full trigger URL including its SAS query string, since the signature is part of what authorises the call; carry ref, token, and slot as hidden form fields. And validate independently in the POST branch rather than trusting that the GET already checked, because a POST can be issued directly without the GET ever running.
2.1 Validate before doing anything
Applies to both branches. Run it first in each.
Get item on Requests using triggerOutputs()[‘queries’][‘ref’], then a single condition with three tests:
@and(
equals(outputs('Get_item')?['body/OfferToken'], triggerOutputs()['queries']['token']),
equals(outputs('Get_item')?['body/Status'], 'AwaitingCustomer'),
less(utcNow(), outputs('Get_item')?['body/OfferExpiry'])
)
Three separate protections. The token stops someone guessing a request id and booking against it. The status check makes the link single-use, since Flow 2 sets Booked on success. The expiry check stops an old email being actioned weeks later.
On failure, respond with a plain page explaining the link has expired and offering a contact route. Do not return a raw error.
2.1a Securing the endpoint
The trigger URL contains a SAS signature and is therefore a secret. Anyone holding the full link can invoke the flow, which is why the token check matters as an independent layer rather than a convenience.
Three further controls worth applying, all of which Microsoft documents and each of which should be confirmed against your current tenant configuration.
Trigger authentication. Beyond the default SAS-based scheme, the Request trigger supports restricting who can call it, including tenant-scoped and Microsoft Entra ID based authentication options. Where the caller is inside your tenant, prefer that over relying on URL secrecy alone. For an anonymous external customer clicking an emailed link, the token plus status plus expiry combination is doing the real work, so keep all three.
Secure inputs and outputs. The OfferToken, customer email, and site address will otherwise appear in plain text in run history, visible to anyone with access to the flow. Enable Secure Inputs and Secure Outputs in the Settings of the actions that handle them, particularly the HTTP trigger, the Get item that returns the token, and the email send. The trade-off is real: secured values are hidden from you too, which makes debugging harder, so enable them once the flow is stable rather than during the build.
IP restrictions. If slot selection happens only from known networks, the flow’s trigger settings support restricting inbound IP ranges. This does not apply to public customer links, but it is worth using for any internal-only variant of this flow.
For anything handling genuinely sensitive data, put Power Pages or an API gateway in front rather than exposing the flow URL directly.
2.2 Re-check availability (POST branch only)
Hours may have passed since the offer. Parse the stored slots, take the selected one, and call Get calendar view of events (V3) again for that engineer, bounded tightly to the slot window:
Start time: selected slot startUtc
End time: selected slot endUtc
If the returned event array is non-empty, the slot is gone. Set Status = ‘Coordinator’, write FailureReason, notify the coordinator, and return a page telling the customer their chosen time was taken and someone will call. Do not silently book a different time.
2.2a Known limitation: this is a check, not a lock
Be clear with yourself and with the client about what the re-check does and does not achieve.
Power Automate has no distributed lock primitive. Between the availability re-check and the Create event action there is a window, typically short but real, in which another run can book the same slot. Both runs read “free,” both proceed, both create an event. Outlook will not stop this: it accepts overlapping events on a calendar without complaint. The calendar is a record, not a mutex.
The re-check narrows the race from hours to something like a second. It does not close it.
Practical mitigations, in ascending order of cost:
Trigger concurrency control set to 1. In the trigger’s settings, limiting concurrency serialises Flow 2 runs so two confirmations queue rather than interleave. This is the single most effective mitigation and usually sufficient at field-service booking volumes. The cost is throughput: confirmations process one at a time, and a backlog forms if volume spikes.
Optimistic concurrency on the request record. Read the SharePoint item’s ETag during validation and include it on the update. If another run has modified the item in between, the update fails and you handle the collision explicitly rather than silently overwriting.
A claim record. Write a row to a SlotClaims list keyed on engineer plus slot start before creating the event, and treat a duplicate-key failure as “already claimed.” This gives stronger guarantees but adds a list, a failure path, and cleanup of stale claims.
Detect and reconcile. Accept the residual risk, and run a scheduled flow that looks for overlapping events on engineer calendars and alerts the coordinator. For many operations this is the pragmatic choice: rare collisions caught quickly by a person beat a complex locking scheme nobody maintains.
Whichever you choose, document the residual risk in the handover. A client who believes the system cannot double-book will treat the first occurrence as a defect rather than a known limit with an agreed response.
2.3 Create the event and confirm (POST branch)
Create event (V4)
Calendar id: engineer UPN
Subject: concat(outputs('Get_item')?['body/Title'], ' - ',
outputs('Get_item')?['body/JobType/Value'])
Start time: selected slot startUtc
End time: selected slot endUtc
Time zone: UTC
Body: site address, customer contact, job reference
Then update the request with Status = 'Booked', the engineer lookup, slot times, and EventId from outputs('Create_event')?['body/id'].
Finally, Response with a confirmation page, and a separate confirmation email including a reschedule link carrying a fresh token.
Flow 3: Reminders
Trigger: Recurrence, daily, timed for the morning in your working timezone.
Get items on Requests
Filter Query:
Status eq 'Booked'
and SlotStart ge '@{formatDateTime(addDays(utcNow(),1),'yyyy-MM-ddTHH:mm:ssZ')}'
and SlotStart lt '@{formatDateTime(addDays(utcNow(),2),'yyyy-MM-ddTHH:mm:ssZ')}'
Send one reminder per item with the reschedule link. Keep the sequence short. One reminder people read beats three they filter.
Add a ReminderSent Yes/No column and filter on it, so a mid-run failure and re-run does not double-send.
Reschedule and cancel
Both are variations of Flow 2 with EventId in hand. Reschedule: validate token, Delete event (V4) or Update event (V4) on the stored EventId, then re-enter Flow 1’s matching logic. Cancel: delete the event, set Status = ‘Cancelled’, notify the coordinator.
Update rather than delete-and-recreate where you can. Recreating changes the event id, breaking any downstream reference, and sends the engineer a fresh invite rather than an update.
Error handling
Wrap the main body of each flow in the scope pattern.
Scope: TRY
(all booking actions)
Scope: CATCH
Configure run after TRY: has failed, is skipped, has timed out
-> Create item in FlowLog
-> Post to coordinator channel
-> Update Requests: Status = 'Failed'
-> Terminate (Failed)
In the catch, capture the real error rather than a generic message:
first(
where(
result('Try_scope'),
equals(item()?['status'], 'Failed')
)
)?['error']?['message']
result() returns the status and error of every action inside the named scope. Without this, your log records that the flow failed and you debug by re-running it.
Set retry policies per action. Calendar and Graph calls warrant exponential retry, four attempts. Teams and email posts should have retry disabled, because a duplicate notification to a customer is worse than a missed one.
Timezone handling
The most common source of bugs that appear only twice a year.
Store everything in UTC: SharePoint columns, event creation, comparisons. Convert only for display, using convertFromUtc(value, parameters(‘env_TimeZone’)). Set SharePoint date columns to Date and Time, and be aware the SharePoint list UI renders them in the site’s regional setting, which may differ from your display timezone. Working windows in env_WorkDayStart are local times and must be converted to UTC before gap comparison using convertToUtc, not compared directly.
Test explicitly across a daylight saving boundary by temporarily setting the search window to span one. Bugs here are invisible for months and then produce bookings an hour out for everybody.
Testing before go-live
Run in shadow mode: everything through ranking, but replace the customer email with a Teams post to the coordinator showing what would have been offered. Run alongside the manual process for two to three weeks.
This reliably surfaces data problems rather than logic problems, and there are always some. The usual culprits are a certification string in Engineers that does not exactly match JobTypes.RequiredCert, a postcode prefix missing from an engineer’s record, and engineers whose calendars are not blocked consistently. String matching is unforgiving and shadow mode is where you find that cheaply.
Then go live on one region or one job type.
Production failure modes
Graph throttling under concurrent calendar reads. Intermittent failures that pass on retry. Fix with concurrency limits and exponential retry, not by removing the loop.
The stale-slot race, narrowed by the Flow 2 re-check but not eliminated. See the concurrency limitation above; decide your mitigation and document the residual risk.
Phantom bookings from link prefetching, if a GET endpoint is allowed to write. Mail scanners and link-preview services will trigger it. The GET-displays, POST-books split is the fix.
Duration drift. Six months on, real durations no longer match DurationMins. Schedule a quarterly comparison of SlotEnd against actual completion times.
Calendar hygiene decay. Engineers stop blocking personal time, gaps appear that are not real. This is an operational problem the flow cannot solve, but it shows up as complaints about the automation.
Flows edited in production. Build in a solution, use environment variables, export between environments.
What stays with a person
Encoded in AutoScheduleAllowed plus the runtime exits: emergencies and same-day breakdowns, jobs needing a named specialist, customers with an open complaint, multi-engineer or multi-day work, sites with access restrictions, and anything unmatched.
Emergencies stay human because urgency is a judgment, not a field. “Urgent” from a customer and a production line being down look identical to a matching rule, and confusing them is costly in both directions.
Every exception reaches the coordinator with customer, postcode, job type, and reason already gathered. The fallback is a faster manual booking, not a lost request.
Handover
Build in the client’s tenant, in a solution, with environment variables covering anything an operations lead may reasonably change. Train them to add engineers, edit coverage prefixes and certifications, change durations, and withdraw a job type from automation.
Document the four data-quality rules the system depends on: certification strings must match exactly between lists, postcode prefixes must be maintained, durations reviewed quarterly, and engineer calendars kept current. Those four are what actually determine whether this keeps working.
Building this on your own systems
The three things to get right before writing a single action are the coverage data, the real job durations, and the exception list. Those determine whether the build works. The flows themselves are the straightforward part.
Zipprr builds automations like this on the systems you already run. If you want to pressure-test the approach against your real numbers first, get in touch for a workflow review.
Ready to automate your field service scheduling?
If your coordinator spends the week matching engineers to postcodes and chasing customers for a confirmed time, this is the exact problem the build above solves. Zipprr builds automations like this on Microsoft 365 and other systems you already run. Get in touch for a workflow review, and we will pressure-test the approach against your real coverage data, job durations, and volumes.



