Skip to content

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.

POST /oauth/token HTTP/1.1
Host: gateway.doshi.app
Content-Type: application/x-www-form-urlencoded
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer
&assertion=eyJhbGciOiJFUzI1NiIsImtpZCI6...
Parameter Value
grant_type urn:ietf:params:oauth:grant-type:jwt-bearer
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.

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.

{
"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.

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.

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.

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.

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 Requests
Retry-After: 2
Content-Type: application/json
{"error":"slow_down","error_description":"this client is sending requests faster than its limit — retry after the Retry-After header"}

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.