Portal API — build your own UI

Use the same REST API that powers the StrikeUp Customer Portal to build a fully custom interface inside your own product.

Who is this for?

You should use the Portal API directly if you want to:

  • Embed customer-management UI inside your own app instead of using the StrikeUp Portal modal.

  • Match the customer experience pixel-for-pixel with your own design system.

  • Build a thin server-side proxy that calls the Portal API on behalf of authenticated tenant customers.

  • Add fields, custom flows, or analytics that are not part of the built-in Portal.

If you just want the off-the-shelf Portal, use the bundled widget UI — see Customer Portal in this doc set.

Authentication overview

The Portal API uses a separate identity scheme from the StrikeUp admin API. Customers authenticate with their own JWT (audience strikeup-customer), delivered as HttpOnly cookies and bound to the browser they were issued on.

Cookie

HttpOnly

Purpose

sb_access

Yes

Customer JWT used to authorise every Portal call.

sb_refresh

Yes

Opaque refresh token, rotated on every refresh.

sb_bind

No

Browser-binding nonce. JS reads it and sends as X-Browser-Binding on real-time upgrades. A mismatch immediately revokes the session.

All requests must include cookies — set credentials: 'include' on every fetch() call and configure your domain CORS to permit credentialed requests from your UI origin.

Getting a customer session

You have three options for establishing a customer session before calling any Portal endpoint:

Direct customer login

The customer types their PIN or password into your UI; you call the login endpoint, the backend sets cookies.

POST /api/customer-auth/login
Content-Type: application/json

{
  "customerId": "alice@example.com",
  "widgetId":   "wgt_abc123",
  "credential": "1234",
  "rememberMe": true
}

→ 200 OK
   Sets cookies: sb_access, sb_refresh, sb_bind
POST /api/customer-auth/login
Content-Type: application/json

{
  "customerId": "alice@example.com",
  "widgetId":   "wgt_abc123",
  "credential": "1234",
  "rememberMe": true
}

→ 200 OK
   Sets cookies: sb_access, sb_refresh, sb_bind
POST /api/customer-auth/login
Content-Type: application/json

{
  "customerId": "alice@example.com",
  "widgetId":   "wgt_abc123",
  "credential": "1234",
  "rememberMe": true
}

→ 200 OK
   Sets cookies: sb_access, sb_refresh, sb_bind

Tenant-issued login ticket (Mode A)

If your backend already knows who the user is (your own SSO session), request a one-shot login ticket server-to-server, hand it to the browser, redeem it for cookies. The customer never types a credential in your UI.

# 1) Server-side — your backend authenticates with a tenant JWT
POST /api/customer-auth/issue-ticket
Authorization: Bearer <tenant-jwt>
Content-Type: application/json

{ "customerId": "alice@example.com", "widgetId": "wgt_abc123" }

→ 200 { "ticket": "tkt_eyJ...", "expiresAt": "..." }

# 2) Browser — redeem for cookies
POST /api/customer-auth/redeem-ticket
Content-Type: application/json

{ "ticket": "tkt_eyJ..." }

→ 200 OK with Set-Cookie: sb_access, sb_refresh, sb_bind
# 1) Server-side — your backend authenticates with a tenant JWT
POST /api/customer-auth/issue-ticket
Authorization: Bearer <tenant-jwt>
Content-Type: application/json

{ "customerId": "alice@example.com", "widgetId": "wgt_abc123" }

→ 200 { "ticket": "tkt_eyJ...", "expiresAt": "..." }

# 2) Browser — redeem for cookies
POST /api/customer-auth/redeem-ticket
Content-Type: application/json

{ "ticket": "tkt_eyJ..." }

→ 200 OK with Set-Cookie: sb_access, sb_refresh, sb_bind
# 1) Server-side — your backend authenticates with a tenant JWT
POST /api/customer-auth/issue-ticket
Authorization: Bearer <tenant-jwt>
Content-Type: application/json

{ "customerId": "alice@example.com", "widgetId": "wgt_abc123" }

→ 200 { "ticket": "tkt_eyJ...", "expiresAt": "..." }

# 2) Browser — redeem for cookies
POST /api/customer-auth/redeem-ticket
Content-Type: application/json

{ "ticket": "tkt_eyJ..." }

→ 200 OK with Set-Cookie: sb_access, sb_refresh, sb_bind

Tickets are single-use, short-lived, and only valid for the customerId / widgetId pair they were issued for.

Self-registration

