Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Microsoft Teams By James Joyner IV · · 11 min read

Chaining Teams Provisioning Steps With Graph $batch and dependsOn

Provision a Team, its channels, and members in ordered Graph $batch requests using dependsOn — with the 20-request limit, 424 handling, and idempotent retries covered.

  • #microsoft-teams
  • #ai
  • #microsoft-graph
  • #batch
  • #provisioning

I once watched a team-provisioning script add members to a Team that didn’t exist yet. The script fired three Graph calls in quick succession — create the team, add the members, post a welcome card — and because they ran concurrently, the member-add raced the team-create and lost. The fix wasn’t more retries or longer sleeps. It was the Graph $batch endpoint with explicit dependency ordering, which lets you say “run step two only if step one succeeded” in a single round trip. This post is about building that correctly, including the parts the happy-path examples skip.

Why $batch, and why dependsOn specifically

The $batch endpoint bundles up to 20 sub-requests into one HTTP POST to https://graph.microsoft.com/v1.0/$batch. By default those sub-requests run in no guaranteed order, which is fine for independent reads but useless for provisioning, where each step depends on the last. The dependsOn property is what makes batching safe for ordered work: a sub-request with dependsOn won’t execute until its named predecessor returns a success status.

Here’s the shape for the create-team-then-add-channel sequence:

{
  "requests": [
    {
      "id": "1",
      "method": "POST",
      "url": "/teams",
      "headers": { "Content-Type": "application/json" },
      "body": {
        "template@odata.bind": "https://graph.microsoft.com/v1.0/teamsTemplates('standard')",
        "displayName": "Incident-2026-0142",
        "description": "War room for INC-0142"
      }
    },
    {
      "id": "2",
      "method": "POST",
      "url": "/teams/{teamId}/channels",
      "dependsOn": ["1"],
      "headers": { "Content-Type": "application/json" },
      "body": { "displayName": "timeline", "description": "Incident timeline" }
    }
  ]
}

There’s a wrinkle in that example worth flagging: team creation via template is asynchronous, and the new team id isn’t always available inline for the next request in the same batch. For genuinely async operations, you often have to split the batch — create the team in batch one, read the resulting id from the Location header, then run channels and members in batch two. Don’t assume every dependent step can live in a single batch; the dependency graph has to respect Graph’s own async semantics.

Reading the response array

A $batch returns a responses array, and each entry carries the id you assigned plus its own status and body. You map results back by id:

resp = graph.post("/$batch", json=batch).json()
by_id = {r["id"]: r for r in resp["responses"]}

for step_id, r in sorted(by_id.items()):
    status = r["status"]
    if status == 424:
        log.warning("step %s skipped: dependency failed", step_id)
    elif status == 429:
        retry_after = r["headers"].get("Retry-After", "?")
        log.warning("step %s throttled, retry-after=%s", step_id, retry_after)
    elif status >= 400:
        log.error("step %s failed: %s", step_id, r["body"])
    else:
        log.info("step %s ok", step_id)

The status to internalize is 424 Failed Dependency. When step 1 fails, every downstream step that dependsOn it returns 424 — and that is not a real error. It means “I was skipped because my prerequisite didn’t succeed.” If your error handling treats 424 the same as a 500, your retry logic will do the wrong thing, replaying steps that were correctly skipped. Treat 424 as “skipped,” figure out why the dependency failed, fix that, and re-run.

The 20-request ceiling and chunking

A single batch holds at most 20 sub-requests. Provisioning a Team with forty members blows past that immediately, so you chunk. The trick is threading state between chunks: batch one creates the team and captures the id; batches two and three add members twenty at a time, all targeting that id.

def chunk(items, n=20):
    for i in range(0, len(items), n):
        yield items[i:i + n]

team_id = create_team()  # batch 1, read id from response
for group in chunk(member_upns, 19):  # leave room for any header request
    batch = build_member_batch(team_id, group)
    process(graph.post("/$batch", json=batch).json())

I leave headroom under 20 deliberately — if you also want a confirmation read or a welcome-card post in the same batch, you need a slot for it. Packing exactly 20 member-adds and then discovering you have nowhere to put the welcome step is an annoying refactor.

I usually have an AI assistant draft the batch payloads from a plain-English description of the provisioning steps, because hand-writing the dependsOn graph for a ten-step flow is tedious and error-prone. A prompt like the one below gets you a structurally correct starting point that you then verify against the live Graph behavior:

Prompt: “Build a Microsoft Graph $batch JSON that creates a Team from the standard template, adds two channels (timeline, comms), and adds these 15 members, using dependsOn so members only add after the team and channels exist. Note where async team creation forces a batch split.”

Output (excerpt): A requests array with the team-create as id 1, channel-creates as ids 2 and 3 each dependsOn: ["1"], member-adds depending on the team, plus a comment that the member-adds should move to a second batch because the team id from a template-based create isn’t reliably available inline.

Treat that as a draft. The model is good at the JSON structure and the dependency wiring; you still verify the async-split decision and the exact endpoint paths against current Graph docs, because those are precisely where a confidently-wrong answer would bite.

Throttling inside a batch

Sub-requests get throttled individually. A batch of twenty member-adds might return eighteen 201s and two 429s, each 429 carrying its own Retry-After. The wrong move is replaying the whole batch — that re-adds the eighteen members who already succeeded. The right move is selective retry: pull out only the sub-requests that returned 429, wait the longest Retry-After among them, and resubmit just those.

throttled = [by_id[i] for i in by_id if by_id[i]["status"] == 429]
if throttled:
    wait = max(int(r["headers"].get("Retry-After", 5)) for r in throttled)
    time.sleep(wait)
    retry_batch = rebuild_only(throttled)
    process(graph.post("/$batch", json=retry_batch).json())

Idempotent re-runs

Provisioning fails halfway. It just does — a transient Graph error, a throttle you didn’t fully drain, a deploy that killed your worker mid-flow. The thing that makes that survivable is making the whole flow idempotent: before adding a channel, check whether it exists; before adding a member, check membership. Then a re-run skips what’s already done and finishes the rest, instead of duplicating channels and erroring on already-present members.

That idempotency check is cheap to add and saves you from the worst failure mode, where a retry leaves you with timeline, timeline-2, and timeline-3 channels because nobody checked first.

Where to go next

Batching is one half of resilient Graph automation; throttling is the other half, and it deserves its own treatment beyond the per-sub-request handling here. If you’re building broader Teams automation, start from the Microsoft Teams category for the surrounding patterns, and keep a tested batch-and-dependsOn prompt in your toolkit — the prompts library has one you can adapt rather than writing the dependency graph by hand each time.

Build the batch, order it with dependsOn, respect the 20-request ceiling, treat 424 as “skipped,” retry only the throttled sub-requests, and make the whole thing idempotent. Do those five things and multi-step Teams provisioning stops being a race condition and becomes a single, auditable round trip.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.