Skip to content
Coritan Docs

Sign customers in to your storefront

Sign in customers with a password, an emailed code or a social account, give guests a session at checkout, confirm actions by code, and add two-factor.

View as Markdown

Your customers have accounts with your organization, separate from Coritan accounts. Your storefront creates them and signs them in through /auth/..., and gets back a customer token that every /portal/... call takes as Authorization: Bearer.

  • Choose how your organization's email leaves in Settings: an SMTP account under Outgoing email (SMTP), or Coritan's own mail service on the Outbound transport card. We send your customers welcome, verification, password reset and sign-in code emails by whichever route you choose. See Choose how your email leaves.
  • Set your Custom domain in the same place. Emails link to pages on it, which your storefront must serve: /verify-email?id=...&token=..., /reset-password?id=...&token=... and /billing/invoices/{invoice_id}. Every password link we send names the customer in id. A link sent before that opens /reset-password?token=..., so keep serving that form as well. Social sign-in and support sessions return to /auth/callback.
  • If GET /storefront/branding reports turnstile.enabled, show the challenge with its turnstile.site_key on your sign-up form, the form that asks for a sign-in code and the checkout form that asks for an email address, and send its answer as turnstile_token with the request. The Store API's GET /store reports the same widget. Without a turnstile.provider it is a Cloudflare Turnstile widget. When turnstile.provider is wicket it is a Wicket widget: load the script at turnstile.script_url, and draw the widget with window.wicket.render(element, { sitekey, callback }), which takes the options Turnstile's render takes. The answer goes in turnstile_token either way.
  • A Turnstile widget loads only on the domains it was made for. If your storefront runs on a domain of its own, it needs a widget of its own: create one for your domain in the Cloudflare dashboard, then send its site key and secret key to support, and Coritan staff enter them for your organization. From then on your storefront renders your widget and we check sign-ups with its secret. Until then, the widget cannot load on your domain, and every request that needs its answer is refused with 403.
  • Instead of a Turnstile widget of your own, you can ask support to set your organization up with Wicket, Coritan's own bot check. You need no keys: we make a Wicket widget on the account that owns your organization, named <your organization> storefront, for your storefront's domains, and from then on GET /storefront/branding reports turnstile.provider as wicket. The widget shows in that account's Wicket section like any other, and its hostnames must keep every domain your storefront runs on, or checks started there are refused. If the widget is turned off or deleted, the storefront goes back to Turnstile until we set it up again. A new domain needs one more request to support, or an added hostname on the widget.
Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/auth/register" \
  -H "Content-Type: application/json" \
  -d '{"email": "alex@example.com", "password": "a long passphrase", "username": "alex_builds", "first_name": "Alex", "turnstile_token": "..."}'

email and password are required, and the password needs at least 8 characters. username, first_name, last_name, company, phone and marketing_consent are optional. Without a username we generate one. marketing_consent is false unless your form sends true for a ticked checkbox, and we store the answer with the time and the source signup. An address on your suppression list is stored as sent and still gets no marketing email. GET /auth/username-available?username=... says whether a name is free, and why not in reason, while the customer types.

A username has 3–20 letters, digits and underscores. Names that pass for staff, and names our moderation filter refuses, are not available. A customer can change theirs once every 7 days. Changing only the capital letters of their own username, such as alex_builds to Alex_Builds, works at any time and does not restart the 7 days. We save the new spelling as sent.

The answer is 201 with the customer's tokens, as for signing in. We also send your welcome email and an email to confirm the address, and your webhooks receive customer.created. Confirming the address is optional until the customer connects a social account, which needs a confirmed address (Social sign-in).

A refusal has detail, a sentence to show, and code, a word your page can act on. detail keeps the same text it always had.

409 with email_taken
An account here has the address. Sign the customer in instead.
409 with username_taken
The username is in use. Ask for another.
403 with email_banned
Your staff banned an account with that address.
403 with customer_limit_reached
Your organization holds as many customers as it may.
409 with account_on_old_site
Your old site still holds an account with the email or the username. We ask it only while the link to your old site is on and its sign-in fallback is on. Show detail, and send the customer to your sign-in form: their old username and password sign them in and offer the move. When the old site does not answer within 5 seconds, the sign-up goes ahead.
Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"identifier": "alex@example.com", "password": "a long passphrase"}'

