The token exchange
The request your backend makes, the addresses it may make it from, the member token it gets back, and the rate limits it must respect.
The exchange is one endpoint. Your backend posts an assertion; it gets a member token.
It is a literal RFC 7523 §2.1 jwt-bearer grant, so an off-the-shelf OAuth client can
talk to it — but a form post is all it is.
Request
Section titled “Request”POST /oauth/token HTTP/1.1Host: gateway.doshi.appContent-Type: application/x-www-form-urlencoded
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=eyJhbGciOiJFUzI1NiIsImtpZCI6...| Parameter | Value |
|---|---|
grant_type |
urn: |
assertion |
The signed JWT from Sign an assertion. |
The exact URL is the same string as your assertion’s aud, in Live and Test alike —
see Environments.
Backend-to-backend only. What makes that true is that the signing key and the assertion never leave your backend — a browser has nothing to post. Be precise about the CORS half: this is a form-encoded POST, which is a simple request, so there is no preflight to fail. A browser holding an assertion could send it and we would mint a token; the exchange sends no CORS headers, so the page could not read the response. That is a backstop against a leaked token being useful, not the control. The control is that the key stays server-side.
Where you call from
Section titled “Where you call from”The exchange accepts a request only from an address on your environment’s Allowed IPs, which you manage in the console:
- Live requires the list. Doshi refuses a Live exchange from any address not on it, and refuses every Live exchange while the list is empty.
- Test accepts any address while its list is empty, and checks the list once it has an entry.
The address is the IPv4 address your request reaches us from: behind a NAT gateway or an
egress proxy, that one, not your server’s private address. A refused request gets
400 unauthorized_client, and its error_description names the address we saw, so you can
add it. The console’s suggested addresses list it too. We check the address after your
assertion’s signature, so a request we refuse for its address was signed with your key.
Response
Section titled “Response”{ "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6...", "token_type": "Bearer", "expires_in": 900}| Field | Meaning |
|---|---|
access_token |
The member token. Hand it to your page; your page hands it to Doshi. |
token_type |
Always Bearer. |
expires_in |
Seconds. 900 — the member token lives 15 minutes. |
There is no refresh token
Section titled “There is no refresh token”Calling the exchange again is the refresh, and that is the design: every renewal re-attests that the person is still signed in to your app. There is no absolute session ceiling on our side either — your session length is the ceiling.
Doshi asks your page for a new token ahead of each expiry — see the cadence in Hand over the token.
What is in the member token
Section titled “What is in the member token”You do not need to read it — pass it through. For completeness, it is an RFC 9068
access token (typ: at+jwt) signed ES256 by a non-extractable key we hold, carrying iss,
sub, aud, exp, iat, jti and client_id. sub is the coach member id we
generated; it is not your sub, and it is not a value you should map or store.
Four further claims — tenant_id, role, environment_id and environment_kind —
are present for our own use. They are not part of the contract: their values, and
their presence, may change without notice. Do not branch on them, and do not branch on
anything else not listed above.
Members are found or created
Section titled “Members are found or created”The first assertion for a given sub creates the coach member; every later one finds
it. There is no separate provisioning call and no “no such member” error — which also
means the exchange cannot be used to probe whether a person exists.
Two people at two different clients are two separate, unlinkable members, even if they are the same human. That is not a limitation we can lift.
Rate limits
Section titled “Rate limits”Limits are per client — that is, per iss, across all your members. The
defaults are derived from the 15-minute renewal cadence and are generous for it:
| Default | |
|---|---|
| Sustained | 10 requests/second |
| Burst | 50 requests |
Your limits are set at onboarding; email hello@doshi.app if your population needs more.
Read these as a ceiling to code against, not as a promise about what you will see. The limits are configured per client, and every client has limits from its first call: a client whose limits are not yet set gets the defaults above. Write the backoff path now: 429 is part of the contract from your first call, and the first time you meet it should not be the day your population doubles.
Only requests your key signed count against these limits: an assertion whose signature does
not verify spends none of them, whoever sent it. Each calling address has a limit of its own
on requests that never verify, such as a malformed body, an unknown iss or a wrong key: 10
a second, with a burst of 100. A form body over 8 KB is refused unread.
Over the limit, you get HTTP 429 with a Retry-After header in seconds. Honour
it — back off for that long rather than retrying immediately. A backend that retries a
429 in a tight loop will stay rate-limited.
HTTP/1.1 429 Too Many RequestsRetry-After: 2Content-Type: application/json
{"error":"slow_down","error_description":"this client is sending requests faster than its limit — retry after the Retry-After header"}When it goes wrong
Section titled “When it goes wrong”Every rejection the exchange makes is a JSON body with an error code from the
error table. Branch on error, never on the HTTP status: every
client-side rejection is a 400 except rate limiting, which is 429.