The shared services and tokens for an API
Domains, mailboxes, service tokens, and access tokens for an API with a claims hook.
https://accounts.praecise.com/api
Domain verification and mailbox provisioning are account-level facts. Every product in the estate needs them, and none of them should learn to talk to the mail service directly — that is how one estate ends up with four domain-verification flows and a person proving the same domain four times.
There is no /v1. A version in a path is a promise to run two of everything.
Signing a person in to a product (the sign-in request, the ID token's claims, silent sign-in, asking for a passkey, sign-out and logout tokens) is in sign-in.md. This page is the shared services and the tokens for an API.
Authenticating
Two ways in, different in kind.
The gateway session cookie (praecise_gateway), for a browser that is
already signed in. Acts as that person.
A service token, for a product calling server to server:
Authorization: Bearer prc_…
A token acts as exactly one account — the account it was issued for. It is stored only as a SHA-256, so the table is a list of account ids and dates rather than a set of working credentials, and a leak costs one account rather than the estate.
Everything answers JSON. 401 means neither credential was accepted.
Getting a token
A person signs in to accounts.praecise.com and mints one:
POST /api/tokens {"name": "rivier, production"}
→ 201 {"name": …, "token": "prc_…", "note": …}
GET /api/tokens → 200 {"tokens": [{id, name, created, lastUsed}]}
DELETE /api/tokens/:id → 204 (idempotent)
The value is shown once. Minting and revoking accept the session cookie only:
a token that could mint its successors would make revoking the original
pointless, so presenting a bearer here is a 403, not a 401.
The account
GET /api/me → {"account": {"id": …, "email": …}}
The cheapest way for a caller to check that a token still works.
For a product's server: is this a Praecise account?
A product that acts on accounts on another product's word (the Praecise Cloud console, whose project API lets Rivier act for an account) must not take that word for it:
GET /api/accounts/lookup?email=person@example.com (or ?id=<account id>)
Authorization: Basic base64(<client id>:<client secret>)
→ 200 {"account": {"id": "…", "email": "person@example.com", "confirmed": true}}
→ 404 {"error": "no Praecise account has that address"}
The credential is the product's own client id and secret, as at the token
endpoint; a person's service token or session is refused here (401), and so
is a product not marked for it in the client list (403). Give exactly one
of email and id (400 otherwise), and prefer id (the sub of ID
tokens) whenever you hold it: it cannot be guessed and does not change when
an address does. confirmed says whether the person has proven the mailbox;
when it is false, do not act for the account on the strength of the address.
At most 120 calls a minute for each client (429 with Retry-After), and
every call is logged with who asked and the outcome, never the address.
Domains
GET /api/domains
POST /api/domains {"domain": "example.com"}
POST /api/domains/:domain/verify
records is what still has to be published. An empty list on an unverified
domain means the records are there and DNS has not caught up.
If Praecise holds the registrar for the domain, POST /api/domains publishes
the ownership record and checks it in the same call — published: true, and
usually verified: true with it. Otherwise the records come back for somebody
to publish, which is the honest answer rather than a worse one.
Mailboxes
GET /api/mailboxes
POST /api/mailboxes {"localpart": "alerts", "domain": "example.com",
"name": "Alerts", "kind": "machine"}
GET /api/mailboxes/:email
kind is human, agent or machine — a server, a robot, an autonomous
machine. It is a Praecise fact and is kept here: the mail service's own
vocabulary is human and send-only, derived from whether a mailbox has an
inbox, and it discards anything else it is sent. That is reasonable of it —
delivery does not care who is at the other end — but it is what makes a record
readable afterwards. A thread between two addresses is two strangers; a thread
between a machine and an operator is an outage being reported.
409 means the domain is on the account but not verified yet. 404 means it
is not on the account at all, and is also the answer for a mailbox that
belongs to somebody else — there is no way to learn from here whether an
address exists elsewhere.
Mail hosts
Clients connect to mail.praecise.com: IMAP on 993, submission on 465,
both with a certificate for that name. _imaps._tcp and _submissions._tcp
SRV records say the same, and autoconfig.praecise.com answers for clients
that look there.
Inbound delivery still has mail.furcate.foundation as the MX. That is not
drift: the MTA-STS policy at mta-sts.praecise.com is generated by the mail
server from its own Hostname and is served in mode: enforce, so an MX
naming anything the policy does not list would be skipped by every sender that
honours it. The MX moves when that Hostname does, which is a change on the
gateway host itself.
Access tokens for a resource
Apart from the shared services above, this host is an OAuth authorization
server: it issues access tokens that another API accepts. What a client or a
resource server needs is in the metadata, at
/.well-known/openid-configuration and, with the same content,
/.well-known/oauth-authorization-server:
| field | what it is for |
|---|---|
issuer | the iss of every token; a resource server compares it exactly |
jwks_uri | the keys access tokens and ID tokens are signed with |
id_token_signing_alg_values_supported | the signing algorithms; access tokens use the same key and algorithm as ID tokens (RS256), and each key in the JWKS names its alg |
dpop_signing_alg_values_supported | ES256, EdDSA |
revocation_endpoint, introspection_endpoint | see below |
client_id_metadata_document_supported | true |
acr_values_supported | phr |
authorization_response_iss_parameter_supported | true (RFC 9207) |
A token is for exactly one resource. The client names it with resource
(RFC 8707) on the authorization request, and the token's aud is that value.
The Skerry API
Resource https://api.skerry.xyz (SKERRY_RESOURCE). The access token is a
JWT with header typ: at+jwt:
| claim | value |
|---|---|
iss | this issuer |
aud | https://api.skerry.xyz |
sub | the account id; for the client credentials grant, the client id |
client_id | the client |
scope | Skerry scope names, space separated; never * |
iat, exp | at most 900 seconds apart |
jti | unique per token |
skerry_partner | the Skerry account the token acts in |
skerry_mode | sandbox or live |
skerry_agent | the registered agent, when the client acts as one |
cnf.jkt | the key the token is bound to, when DPoP was used |
The three skerry_ claims and the final scope come from Skerry's claims hook.
The scope names are listed in src/resources.js, from the Skerry API's
reference.
A person, through an application (authorization code with PKCE):
GET /auth?response_type=code
&client_id=https://host.example/oauth/client.json
&redirect_uri=http://127.0.0.1:3000/callback
&resource=https://api.skerry.xyz
&scope=payments:read customers:read offline_access
&code_challenge=…&code_challenge_method=S256&state=…
skerry_partner and skerry_mode may be added to say which Skerry account
and environment. Without them, a person who can act for more than one is
asked. The consent screen is shown every time, and what it lists is what
Skerry allows that person, which may be less than was asked. offline_access
gets a refresh token; each use replaces it, and using an old one again
revokes the grant.
An agent, with no person present (client credentials):
POST /token
Authorization: Basic <client_id:client_secret>
grant_type=client_credentials
&resource=https://api.skerry.xyz
&scope=payments:read
&skerry_agent=agt_…
Only a client in AGENT_CLIENTS may use this grant, and the token is issued
only if the agent's record at Skerry lists that client. There is no refresh
token; the client asks again when the token expires.
DPoP (RFC 9449). Send a DPoP proof header with the token request and
the token comes back with token_type: DPoP and cnf.jkt. A client marked
dpop_required is refused without one.
Revocation and introspection. POST /token/revocation with a refresh
token ends it and the grant it belongs to. A JWT access token cannot be
revoked or introspected here (the endpoint answers unsupported_token_type);
a resource server verifies it itself and it expires within fifteen minutes.
POST /token/introspection answers for opaque tokens.
When no token is issued. At the token endpoint: invalid_grant (or
unauthorized_client for the client credentials grant) when the resource
refuses, with the resource's reason in error_description; invalid_scope
when none of the scopes may be granted; HTTP 503 temporarily_unavailable
when the resource's hook could not be asked. A refresh token is not used up
by a refusal or a 503.
The claims hook
A resource that wants its own facts in its access tokens registers a hook: an
entry in HOOKED_RESOURCES (src/resources.js) with the URL, the name of the
environment variable holding a shared secret, the request parameters it takes
and the claims it may set. Nothing else here is specific to a resource.
The hook is asked when the consent screen is drawn, when Allow is pressed, and every time a token for the resource is issued (code exchange, refresh, client credentials). Answers are not kept from one request to the next.
Request
POST <hook url>
Content-Type: application/json
Praecise-Signature: t=1794571200000,v1=5f2c…
{
"account": {"id": "…", "email": "person@example.com", "email_verified": true},
"client_id": "https://host.example/oauth/client.json",
"scopes": ["payments:read", "customers:read"],
"resource": "https://api.skerry.xyz",
"requested": {"skerry_partner": "…", "skerry_mode": "sandbox"}
}
account is null for the client credentials grant. scopes is what the
token would carry if nothing is narrowed. requested holds the resource's own
parameters: those sent on the authorization request, what the person chose, or
for client credentials those sent on the token request.
v1 is the hex HMAC-SHA256, keyed with the shared secret, of the t value, a
dot, and the raw body. t is the time in milliseconds. The resource refuses a
request whose t is more than five minutes from its own clock. The check is
verifySignature in src/claims-hook.js.
Answers
| answer | body | effect |
|---|---|---|
| claims | {"claims": {…}, "scopes": […], "label": "…", "requested": {…}} | the claims go in the token; the token carries the listed scopes that were asked for |
| choices | {"choices": [{"label": "…", "detail": "…", "requested": {…}}]} | on the consent screen the person picks one, and the hook is asked again with that requested; at the token endpoint this is a refusal |
| refusal | {"error": "access_denied", "error_description": "…"}, at any status | no token |
label is what the consent screen calls the account. requested, in a claims
answer, is the parameters that select the same answer again; it is kept with
the grant so that a refresh asks about the account that was consented to.
Both are optional.
The answer can only narrow: a scope that was not asked for is dropped. A claim
must be one the resource registered, and never iss, sub, aud, exp,
iat, nbf, jti, client_id, scope or cnf. Any other answer is a
failure, and a failure means no token: a status outside 2xx without an
error, a body that is not JSON or is larger than 16 kB, a redirect, or no
answer within the timeout (3 seconds unless configured).
Asking for a passkey
A client that needs a passkey assertion, not just a session, adds to its authorization request:
&acr_values=phr&max_age=300
The person is asked for a passkey unless the session was signed in with one
within max_age seconds. The ID token then carries acr: "phr", amr
(hwk or swk, user, mfa; a sign-in that began with the password lists
pwd as well) and auth_time; the client checks all three. A nonce sent
with the request comes back unchanged, so the token can be tied to one action.
auth_time is included when max_age or prompt=login is sent. With
prompt=none and no such session the answer is login_required. Cancel on
the screen returns access_denied. Scope openid is required, as for any
request that uses these parameters.
Written and kept by Praecise account. Read from https://accounts.praecise.com/guides/api.md.