Client API
PowerPortalsPro publishes a JSON-over-HTTP surface under /api/* that any single-page application can call — the in-box Blazor WebAssembly client uses it to back IPowerPortalsProService and IAuthService, but the same endpoints are equally usable from a React, Vue, or vanilla-JS front end hosted alongside (or even cross-origin from) the server. This page is the full reference: every client-callable endpoint, with a sample request and response.
Who this is for
If you're building a Blazor app and consuming the framework via
IPowerPortalsProService, you do not need to call these endpoints directly — the client implementation does it for you. This page documents the surface for teams writing a non-Blazor SPA, or wiring up a custom HTTP client, against the same server.
Enabling the endpoints
UsePowerPortalsProWebServer wires the data endpoints (table CRUD, FetchXML, metadata, files, localization, admin). MapAuthEndpoints<TUser> wires the SPA-facing auth surface — call it explicitly if your host needs cookie-auth from a SPA.
// Program.cs — server pipeline
app.UsePowerPortalsProWebServer();
app.MapAuthEndpoints<PortalUser>();
Both calls are no-ops when their feature isn't in use, so wire them once in Program.cs regardless of which interactivity mode the host runs.
The Routes type — single source of truth
PowerPortalsPro.Web.Common.Routes exposes every endpoint path as a strongly-typed property. Both the in-box client and any C#-based external SPA should reference these constants instead of hand-writing strings, so a server-side rename surfaces as a compile error rather than a runtime 404. JavaScript/TypeScript clients will of course need to inline the paths, but the C# side of Routes remains the canonical reference for what those paths are.
// Anywhere — both PowerPortalsPro.Web.Client and external C# SPAs.
var loginUrl = Routes.Api.Auth.Login; // "/api/auth/login"
var meUrl = Routes.Api.Auth.Me; // "/api/auth/me"
var createUrl = Routes.Api.Tables.GetCreateRoute("contact");
var fetchUrl = Routes.Api.GetRetrieveMultipleRoute(fetchXml);
Routes.Api covers the data and admin endpoints; Routes.Api.Auth covers sign-in / sign-up; Routes.Api.Auth.Manage covers the signed-in user's account-management operations.
Authentication model — cookies, not tokens
Auth is browser-cookie based. There is no JWT issuance step. A SPA calls /api/auth/login, the server sets the .AspNetCore.Identity.Application cookie on the response, and the browser attaches it on every subsequent request — including data calls under /api/table/*. From JavaScript this means fetch(..., { credentials: 'include' }) on every call (or axios.defaults.withCredentials = true); without it the cookie is dropped and the server returns 401. Every example below includes it.
// React / Vue / plain fetch — credentials: 'include' is required so the
// browser sends and stores the auth cookie issued by /api/auth/login.
const me = await fetch('/api/auth/me', { credentials: 'include' })
.then(r => r.json());
if (me.isAuthenticated) {
console.log(me.userName, me.roles);
}
Cross-origin SPAs
If the SPA and server are on different origins, the server must send
Access-Control-Allow-Origin: <spa-origin>(not*) andAccess-Control-Allow-Credentials: true, and the SPA must usecredentials: 'include'. Same-origin hosting (the SPA served from the same site as the API) avoids the issue entirely.
The record shape
Every data endpoint that reads or writes a row exchanges the same TableRecord envelope. properties is a map of column logical name → a typed value object whose $type discriminator identifies the column kind. permissions is a bit-flag mask (Read 1, Create 2, Write 4, Delete 8, Append 16, AppendTo 32) describing what the current user may do with the row. On writes you only need to send the columns you're changing.
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"tableName": "account",
"permissions": 15,
"properties": {
"name": { "$type": 14, "value": "Acme Corporation" },
"revenue": { "$type": 8, "value": 5000000, "formattedValue": "$5,000,000.00" },
"createdon": { "$type": 2, "value": "2026-05-15T10:30:00Z", "formattedValue": "May 15, 2026 10:30 AM" },
"primarycontactid": { "$type": 6, "value": "9f8e7d6c-...", "name": "Jane Doe", "tableName": "contact" }
},
"formattedValues": { "revenue": "$5,000,000.00" },
"currency": { "isoCode": "USD", "symbol": "$", "precision": 2 }
}
The $type values mirror the Dataverse attribute kind: 0 Boolean, 2 DateTime, 3 Decimal, 4 Double, 5 Integer, 6 Lookup, 8 Money, 11 Choice, 14 String, 15 Uniqueidentifier, 40 MultiSelectChoice, 41 File, 42 Image. Lookups add name + tableName; number, money and date values carry a Dataverse-formatted formattedValue.
Error responses
Failed calls return RFC 9457 application/problem+json. The in-box client implementation rehydrates the original CLR exception type from the problem details so server-side throws surface as the same exception on the client; for non-.NET SPAs, the type / title / detail / status fields are the standard handle.
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "The record could not be saved because a required column was missing."
}
Endpoint reference
Each endpoint below shows the method and path, what it does, a sample request (as a browser fetch call), and a sample response. Path segments in {braces} are placeholders. Unless noted otherwise, a 2xx response is the success case and failures come back as problem+json.
Records & CRUD
Single-record create, read, update and delete, plus arbitrary FetchXML queries and transactional batches. Backed by UsePowerPortalsProWebServer; every read and write applies the consumer's ITablePermissionHandler / ITableRecordPermissionHandler interceptors and any registered IFetchXmlBuilderInterceptor.
POST /api/table/{tableLogicalName}
Creates a new row in the named table. Send a TableRecord carrying the columns to set; the response returns the new record's id.
Request
const res = await fetch('/api/table/account', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tableName: 'account',
properties: {
name: { $type: 14, value: 'Acme Corporation' },
revenue: { $type: 8, value: 5000000 }
}
})
});
const { id } = await res.json();
Response
{
"responseName": "CreateResponse",
"outputParameters": {},
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
GET /api/table/{tableLogicalName}/{recordId}
Reads a single row by id. The optional ?columns= query parameter (comma-separated logical names) narrows the projection — omit it to retrieve the table's default column set.
Request
const record = await fetch(
'/api/table/account/a1b2c3d4-e5f6-7890-abcd-ef1234567890?columns=name,revenue',
{ credentials: 'include' }
).then(r => r.json());
Response
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"tableName": "account",
"permissions": 15,
"properties": {
"name": { "$type": 14, "value": "Acme Corporation" },
"revenue": { "$type": 8, "value": 5000000, "formattedValue": "$5,000,000.00" }
},
"formattedValues": { "revenue": "$5,000,000.00" },
"currency": { "isoCode": "USD", "symbol": "$", "precision": 2 }
}
PATCH /api/table/{tableLogicalName}/{recordId}
Updates an existing row. Only the columns present in properties are written, so send just the changed values.
Request
await fetch('/api/table/account/a1b2c3d4-e5f6-7890-abcd-ef1234567890', {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tableName: 'account',
properties: { revenue: { $type: 8, value: 6500000 } }
})
});
Response
{ "responseName": "UpdateResponse", "outputParameters": {} }
DELETE /api/table/{tableLogicalName}/{recordId}
Deletes the row by id.
Request
await fetch('/api/table/account/a1b2c3d4-e5f6-7890-abcd-ef1234567890', {
method: 'DELETE',
credentials: 'include'
});
Response
{ "responseName": "DeleteResponse", "outputParameters": {} }
GET /api/retrieveMultiple?fetchXml=…
Runs an arbitrary FetchXML query and returns the matching rows plus paging info. Encode the FetchXML into the fetchXml query string — from C#, Routes.Api.GetRetrieveMultipleRoute(fetchXml) does this for you.
Request
const fetchXml = `<fetch><entity name="account">
<attribute name="name" /><attribute name="revenue" />
<order attribute="name" />
</entity></fetch>`;
const result = await fetch(
'/api/retrieveMultiple?fetchXml=' + encodeURIComponent(fetchXml),
{ credentials: 'include' }
).then(r => r.json());
Response
{
"pagingInfo": { "totalRecordCount": 42, "pagingCookie": "<cookie page=…>", "pageNumber": 1 },
"tableRecords": [
{
"id": "a1b2c3d4-…",
"tableName": "account",
"permissions": 1,
"properties": { "name": { "$type": 14, "value": "Acme Corporation" } },
"formattedValues": {}
}
]
}
POST /api/executeMultiple?returnResponses=true|false
Executes a batch of heterogeneous requests (Create / Update / Delete / Associate / Disassociate) in a single database transaction — if any one fails, the whole batch rolls back. The body is a JSON array of OrganizationRequest objects discriminated by $type. Pass ?returnResponses=false to skip building the per-request response list.
Request
await fetch('/api/executeMultiple?returnResponses=true', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([
{ $type: 'CreateRequest', record: { tableName: 'contact',
properties: { lastname: { $type: 14, value: 'Doe' } } } },
{ $type: 'DeleteRequest', record: { tableName: 'account', id: 'old-guid' } }
])
});
Response
[
{ "$type": "CreateResponse", "responseName": "CreateResponse", "outputParameters": {}, "id": "new-guid" },
{ "$type": "DeleteResponse", "responseName": "DeleteResponse", "outputParameters": {} }
]
Grids & charts
Server-composed query endpoints. Rather than have the client build FetchXML, you pass a view id (or your own FetchXML) plus search / sort / paging, and the server resolves columns, applies permissions and runs the query — the same single-source-of-truth path MainGrid and the chart components use.
POST /api/grids/data
Loads a page of grid data. Supply either a viewId or your own fetchXml, plus optional searchText, sorts, paging and column filters. The response carries the rows, the resolved column definitions, and the caller's table-permission mask.
Request
const page = await fetch('/api/grids/data', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
viewId: '00000000-0000-0000-0000-000000000001',
searchText: 'acme',
sorts: [{ columnName: 'name', descending: false }],
pageNumber: 1,
pageSize: 50
})
}).then(r => r.json());
Response
{
"pagingInfo": { "totalRecordCount": 2, "pageNumber": 1 },
"tableRecords": [ { "id": "a1b2c3d4-…", "tableName": "account",
"properties": { "name": { "$type": 14, "value": "Acme Corporation" } } } ],
"columns": [
{ "columnName": "name", "displayName": "Name", "type": 14, "isSortable": true, "isPrimaryName": true }
],
"tablePermissions": 15
}
POST /api/charts/data
Loads aggregated chart data shaped for the charting components. Accepts an aggregate config, a viewId, or raw fetchXml, plus the label / value / series column mapping; returns Chart.js-style labels and datasets.
Request
const chart = await fetch('/api/charts/data', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
viewId: '00000000-0000-0000-0000-000000000002',
labelColumn: 'statuscode',
valueColumn: 'count',
singleSeriesLabel: 'Accounts by Status'
})
}).then(r => r.json());
Response
{
"data": {
"labels": ["Active", "Inactive"],
"datasets": [ { "label": "Accounts by Status", "data": [ { "value": 25 }, { "value": 8 } ] } ]
}
}
Metadata & permissions
Read-only lookups for table and view metadata, the current user's permission mask, and org-wide settings. All are cached server-side, so repeat calls are cheap.
GET /api/tableMetadata/{tableLogicalName}
Returns a table's metadata — columns (with types, labels and constraints), primary id / name / image columns, and relationships.
Request
const meta = await fetch('/api/tableMetadata/account', { credentials: 'include' })
.then(r => r.json());
Response
{
"tableName": "account",
"objectTypeCode": 1,
"primaryIdColumn": "accountid",
"primaryNameColumn": "name",
"isIntersect": false,
"columns": [ { "$type": 14, "columnName": "name", "displayName": "Name", "maxLength": 160 } ],
"oneToMany": [ { "relationshipName": "account_contacts", "referencingEntity": "contact" } ],
"manyToOne": [],
"manyToMany": []
}
GET /api/permissions/table/{tableLogicalName}
Returns the current user's combined TableSecurityPermission mask for the table as a single integer (bit flags: Read 1, Create 2, Write 4, Delete 8, Append 16, AppendTo 32).
Request
const mask = await fetch('/api/permissions/table/account', { credentials: 'include' })
.then(r => r.json());
// 15 === Read(1) | Create(2) | Write(4) | Delete(8)
Response
15
GET /api/viewMetadata/{viewId}
Returns the metadata for one saved view by its GUID — its FetchXML, layout columns and view flags. The route requires a GUID, which is how it's disambiguated from the all-views route below.
Request
const view = await fetch('/api/viewMetadata/00000000-0000-0000-0000-000000000001',
{ credentials: 'include' }).then(r => r.json());
Response
{
"id": "00000000-0000-0000-0000-000000000001",
"name": "All Accounts",
"tableName": "account",
"isDefault": true,
"fetchXml": "<fetch>…</fetch>",
"columns": [ { "columnName": "name", "displayName": "Name", "width": 300 } ]
}
GET /api/viewMetadata/{tableLogicalName}
Returns every saved view for a table. Shares the /api/viewMetadata/ prefix with the by-id route — a non-GUID segment (a table logical name) lands here.
Request
const views = await fetch('/api/viewMetadata/account', { credentials: 'include' })
.then(r => r.json());
Response
[
{ "id": "0000…0001", "name": "All Accounts", "isDefault": true, "tableName": "account" },
{ "id": "0000…0002", "name": "Active Accounts", "isDefault": false, "tableName": "account" }
]
GET /api/organizationSettings
Returns org-wide settings sourced from the Dataverse organization record: the default currency, the blocked file-extension list, and the maximum upload size in bytes.
Request
const settings = await fetch('/api/organizationSettings', { credentials: 'include' })
.then(r => r.json());
Response
{
"defaultCurrency": { "isoCode": "USD", "symbol": "$", "precision": 2 },
"blockedFileExtensions": ["exe", "bat", "js"],
"maxUploadFileSizeInBytes": 10485760
}
Files
Read file and image column content. Binary payloads are returned base64-encoded inside JSON; the includeData flag lets you fetch metadata only (e.g. to render a download list) without transferring bytes.
GET /api/files/{tableLogicalName}/{recordId}/{columnName}?includeData=…
Returns the file metadata for a file / image column on one record. With ?includeData=true the base64 content is embedded; with false only the name and size come back.
Request
const file = await fetch(
'/api/files/account/a1b2c3d4-…/new_attachment?includeData=true',
{ credentials: 'include' }
).then(r => r.json());
Response
{
"fileName": "contract.pdf",
"fileSizeInBytes": 245678,
"fileData": "JVBERi0xLjQKJeLjz9M…"
}
POST /api/files/{tableLogicalName}/{columnName}/batch?includeData=…
Fetches file metadata (and optionally content) for many records of the same table / column in one round-trip — the body is a JSON array of record GUIDs. Used by the FileGrid's Download Selected so the client doesn't fire N separate calls.
Request
const files = await fetch('/api/files/account/new_attachment/batch?includeData=false', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(['a1b2c3d4-…', 'b2c3d4e5-…'])
}).then(r => r.json());
Response
[
{ "fileName": "contract.pdf", "fileSizeInBytes": 245678, "fileData": null },
{ "fileName": "invoice.docx", "fileSizeInBytes": 89456, "fileData": null }
]
POST /api/files/createFileArchive
Zips the chosen file-column values for a set of records server-side. Returns the raw application/zip stream by default, or — with responseFormat: 1 — a JSON envelope carrying the base64 archive. POST (not GET) so large id lists don't hit URL-length limits.
Request
// Default: raw application/zip stream. Pass responseFormat: 1 for a JSON envelope.
const blob = await fetch('/api/files/createFileArchive', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tableName: 'account',
columnName: 'new_attachment',
recordIds: ['a1b2c3d4-…', 'b2c3d4e5-…']
})
}).then(r => r.blob());
Response
// responseFormat: 1 (Json) instead of the default binary stream
{
"fileName": "account-files.zip",
"contentType": "application/zip",
"data": "UEsDBBQAAAAIAP2t…"
}
Localization bundles
Localized strings for non-Blazor front ends. The /api/localizedStrings route returns the whole tree for a culture; the /localizations/* routes serve thumbprinted, immutable-cached bundles (default, per-table, per-view) for efficient incremental loading — these are public and don't require the auth cookie.
GET /api/localizedStrings/{culture}
Returns the full localized-string tree for a culture as a nested object — framework strings, app overrides, table and choice labels.
Request
const strings = await fetch('/api/localizedStrings/fr-fr', { credentials: 'include' })
.then(r => r.json());
Response
{
"app": { "navigation": { "home": "Accueil" } },
"tables": { "account": { "columns": { "name": { "label": "Nom du compte" } } } }
}
GET /localizations/version
Returns the localization manifest — just the list of supported locales. Served no-cache so a new release is detected on the next page load. Public.
Request
const manifest = await fetch('/localizations/version').then(r => r.json());
Response
{ "supportedLocales": ["en-us", "fr-fr", "de-de"] }
GET /localizations/{locale}/thumbprints
Returns the content thumbprints for one locale: the default bundle plus every loaded table and view. Clients fetch these, then request only the bundles whose thumbprint changed. Public.
Request
const thumbs = await fetch('/localizations/fr-fr/thumbprints').then(r => r.json());
Response
{
"bundle": "a3f5e8c2d",
"tables": { "account": "b7f2d9e1a", "contact": "c4f8a1b3e" },
"views": { "550e8400e29b41d4a716446655440000": "e2f4b8d1c" }
}
GET /localizations/default/{filename} · /tables/{tableName}/{filename} · /views/{viewId}/{filename}
The three bundle families — default (cross-cutting strings), per-table (a table's strings plus the global choices its columns reference), and per-view. The filename is {locale}.{thumbprint}.json and each is served public, immutable, max-age=31536000, so a stable thumbprint is a guaranteed cache hit. Public.
Request
// filename is `{locale}.{thumbprint}.json`, served immutable + cache-forever.
const bundle = await fetch('/localizations/tables/account/fr-fr.b7f2d9e1a.json')
.then(r => r.json());
Response
{
"tables": { "account": { "label": "Compte", "columns": { "name": { "label": "Nom du compte" } } } },
"choices": { "account_industrycode": { "1": "Fabrication", "2": "Services" } }
}
Culture
Switches the browser's active culture by writing the culture cookie.
GET /Culture/{culture}?redirectUri=…
Sets the culture cookie and 302-redirects to redirectUri. Navigate to it (full page load) rather than fetching it, so the Set-Cookie and redirect take effect. Public.
Request
// Full-page navigate so the Set-Cookie + redirect are honoured.
window.location.href = '/Culture/fr-fr?redirectUri=' + encodeURIComponent('/dashboard');
Response
// 302 Found → Location: /dashboard
// Set-Cookie: .AspNetCore.Culture=c=fr-fr|uic=fr-fr
Admin — cache management
Server-cache inspection and invalidation. All three endpoints are gated by [Authorize(Roles = "SystemAdmin")] — only a signed-in administrator can call them.
GET /api/caches
Lists the names of every registered server-side cache. Requires the SystemAdmin role.
Request
const names = await fetch('/api/caches', { credentials: 'include' }).then(r => r.json());
Response
["TableMetadataCache", "ViewMetadataCache", "CurrencyCache"]
POST /api/caches/clear
Clears every server-side cache and returns a per-cache result (whether it succeeded and how long it took). Requires the SystemAdmin role.
Request
const results = await fetch('/api/caches/clear', { method: 'POST', credentials: 'include' })
.then(r => r.json());
Response
[
{ "name": "TableMetadataCache", "succeeded": true, "error": null, "elapsedMs": 45 },
{ "name": "ViewMetadataCache", "succeeded": false, "error": "Timed out", "elapsedMs": 5000 }
]
POST /api/caches/{cacheName}/clear
Clears a single named cache (the name comes from the list endpoint). Returns 404 if no cache by that name is registered. Requires the SystemAdmin role.
Request
const result = await fetch('/api/caches/TableMetadataCache/clear',
{ method: 'POST', credentials: 'include' }).then(r => r.json());
Response
{ "name": "TableMetadataCache", "succeeded": true, "error": null, "elapsedMs": 132 }
Authentication
Sign-in, sign-up and the account lifecycle, backed by MapAuthEndpoints<TUser>. Cookie-based: a successful sign-in sets the ASP.NET Core Identity application cookie, and every later call authenticates by presenting it back. Most return a result enum at HTTP 200 rather than signalling outcome through the status code.
POST /api/auth/login
Signs in with email + password. The result enum distinguishes success, a required second factor, bad credentials, an unconfirmed email, and lockout. On success the auth cookie is set on the response.
Request
const { result } = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com', password: 'P@ssw0rd!', rememberMe: true })
}).then(r => r.json());
// result: 0 Success · 1 RequiresTwoFactor · 2 InvalidCredentials · 3 EmailNotConfirmed · 4 LockedOut
Response
{ "result": 0 }
POST /api/auth/login/2fa
Completes a sign-in that returned RequiresTwoFactor by submitting the authenticator (or recovery) code. rememberMachine sets the trusted-browser cookie so future sign-ins on this browser skip the second factor.
Request
const { success } = await fetch('/api/auth/login/2fa', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: '123456', rememberMachine: false })
}).then(r => r.json());
Response
{ "success": true }
POST /api/auth/logout
Clears the auth cookie, ending the session.
Request
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
Response
// 200 OK — no body. The auth cookie is cleared on the response.
POST /api/auth/register
Creates a new local account. Depending on configuration the result is either a confirmation email sent, an immediate sign-in, or a clash with an existing email. Weak-password and other validation failures come back as 400 problem+json.
Request
const { result } = await fetch('/api/auth/register', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'newuser@example.com', password: 'P@ssw0rd!' })
}).then(r => r.json());
// result: 0 ConfirmationEmailSent · 1 SignedIn · 2 EmailAlreadyInUse
Response
{ "result": 0 }
POST /api/auth/forgot-password
Starts a password reset by emailing a reset link. Always returns 200 with no body whether or not the address exists, so it can't be used to probe for registered emails.
Request
await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com' })
});
Response
// 200 OK — no body, regardless of whether the address exists (enumeration-safe).
POST /api/auth/reset-password
Completes a reset using the token from the email plus the new password. result distinguishes success, an invalid / expired token, and a rejected password (with the validation messages in errors).
Request
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com', code: '<token-from-email>', newPassword: 'N3wP@ss!' })
}).then(r => r.json());
// result: 0 Success · 1 InvalidOrExpiredToken · 2 InvalidPassword
Response
{ "result": 0, "errors": [] }
POST /api/auth/confirm-email
Confirms a newly-registered email using the user id and token from the confirmation link.
Request
const { success } = await fetch('/api/auth/confirm-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: '550e8400-…', code: '<token-from-email>' })
}).then(r => r.json());
Response
{ "success": true }
POST /api/auth/resend-email-confirmation
Re-sends the email-confirmation link. Like forgot-password, it always returns 200 with no body to avoid leaking which addresses are registered.
Request
await fetch('/api/auth/resend-email-confirmation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com' })
});
Response
// 200 OK — no body (enumeration-safe).
GET /api/auth/options
Returns the sign-in configuration in one anonymous call: localAccountsEnabled (whether the portal accepts local username/password accounts) plus the configured external (OAuth) providers — scheme name and display name — for the sign-in buttons.
Request
const options = await fetch('/api/auth/options').then(r => r.json());
Response
{
"localAccountsEnabled": true,
"externalProviders": [
{ "name": "Microsoft", "displayName": "Microsoft" },
{ "name": "Google", "displayName": "Sign in with Google" },
{ "name": "Facebook", "displayName": "Facebook" }
]
}
GET /api/auth/external-login?provider=…&returnUrl=…
Kicks off the OAuth flow for a provider. It returns a 302 challenge to the provider, so navigate the browser to it (don't fetch) — a SPA should set window.location.href.
Request
// Full-page navigate — do NOT fetch — so the browser follows the OAuth 302 chain.
window.location.href =
'/api/auth/external-login?provider=Microsoft&returnUrl=' + encodeURIComponent('/dashboard');
Response
// 302 Found → the external provider's sign-in page (plus correlation cookies).
GET /api/auth/external-login/pending
After the OAuth callback, snapshots the in-flight external login: the provider, the identity's claims, and — when the email matches more than one portal identity — the candidate list to choose from. Returns 204 when there's no pending login.
Request
const res = await fetch('/api/auth/external-login/pending', { credentials: 'include' });
const pending = res.status === 204 ? null : await res.json();
Response
{
"loginProvider": "Microsoft",
"providerDisplayName": "Microsoft",
"identityEmail": "user@company.com",
"requiresChoice": false,
"candidates": []
}
POST /api/auth/external-login/confirm
Completes a first-time external sign-in for a new account by confirming the email to associate. Resolves to a sign-in, a confirmation email, no-pending, or failure.
Request
const { result } = await fetch('/api/auth/external-login/confirm', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@company.com' })
}).then(r => r.json());
// result: 0 SignedIn · 1 ConfirmationEmailSent · 2 NoPendingExternalLogin · 3 Failure
Response
{ "result": 0, "errors": [] }
POST /api/auth/external-login/select
Completes an external sign-in when the identity matched multiple portal identities, by choosing which one (Contact or SystemUser) to sign in as.
Request
const { result } = await fetch('/api/auth/external-login/select', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 1 }) // 0 Contact · 1 SystemUser
}).then(r => r.json());
Response
{ "result": 0, "errors": [] }
GET /api/auth/me
Snapshot of the current principal — id, name, email, roles, and the backing table (contact vs. systemuser), plus any alternate sibling identity. Returns an anonymous shape (isAuthenticated: false) rather than 401 when no cookie is present, so a SPA can call it on first paint without branching on status code.
Request
const me = await fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json());
Response
{
"isAuthenticated": true,
"userId": "550e8400-…",
"userName": "user@example.com",
"email": "user@example.com",
"roles": ["Member"],
"tableName": "contact",
"altIdentityTableName": "systemuser",
"altIdentityUserId": "660e8400-…"
}
POST /api/auth/switch-identity
Swaps the current cookie for the user's alternate sibling identity (the Contact↔SystemUser pairing surfaced on /api/auth/me). The JSON body is an empty object.
Request
const { result } = await fetch('/api/auth/switch-identity', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: '{}'
}).then(r => r.json());
// result: 0 Switched · 1 NoAltIdentity · 2 AltIdentityNotFound · 3 NotAuthenticated
Response
{ "result": 0 }
Account management
The signed-in user's self-service operations under /api/auth/manage/* — profile, password, email, two-factor, linked external logins, and personal data. All require an authenticated session and mirror the framework's classic /Account/Manage Razor pages.
GET /api/auth/manage/profile
Returns the user's profile (name, mobile, email) read from the linked Dataverse contact, plus Identity status flags (email confirmed, has a password, 2FA enabled, read-only).
Request
const profile = await fetch('/api/auth/manage/profile', { credentials: 'include' })
.then(r => r.json());
Response
{
"firstName": "John", "lastName": "Doe", "mobilePhone": "+1-555-0123",
"email": "john.doe@example.com",
"isEmailConfirmed": true, "hasPassword": true, "isTwoFactorEnabled": false, "isReadOnly": false
}
POST /api/auth/manage/profile
Updates the first / last name and mobile phone on the linked contact. Returns 200 on success; SystemUser-backed identities are read-only and get 403.
Request
await fetch('/api/auth/manage/profile', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ firstName: 'Jane', lastName: 'Smith', mobilePhone: '+1-555-9876' })
});
Response
// 200 OK — no body. (403 for read-only SystemUser-backed identities.)
POST /api/auth/manage/password/set
Adds a local password to an account that doesn't have one (e.g. an external-login-only account). Validation messages, if any, come back in errors.
Request
const res = await fetch('/api/auth/manage/password/set', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newPassword: 'N3wP@ss!' })
}).then(r => r.json());
Response
{ "success": true, "errors": [] }
POST /api/auth/manage/password/change
Changes the local password; requires the current password. result distinguishes success, a wrong current password, and a rejected new password.
Request
const { result } = await fetch('/api/auth/manage/password/change', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPassword: 'Old!', newPassword: 'N3wP@ss!' })
}).then(r => r.json());
// result: 0 Success · 1 IncorrectOldPassword · 2 InvalidPassword
Response
{ "result": 0, "errors": [] }
POST /api/auth/manage/email/change
Starts an email change by sending a confirmation link to the new address. The change only takes effect once that link is followed.
Request
const { result } = await fetch('/api/auth/manage/email/change', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newEmail: 'new@example.com' })
}).then(r => r.json());
// result: 0 ConfirmationEmailSent · 1 SameAsCurrentEmail
Response
{ "result": 0 }
POST /api/auth/manage/email/send-confirmation
Re-sends the confirmation link for the user's current email. sent is false when the email is already confirmed.
Request
const { sent } = await fetch('/api/auth/manage/email/send-confirmation',
{ method: 'POST', credentials: 'include' }).then(r => r.json());
Response
{ "sent": true }
GET /api/auth/manage/2fa
Returns the 2FA state — whether an authenticator is enrolled, whether 2FA is on, whether this browser is remembered, and how many recovery codes remain.
Request
const status = await fetch('/api/auth/manage/2fa', { credentials: 'include' })
.then(r => r.json());
Response
{ "hasAuthenticator": true, "is2faEnabled": true, "isMachineRemembered": false, "recoveryCodesLeft": 8 }
GET /api/auth/manage/authenticator/setup
Returns the shared key and otpauth:// URI for the QR-code enrollment screen. Pair it with the verify endpoint to finish enabling 2FA.
Request
const setup = await fetch('/api/auth/manage/authenticator/setup', { credentials: 'include' })
.then(r => r.json());
Response
{
"sharedKey": "abcd efgh ijkl mnop",
"authenticatorUri": "otpauth://totp/PowerPortalsPro:user@example.com?secret=ABCD…&issuer=PowerPortalsPro"
}
POST /api/auth/manage/authenticator/verify
Verifies a code from the authenticator app and enables 2FA. On the first enrollment the response also returns the initial set of recovery codes.
Request
const res = await fetch('/api/auth/manage/authenticator/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: '123456' })
}).then(r => r.json());
Response
{ "success": true, "recoveryCodes": ["ABC123DEF456", "GHI789JKL012", "…"] }
POST /api/auth/manage/authenticator/reset
Rotates the authenticator key. This also disables 2FA, so the user must re-enroll.
Request
const { success } = await fetch('/api/auth/manage/authenticator/reset',
{ method: 'POST', credentials: 'include' }).then(r => r.json());
Response
{ "success": true }
POST /api/auth/manage/2fa/disable
Turns 2FA off for the account.
Request
const { success } = await fetch('/api/auth/manage/2fa/disable',
{ method: 'POST', credentials: 'include' }).then(r => r.json());
Response
{ "success": true }
POST /api/auth/manage/2fa/recovery-codes/generate
Regenerates the recovery codes, replacing any existing set, and returns the new codes.
Request
const { recoveryCodes } = await fetch('/api/auth/manage/2fa/recovery-codes/generate',
{ method: 'POST', credentials: 'include' }).then(r => r.json());
Response
{ "recoveryCodes": ["ABC123DEF456", "GHI789JKL012", "…"] }
POST /api/auth/manage/2fa/forget-browser
Clears this browser's trusted-device cookie, so 2FA will be required again on the next sign-in here.
Request
const { success } = await fetch('/api/auth/manage/2fa/forget-browser',
{ method: 'POST', credentials: 'include' }).then(r => r.json());
Response
{ "success": true }
GET /api/auth/manage/external-logins
Lists the external logins currently linked to the account.
Request
const logins = await fetch('/api/auth/manage/external-logins', { credentials: 'include' })
.then(r => r.json());
Response
{
"currentLogins": [
{ "loginProvider": "Microsoft", "providerKey": "oid-…", "providerDisplayName": "Microsoft" }
]
}
GET /api/auth/manage/login-info
A combined view of the user's sign-in paths — linked external logins plus whether a local password is set — used to decide whether removing a login would lock the user out.
Request
const info = await fetch('/api/auth/manage/login-info', { credentials: 'include' })
.then(r => r.json());
Response
{
"externalLogins": [ { "loginProvider": "Microsoft", "providerKey": "oid-…", "providerDisplayName": "Microsoft" } ],
"hasLocalPassword": true
}
GET /api/auth/manage/external-logins/link?provider=…
Starts the OAuth flow that links an additional provider to the signed-in account. Returns a 302 challenge, so navigate to it rather than fetching it.
Request
// Full-page navigate; the callback below adds the login and redirects back.
window.location.href =
'/api/auth/manage/external-logins/link?provider=Google&returnUrl=' + encodeURIComponent('/account');
Response
// 302 Found → the external provider's consent page.
GET /api/auth/manage/external-logins/link/callback
The OAuth callback for the link flow. The provider redirects the browser here; the server attaches the login and 302-redirects to the returnUrl originally supplied. You don't call this directly.
Request
// Hit by the OAuth provider's redirect — not called directly by your code.
Response
// 302 Found → the returnUrl supplied to the link endpoint, with the login now attached.
POST /api/auth/manage/external-logins/remove
Unlinks one external login by provider + provider key.
Request
const { success } = await fetch('/api/auth/manage/external-logins/remove', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ loginProvider: 'Microsoft', providerKey: 'oid-…' })
}).then(r => r.json());
Response
{ "success": true }
GET /api/auth/manage/personal-data
Exports the user's personal data — every [PersonalData] property plus their linked external logins — for GDPR-style download.
Request
const data = await fetch('/api/auth/manage/personal-data', { credentials: 'include' })
.then(r => r.json());
Response
{
"personalData": { "Id": "550e8400-…", "Email": "john.doe@example.com" },
"externalLogins": { "Microsoft": "oid-…" }
}
POST /api/auth/manage/personal-data/delete
Permanently deletes the user's account and signs them out. Requires the current password when the account has one; pass null for external-only accounts. result distinguishes success, a wrong password, and the case where a password is required but wasn't supplied.
Request
const { result } = await fetch('/api/auth/manage/personal-data/delete', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'CurrentP@ss!' }) // null for external-only accounts
}).then(r => r.json());
// result: 0 Success · 1 IncorrectPassword · 2 RequireLocalPassword
Response
{ "result": 0 }
See also
Related documentation:
IPowerPortalsProService — the C# wrapper around these endpoints — what your Blazor components inject when they don't need to issue raw HTTP themselves.SystemUser sign-in — background on why/api/auth/mereportstableName: "systemuser"for some principals andtableName: "contact"for others.