When the customer company allows self-registration, your UI can create the customer in one call (it both registers and signs in).

POST /api/customer-auth/register
Content-Type: application/json

{
  "customerId":        "alice@example.com",
  "customerCompanyId": "cc_xxx",
  "widgetId":          "wgt_abc123",
  "credential":        "1234",
  "credentialType":    "Pin",
  "name":              "Alice"
}

→ 201 Created — session cookies set
POST /api/customer-auth/register
Content-Type: application/json

{
  "customerId":        "alice@example.com",
  "customerCompanyId": "cc_xxx",
  "widgetId":          "wgt_abc123",
  "credential":        "1234",
  "credentialType":    "Pin",
  "name":              "Alice"
}

→ 201 Created — session cookies set
POST /api/customer-auth/register
Content-Type: application/json

{
  "customerId":        "alice@example.com",
  "customerCompanyId": "cc_xxx",
  "widgetId":          "wgt_abc123",
  "credential":        "1234",
  "credentialType":    "Pin",
  "name":              "Alice"
}

→ 201 Created — session cookies set

Driving the sign-in UX

Before showing a sign-in form, ask the backend what state the customer is in:

GET /api/customer-auth/probe?customerId=alice@example.com&widgetId=wgt_abc123

→ 200
{
  "exists":          true,
  "credentialIsSet": true,
  "credentialType":  "Pin",          // "Pin" | "Password"
  "isDisabled":      false,
  "isLocked":        false
}
GET /api/customer-auth/probe?customerId=alice@example.com&widgetId=wgt_abc123

→ 200
{
  "exists":          true,
  "credentialIsSet": true,
  "credentialType":  "Pin",          // "Pin" | "Password"
  "isDisabled":      false,
  "isLocked":        false
}
GET /api/customer-auth/probe?customerId=alice@example.com&widgetId=wgt_abc123

→ 200
{
  "exists":          true,
  "credentialIsSet": true,
  "credentialType":  "Pin",          // "Pin" | "Password"
  "isDisabled":      false,
  "isLocked":        false
}

Use the response to pick the right screen — first-time setup, standard login, locked-out, or disabled.

First-time setup hits a different endpoint than login:

POST /api/customer-auth/setup-credential
Content-Type: application/json

{
  "customerId":     "alice@example.com",
  "widgetId":       "wgt_abc123",
  "credential":     "newPIN1234",
  "credentialType": "Pin"
}

→ 200 OK — session cookies set
POST /api/customer-auth/setup-credential
Content-Type: application/json

{
  "customerId":     "alice@example.com",
  "widgetId":       "wgt_abc123",
  "credential":     "newPIN1234",
  "credentialType": "Pin"
}

→ 200 OK — session cookies set
POST /api/customer-auth/setup-credential
Content-Type: application/json

{
  "customerId":     "alice@example.com",
  "widgetId":       "wgt_abc123",
  "credential":     "newPIN1234",
  "credentialType": "Pin"
}

→ 200 OK — session cookies set

Knowing which pages to render

The tenant configures which Portal pages are visible per widget. Read the flags before rendering your sidebar:

GET /api/TokenValidation/validate/{applicationToken}?widgetSetup=true

→ 200
{
  "isValid":                    true,
  "requiresEndUserAuth":        true,
  "portalEnabled":              true,
  "portalMembersEnabled":       true,
  "portalKnowledgeBaseEnabled": false,
  "portalSkillsEnabled":        true,
  "portalWorkflowsEnabled":     true,
  "portalIntegrationsEnabled":  true,
  "portalSettingsEnabled":      true
}
GET /api/TokenValidation/validate/{applicationToken}?widgetSetup=true

→ 200
{
  "isValid":                    true,
  "requiresEndUserAuth":        true,
  "portalEnabled":              true,
  "portalMembersEnabled":       true,
  "portalKnowledgeBaseEnabled": false,
  "portalSkillsEnabled":        true,
  "portalWorkflowsEnabled":     true,
  "portalIntegrationsEnabled":  true,
  "portalSettingsEnabled":      true
}
GET /api/TokenValidation/validate/{applicationToken}?widgetSetup=true

→ 200
{
  "isValid":                    true,
  "requiresEndUserAuth":        true,
  "portalEnabled":              true,
  "portalMembersEnabled":       true,
  "portalKnowledgeBaseEnabled": false,
  "portalSkillsEnabled":        true,
  "portalWorkflowsEnabled":     true,
  "portalIntegrationsEnabled":  true,
  "portalSettingsEnabled":      true
}

