I was building a managed metadata picker into an Office add-in. Nothing exotic: a term set, a popover, a search box, the same thing SharePoint’s own tagging dialog does. The term sets in play run to thousands of terms.
The obvious cheap route is to download the term set once and filter it in the browser. I didn’t want that. Waiting for a full load before the first keystroke does anything is a bad experience, and it stops scaling the moment someone points the column at a bigger set. Server-side filtering or nothing.
So I went looking for the search endpoint. What I never found was anyone who could point me at one.
TL;DR
Microsoft Graph can search the SharePoint term store, even though the reference docs don’t document it and the PnP libraries dropped their search helper when taxonomy moved to Graph.GET /sites/{site-id}/termStore/sets/{set-id}/terms?$filter=labels/any(a: startswith(tolower(a/name),'proc')) filters server-side across every level of the set, with nothing but delegated TermStore.Read.All and admin consent, and no second token flow the way SharePoint’s own _api/v2.1/termStore/searchTerm needs. Plain startswith is case-sensitive, so you need tolower(), and flat results carry no parent path. Prove it on your own tenant before you trust it: the termStore workload ignores unsupported query parameters silently, so a nonsense filter that comes back empty is the only real evidence the filter ran.I Couldn’t Find a Single “Just Do This”
I started where I usually start, with the libraries. PnPjs had a searchTerm method for years, built on SharePoint’s taxonomy REST API, and it didn’t come along when v4 moved taxonomy over to Microsoft Graph. Issue #3054 is where that question lands, and the answer there is that Graph’s taxonomy endpoints don’t support search. PnP Core has an open feature request, #892, for term search at store level, so nothing to copy from there either.
Next I looked at how Microsoft does it in its own component. The Graph Toolkit’s mgt-taxonomy-picker loads children and narrows them in the browser. That’s the design you land on when the service won’t filter for you, and it’s the design I was specifically trying to get away from.
Then the reference docs, hoping for an example I could adapt. The v1.0 term resource type page lists Get term, Create term, Update term, Delete term, List children, List relations. There is no List terms operation. No $filter example appears anywhere in the termStore API docs.
Nothing hostile, nothing wrong. Just no map, and no forum answer saying “you want this URL”.
There is a supported answer, and it’s SharePoint’s own: _api/v2.1/termStore/searchTerm(label='proc',languageTag='en-US'). I never called it. My add-in holds Microsoft Graph tokens, and that endpoint wants a SharePoint resource token with its own TermStore.Read.All scope on top. Adding a second token flow to a picker is a lot of moving parts for one text box, so I stayed on the API I already had a token for.
Which left me poking at the Graph endpoints myself, with no example to copy and no idea whether there was anything to find.
Can Microsoft Graph Search the SharePoint Term Store?
Yes, and I run it in production. A term set’s terms collection accepts $filter=labels/any(a: startswith(tolower(a/name),'proc')), an OData filter over the whole label collection, and it runs server-side across every level of the set:
GET https://graph.microsoft.com/v1.0
/sites/{site-id}/termStore/sets/{set-id}/terms
?$filter=labels/any(a: startswith(tolower(a/name),'proc'))
&$select=id,labels
&$top=50
Delegated TermStore.Read.All with admin consent is the whole permission story, and delegated is also the only story: every termStore operation I use lists application permissions as “Not supported”, so this is a signed-in-user API whether you’re building a picker or a nightly job. The catch isn’t permissions, it’s that this is undocumented, and that testing it naively will lie to you.
A 200 OK Proves Nothing Here
Graph’s own query parameter documentation says it plainly:
However, sometimes query parameters specified in a request fail silently. For example, for unsupported query parameters and for unsupported combinations of query parameters. In these cases, examine the data returned by the request to determine whether the query parameters you specified had the desired effect.
The termStore workload does exactly this, and there’s a well-known example: $expand=children on terms returns 200 OK with the children quietly missing. No error, no hint, just a response that looks fine and isn’t.
Here’s the test I nearly settled for. My term set has a root term called “Procurement”. I filtered for Procurement. One term came back, and it was the right one. Proof?
No. That’s the exact response you’d get if the service threw your filter away and handed you a slice of the collection that happens to contain what you asked for. A plausible-looking 200 is not evidence when silent-ignore is the documented failure mode. You have to prove the negative.
The Control Experiments
Eight requests, two Graph Explorer batches, one paste each. JSON batching takes up to 20 requests in a single call, which makes it an underrated way to run a whole experiment matrix at once instead of clicking through it:
POST https://graph.microsoft.com/v1.0/$batch
{
"requests": [
{ "id": "1", "method": "GET",
"url": "/sites/{site-id}/termStore/groups/{group-id}/sets/{set-id}/children?$select=id,labels" },
{ "id": "2", "method": "GET",
"url": "/sites/{site-id}/termStore/groups/{group-id}/sets/{set-id}/children?$filter=labels/any(a: a/name eq 'zzz_no_such_term')" },
{ "id": "3", "method": "GET",
"url": "/sites/{site-id}/termStore/groups/{group-id}/sets/{set-id}/terms?$filter=labels/any(a: startswith(a/name,'Proc'))" },
{ "id": "4", "method": "GET",
"url": "/sites/{site-id}/termStore/groups/{group-id}/sets/{set-id}/terms/{procurement-term-id}/children?$select=id,labels" },
{ "id": "5", "method": "GET",
"url": "/sites/{site-id}/termStore/groups/{group-id}/sets/{set-id}/terms?$select=id,labels&$top=10" }
]
}
The second batch was the case-sensitivity pair plus a nested-term probe: startswith(a/name,'proc'), startswith(tolower(a/name),'proc') and startswith(a/name,'Shipp').
Here’s what came back, re-run against a live tenant on 2026-09-06:
| # | Request | Result | What it proves |
|---|---|---|---|
| 1 | /children, no filter | 9 root terms | Baseline, so filtered results can be compared to unfiltered |
| 2 | /children filtered on a/name eq 'zzz_no_such_term' | "value": [] | The filter is genuinely applied, not discarded |
| 3 | /terms filtered on startswith(a/name,'Proc') | 1 term, “Procurement” | Prefix filtering works on the flat whole-set collection |
| 4 | /children of the Procurement term | 5 terms, including “Shipping” | Establishes that “Shipping” lives one level down |
| 5 | /terms?$top=10 | 10 terms plus @odata.nextLink carrying $skiptoken=MTA | Paging is honored on the flat listing |
| a | /terms filtered on startswith(a/name,'proc') | "value": [] | Plain startswith is case-sensitive |
| b | /terms filtered on startswith(tolower(a/name),'proc') | 1 term, “Procurement” | tolower() gives you case-insensitive search |
| c | /terms filtered on startswith(a/name,'Shipp') | 1 term, “Shipping” | The flat search reaches nested terms, not just the top level |
Two of these carry the whole argument. Request 2, filtering /children for a label that doesn’t exist and getting [] back, is the only thing standing between “the filter works” and “the service ignored me politely”. Request 4, listing the children of the Procurement term, is what turns the nested hit into a claim about depth: “Shipping” is a child of “Procurement”, it isn’t in the root listing, and the flat /terms filter found it anyway.
One path detail, since I’m making a fuss about proof. The batch above addresses the set through /termStore/groups/{group-id}/sets/{set-id}, because that’s the shape Graph Explorer left me with. The documented path for a set is the shorter /termStore/sets/{set-id}, no group segment, and both reach the same set. The picker below uses the short one, which is also the form running in production.
The Picker Query
This is the search path from the add-in, trimmed to the parts that matter:
/** Result cap for server-side type-ahead queries. */
const TYPEAHEAD_MAX_RESULTS = 50;
/** Escape single quotes for an OData string literal. */
function odataQuote(value: string): string {
return value.replace(/'/g, "''");
}
function defaultLabel(term: GraphTerm): string {
return term.labels?.find(l => l.isDefault)?.name ?? term.labels?.[0]?.name ?? term.id ?? '';
}
async searchTerms(siteId: string, termSetId: string, query: string): Promise<TermOption[]> {
const filter = encodeURIComponent(
`labels/any(a: startswith(tolower(a/name),'${odataQuote(query.trim().toLowerCase())}'))`
);
const res = await this.graphFetch(
`/sites/${siteId}/termStore/sets/${termSetId}/terms` +
`?$filter=${filter}&$select=id,labels&$top=${TYPEAHEAD_MAX_RESULTS}`
);
return asCollection<GraphTerm>(res)
.filter(t => !!t.id)
.map(t => ({ id: t.id!, label: defaultLabel(t) }));
}
What’s happening here?
- The query is lowercased on the client and the stored label is lowercased by
tolower()inside the filter. Both halves are required. Lowercase input against a plainstartswithis experiment (a): an empty array, for a term that exists. odataQuotedoubles single quotes, because a user typingO'Brienwould otherwise close the string literal and hand the service a broken filter.encodeURIComponenton the whole filter value isn’t optional either. A#in the query truncates the URL at the fragment, and the request you send stops being the request you wrote.$select=id,labelskeeps the payload small. Without it you also getcreatedDateTime,lastModifiedDateTimeanddescriptionson every hit, which a picker has no use for.$top=50caps the type-ahead. Nobody scrolls past 50 suggestions, and the cap stops a two-character query from dragging half the set across the wire.
In the component, the input is debounced 300 ms and search only fires from 2 characters. Browsing is a separate path: the tree loads one level at a time through /termStore/sets/{set-id}/children and /terms/{term-id}/children, paged at 50, so opening the picker on a huge set costs one small request instead of the whole taxonomy.
Gotchas
- Prove the negative on your own tenant before you ship this. One request with a nonsense label that must come back empty. Silent-ignore is the documented behavior of this workload, so that empty array is your only proof, and it costs nothing to keep as a test.
- Flat results carry no ancestry. A hit gives you
idandlabels, no parent path, no way to scope the filter to a subtree. Anchor-bound columns therefore can’t use it. In my add-in they take a different route: load the (usually small) subtree with a bounded walk and filter client-side, with a visible note when the walk gets truncated. $expand=childrenis one of the parameters that gets ignored. You can’t cheapen the tree loading that way, and it won’t tell you it failed.- v1.0 has no
childrenCount. A lazy tree can’t know whether a node is expandable without fetching it. Render every node as a branch, then demote it to a leaf once its expansion comes back empty. - Custom sort order isn’t exposed. Graph returns terms alphabetically. If a term set relies on the custom ordering the SharePoint UI shows, your picker won’t match it, and someone will notice.
- Watch your HTTP client for the paging link. I ended up calling these endpoints with a plain
fetch, because the client library I use unwrapsvalueand drops@odata.nextLinkon the floor. No nextLink, no paging. - Non-default labels match too. The filter runs over the whole
labelscollection, so a synonym can produce the hit. Display the term’sisDefaultlabel, or users will pick a term whose name they never typed. - This is undocumented. It isn’t a preview flag or a beta endpoint, it’s real OData surface with no documented list operation, and Microsoft owes you nothing if it changes. Make failure visible in the UI rather than silently degrading to something worse.
Credit where it belongs: the same labels/any filter shape turns up in a 2022 post on getting terms by label, aimed at Power Automate flows against SharePoint’s _api/v2.1/termStore. That’s the SharePoint REST side of the fence. The question I needed answered was whether it survives on graph.microsoft.com/v1.0 with a plain Graph token and nothing else. It does.
Wrapping Up
Microsoft Graph can search the term store. GET /sites/{site-id}/termStore/sets/{set-id}/terms?$filter=labels/any(a: startswith(tolower(a/name),'proc')), delegated TermStore.Read.All, server-side, all depths, undocumented. Wrap it in a debounce and you have a type-ahead picker that doesn’t download a taxonomy first.
The rule of thumb I’d rather you take away is the testing one: when a workload is documented to ignore parameters it doesn’t like, a 200 OK full of sensible-looking data is not evidence of anything. Ask it for something that cannot exist. If you get rows back, your filter was never running.
If you’re weighing up which API to reach for on SharePoint work in general, CSOM vs SharePoint REST vs Graph is my pick-one list, including the metadata gaps Graph won’t tell you about. And for the other end of the taxonomy problem, reading terms off list items fast, there’s Load SharePoint Taxonomy Fields Fast With FieldValuesForEdit.
