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=nometadata
  • Content-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: MERGE
  • IF-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: MERGE
IF-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)

  1. 400 Bad Request
  • wrong field internal name
  • wrong entity type
  • invalid JSON payload
  1. 403 Forbidden
  • token scopes do not grant list write access
  • user lacks SharePoint permissions
  • wrong site URL
  1. 404 Not Found
  • wrong list title or wrong site path
  1. 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.”


Edvaldo Guimrães Filho Avatar

Published by