Article 3 — Implementing SharePoint “Upsert” with SharePoint REST API (WPF Study & Rating App)
This article shows how to implement the same Upsert behavior (create/update by VideoId) using SharePoint’s native REST API (_api/...) instead of Microsoft Graph.
This is written for a public post:
- uses placeholder site URL and list name
- no tenant-specific IDs
- no credentials or secrets
- focuses on patterns and payloads
Why SharePoint REST?
SharePoint REST is ideal when you want:
- direct control of list semantics
- transparent HTTP debugging (headers, payloads, OData)
- tight SharePoint-specific behavior (ETag concurrency, metadata)
It’s also a strong learning path if your long-term goal includes SharePoint development.
The Upsert Goal (Same as Graph)
Given a VideoRecord:
- find the list item where
VideoId == record.VideoId - if exists → update fields
- if not exists → create item
Base Configuration (Placeholders)
- Site URL:
https://contoso.sharepoint.com/sites/LearningHub - List title:
Learning Videos
REST endpoints will be under:
https://contoso.sharepoint.com/sites/LearningHub/_api/...
Authentication Notes (Public-Safe)
SharePoint REST calls require authentication. In practice, you have common options:
- MSAL token for SharePoint resource
- cookie-based auth in browser contexts (not recommended for desktop apps)
- app-only flows (enterprise / admin controlled)
For a desktop WPF app, the safe, modern approach is still: use MSAL and attach a Bearer token to your _api requests.
Public reminder: never publish real scopes, IDs, or secrets. Use placeholders.
REST Building Blocks You Need
1) Accept and Content-Type headers
Use JSON:
Accept: application/json;odata=nometadataContent-Type: application/json;odata=nometadata
2) ETag handling for updates
SharePoint uses ETags for concurrency:
- Read item → get its
ETag - Update item → send
IF-MATCH: "<etag>"
Or if you want “force update”:
IF-MATCH: *
3) Update method
SharePoint REST supports MERGE semantics. Common pattern:
X-HTTP-Method: MERGEIF-MATCH: *
Or modern PATCH approaches depending on environment. MERGE remains widely used.
Step 1 — Query: Find item by VideoId
REST request
GET /_api/web/lists/getbytitle('Learning Videos')/items ?$select=Id,Title,VideoId &$filter=VideoId eq 'abc123'
C# (template)
public async Task<int?> FindItemIdByVideoIdAsync(string siteUrl, string listTitle, string videoId, Func<Task<string>> getToken){ var safeList = Uri.EscapeDataString(listTitle); var safeVideo = videoId.Replace("'", "''"); var url = siteUrl.TrimEnd('/') + "/_api/web/lists/getbytitle('" + safeList + "')/items" + "?$select=Id,Title,VideoId" + "&$filter=VideoId eq '" + safeVideo + "'"; using (var http = new HttpClient()) { var token = await getToken(); http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); http.DefaultRequestHeaders.Add("Accept", "application/json;odata=nometadata"); var resp = await http.GetAsync(url); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadAsStringAsync(); using (var doc = System.Text.Json.JsonDocument.Parse(json)) { var value = doc.RootElement.GetProperty("value"); if (value.GetArrayLength() == 0) return null; return value[0].GetProperty("Id").GetInt32(); } }}
Note: list title quoting/escaping can be tricky. In real code, validate list naming and avoid special characters when possible.
Step 2 — Create Item (when not found)
REST request
POST /_api/web/lists/getbytitle('Learning Videos')/items
Payload typically includes __metadata in verbose mode. With odata=nometadata, you can often send fields directly — but behavior varies.
Reliable payload pattern (verbose)
{ "__metadata": { "type": "SP.Data.Learning_x0020_VideosListItem" }, "Title": "Video title", "VideoId": "abc123", "VideoUrl": "https://youtube.com/watch?v=abc123", "Channel": "Channel Name", "ThumbnailUrl": "https://i.ytimg.com/...", "SearchQuery": "spfx pnp", "Rating": 9, "Status": "Watched", "Notes": "My notes..."}
The hard part: type name (SP.Data.<ListInternalName>ListItem) depends on the list’s internal name.
How to get the list item entity type
Call:
GET /_api/web/lists/getbytitle('Learning Videos')?$select=ListItemEntityTypeFullName
Then use that in __metadata.type.
C# (template)
public async Task<string> GetListItemEntityTypeAsync(string siteUrl, string listTitle, Func<Task<string>> getToken){ var safeList = Uri.EscapeDataString(listTitle); var url = siteUrl.TrimEnd('/') + "/_api/web/lists/getbytitle('" + safeList + "')?$select=ListItemEntityTypeFullName"; using (var http = new HttpClient()) { var token = await getToken(); http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); http.DefaultRequestHeaders.Add("Accept", "application/json;odata=nometadata"); var resp = await http.GetAsync(url); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadAsStringAsync(); using (var doc = System.Text.Json.JsonDocument.Parse(json)) { return doc.RootElement.GetProperty("ListItemEntityTypeFullName").GetString(); } }}
Create call
public async Task CreateItemAsync(string siteUrl, string listTitle, string entityType, VideoRecord record, Func<Task<string>> getToken){ var safeList = Uri.EscapeDataString(listTitle); var url = siteUrl.TrimEnd('/') + "/_api/web/lists/getbytitle('" + safeList + "')/items"; var payloadObj = new { __metadata = new { type = entityType }, Title = record.Title, VideoId = record.VideoId, VideoUrl = record.VideoUrl, Channel = record.Channel, ThumbnailUrl = record.ThumbnailUrl, SearchQuery = record.SearchQuery, Rating = record.Rating, Status = record.Status, Notes = record.Notes }; var payload = System.Text.Json.JsonSerializer.Serialize(payloadObj); using (var http = new HttpClient()) { var token = await getToken(); http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); http.DefaultRequestHeaders.Add("Accept", "application/json;odata=verbose"); var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json;odata=verbose"); var resp = await http.PostAsync(url, content); resp.EnsureSuccessStatusCode(); }}
Step 3 — Update Item (when found)
REST update pattern (MERGE)
POST /_api/web/lists/getbytitle('Learning Videos')/items(123)X-HTTP-Method: MERGEIF-MATCH: *Content-Type: application/json;odata=verbose
Payload includes __metadata.type again.
C# (template)
public async Task UpdateItemAsync(string siteUrl, string listTitle, int itemId, string entityType, VideoRecord record, Func<Task<string>> getToken){ var safeList = Uri.EscapeDataString(listTitle); var url = siteUrl.TrimEnd('/') + "/_api/web/lists/getbytitle('" + safeList + "')/items(" + itemId + ")"; var payloadObj = new { __metadata = new { type = entityType }, Title = record.Title, VideoUrl = record.VideoUrl, Channel = record.Channel, ThumbnailUrl = record.ThumbnailUrl, SearchQuery = record.SearchQuery, Rating = record.Rating, Status = record.Status, Notes = record.Notes }; var payload = System.Text.Json.JsonSerializer.Serialize(payloadObj); using (var http = new HttpClient()) { var token = await getToken(); http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); http.DefaultRequestHeaders.Add("Accept", "application/json;odata=verbose"); http.DefaultRequestHeaders.Add("IF-MATCH", "*"); http.DefaultRequestHeaders.Add("X-HTTP-Method", "MERGE"); var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json;odata=verbose"); var resp = await http.PostAsync(url, content); resp.EnsureSuccessStatusCode(); }}
Step 4 — The Upsert Service (REST)
public sealed class SharePointRestUpsertService{ private readonly string _siteUrl; private readonly string _listTitle; private readonly Func<Task<string>> _getToken; private string _entityType; public SharePointRestUpsertService(string siteUrl, string listTitle, Func<Task<string>> getToken) { _siteUrl = siteUrl; _listTitle = listTitle; _getToken = getToken; } public async Task UpsertAsync(VideoRecord record) { if (string.IsNullOrWhiteSpace(_entityType)) { _entityType = await GetListItemEntityTypeAsync(_siteUrl, _listTitle, _getToken); } var existingId = await FindItemIdByVideoIdAsync(_siteUrl, _listTitle, record.VideoId, _getToken); if (!existingId.HasValue) { await CreateItemAsync(_siteUrl, _listTitle, _entityType, record, _getToken); } else { await UpdateItemAsync(_siteUrl, _listTitle, existingId.Value, _entityType, record, _getToken); } } // Reuse methods shown earlier (FindItemIdByVideoIdAsync, GetListItemEntityTypeAsync, CreateItemAsync, UpdateItemAsync)}
Debugging Tips (REST-specific)
- 400 Bad Request
- wrong field internal name
- wrong entity type
- invalid JSON payload
- 403 Forbidden
- token scopes do not grant list write access
- user lacks SharePoint permissions
- wrong site URL
- 404 Not Found
- wrong list title or wrong site path
- Status choice values
- must match exact choice text
REST vs Graph: Practical Summary
- REST is SharePoint-native and transparent
- Graph is modern and cross-M365
- Both can upsert; REST often requires more SharePoint-specific knowledge (metadata, entity type, MERGE/ETag)
If your audience is SharePoint-heavy, REST is a great article because it teaches “how SharePoint really works.”