identifier takes an email address, a username, or a Minecraft player name the customer bought for or saved (Remember the customer's players). A customer's username comes first; otherwise a player name signs in the active customer who used it first. The answer is one of two shapes:

  • A session: access_token, refresh_token, token_type, expires_in and customer_id. The access token lasts 24 hours and the refresh token 30 days. customer_id is the signed-in customer's ID, which a storefront hashes for the analytics tags' user ID.
  • A two-factor step: mfa_token with mfa_required: true when the customer has two-factor authentication on, or mfa_setup_required: true when your organization requires it and the customer has not set it up. Continue under Two-factor authentication.

When Coritan has linked your old site and turned on its sign-in fallback, a login your storefront does not know, or a password that fails here, is checked against your old site. When it opens an old account, the answer is 200 with a legacy block instead of 401:

  • {"legacy": {"action": "move", "ticket": "..."}}: the account can move. Send the browser to /migrate#ticket=...; the password the customer typed counts as their confirmation.
  • {"legacy": {"action": "old_site", "url": "..."}}: the account still has services on your old site. Send the browser to url, which signs the customer in there. We pass on only an https address on your old site's own domain.

A password longer than 256 characters is never sent to your old site. A name that was an old username of an account that already moved signs in to that account here, when the password is right and the account has a password: the same two-factor step and failure limits apply as for any sign-in. Link your old site to your storefront covers the move.

Only active customers can sign in. A customer who signed up with a social account, with a code or at checkout has no password until they set one, and a password sign-in answers 401 with code set to no_password. Offer POST /auth/forgot-password, and a code when your storefront has a code form.

Every refusal of POST /auth/login has detail, a sentence that older pages show as text, and code, a word your page can act on:

Status code What it means
401 invalid_credentials The identifier or the password is wrong, or the customer is not active. detail is Invalid credentials.
401 no_password The account has no password yet. detail is This account has no password yet. Use Forgot password to choose one. We do not ask your old site.
429 rate_limited Your old site asked the customer to wait. Retry-After is 300, and the attempt counts as a failed one from that network.
401 legacy_unavailable The sign-in fallback was on, your old site did not answer within 5 seconds, and no account here opened. The password may be right. detail reads We could not reach the old {your organization's name} to check this password. Try again in a minute.

A network that fails too often also gets 429 from the sign-in limit itself, with Retry-After: 60, code set to rate_limited and detail set to Too many authentication attempts. Please try again later. The same code is on that answer for POST /auth/register, POST /auth/forgot-password and the routes that check an emailed code. Rate limits lists the limits.

Customers moved from WordPress

Section titled Customers moved from WordPress

Customers you bring from a WordPress or WooCommerce store keep their passwords. Their imported password hashes work wherever a customer types a password: signing in, changing the password or the email address, turning off two-factor authentication and closing the account. We accept every format WordPress has stored:

  • bcrypt hashes starting $2y$, $2a$ or $2b$;
  • WordPress 6.8 and later, starting $wp$;
  • phpass hashes starting $P$ (WordPress before 6.8) or $H$ (phpBB);
  • plain MD5, 32 hexadecimal characters, from the oldest WordPress sites.

The first time a customer signs in with the right password, we replace the old hash with a bcrypt hash of the same password. The customer notices nothing.

A guest who places an order can ask for an account in the same request, with or without a password. Without one, we send the account_set_password email, whose link opens /reset-password?id=...&token=... like a password reset and works for 3 days. A forgot-password request does not cancel it (Verify an address and reset a password). The email reads the same whether the account came before the order or after it. Using the link also proves the address, as Verify an address and reset a password says. See Create an account at checkout. A checkout that needs the customer signed in before the order is placed gets a session from the email address instead: see Give a guest a session at checkout.

Sign a customer in with a code

Section titled Sign a customer in with a code

A customer can sign in with a 6-digit code that we email them, instead of a password. A code also makes the account when no account has the address yet, so one form can sign customers in and sign them up.

  1. Send the email address or the Minecraft player name the customer typed to POST /auth/code/request:

    Shell
    curl -X POST "https://api.coritan.com/api/v1/orgs/acme/auth/code/request" \
      -H "Content-Type: application/json" \
      -d '{"identifier": "alex@example.com", "turnstile_token": "..."}'
    

    The answer says where the code went:

    JSON
    {"sent": true, "challenge": "...", "email_hint": "a•••@example.com", "new_account": false, "expires_in": 600, "resend_after": 30}
    

    Show email_hint, so the customer knows which inbox to open, and keep challenge for the next step. new_account is true when no account has the address yet. For a player name or username the answer has no email_hint (it is null) and new_account is false, whether or not an account uses the name. Show a sentence that does not say which, such as "If an account uses that name, we sent a code to its email address."

  2. Send the challenge with the code the customer types to POST /auth/code/verify:

    Shell
    curl -X POST "https://api.coritan.com/api/v1/orgs/acme/auth/code/verify" \
      -H "Content-Type: application/json" \
      -d '{"challenge": "...", "code": "482913"}'
    

    The answer is the one POST /auth/login gives: a session, or a two-factor step. For a new address we make the account first, with no password, send your welcome email, and your webhooks receive customer.created.

A player name is written as the game shows it, in any case. The . or - your network puts before an edition's names may be left off: Notch_Fan finds a player saved as .Notch_Fan, though a player saved exactly as typed comes first. It finds the active customer who first bought for that player or saved it (Remember the customer's players), and otherwise the customer whose username it is. A later customer who buys for or saves the same name does not take it over, and can still sign in with their email address. The code goes to that customer's address.

A name gets the same answer whether or not a customer used it, so nobody can test which names have accounts or read part of an address. A name that no active customer has used gets a challenge too, but we send no email and no code opens it: the customer's tries run out, or it expires after 10 minutes, as for a wrong code. Names count against the limits below in the same way as addresses, by name and by network.

  • A code works for 10 minutes and 5 tries. Spaces and dashes in it are ignored, and text that is not 6 digits costs no try. After the fifth wrong code the challenge stops working, and the customer asks for a new code.
  • An address can ask for another code 30 seconds after the last one, and gets at most 5 codes in 15 minutes. One network gets at most 20 codes an hour. A request past a limit answers 429, with Retry-After in seconds and a sentence in detail to show.
  • A network that sends many wrong codes to POST /auth/code/verify is slowed down: after 40 wrong codes in a minute from one network to one organization, the route answers 429 with Retry-After for the rest of that minute.
  • When a code proves an address that nobody had confirmed before, we mark it confirmed and end every session the account had, such as one a checkout gave. On an account with no password, we also remove any authenticator on it, because only a session that nobody had vouched for can have added one, and an organization that requires two-factor authentication asks the owner to set up their own. Invites to share a server with the address now apply: until an address is proven, they wait. We also remove the sign-in links that a move from your old site carried to an account whose address the old site never confirmed.
  • We send the code in the login_code email. The email log keeps the email without the code. When your organization cannot send email, the request answers 503 and no code exists.
  • An address with no account is refused as sign-up refuses it: a blocked email domain or the customer limit answers 403. A banned, suspended or closed account answers 403 too.

Give a guest a session at checkout

Section titled Give a guest a session at checkout

POST /auth/checkout-session turns the email address a visitor gives at checkout into a customer session, so your checkout can place the order on an account:

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/auth/checkout-session" \
  -H "Content-Type: application/json" \
  -d '{"email": "alex@example.com", "first_name": "Alex", "country_code": "DE", "address": {"line1": "1 Example Street", "city": "Berlin", "postal_code": "10115"}, "turnstile_token": "..."}'

first_name, last_name, company, country_code (ISO 3166-1 alpha-2) and address (line1, line2, city, postal_code and state) are optional. The answer depends on the address:

201 with status set to created
No account had the address, and verify was false or left out. We made the account with no password and the details sent, and the answer holds a session (access_token, refresh_token, expires_in) and the account in customer. We email a link to choose a password (account_set_password), which opens /reset-password?id=...&token=... and works for 3 days, and your webhooks receive customer.created. When your organization requires two-factor authentication, the answer holds the set-up step (mfa_token with mfa_setup_required: true) instead of a session.
200 with status set to code_sent
No account had the address, and you sent verify: true, for an order that needs a proven address. We emailed a code, and the answer holds challenge, email_hint, expires_in, resend_after and new_account: true, as for POST /auth/code/request. The code, sent to POST /auth/code/verify, makes the account and signs the customer in.
409 with detail.error set to account_exists
An account has the address. We emailed it a code, and detail holds message, challenge, email_hint, expires_in, resend_after and has_password. The answer never holds a session: ask for the code and send it to POST /auth/code/verify, or offer the password form as well when has_password is true.

We save the names, company, country and address only on an account this request makes. On an account that exists, PATCH /auth/me changes the names and company once the customer has signed in. A session with status set to created belongs to an address nobody has proven yet, so it ends the first time the customer signs in with a code, follows the confirmation link, or chooses a password from the link. Until then it cannot set a password (POST /auth/oauth/set-password answers 403 with detail.error set to email_unproven), connect a social account, or receive a server that someone shared with the address. The request checks the same Turnstile answer as POST /auth/code/request, a code it sends counts against the same limits, and one network can make at most 20 accounts an hour this way.

Remember the customer's players

Section titled Remember the customer's players

A storefront for a Minecraft network can keep the players each customer buys for, so the customer can sign in with a player name and pick a player again at checkout. We remember every player a basket order buys for. To save one the customer names, such as in a sign-in dialog, send it with the customer's token:

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/portal/players" \
  -H "Authorization: Bearer $CUSTOMER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Alex", "edition": "java", "uuid": "069a79f444e94726a5befca90e38aaf5"}'

name is the name as the game shows it: 1–16 letters, digits and underscores, after a . or - when your network puts one before an edition's names. edition is java, java_offline or bedrock. uuid is optional: send the one GET /network/players/lookup found (What your website reads), with or without dashes. Anything else answers 422. A name the customer already has, in any case, is updated in place: its spelling, its edition, its UUID and the time it was last used. Without a uuid, it keeps the UUID it had when the edition stays the same.

The answer is {"player": {"name": "Alex", "edition": "java", "uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5", "last_used_at": "..."}}. GET /portal/players answers {"players": [...]} in the same shape, the most recently used first, at most 20. The first customer to use a name keeps the sign-in codes for it. Saving a name that another customer used first works and answers the same way, but the codes for it still go to the first customer. Closing the account deletes the saved players, the billing address a checkout saved and the sign-in codes sent to the address.

  • Before the access token expires, send {"refresh_token": "..."} to POST /auth/refresh. The answer is a new pair, and the old tokens stop working at once.
  • POST /auth/logout signs out the session whose token it carries.
  • GET /auth/sessions lists where the customer is signed in, with the address, country, browser and expiry of each. current marks the one asking, and support_session marks a support session that your team or Coritan support opened as the customer.
  • POST /auth/sessions/revoke-others signs out every other session, support sessions included, and answers with how many it ended.

GET /auth/me returns the customer's profile, and support_session in it is null unless the token belongs to a support session. credit_balance is in credit_currency, always USD, and currency is the currency the customer pays in. It also returns marketing_consent, marketing_consent_at and marketing_consent_source, the customer's answer about marketing email, when they gave it and where. PATCH /auth/me changes first_name, last_name, company, phone, chat_handle (the username) or marketing_consent. Sending marketing_consent stores the customer's own choice with the source account, and true also takes their address off your suppression list. A support session cannot change it. See Collect newsletter sign-ups and manage unsubscribes. To change the password, send password with current_password.

To change the email address, send email with current_password:

  • The address must not belong to another of your customers, which answers 409. A customer of another organization with the same address does not count.
  • An account that signs in only with Discord, GitHub, Google or Microsoft answers 400 until the customer sets a password with POST /auth/oauth/set-password. That route needs a proven address: for an account whose address nobody has proven, it answers 403 with detail.error set to email_unproven and a message to show. Offer POST /auth/forgot-password instead, whose link proves the address and sets the password in one step.
  • The new address is saved unverified, and we send the verification email to it. A notice goes to the old address, so the customer hears of a change they did not make.
  • The customer stays signed in. Their open cart and their future orders use the new address, and orders already placed keep the one they were placed with.
  • A customer can change the address 10 times an hour; after that the answer is 429, with Retry-After.

Customers turn on two-factor authentication with an authenticator app. To require it of every customer, turn it on in your staff console's settings. See Manage the staff team and console settings.

  1. POST /auth/mfa/setup returns a secret, an otpauth_uri and a qr_svg to show.
  2. The customer scans it and sends a code to POST /auth/mfa/enable as {"code": "123456"}. The answer holds 10 recovery_codes, shown once. When the request carried an mfa_token, it also holds the session in tokens.
  3. From then on, signing in answers with an mfa_token. Send it as the bearer token to POST /auth/mfa/verify with a code from the app or a recovery code, and the answer is the session.

An mfa_token works only on the /auth/mfa routes. It lasts 5 minutes for a sign-in, and 30 minutes for setting up when your organization requires two-factor authentication.

A session can exist for an address that nobody has proven, such as the one POST /auth/checkout-session returns, so a customer who has no password can set up two-factor authentication there. The first time the address is proven, we remove an authenticator from an account that has no password. Your organization may then require two-factor authentication, and the owner sets up their own at that sign-in. An account with a password keeps its authenticator.

GET /auth/mfa says whether it is on and how many recovery codes are left. POST /auth/mfa/recovery-codes replaces the codes, given a current code. POST /auth/mfa/disable turns it off, given a code and the password when the account has one. It is refused while your organization requires two-factor authentication; your staff can reset a customer's instead.

Confirm an irreversible action with a code

Section titled Confirm an irreversible action with a code

Deleting a server or closing the account needs proof that the person at the keyboard is the customer. A customer with a password types it, and one with a linked social account can confirm with that account. Any customer can instead confirm with a code we email to the address on the account, which works for an account with no password and no linked provider. All three give the request that needs it a password or a reauth_token.

  1. Send an empty body to POST /auth/reauth/code with the customer's token:

    Shell
    curl -X POST "https://api.coritan.com/api/v1/orgs/acme/auth/reauth/code" \
      -H "Authorization: Bearer $CUSTOMER_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{}'
    

    The answer says where the code went, and carries the challenge for the next step:

    JSON
    {"sent": true, "challenge": "...", "email_hint": "a•••@example.com", "expires_in": 600, "resend_after": 30}
    
  2. Send the challenge with the code the customer types to POST /auth/reauth/code/verify, with the same token:

    Shell
    curl -X POST "https://api.coritan.com/api/v1/orgs/acme/auth/reauth/code/verify" \
      -H "Authorization: Bearer $CUSTOMER_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"challenge": "...", "code": "482913"}'
    

    The answer is {"reauth_token": "...", "expires_in": 300}. Send the token as reauth_token with the request that needs the confirmation, such as POST /portal/account/close, within 5 minutes.

The code goes to the account's own address, never to one in the request. We send it in the reauth_code email, and the email log keeps the email without the code. A code works for 10 minutes and 5 tries, and counts against the same limits as a sign-in code: the address can ask for another after 30 seconds and gets at most 5 codes in 15 minutes, and one network gets at most 20 an hour. A code sent for signing in cannot confirm an action, and a code sent to confirm one cannot sign in. After 40 wrong codes in a minute from one network, POST /auth/reauth/code/verify answers 429 with Retry-After. A support session cannot ask for a code: the answer is 403, because the customer has to confirm for themselves.

Coritan configures the providers. GET /auth/oauth/providers lists the ones your storefront can offer, from discord, github, google and microsoft. Show a button only for a provider in that list.

Coritan can also limit your organization to some of the providers. One left out is missing from that list, authorize refuses it, and a sign-in with it that was already under way comes back to /auth/callback with error=provider_not_available. A customer who linked it earlier still sees it in GET /auth/oauth/connections, but cannot sign in with it. Without a password, that customer sets one through POST /auth/forgot-password.

  1. Call GET /auth/oauth/{provider}/authorize and send the customer to the authorization_url it returns.
  2. After the provider, we send the customer back to /auth/callback on your storefront with the result in the URL fragment: access_token, refresh_token and expires_in, or mfa_token and its flags, or error. provider names the provider the customer signed in with, and new_account=true marks a customer we just registered. When the browser sends its _ct_sid cookie to authorize, the sign-in remembers the visit, so a new customer is credited to it (attribution).

We return the customer only to a storefront address that Coritan has registered for your organization. Contact support to register yours before you offer social sign-in.

A provider vouches for an email address only when it says it checked it. Discord does when the address is verified, Google when verified_email is true, and GitHub when the address we use is verified in the customer's GitHub emails. Microsoft does only for a personal account, such as an outlook.com or hotmail.com address, and never for a work or school account. We decide a sign-in in this order:

  1. The provider account is already linked to a customer: they sign in. When the provider vouches for the account's own address and nobody had proven it yet, this sign-in proves it, and the account keeps this link.
  2. The provider account is not linked, and an account here has the provider's email address: we link nothing, even when the provider vouches for the address. The customer returns to /auth/callback with error=account_exists_login_required when the account has a password. They sign in with it, then connect the provider in their account settings. They return with error=account_exists_no_password when the account has none. They sign in with a code, or choose a password with POST /auth/forgot-password, then connect the provider.
  3. The provider account is not linked, and no account has the address: we make the account only when the provider vouches for the address. Otherwise the customer returns with error=email_unverified_by_provider and we make nothing. They verify the address with the provider and try again, or sign up with their email address.

A provider that shares no address comes back with error=no_email_from_provider.

A signed-in customer connects another provider by calling authorize with their token. The customer's address must be proven first, whether the account has a password or not. For an account whose address nobody has proven, authorize answers 403 with detail.error set to email_unproven and a message to show, and a connection already under way comes back to /auth/callback with error=email_unproven. Offer POST /auth/resend-verification, for a link that proves the address. GET /auth/oauth/connections lists the linked providers, and DELETE /auth/oauth/connections/{provider} removes one. For a Discord account, each row's account_created_at says when the Discord account itself was made. POST /auth/oauth/set-password gives a social-only account a password: 8–128 characters with a lowercase letter, an uppercase letter, a digit and a symbol.

Discord accounts that move between customers

Section titled Discord accounts that move between customers

A Discord account can be linked to one of your customers at a time. Moving it to a different customer counts as a move, whether the customer links it from their account or signs up with it. A Discord account can move at most 2 times in 7 days, which is enough to move it to a second account and back. Its first link is not a move, and neither is linking it again to the customer who had it last. A third move in 7 days comes back to /auth/callback with error=discord_link_limit and retry_at, the time it can be linked again (ISO 8601 in UTC, to the second). A link at retry_at or later succeeds, so show the time as well as the day, in the customer's time zone.

We keep a record of every link, move, unlink and refused attempt, with the customer who made it. A free order from a VPN, proxy or hosting network needs a linked Discord account at least 30 days old (Who can order a free server).

Coritan can block an email domain that abuses the free plan, and a block covers the domain's subdomains. A sign-up with an address on a blocked domain answers 403 with detail.error set to email_domain_blocked, and a social sign-up comes back to /auth/callback with error=email_domain_blocked. Customers who already have an account on the domain can still sign in.

What a storefront built on these routes shows

Section titled What a storefront built on these routes shows

A storefront we build on these routes reads code to give each refusal its own words. Use it as a model for yours.

  • Sign-in: a wrong password says that the username or password is not right. rate_limited says to wait a few minutes. legacy_unavailable says it could not reach the old site and to try again in a minute.
  • A no_password answer replaces the form with "This account has no password yet." and the ways on: the provider buttons, "Email me a sign-in code" and "Email me a link to set a password". The link button calls POST /auth/forgot-password, and asks for the email address first when the customer typed a username. "Use a different account" goes back to the form.
  • "Sign in with a code" opens from the sign-in page, and directly at /login?method=code. It sends a code with POST /auth/code/request and signs in with POST /auth/code/verify.
  • Sign-up shows account_on_old_site as a notice with a "Sign in instead" button that takes the typed email address to the sign-in form. email_taken offers the sign-in form and Forgot password, and email_banned offers support.
  • The error values from Who a provider signs in each have a sentence, and the ones that point to a next step have a button for it, such as "Email me a sign-in code".
  • On the Account settings page, a customer whose address is not proven who tries to add a password or connect a sign-in account sees "Confirm your email address first" with a "Send confirmation email" button. It warns that sign-in accounts that came from the old site can be removed when the customer confirms the address.

Your team can sign in as a customer from the dashboard or the staff console (Sign in as a customer), and Coritan support can from its admin console. Either way, a new tab opens /auth/callback on your custom domain, or on your store's storefront address when you have no custom domain, with the customer's token in the URL fragment:

Text
https://example.com/auth/callback#access_token=...&impersonated=1&elevated=1&mode=full&from=dashboard
access_token
The customer's token. It lasts 30 minutes and comes with no refresh token.
impersonated
Always 1: the address opens a support session.
elevated and mode
elevated=1 with mode=servers or mode=full for a session that can make changes. A read-only session has neither.
from
Where the session was opened: staff (the staff console), dashboard, or admin (Coritan's admin console).
customer
The customer's ID, only when from is staff, so that ending the session can reopen their page in the staff console.
next
A path on your storefront to open once the session is kept, when the person asked for one.

On the callback page:

  1. Read the fragment, then remove it from the address bar at once with history.replaceState, so the token stays out of the history, bookmarks and anything the page sends on.
  2. Check the token with GET /auth/me, sent as the bearer token. Keep the session only when the answer's support_session is set, and take the mode and the end from that answer, because anyone can edit a fragment. When support_session is null, or the request fails, keep nothing: the address carried a token that is not a support session's.
  3. Keep the token until support_session.expires_at, and never try to refresh it. When the visitor was signed in on your storefront, set their own tokens aside and give them back when the session ends.
  4. On every page, show whose account it is and when the session ends, with a way to end it.
  5. Leave the visitor's cart out of it: start the session with no cart, and give the cart back when the session ends. In full mode, a cart the session changes becomes the customer's.
  6. To end it, send POST /auth/logout with the support token, then drop the token and give the visitor's own session back. Do the same when any request answers 401, which means the session has ended.
Shell
curl https://api.coritan.com/api/v1/orgs/acme/auth/me \
  -H "Authorization: Bearer $SUPPORT_TOKEN"

The answer is the customer's profile, with:

JSON
{"email": "alex@example.com", "support_session": {"mode": "full", "expires_at": "2026-09-26T10:00:00Z"}}

A read_only session can read anything the customer can, and every other request answers 403 with This is a read-only support session. Ask the customer to make this change, or use the staff console. A servers session may also change the customer's servers and snapshots under /portal/servers/ and /portal/snapshots. A full session may make any change the customer could, except change their profile, billing details, password, email, two-factor, sign-in methods or sessions, confirm an irreversible action, or close the account. Those answer 403 with A support session cannot change the customer's password, email, two-factor, sign-in methods or sessions, or close the account. Use the staff console for those. POST /auth/logout works in every mode. Every change a servers or full session makes is recorded in your organization's audit log against the person who opened it.

The Next.js starter does all of this.

Verify an address and reset a password

Section titled Verify an address and reset a password
  • The confirmation email links to /verify-email?id=...&token=.... Post both values to POST /auth/verify-email as customer_id and token. A link works for 24 hours, and only the newest one works. The first time a link confirms an address, every session the account had ends, an authenticator on an account with no password is removed, invites to share a server with the address apply, and the sign-in links that a move from your old site carried to an account whose address the old site never confirmed are removed. A customer who moved the account to a new address themselves (PATCH /auth/me with email and current_password) stays signed in when they confirm it, because they gave the password to change it. POST /auth/resend-verification sends another; sent: false with email_unavailable means your organization cannot send email right now. That happens when neither your organization nor Coritan has an SMTP host and your in-house transport is not ready, or when you chose In-house only and its tenant is not ready. The Outbound transport card says which.
  • POST /auth/forgot-password with email sends a reset link to /reset-password?id=...&token=.... The answer is the same whether or not the account exists, and whether or not your organization can send email right now. Post id, the token and the new password to POST /auth/reset-password within 2 hours. The reset signs the customer out everywhere. The link went to the account's address, so using it also proves the address, with the same effects as the confirmation link. The answer is {"message": "Password updated", "email": "alex@example.com"} with the account's address, so your page can sign the customer in with the new password at once.
  • One network can send 10 forgot-password requests an hour, and the next answers 429 with Retry-After. One address gets 3 links an hour. Later requests for it answer the same 200 and send nothing. Both counts include addresses that have no account, so neither tells a caller whether an address has one.
  • Every link names the customer in id, and a request that sends id with token checks the token on that one customer, so it costs the same however many customers you have. A link sent before links named the customer carries token alone. A request without id still works for it: we look for the token among your customers, and one network can make 10 such requests an hour before the answer is 429.
  • A forgot-password link has a slot of its own. The links we make on our own share the other slot: the set-password button of a customer moved from a previous site (30 days), the link of an account made at checkout (3 days), the link that POST /auth/reset-password/resend sends (7 days) and the links your staff send or make with a new account. A forgot-password request replaces only the earlier forgot-password link, so a stranger who types an address cannot cancel the link its owner was sent. A new link of our own replaces an earlier one of our own, and using either kind clears both.
  • A refusal answers 400 Invalid reset token whether the token is wrong, was replaced by a newer link or was used already, or the id is unknown, belongs to another organization or names a customer who is not active. A token that is ours but has run out answers 400 Reset token expired. When the request carried an id, detail is the object {"code": "reset_link_expired", "message": "Reset token expired"} instead of a string, so your page can tell the customer the link ran out and offer a new one. Show detail.message for any object detail.
  • POST /auth/reset-password/resend with the link's id and token emails a new link to the address on the account. It works for 7 days and replaces every earlier link of the customer, which stop working. Send it whether or not the old link has expired. The answer is always 200 with {"message": "If that link was ours, we sent a new one to the address on file."}, so it says nothing about which customers exist. We send nothing when the token is wrong, the customer is not active, or your organization cannot send email. A customer can ask for 3 new links an hour, and one network can ask 20 times an hour for each organization. After that the answer is 429 with Retry-After. The email is the password_link_resent template.

The customer has a session your storefront keeps in the browser and sends to every /portal/... call. The customer appears on the Customers tab.

401 Invalid credentials with code set to invalid_credentials
The identifier or the password is wrong, or the customer is not active.
401 This account has no password yet. Use Forgot password to choose one. with code set to no_password
The account was made with a social account, with a code or at checkout. The customer chooses a password with POST /auth/forgot-password, or signs in with a code or with the social account.
401 with code set to legacy_unavailable
Your old site was asked about the sign-in and did not answer. The password may be right. Show detail and let the customer try again in a minute.
429 with code set to rate_limited
Either your old site asked the customer to wait (Retry-After is 300), or the network failed too many sign-ins (Retry-After is 60). Show detail, and try again after Retry-After seconds.
403 with email_unproven
The customer tried to connect a social account, or to set a first password with POST /auth/oauth/set-password, on an account whose address nobody has proven, such as one a checkout made or a sign-up that never followed the confirmation link. Connecting a social account needs a proven address with a password as well. Show detail.message, and offer POST /auth/resend-verification for a link that proves the address, or POST /auth/forgot-password for a link that proves it and sets a password.
error=account_exists_login_required, error=account_exists_no_password or error=email_unverified_by_provider at /auth/callback
A social sign-in found an account with the provider's email address, or the provider did not say it had verified the address. See Who a provider signs in for what to tell the customer.
403 A support session cannot confirm an action for the customer. ...
A support session asked for a confirmation code. The customer confirms for themselves.
400 with wrong_code, expired or used
The code is wrong (attempts_left says how many tries remain), more than 10 minutes old, already used, or had 5 wrong tries. A challenge your organization did not issue reads as expired. Show detail.message, and offer to send a new code.
429 with Retry-After
The address or the player name asked for a code less than 30 seconds ago or had 5 in 15 minutes, the network had 20 codes in an hour, the network made 20 accounts at checkout in an hour, or the network sent 10 forgot-password requests in an hour. Show detail, and try again after Retry-After seconds.
503 We cannot send email right now, so no code went out. Try again later.
Your organization cannot send email at the moment. See Before you begin.
409 with account_exists
POST /auth/checkout-session found an account with the address and emailed it a code. Ask for the code, or the password when has_password is true.
404 This sign-in method is not available here
Your organization does not offer that provider. Show only the providers GET /auth/oauth/providers lists.
409 Email already registered or 409 That username is already taken.
code is email_taken or username_taken. Sign the customer in instead, or choose another username.
409 with code set to account_on_old_site
Your old site holds an account with the email or the username. The customer signs in with their old username and password, and we offer to move the account (Register a customer).
403 with turnstile_failed
The challenge answer was missing or did not pass. On a domain of your own, check that the turnstile.site_key from GET /storefront/branding is your own widget's key in Cloudflare. If it is not, Coritan does not have your widget's keys yet: see Before you begin. When turnstile.provider is wicket, check that the page loaded turnstile.script_url and that its domain is one the widget lists.
403 This email address cannot be used to register.
Your staff banned an account with that address. code is email_banned.
403 We don't accept sign-ups from this email domain. Use a different address.
The address's domain is blocked (email_domain_blocked). The customer signs up with another address.
error=discord_link_limit at /auth/callback
The Discord account moved between your customers twice in the last 7 days. It can be linked again at retry_at.
403 Customer limit reached (100/100)
Your organization holds as many customers as it may. code is customer_limit_reached. Ask support to raise the limit.
401 Invalid refresh token or 401 Refresh token expired
The refresh token was already used, revoked or is older than 30 days. Sign the customer in again.
401 with mfa_required
The token is still waiting for the second factor. Finish with POST /auth/mfa/verify.
403 Account is suspended or closed or 403 We have closed this account.
Your staff suspended, closed or banned the customer.
403 This account cannot sign in. Contact us if you think this is a mistake.
The account with that address is suspended or closed, so we sent no code.
400 That is not an email address.
The identifier sent to POST /auth/code/request has an @ but is not an email address, or the email sent to POST /auth/checkout-session is not one.
403 This is a read-only support session. ...
A read-only support session sent a change, or a servers session sent one outside the server routes. Show the message: the person asks the customer to make the change, or makes it in the staff console.
403 A support session cannot change the customer's password, ...
A full-access support session tried to change the customer's profile, billing details, password, email, two-factor, sign-in methods or sessions, or to close the account. Staff make those changes in the staff console.
400 Invalid reset token or 400 Reset token expired
The link is wrong, was replaced by a newer one, or is more than 2 hours old (3 days for the link sent after an account was made at checkout, 30 days for the set-password button of a moved account, 7 days for a link that POST /auth/reset-password/resend sent). Ask for a new one with POST /auth/forgot-password. When the request had an id and detail.code is reset_link_expired, offer POST /auth/reset-password/resend instead.

API operations on this page

MethodPathWhat it does
POST/api/v1/orgs/{org_slug}/auth/checkout-sessionGive a visitor at checkout a customer session from their email address
POST/api/v1/orgs/{org_slug}/auth/code/requestEmail the customer a 6-digit code to sign in with
POST/api/v1/orgs/{org_slug}/auth/code/verifySign in with the code from the email
POST/api/v1/orgs/{org_slug}/auth/forgot-passwordEmail a link that sets a new password, to the address of an active account
POST/api/v1/orgs/{org_slug}/auth/loginPassword sign-in with an email address or a username
POST/api/v1/orgs/{org_slug}/auth/logoutRevoke the current access token (and its refresh twin if present)
GET/api/v1/orgs/{org_slug}/auth/meThe signed-in customer
PATCH/api/v1/orgs/{org_slug}/auth/meUpdate customer profile
POST/api/v1/orgs/{org_slug}/auth/me/new-account-notice/dismissHide the "this is a new account" notice for good
GET/api/v1/orgs/{org_slug}/auth/mfaCustomer MFA status
POST/api/v1/orgs/{org_slug}/auth/mfa/disableTurn the factor off with a current code, and with the password when the account has one
POST/api/v1/orgs/{org_slug}/auth/mfa/enableCustomer MFA enable
POST/api/v1/orgs/{org_slug}/auth/mfa/recovery-codesCustomer MFA regenerate recovery codes
POST/api/v1/orgs/{org_slug}/auth/mfa/setupCustomer MFA setup
POST/api/v1/orgs/{org_slug}/auth/mfa/verifyCustomer MFA verify
GET/api/v1/orgs/{org_slug}/auth/oauth/connectionsList OAuth connections
DELETE/api/v1/orgs/{org_slug}/auth/oauth/connections/{provider}Disconnect OAuth provider
GET/api/v1/orgs/{org_slug}/auth/oauth/providersPublic: which social providers the storefront offers
POST/api/v1/orgs/{org_slug}/auth/oauth/set-passwordOAuth set password
GET/api/v1/orgs/{org_slug}/auth/oauth/{provider}/authorizeOAuth authorize
GET/api/v1/orgs/{org_slug}/auth/oauth/{provider}/callbackFinish a provider sign-in and send the customer back to the storefront
POST/api/v1/orgs/{org_slug}/auth/reauth/codeRequest reauth code
POST/api/v1/orgs/{org_slug}/auth/reauth/code/verifyTrade the code from POST /auth/reauth/code for a reauthtoken
POST/api/v1/orgs/{org_slug}/auth/refreshCustomer refresh token
POST/api/v1/orgs/{org_slug}/auth/registerSelf-service customer signup for the organization's storefront
POST/api/v1/orgs/{org_slug}/auth/resend-verificationSend another confirmation link to the signed-in customer's own address
POST/api/v1/orgs/{org_slug}/auth/reset-passwordSet a new password with the token from a password reset email
POST/api/v1/orgs/{org_slug}/auth/reset-password/resendEmail a new password link to the customer a link was made for
GET/api/v1/orgs/{org_slug}/auth/sessionsWhere this account is currently signed in
POST/api/v1/orgs/{org_slug}/auth/sessions/revoke-othersSign out everywhere except here
GET/api/v1/orgs/{org_slug}/auth/username-availableWhether a username can be taken at signup, and why not when it cannot
POST/api/v1/orgs/{org_slug}/auth/verify-emailConfirm an address from an emailed link
GET/api/v1/orgs/{org_slug}/portal/playersList my players
POST/api/v1/orgs/{org_slug}/portal/playersRemember my player