Profile is always visible — there is no flag for it. The other six toggles map 1:1 to Portal pages. Default to true for any flag that is missing from the response so older backends keep working.

Endpoint inventory

Customer auth — /api/customer-auth/*

Verb

Path

Auth

Purpose

GET

/probe

Anonymous

Probe customer + widget to drive the sign-in UX.

POST

/login

Anonymous

Verify credential, issue cookies.

POST

/setup-credential

Anonymous

First-time credential setup.

POST

/register

Anonymous

Self-service open signup.

POST

/refresh

Refresh cookie

Rotate session. Reuse detection revokes the family on replay.

POST

/logout

Refresh cookie

Revoke this session, clear cookies.

GET

/me

Customer

Profile of the current customer.

POST

/change-credential

Customer

Change own credential. Revokes other live sessions.

POST

/issue-ticket

Tenant JWT

Mode A — server-side login ticket.

POST

/redeem-ticket

Anonymous

Mode A — redeem ticket for cookies.

Customer Portal — /api/portal/*

All endpoints require a Customer JWT (the sb_access cookie). Admin-only routes additionally check that the customer’s role is Admin.

Verb

Path

Role

Purpose

GET

/me

Member+

Own profile.

PUT

/me

Member+

Update own name / email.

GET

/customer-company

Member+

Own customer company.

PUT

/customer-company

Admin

Update self-registration / default credential type / agent instruction.

GET

/members

Admin

List members of the caller’s company.

PUT

/members/{id}/role

Admin

Change a member’s role. Guard prevents removing the last admin.

POST

/members/{id}/disable

Admin

Soft-disable and revoke sessions.

POST

/members/{id}/enable

Admin

Re-enable a disabled member.

GET

/skills

Admin

List customer-company skills.

POST

/skills

Admin

Create a company-scoped skill.

PUT

/skills/{id}

Admin

Update a company-scoped skill.

DELETE

/skills/{id}

Admin

Delete a company-scoped skill.

Response shape — GET /api/portal/me

{
  "id":                "cust_abc...",
  "customerId":        "alice@example.com",
  "name":              "Alice",
  "email":             "alice@example.com",
  "role":              "Admin",            // "Admin" | "Member"
  "isActive":          true,
  "credentialType":    "Pin",
  "customerCompanyId": "cc_xxx",
  "customerCompanyName": "Acme Corp",
  "createdAt":         "2026-04-01T12:00:00Z",
  "lastSignInAt":      "2026-05-20T09:14:23Z"
}
{
  "id":                "cust_abc...",
  "customerId":        "alice@example.com",
  "name":              "Alice",
  "email":             "alice@example.com",
  "role":              "Admin",            // "Admin" | "Member"
  "isActive":          true,
  "credentialType":    "Pin",
  "customerCompanyId": "cc_xxx",
  "customerCompanyName": "Acme Corp",
  "createdAt":         "2026-04-01T12:00:00Z",
  "lastSignInAt":      "2026-05-20T09:14:23Z"
}
{
  "id":                "cust_abc...",
  "customerId":        "alice@example.com",
  "name":              "Alice",
  "email":             "alice@example.com",
  "role":              "Admin",            // "Admin" | "Member"
  "isActive":          true,
  "credentialType":    "Pin",
  "customerCompanyId": "cc_xxx",
  "customerCompanyName": "Acme Corp",
  "createdAt":         "2026-04-01T12:00:00Z",
  "lastSignInAt":      "2026-05-20T09:14:23Z"
}

End-to-end example

Minimal browser-side flow: probe → login → load profile → list members.

const API = "https://api.your-strikeup-tenant.com";
const widgetId = "wgt_abc123";
const customerId = "alice@example.com";

const opts = { credentials: "include" as const };

// 1) Probe — decide which screen to render
const probe = await fetch(
  `${API}/api/customer-auth/probe?customerId=${customerId}&widgetId=${widgetId}`,
  opts,
).then(r => r.json());

if (!probe.exists || !probe.credentialIsSet) {
  // → show first-time setup screen (POST /setup-credential)
} else {
  // 2) Login
  await fetch(`${API}/api/customer-auth/login`, {
    ...opts,
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ customerId, widgetId, credential: "1234" }),
  });
}

// 3) Profile — cookies are sent automatically
const me = await fetch(`${API}/api/portal/me`, opts).then(r => r.json());

// 4) Members — admin only
if (me.role === "Admin") {
  const members = await fetch(`${API}/api/portal/members`, opts)
    .then(r => r.json());
}
const API = "https://api.your-strikeup-tenant.com";
const widgetId = "wgt_abc123";
const customerId = "alice@example.com";

const opts = { credentials: "include" as const };

// 1) Probe — decide which screen to render
const probe = await fetch(
  `${API}/api/customer-auth/probe?customerId=${customerId}&widgetId=${widgetId}`,
  opts,
).then(r => r.json());

if (!probe.exists || !probe.credentialIsSet) {
  // → show first-time setup screen (POST /setup-credential)
} else {
  // 2) Login
  await fetch(`${API}/api/customer-auth/login`, {
    ...opts,
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ customerId, widgetId, credential: "1234" }),
  });
}

// 3) Profile — cookies are sent automatically
const me = await fetch(`${API}/api/portal/me`, opts).then(r => r.json());

// 4) Members — admin only
if (me.role === "Admin") {
  const members = await fetch(`${API}/api/portal/members`, opts)
    .then(r => r.json());
}
const API = "https://api.your-strikeup-tenant.com";
const widgetId = "wgt_abc123";
const customerId = "alice@example.com";

const opts = { credentials: "include" as const };

// 1) Probe — decide which screen to render
const probe = await fetch(
  `${API}/api/customer-auth/probe?customerId=${customerId}&widgetId=${widgetId}`,
  opts,
).then(r => r.json());

if (!probe.exists || !probe.credentialIsSet) {
  // → show first-time setup screen (POST /setup-credential)
} else {
  // 2) Login
  await fetch(`${API}/api/customer-auth/login`, {
    ...opts,
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ customerId, widgetId, credential: "1234" }),
  });
}

// 3) Profile — cookies are sent automatically
const me = await fetch(`${API}/api/portal/me`, opts).then(r => r.json());

// 4) Members — admin only
if (me.role === "Admin") {
  const members = await fetch(`${API}/api/portal/members`, opts)
    .then(r => r.json());
}

Bulk pre-create customers (with optional server credentials)

Tenants can provision customers ahead of time — either via CSV upload or a structured JSON batch. Both paths accept an optional serverAuthorizations array per customer, which pre-fills the per-server API credentials (ServerCustomerAuthorization rows) in the same atomic transaction as the customer create. The end user lands directly in the widget — they enter a PIN and the backend already has their downstream API credentials ready.

Verb

Path

Body

Purpose

POST

/api/customer-import/preview

multipart/form-data (file)

Validate a CSV without writing.

POST

/api/customer-import/commit

multipart/form-data (file)

Commit the CSV; all-or-nothing.

POST

/api/customer-import/preview-json

application/json

JSON-shaped preview — same semantics as the CSV preview.

POST

/api/customer-import/commit-json

application/json

JSON-shaped commit — same atomic all-or-nothing semantics.

All four endpoints authenticate as a StrikeUp staff user (tenant JWT — not a customer cookie). They are not part of /api/portal/* — a customer can never call them.

CSV format

Header row required. Columns:

CustomerId,CustomerEmail,CustomerCompanyId,CustomerCompanyName,Pin,Password,Role,ServerAuthorizationsJson
CustomerId,CustomerEmail,CustomerCompanyId,CustomerCompanyName,Pin,Password,Role,ServerAuthorizationsJson
CustomerId,CustomerEmail,CustomerCompanyId,CustomerCompanyName,Pin,Password,Role,ServerAuthorizationsJson

ServerAuthorizationsJson is optional. When set, the cell value is a JSON-encoded array (CSV- escape the inner quotes with "").

JSON request body

POST /api/customer-import/commit-json
Authorization: Bearer <tenant-jwt>
Content-Type: application/json

{
  "customers": [
    {
      "customerId":          "acme-user-1",
      "customerEmail":       "user1@acme.example",
      "customerCompanyName": "Acme Co",
      "pin":                 "1234",
      "role":                "Admin",
      "serverAuthorizations": [
        {
          "serverId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
          "authData": { "Authorization": "Bearer sk_live_abc" }
        }
      ]
    },
    {
      "customerId":          "acme-user-2",
      "customerEmail":       "user2@acme.example",
      "customerCompanyName": "Acme Co",
      "pin":                 "5678",
      "role":                "Member"
    }
  ]
}
POST /api/customer-import/commit-json
Authorization: Bearer <tenant-jwt>
Content-Type: application/json

{
  "customers": [
    {
      "customerId":          "acme-user-1",
      "customerEmail":       "user1@acme.example",
      "customerCompanyName": "Acme Co",
      "pin":                 "1234",
      "role":                "Admin",
      "serverAuthorizations": [
        {
          "serverId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
          "authData": { "Authorization": "Bearer sk_live_abc" }
        }
      ]
    },
    {
      "customerId":          "acme-user-2",
      "customerEmail":       "user2@acme.example",
      "customerCompanyName": "Acme Co",
      "pin":                 "5678",
      "role":                "Member"
    }
  ]
}
POST /api/customer-import/commit-json
Authorization: Bearer <tenant-jwt>
Content-Type: application/json

{
  "customers": [
    {
      "customerId":          "acme-user-1",
      "customerEmail":       "user1@acme.example",
      "customerCompanyName": "Acme Co",
      "pin":                 "1234",
      "role":                "Admin",
      "serverAuthorizations": [
        {
          "serverId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
          "authData": { "Authorization": "Bearer sk_live_abc" }
        }
      ]
    },
    {
      "customerId":          "acme-user-2",
      "customerEmail":       "user2@acme.example",
      "customerCompanyName": "Acme Co",
      "pin":                 "5678",
      "role":                "Member"
    }
  ]
}

serverAuthorizations entry shape

{
  "serverId": "<server-guid-mapped-to-one-of-your-widgets>",
  "authData": {
    "Authorization":  "Bearer sk_live_...",
    "X-Tenant-Key":   "abc123"
  }
}
{
  "serverId": "<server-guid-mapped-to-one-of-your-widgets>",
  "authData": {
    "Authorization":  "Bearer sk_live_...",
    "X-Tenant-Key":   "abc123"
  }
}
{
  "serverId": "<server-guid-mapped-to-one-of-your-widgets>",
  "authData": {
    "Authorization":  "Bearer sk_live_...",
    "X-Tenant-Key":   "abc123"
  }
}
  • serverId — must belong to your tenant AND be mapped to at least one of your widgets. Servers not mapped to any widget are rejected.

  • authType — inferred server-side from Server.AuthorizationType. Servers with AuthorizationType="None" are rejected — no credentials are accepted by that server.

  • authData — dictionary of header name → value. Keys must match the HTTP headers the upstream API expects (same shape as the widget’s in-product auth-update flow).

Atomic semantics

Both the customer create AND each ServerCustomerAuthorization upsert run inside one Company-DB transaction. A single auth-row validation failure (unknown server, server not mapped to a widget, AuthorizationType=None, empty authData) rolls back the whole batch — there is no partial state where the customer exists but the auth row is missing.

Re-running the same payload is idempotent — SaveServerAuthorizationAsync upserts on {customer.CustomerId, server.Id}. Useful for rotating credentials without re-creating customers.

End-to-end (server-side)

# 1) Preview — validate without writing
curl -X POST "$API/api/customer-import/preview-json" \
  -H "Authorization: Bearer $TENANT_JWT" \
  -H "Content-Type: application/json" \
  -d '{ "customers": [{ "customerId":"u1", "customerCompanyName":"Acme",
        "pin":"1234", "role":"Admin",
        "serverAuthorizations":[
          { "serverId":"3fa85f64-...", "authData":{"Authorization":"Bearer x"}}
        ]}]}'

# → 200 OK
#   { totalRows:1, creates:1, updates:0, skips:0, errors:0,
#     rows:[ { resolvedRole:"Admin", serverAuthorizations:[
#       { serverId:"3fa85f64-...", resolvedAuthType:"CustomToken", errors:[] }]}] }

# 2) Commit
curl -X POST "$API/api/customer-import/commit-json" \
  -H "Authorization: Bearer $TENANT_JWT" \
  -H "Content-Type: application/json" \
  -d @customers.json

# → 200 OK
#   { created:1, updated:0, skipped:0, errored:0, rows:[...] }
# 1) Preview — validate without writing
curl -X POST "$API/api/customer-import/preview-json" \
  -H "Authorization: Bearer $TENANT_JWT" \
  -H "Content-Type: application/json" \
  -d '{ "customers": [{ "customerId":"u1", "customerCompanyName":"Acme",
        "pin":"1234", "role":"Admin",
        "serverAuthorizations":[
          { "serverId":"3fa85f64-...", "authData":{"Authorization":"Bearer x"}}
        ]}]}'

# → 200 OK
#   { totalRows:1, creates:1, updates:0, skips:0, errors:0,
#     rows:[ { resolvedRole:"Admin", serverAuthorizations:[
#       { serverId:"3fa85f64-...", resolvedAuthType:"CustomToken", errors:[] }]}] }

# 2) Commit
curl -X POST "$API/api/customer-import/commit-json" \
  -H "Authorization: Bearer $TENANT_JWT" \
  -H "Content-Type: application/json" \
  -d @customers.json

# → 200 OK
#   { created:1, updated:0, skipped:0, errored:0, rows:[...] }
# 1) Preview — validate without writing
curl -X POST "$API/api/customer-import/preview-json" \
  -H "Authorization: Bearer $TENANT_JWT" \
  -H "Content-Type: application/json" \
  -d '{ "customers": [{ "customerId":"u1", "customerCompanyName":"Acme",
        "pin":"1234", "role":"Admin",
        "serverAuthorizations":[
          { "serverId":"3fa85f64-...", "authData":{"Authorization":"Bearer x"}}
        ]}]}'

# → 200 OK
#   { totalRows:1, creates:1, updates:0, skips:0, errors:0,
#     rows:[ { resolvedRole:"Admin", serverAuthorizations:[
#       { serverId:"3fa85f64-...", resolvedAuthType:"CustomToken", errors:[] }]}] }

# 2) Commit
curl -X POST "$API/api/customer-import/commit-json" \
  -H "Authorization: Bearer $TENANT_JWT" \
  -H "Content-Type: application/json" \
  -d @customers.json

# → 200 OK
#   { created:1, updated:0, skipped:0, errored:0, rows:[...] }

Session refresh & lifetimes

Access cookies are short-lived; refresh cookies are long-lived and rotate on every call. Refresh proactively when an authenticated call returns 401:

POST /api/customer-auth/refresh?widgetId=wgt_abc123

→ 200 OK
   Set-Cookie: sb_access (rotated), sb_refresh (rotated)
   X-Browser-Binding (header): must match the sb_bind cookie value
POST /api/customer-auth/refresh?widgetId=wgt_abc123

→ 200 OK
   Set-Cookie: sb_access (rotated), sb_refresh (rotated)
   X-Browser-Binding (header): must match the sb_bind cookie value
POST /api/customer-auth/refresh?widgetId=wgt_abc123

→ 200 OK
   Set-Cookie: sb_access (rotated), sb_refresh (rotated)
   X-Browser-Binding (header): must match the sb_bind cookie value

Reuse detection: replaying a stale refresh token invalidates the whole token family. Your UI should treat any non-200 from /refresh as a forced sign-out.

Error responses

Status

Meaning

400

Validation error — check the response body for details.

401

Not authenticated, expired session, or browser-binding mismatch. Try refresh; otherwise sign out.

403

Authenticated but role too low (e.g. member trying to hit an admin endpoint) — or attempting to act outside your own customer company.

404

Resource doesn’t exist or isn’t in your customer company.

409

Conflict — e.g. trying to demote the last admin, or registering a customerId that already exists.

423

Account locked after repeated credential failures. Wait out the lockout or reset the credential.

Cross-origin & cookie checklist

  • Your UI origin must be allow-listed on the StrikeUp tenant for credentialed CORS. Reach out if it’s not.

  • Always send credentials: 'include' on fetch — without it cookies won’t flow.

  • Cookies are issued with Secure; SameSite=None, so all calls must be over HTTPS.

  • The browser-binding cookie (sb_bind) is not HttpOnly; your client should attach it as the X-Browser-Binding header on real-time connections (SignalR / WebSocket upgrades).

  • Never proxy customer credentials through a server endpoint that isn’t HTTPS. Use ticket-redemption (Mode A) if you want zero-credential flows.

Best practices

  • Use the probe endpoint first. It tells you whether to show first-time setup, login, locked, or disabled screens — saving a round of failed login attempts.

  • Pre-fetch /api/TokenValidation/validate?widgetSetup=true on app boot to know which Portal pages to render. Default any missing flag to true.

  • Treat the customer’s role as the source of truth for client-side gating. Always re-validate on the server too — the API enforces the same checks.

  • On any 401 response, try /refresh once before forcing the user to sign in again.

  • For Mode A integrations, keep the ticket window narrow — issue the ticket inside the same HTTPS request that loads the page, redeem within seconds.

  • Don’t poll /me — fetch it once after login and cache. Re-fetch only after a credential change or a profile-edit response.

See also

  • Customer Portal — what the bundled Portal UI looks like and what each page does.

  • Authentication — tenant-side authorization and credential handling.