Skip to content

The account-owner gate

Axowl has two front doors, and they are not the same door.

End-user gateAccount-owner gate
WhereHosted Account Portal, /login/{appKey}Dashboard, /login
IdentityEndUserUser
Means”I am a customer of this app""I create and run an organization”

Signing someone into your app puts them through the first door. Creating an organization requires the second. Nothing converts one into the other; they are joined only by matching e-mail address.

If you link a user straight to the dashboard, they enter beside its front door. Whatever Axowl session that browser already held simply wins. They then create an organization owned by that account, not theirs.

Your app looks organizations up by their owner’s e-mail, so it never sees the one they just made. Your gate keeps saying “you have no organization” immediately after they created one, and nothing on screen explains why. Signing out of your own app does not help, because the session that won was Axowl’s, not yours.

POST /api/public/v1/orgs/owner-status
X-Api-Key: ah_live_…
Authorization: Bearer <the user's end-user JWT>
{
"state": "no_owner_account",
"email": "someone@example.com",
"orgs": []
}
stateMeaningSend them to
no_owner_accountNever been through the owner gateOwner sign-up, as this e-mail
owner_account_existsHas an account; orgs lists what they ownOwner sign-in, then org creation

POST /orgs/by-owner answers [] both for “no Axowl account at all” and for “an account that owns nothing”. Those are different journeys — one has to create an owner account first, the other only has to sign in as one — and they need different words. Use owner-status when the difference matters.

Link to the dashboard’s login route, never to /dashboard directly, and name the person you are sending:

https://test.axowl.com/login
?returnUrl=%2Fdashboard
&login_hint=someone%40example.com
  • Session matches the hint → /login forwards immediately. A correctly signed-in user notices nothing.
  • No session → the login form opens with the hinted address filled in, and only that — social and passkey buttons stay closed, because anything else here signs them in as somebody the hint did not name.
  • Session is somebody else → see below. This is the only case where you have a choice to make.

returnUrl=/dashboard is the right target for both “create an organization” and “finish the seal” — both happen from there. Point returnUrl at whatever page the user actually needs; it is only honoured after the identity check passes.

Choose what a session clash does — prompt

Section titled “Choose what a session clash does — prompt”

The browser holding a different Axowl session is the whole reason this gate exists, so you decide what happens at that moment. The parameter follows OIDC’s grammar.

promptWhat the person seesUse it when
omitted (or login)The hint wins. Axowl signs that browser out of the other account and opens the form as the hinted address, saying which session it ended.Default. Your app already knows who they are — the hint came from your own session.
select_accountAxowl stops and lets them pick between the account they hold and the one you sent. Nothing is signed out until they choose.Your users plausibly hold two Axowl accounts in one browser — an agency operator running client organizations — and ending the wrong session would cost them work.

An unrecognised prompt value is treated as the default. A hint is an explicit instruction, and dropping it because a parameter did not parse is the worse failure.

Axowl.Sdk.Identity.Abstractions carries the type so the parameter names and the prompt grammar are not hand-spelled at each call site:

using Axowl.Sdk.Identity.Abstractions.Contracts;
var url = AxowlOwnerGate.BuildLoginUrl(
dashboardBaseUrl: "https://test.axowl.com",
hintedEmail: user.Email,
returnUrl: "/dashboard",
prompt: OwnerGatePrompt.LetUserChoose); // omit for the default

It builds a string and nothing else — no I/O, so it cannot fail on the network. It throws ArgumentException if the base URL or the hinted e-mail is blank, since a hand-over with no name is exactly the failure this gate exists to prevent.

If you open Axowl in a new tab — the usual shape, so your half-filled form survives — nothing tells your page when they return. The tab regains focus, the person looks at a list that was fetched before they created anything, and the organization or app group they just made is simply not in it. The page is stale and has no way to know it.

Give them the re-read. A plain refresh control next to the list, worded as the thing they just did, costs one line and always works:

# made a new one on Axowl? it won't show up by itself — ↻ refresh the list

Put it beside the list itself, not only in the empty state. A first-time user hits the empty state and finds the button there; someone adding their second organization sees a populated list that is quietly out of date, and if the button only exists when the list is empty they have no way forward short of reloading the page. That dead end is easy to ship without noticing, because it does not appear until an account has one of something.

Treat the return trip as a claim, not proof. A user can navigate straight back to your page without ever visiting Axowl, so re-run the same check server-side before you let the work proceed. This is the rule the payment flow already follows: the redirect is a hint, the server decides.