Microsoft Graph + SharePoint Document Libraries (End-to-End with Sites.Selected)
Folders, uploads (small + large), metadata updates, and version listing — with full C# code
This is the practical “day-2 production” extension of the Sites.Selected model: once your app has explicit access to a specific SharePoint site, you can work with document libraries (drives) to:
- Discover the site and its libraries (drives)
- Create folder paths (folder chain)
- Upload files (simple upload up to 250 MB, resumable upload session for larger files)
- Update custom columns/metadata on the uploaded document
- Read the document version history (and what Graph can/can’t do for version migration)
Key rule:
Sites.Selectedalone grants zero access until you explicitly grant the app access to the specific site. After the grant, your app can work with that site’s lists and drives. (Microsoft Learn)
1) How SharePoint document libraries map to Graph (the mental model)
1.1 “Drive” = document library
In Microsoft Graph, a SharePoint document library is a Drive resource. (Microsoft Learn)
1.2 “DriveItem” = file or folder in that library
Everything inside the library is a DriveItem (file/folder). (Microsoft Learn)
1.3 Document metadata lives on the “ListItem”
Every document in a SharePoint library can also be represented as a ListItem, and the custom columns are exposed via its fields. (Microsoft Learn)
Practical consequence:
- Uploading a file gives you a
driveItem - Updating custom columns is usually done via the associated
listItem/fieldsendpoint (or listItem endpoints) (Microsoft Learn)
2) Endpoints you’ll use (and why)
2.1 Resolve the site by path
Use Get SharePoint site by path:GET /sites/{hostname}:/{server-relative-path} (Microsoft Learn)
2.2 Get drives (document libraries)
- Default document library:
GET /sites/{siteId}/drive(Microsoft Learn) - All libraries under a site:
GET /sites/{siteId}/drives(Microsoft Learn)
2.3 Create folders (folder chain)
Create a folder under a parent item:POST /drives/{driveId}/items/{parentId}/children (Microsoft Learn)
2.4 Upload files
- Small/simple upload (up to 250 MB):
PUT /drives/{driveId}/items/{parentId}:/{fileName}:/content(Microsoft Learn) - Large/resumable upload: create an upload session and upload fragments sequentially (< 60 MiB each request): (Microsoft Learn)
2.5 Update metadata (custom columns)
Update the list item fields via:PATCH /sites/{site-id}/lists/{list-id}/items/{item-id}/fields (Microsoft Learn)
Or, for a file in a drive, patch the linked listItem fields:PATCH /drives/{driveId}/items/{itemId}/listItem/fields (this works because library files have a listItem relationship). (Microsoft Learn)
2.6 List file versions (read-only from your app’s perspective)
GET /drives/{driveId}/items/{itemId}/versions (Microsoft Learn)
3) Reality check: what you can/can’t do with version history
Graph can list versions and you’ll naturally create new versions when you update file content. (Microsoft Learn)
However, Graph does not provide a clean “import historical versions as-if they were created in the past” capability. If your goal is true historical version migration (preserve original authors/timestamps per version), you typically need specialized migration approaches outside pure Graph file upload primitives. (I’m calling this out so you don’t waste time trying to force Graph into something it doesn’t support.)
4) Full C# example (end-to-end)
This is a complete console app that:
- Resolves a site by path
- Finds a document library (drive) by name
- Ensures a folder path exists (creates missing folders)
- Uploads a file (simple upload <=250 MB, upload session if bigger)
- Updates custom metadata fields on the uploaded file
- Lists versions for the uploaded file
Uses placeholders (
contoso) so you can publish safely.
appsettings.json
{
"TenantId": "00000000-0000-0000-0000-000000000000",
"ClientId": "11111111-1111-1111-1111-111111111111",
"ClientSecret": "YOUR-SECRET",
"Target": {
"Hostname": "contoso.sharepoint.com",
"SiteServerRelativePath": "/sites/ExampleSite",
"DriveName": "Documents",
"FolderPath": "Incoming/2026/January",
"LocalFilePath": "C:\\Temp\\example.pdf",
"UploadFileName": "example.pdf",
"Metadata": {
"Title": "Example document uploaded by Graph",
"Category": "Engineering"
}
}
}
Program.cs (full code)
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Identity.Client;
internal sealed class RootConfig
{
public string TenantId { get; set; } = "";
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public TargetConfig Target { get; set; } = new();
}
internal sealed class TargetConfig
{
public string Hostname { get; set; } = "contoso.sharepoint.com";
public string SiteServerRelativePath { get; set; } = "/sites/ExampleSite";
public string DriveName { get; set; } = "Documents";
public string FolderPath { get; set; } = "Incoming/2026/January";
public string LocalFilePath { get; set; } = @"C:\Temp\example.pdf";
public string UploadFileName { get; set; } = "example.pdf";
public Dictionary<string, string> Metadata { get; set; } = new();
}
internal static class Program
{
private const string GraphBase = "https://graph.microsoft.com/v1.0";
private static readonly string[] GraphScopes = new[] { "https://graph.microsoft.com/.default" };
public static async Task<int> Main()
{
try
{
var cfg = LoadConfig("appsettings.json");
Validate(cfg);
var token = await AcquireAppOnlyTokenAsync(cfg.TenantId, cfg.ClientId, cfg.ClientSecret);
// 1) Resolve site
var site = await GetSiteByPathAsync(token, cfg.Target.Hostname, cfg.Target.SiteServerRelativePath);
Console.WriteLine($"Site: {site.Id}");
Console.WriteLine($"WebUrl: {site.WebUrl}");
Console.WriteLine();
// 2) Find drive (document library)
var driveId = await FindDriveIdByNameAsync(token, site.Id, cfg.Target.DriveName);
Console.WriteLine($"Drive '{cfg.Target.DriveName}': {driveId}");
Console.WriteLine();
// 3) Ensure folder chain under drive root
var folderId = await EnsureFolderPathAsync(token, driveId, cfg.Target.FolderPath);
Console.WriteLine($"Folder '{cfg.Target.FolderPath}' -> ItemId: {folderId}");
Console.WriteLine();
// 4) Upload (small <= 250MB or large upload session)
var localPath = cfg.Target.LocalFilePath;
if (!File.Exists(localPath))
throw new FileNotFoundException("LocalFilePath not found.", localPath);
var uploadedItem = await UploadFileAutoAsync(token, driveId, folderId, localPath, cfg.Target.UploadFileName);
Console.WriteLine($"Uploaded DriveItemId: {uploadedItem.Id}");
Console.WriteLine($"Uploaded Name: {uploadedItem.Name}");
Console.WriteLine();
// 5) Update metadata (custom columns) on the linked listItem
if (cfg.Target.Metadata != null && cfg.Target.Metadata.Count > 0)
{
await PatchDriveItemListItemFieldsAsync(token, driveId, uploadedItem.Id, cfg.Target.Metadata);
Console.WriteLine("Metadata updated (listItem/fields).");
Console.WriteLine();
}
// 6) List versions
var versions = await ListVersionsAsync(token, driveId, uploadedItem.Id);
Console.WriteLine($"Versions found: {versions.Count}");
foreach (var v in versions.Take(10))
{
Console.WriteLine($"- {v.Id} | lastModifiedDateTime={v.LastModifiedDateTime}");
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine("FAILED:");
Console.Error.WriteLine(ex);
return 1;
}
}
// -------------------- Config --------------------
private static RootConfig LoadConfig(string path)
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<RootConfig>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? throw new InvalidOperationException("Invalid appsettings.json");
}
private static void Validate(RootConfig cfg)
{
if (string.IsNullOrWhiteSpace(cfg.TenantId)) throw new ArgumentException("TenantId is required.");
if (string.IsNullOrWhiteSpace(cfg.ClientId)) throw new ArgumentException("ClientId is required.");
if (string.IsNullOrWhiteSpace(cfg.ClientSecret)) throw new ArgumentException("ClientSecret is required.");
if (string.IsNullOrWhiteSpace(cfg.Target.Hostname)) throw new ArgumentException("Target.Hostname is required.");
if (string.IsNullOrWhiteSpace(cfg.Target.SiteServerRelativePath)) throw new ArgumentException("Target.SiteServerRelativePath is required.");
if (!cfg.Target.SiteServerRelativePath.StartsWith("/")) throw new ArgumentException("Target.SiteServerRelativePath must start with '/'.");
if (string.IsNullOrWhiteSpace(cfg.Target.DriveName)) throw new ArgumentException("Target.DriveName is required.");
if (string.IsNullOrWhiteSpace(cfg.Target.UploadFileName)) throw new ArgumentException("Target.UploadFileName is required.");
if (string.IsNullOrWhiteSpace(cfg.Target.LocalFilePath)) throw new ArgumentException("Target.LocalFilePath is required.");
}
// -------------------- Auth (MSAL) --------------------
private static async Task<string> AcquireAppOnlyTokenAsync(string tenantId, string clientId, string clientSecret)
{
var app = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithClientSecret(clientSecret)
.WithAuthority($"https://login.microsoftonline.com/{tenantId}")
.Build();
var result = await app.AcquireTokenForClient(GraphScopes).ExecuteAsync();
return result.AccessToken;
}
// -------------------- HTTP helpers --------------------
private static HttpClient CreateHttp(string token)
{
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return http;
}
private static async Task<string> SendJsonWithRetryAsync(HttpClient http, HttpMethod method, string url, string? jsonBody = null)
{
const int maxAttempts = 6;
var attempt = 0;
while (true)
{
attempt++;
using var req = new HttpRequestMessage(method, url);
if (jsonBody != null)
{
req.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
}
using var resp = await http.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if ((int)resp.StatusCode == 429)
{
if (attempt >= maxAttempts)
throw new HttpRequestException("Too many throttled attempts (429).");
var delaySeconds = 30;
if (resp.Headers.RetryAfter?.Delta != null)
delaySeconds = (int)Math.Ceiling(resp.Headers.RetryAfter.Delta.Value.TotalSeconds);
await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
continue;
}
if (!resp.IsSuccessStatusCode)
{
throw new HttpRequestException(
$"Graph call failed: {(int)resp.StatusCode} {resp.ReasonPhrase}\nURL: {url}\nBODY:\n{body}"
);
}
return body;
}
}
// -------------------- Graph: Site by path --------------------
private sealed record SiteInfo(string Id, string WebUrl);
private static async Task<SiteInfo> GetSiteByPathAsync(string token, string hostname, string serverRelativePath)
{
// GET /sites/{hostname}:/{server-relative-path}
var url = $"{GraphBase}/sites/{hostname}:{serverRelativePath}";
using var http = CreateHttp(token);
var json = await SendJsonWithRetryAsync(http, HttpMethod.Get, url);
using var doc = JsonDocument.Parse(json);
var id = doc.RootElement.GetProperty("id").GetString() ?? "";
var webUrl = doc.RootElement.TryGetProperty("webUrl", out var w) ? (w.GetString() ?? "") : "";
if (string.IsNullOrWhiteSpace(id))
throw new InvalidOperationException("Could not resolve site id.");
return new SiteInfo(id, webUrl);
}
// -------------------- Graph: Drives under site --------------------
private sealed record DriveInfo(string Id, string Name);
private static async Task<string> FindDriveIdByNameAsync(string token, string siteId, string driveName)
{
// GET /sites/{siteId}/drives
var url = $"{GraphBase}/sites/{siteId}/drives?$select=id,name";
using var http = CreateHttp(token);
var json = await SendJsonWithRetryAsync(http, HttpMethod.Get, url);
using var doc = JsonDocument.Parse(json);
foreach (var d in doc.RootElement.GetProperty("value").EnumerateArray())
{
var name = d.GetProperty("name").GetString() ?? "";
var id = d.GetProperty("id").GetString() ?? "";
if (string.Equals(name, driveName, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(id))
return id;
}
// fallback: default drive if name isn't found
// GET /sites/{siteId}/drive
var defaultDriveJson = await SendJsonWithRetryAsync(http, HttpMethod.Get, $"{GraphBase}/sites/{siteId}/drive?$select=id,name");
using var defDoc = JsonDocument.Parse(defaultDriveJson);
var defId = defDoc.RootElement.GetProperty("id").GetString() ?? "";
if (!string.IsNullOrWhiteSpace(defId))
return defId;
throw new InvalidOperationException($"Drive not found: '{driveName}', and default drive could not be resolved.");
}
// -------------------- Graph: Folder chain --------------------
private static async Task<string> EnsureFolderPathAsync(string token, string driveId, string folderPath)
{
// Start at drive root
var currentId = "root";
var segments = (folderPath ?? "")
.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToArray();
if (segments.Length == 0)
return "root";
foreach (var segment in segments)
{
var existingChildId = await TryFindChildFolderIdAsync(token, driveId, currentId, segment);
if (!string.IsNullOrWhiteSpace(existingChildId))
{
currentId = existingChildId;
continue;
}
// Create folder
currentId = await CreateFolderAsync(token, driveId, currentId, segment);
}
return currentId;
}
private static async Task<string?> TryFindChildFolderIdAsync(string token, string driveId, string parentIdOrRoot, string folderName)
{
// GET /drives/{driveId}/items/{itemId}/children (or /root/children)
var url = parentIdOrRoot == "root"
? $"{GraphBase}/drives/{driveId}/root/children?$select=id,name,folder"
: $"{GraphBase}/drives/{driveId}/items/{parentIdOrRoot}/children?$select=id,name,folder";
using var http = CreateHttp(token);
var json = await SendJsonWithRetryAsync(http, HttpMethod.Get, url);
using var doc = JsonDocument.Parse(json);
foreach (var item in doc.RootElement.GetProperty("value").EnumerateArray())
{
var name = item.GetProperty("name").GetString() ?? "";
var isFolder = item.TryGetProperty("folder", out _);
if (isFolder && string.Equals(name, folderName, StringComparison.OrdinalIgnoreCase))
{
return item.GetProperty("id").GetString();
}
}
return null;
}
private static async Task<string> CreateFolderAsync(string token, string driveId, string parentIdOrRoot, string folderName)
{
// POST /drives/{driveId}/items/{parentId}/children (or /root/children)
var url = parentIdOrRoot == "root"
? $"{GraphBase}/drives/{driveId}/root/children"
: $"{GraphBase}/drives/{driveId}/items/{parentIdOrRoot}/children";
var payload = new
{
name = folderName,
folder = new { },
// If a folder already exists, you may choose: "rename" or "fail".
// We do a pre-check, so "fail" is acceptable too.
@microsoft_graph_conflictBehavior = "fail"
};
// System.Text.Json can't serialize property names with dots easily; use raw JSON:
var jsonBody = $"{{\"name\":\"{EscapeJson(folderName)}\",\"folder\":{{}},\"@microsoft.graph.conflictBehavior\":\"fail\"}}";
using var http = CreateHttp(token);
var json = await SendJsonWithRetryAsync(http, HttpMethod.Post, url, jsonBody);
using var doc = JsonDocument.Parse(json);
var id = doc.RootElement.GetProperty("id").GetString() ?? "";
if (string.IsNullOrWhiteSpace(id))
throw new InvalidOperationException("Folder creation succeeded but no id returned.");
return id;
}
private static string EscapeJson(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"");
// -------------------- Graph: Upload (small vs large) --------------------
private sealed record DriveItemInfo(string Id, string Name);
private static async Task<DriveItemInfo> UploadFileAutoAsync(string token, string driveId, string parentFolderIdOrRoot, string localFilePath, string uploadName)
{
var fi = new FileInfo(localFilePath);
// Simple upload supports up to 250 MB
if (fi.Length <= 250L * 1024L * 1024L)
{
return await UploadSmallFileAsync(token, driveId, parentFolderIdOrRoot, localFilePath, uploadName);
}
return await UploadLargeFileWithSessionAsync(token, driveId, parentFolderIdOrRoot, localFilePath, uploadName);
}
private static async Task<DriveItemInfo> UploadSmallFileAsync(string token, string driveId, string parentFolderIdOrRoot, string localFilePath, string uploadName)
{
// PUT /drives/{driveId}/items/{parentId}:/{fileName}:/content
// For root: /drives/{driveId}/root:/{fileName}:/content
var url = parentFolderIdOrRoot == "root"
? $"{GraphBase}/drives/{driveId}/root:/{Uri.EscapeDataString(uploadName)}:/content"
: $"{GraphBase}/drives/{driveId}/items/{parentFolderIdOrRoot}:/{Uri.EscapeDataString(uploadName)}:/content";
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var fs = File.OpenRead(localFilePath);
using var content = new StreamContent(fs);
// Graph returns the created/updated driveItem JSON
using var resp = await http.PutAsync(url, content);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new HttpRequestException($"Small upload failed: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
using var doc = JsonDocument.Parse(body);
var id = doc.RootElement.GetProperty("id").GetString() ?? "";
var name = doc.RootElement.GetProperty("name").GetString() ?? "";
return new DriveItemInfo(id, name);
}
private static async Task<DriveItemInfo> UploadLargeFileWithSessionAsync(string token, string driveId, string parentFolderIdOrRoot, string localFilePath, string uploadName)
{
// POST /drives/{driveId}/items/{parentId}:/{fileName}:/createUploadSession
// For root: /drives/{driveId}/root:/{fileName}:/createUploadSession
var createUrl = parentFolderIdOrRoot == "root"
? $"{GraphBase}/drives/{driveId}/root:/{Uri.EscapeDataString(uploadName)}:/createUploadSession"
: $"{GraphBase}/drives/{driveId}/items/{parentFolderIdOrRoot}:/{Uri.EscapeDataString(uploadName)}:/createUploadSession";
using var http = CreateHttp(token);
// You can add item properties here; conflictBehavior "replace" overwrites if exists.
var createBody = "{\"item\":{\"@microsoft.graph.conflictBehavior\":\"replace\"}}";
var sessionJson = await SendJsonWithRetryAsync(http, HttpMethod.Post, createUrl, createBody);
using var sessionDoc = JsonDocument.Parse(sessionJson);
var uploadUrl = sessionDoc.RootElement.GetProperty("uploadUrl").GetString();
if (string.IsNullOrWhiteSpace(uploadUrl))
throw new InvalidOperationException("Upload session created but uploadUrl is missing.");
// Upload in sequential chunks. Docs say max bytes per request must be < 60 MiB.
const int chunkSize = 10 * 1024 * 1024; // 10 MiB safe chunk
var fi = new FileInfo(localFilePath);
long total = fi.Length;
using var fs = File.OpenRead(localFilePath);
long offset = 0;
while (offset < total)
{
int toRead = (int)Math.Min(chunkSize, total - offset);
byte[] buffer = new byte[toRead];
int read = await fs.ReadAsync(buffer, 0, toRead);
if (read <= 0) throw new IOException("Unexpected end of file while reading.");
using var chunkContent = new ByteArrayContent(buffer, 0, read);
chunkContent.Headers.ContentLength = read;
chunkContent.Headers.ContentRange = new ContentRangeHeaderValue(offset, offset + read - 1, total);
using var chunkReq = new HttpRequestMessage(HttpMethod.Put, uploadUrl);
chunkReq.Content = chunkContent;
// uploadUrl is a pre-authenticated URL; do not send Authorization header here.
using var chunkHttp = new HttpClient();
using var chunkResp = await chunkHttp.SendAsync(chunkReq);
var chunkBody = await chunkResp.Content.ReadAsStringAsync();
if (!chunkResp.IsSuccessStatusCode)
{
throw new HttpRequestException($"Chunk upload failed: {(int)chunkResp.StatusCode} {chunkResp.ReasonPhrase}\n{chunkBody}");
}
// When final chunk completes, the response is the driveItem JSON.
if (chunkResp.StatusCode == HttpStatusCode.Created || chunkResp.StatusCode == HttpStatusCode.OK)
{
using var finalDoc = JsonDocument.Parse(chunkBody);
var id = finalDoc.RootElement.GetProperty("id").GetString() ?? "";
var name = finalDoc.RootElement.GetProperty("name").GetString() ?? "";
return new DriveItemInfo(id, name);
}
// Otherwise, 202 Accepted with nextExpectedRanges typically (we don't need it for sequential upload).
offset += read;
}
throw new InvalidOperationException("Upload loop ended without returning a driveItem.");
}
// -------------------- Graph: Patch metadata on linked listItem/fields --------------------
private static async Task PatchDriveItemListItemFieldsAsync(string token, string driveId, string driveItemId, Dictionary<string, string> fields)
{
// PATCH /drives/{driveId}/items/{itemId}/listItem/fields
var url = $"{GraphBase}/drives/{driveId}/items/{driveItemId}/listItem/fields";
using var http = CreateHttp(token);
// Build JSON from dictionary
var obj = new Dictionary<string, object>();
foreach (var kvp in fields)
obj[kvp.Key] = kvp.Value;
var jsonBody = JsonSerializer.Serialize(obj);
await SendJsonWithRetryAsync(http, HttpMethod.Patch, url, jsonBody);
}
// -------------------- Graph: List versions --------------------
private sealed record DriveItemVersionInfo(string Id, string? LastModifiedDateTime);
private static async Task<List<DriveItemVersionInfo>> ListVersionsAsync(string token, string driveId, string driveItemId)
{
// GET /drives/{driveId}/items/{itemId}/versions
var url = $"{GraphBase}/drives/{driveId}/items/{driveItemId}/versions?$select=id,lastModifiedDateTime";
using var http = CreateHttp(token);
var json = await SendJsonWithRetryAsync(http, HttpMethod.Get, url);
using var doc = JsonDocument.Parse(json);
var list = new List<DriveItemVersionInfo>();
foreach (var v in doc.RootElement.GetProperty("value").EnumerateArray())
{
var id = v.GetProperty("id").GetString() ?? "";
var lmdt = v.TryGetProperty("lastModifiedDateTime", out var dt) ? dt.GetString() : null;
if (!string.IsNullOrWhiteSpace(id))
list.Add(new DriveItemVersionInfo(id, lmdt));
}
return list;
}
}
5) How this maps to official Graph behavior (what each part relies on)
- Site resolution by path (
GET /sites/{hostname}:/{path}) is the supported way to resolvesiteIdwithout guessing IDs. (Microsoft Learn) - Drive discovery uses
/sites/{siteId}/drivesand falls back to/sites/{siteId}/drive(default library). (Microsoft Learn) - Folder creation uses
driveItem-post-childrenunder a parent item (or root). (Microsoft Learn) - Small upload supports up to 250 MB via
driveItem-put-content. (Microsoft Learn) - Large upload session uses
createUploadSession, and chunks must be uploaded sequentially with max request size limits. (Microsoft Learn) - Metadata updates are done on the
listItem/fieldsrepresentation (document library items can bedriveItemandlistItem). (Microsoft Learn) - Versions listing is done via the versions endpoint. (Microsoft Learn)
Summary tables
A) End-to-end steps
| Step | Goal | Graph API used |
|---|---|---|
| 1 | Resolve the SharePoint site ID | GET /sites/{hostname}:/{path} (Microsoft Learn) |
| 2 | Find the document library (drive) | GET /sites/{siteId}/drives and/or GET /sites/{siteId}/drive (Microsoft Learn) |
| 3 | Ensure folder chain exists | POST /drives/{driveId}/items/{parentId}/children (Microsoft Learn) |
| 4a | Upload file (≤ 250 MB) | PUT ...:/content (Microsoft Learn) |
| 4b | Upload file (large) | POST .../createUploadSession + PUT uploadUrl chunks (Microsoft Learn) |
| 5 | Update custom columns | PATCH /drives/{driveId}/items/{itemId}/listItem/fields (listItem model) (Microsoft Learn) |
| 6 | List versions | GET /drives/{driveId}/items/{itemId}/versions (Microsoft Learn) |
B) Technical takeaways
| Topic | Production rule |
|---|---|
Sites.Selected | Must be consented and explicitly granted on the target site; otherwise you’ll see 403s. (Microsoft Learn) |
| Upload choice | Use simple upload up to 250 MB, otherwise upload session. (Microsoft Learn) |
| Chunk uploads | Upload session chunks are sequential and request size is constrained. (Microsoft Learn) |
| Metadata updates | Update custom columns via listItem fields, not driveItem alone. (Microsoft Learn) |
| Version history | You can list versions; importing historical versions is not something Graph’s upload primitives are designed for. (Microsoft Learn) |
