Errors
Every code the exchange can reject with, what causes it, and what to do about it.
Every rejection is a JSON body shaped like RFC 6749 §5.2:
{ "error": "invalid_grant", "error_description": "assertion expired at 2026-08-12T09:14:02Z (now 2026-08-12T09:21:40Z)"}Branch on error. Every client-side rejection is HTTP 400 except rate limiting,
so the status code does not distinguish them. error_description is for a human
reading a log — it is not stable, so do not parse it.
The table
Section titled “The table”| HTTP | error |
What it means | What to do |
|---|---|---|---|
| 400 | invalid_request |
The request itself is malformed: grant_type absent, assertion absent, assertion not a well-formed JWT, or the body not sent as application/. |
Fix the request shape. Check you are form-encoding, not sending JSON — the content type is enforced. |
| 400 | unsupported_grant_type |
grant_type is present but is not urn:. |
Send the jwt-bearer grant type. |
| 400 | invalid_client |
We cannot attribute the assertion to an issuer or a key: iss absent or unknown, integration paused, kid absent from the header or not an active key of yours, alg not one of RS256/ES256 or disagreeing with that key’s, or your JWKS unreachable or not a valid JWK Set. |
A configuration problem, not a signing one — see below. Rejected exchanges names the reason. |
| 400 | unauthorized_client |
Your assertion is good, but it came from an address your environment’s Allowed IPs do not hold. In Live that includes every request while the list is empty. | Add the address error_description names to Allowed IPs in your console. See Reading unauthorized_client. |
| 400 | invalid_grant |
The assertion was attributed but refused: signature verification failed, expired or not yet valid (including clock skew), lifetime over the 300 s ceiling, wrong aud, a required claim (sub, jti, iat, exp) missing, or a privileged claim present. |
See below — this is the one code that covers several causes on purpose. Rejected exchanges names the reason. |
| 429 | slow_down |
You are over your rate limit. | Back off for the number of seconds in Retry-, then retry. Rejected exchanges shows when. |
| 500 | server_error |
Something failed on our side. | Retry once with backoff. If it persists, email hello@doshi.app with the Reference that Rejected exchanges shows for it, or the jti and timestamp. |
Rate limiting is the one rejection that is not a 400. RFC 6749 defines no code for it,
so the exchange uses slow_down, the rate-limit code of RFC 8628 §3.5. Treat
HTTP 429 itself as the signal, and read Retry-After.
The 500 is the one row whose error_description carries no detail: it is a fixed
string, so there is nothing in it to act on. The jti and the timestamp are what we find
it by in our logs.
Your console’s Rejected exchanges lists the rejections it can tie to your issuer.
There are no other codes. If you see one, it is a bug on our side: tell us at hello@doshi.app.
Every reason and its fix
Section titled “Every reason and its fix”Each error above covers several reasons. Your console’s
Rejected exchanges tab groups your rejections by reason and
shows the same fix as this table. error_description starts
with the text below; for most reasons it then names the specific value it refused.
| Reason | error | error_description starts | Fix |
|---|---|---|---|
grant_type_missing | invalid_request | grant_type is required | Send grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer in the form body. |
grant_type_unsupported | unsupported_grant_type | the only supported grant_type is urn:ietf:params:oauth:grant-type:jwt-bearer | Set grant_type to urn:ietf:params:oauth:grant-type:jwt-bearer, the only grant the token exchange serves. |
assertion_missing | invalid_request | assertion is required | Send the signed assertion in the form body's assertion field. |
assertion_malformed | invalid_request | assertion is not a well-formed JWT | Send the compact JWT your library signs: three base64url parts joined by dots, and nothing around them. |
request_body_malformed | invalid_request | request body must be application/x-www-form-urlencoded | Form-encode the body as application/x-www-form-urlencoded. A JSON body is refused. |
assertion_iss_missing | invalid_client | assertion has no iss claim | Put the iss from Setup values in every assertion. |
issuer_unknown | invalid_client | iss is not a registered issuer | Copy iss from Setup values exactly. Live and Test each have their own. |
issuer_revoked | invalid_client | this issuer's registration is paused | Your integration is paused. Resume it in Settings > Integration once your keys are safe. |
issuer_audience_misconfigured | invalid_client | this issuer is registered against a different exchange endpoint — contact Doshi support | Nothing to change on your side. Email hello@doshi.app and we will correct your registration. |
kid_missing | invalid_client | assertion header has no kid; every assertion must name the key that signed it | Put the kid of the key that signed the assertion in its header. |
kid_unknown | invalid_client | no active key with this kid is registered for you or published at your JWKS | Sign with a kid listed under Signing keys, or re-enable the key if you disabled it. |
alg_unsupported | invalid_client | unsupported alg; the exchange accepts RS256 and ES256 | Sign with ES256 or RS256. |
alg_mismatch | invalid_client | alg does not match the algorithm registered for this key | Sign with the algorithm the key was added with, shown beside it under Signing keys. |
jwks_unavailable | invalid_client | your JWKS could not be fetched | Make your JWKS URL answer a public HTTPS GET with your keys, then retry. |
jwks_malformed | invalid_client | your JWKS is not a valid JWK Set | Serve a JSON JWK Set at your JWKS URL: an object whose keys array holds your public keys. |
signature_invalid | invalid_grant | signature verification failed | It is usually one of three: a signing key whose public half is not the one registered under that kid, an alg that does not match the key, or a malformed key import. |
aud_mismatch | invalid_grant | aud must be the exchange endpoint's URL | Use the exchange URL from Setup values as aud. Live and Test share it. |
sub_missing | invalid_grant | assertion has no sub claim | Put your own ID for the customer in sub. |
jti_missing | invalid_grant | assertion has no jti claim | Give every assertion a fresh jti, such as a random UUID. |
iat_missing | invalid_grant | assertion has no iat claim | Set iat to the time you sign, in seconds since the epoch. |
exp_missing | invalid_grant | assertion has no exp claim | Set exp no more than 300 s after iat. |
assertion_expired | invalid_grant | assertion has expired | Check your server clock. Assertions live at most 300 s, so sign each one just before you send it. |
assertion_not_yet_valid | invalid_grant | assertion is not yet valid (nbf is in the future) | Check your server clock, and leave nbf out or set it no later than iat. |
assertion_iat_in_future | invalid_grant | assertion iat is in the future beyond the permitted clock skew — check NTP | Sync your server clock with NTP, and set iat to the time you sign. |
assertion_ttl_exceeds_ceiling | invalid_grant | assertion lifetime exceeds the permitted ceiling | Set exp at most 300 s after iat. |
privileged_claim_present | invalid_grant | assertion carries a privileged claim; these are rejected outright, never filtered | Leave out the claims Doshi sets itself, such as role and tenant_id. |
unknown_edition | invalid_grant | edition is not one of your editions | Send an edition's key exactly as Setup values lists it, or leave edition out to keep the member where they are. |
ip_not_allowed | unauthorized_client | your server's IP address is not in this environment's Allowed IPs | Add the address your server calls from in the Console, under Integration > Allowed IPs. Live refuses every exchange until its list has one. |
mint_failed | server_error | the mint could not be completed | Something failed on our side. Retry once, and if it keeps failing, email hello@doshi.app with the Reference from the Console. |
rate_limited | slow_down | this client is sending requests faster than its limit — retry after the Retry-After header | Wait as many seconds as Retry-After says before you retry, and reuse each member's token until it expires. |
Reading invalid_client
Section titled “Reading invalid_client”The split between this and invalid_request is attribution, not syntax. A missing
iss or a missing kid is a well-formed JWT — but they are the fields that decide
which issuer and which key we check against, so an assertion without them is
unattributable rather than malformed, and lands here.
alg must agree with the key’s. Each key is added with its algorithm, and
signing with RS256 under a key added as ES256 is an invalid_client, not a
signature failure. This is the one to look at if a key rotation starts failing: to change
algorithm, add a new key with the new algorithm, then delete the old one. Re-signing with
the old kid never works.
One cause here is ours, not yours: an issuer registered with an audience other than
our exchange URL. The error_description says so, and it needs us to fix it. Everything else in
the row is something you can correct: check iss matches your console’s Setup values
exactly, and that the kid you signed with is an active key in that console and, for a
JWKS key, published and reachable at its URL.
Reading unauthorized_client
Section titled “Reading unauthorized_client”You get it for one reason: the request reached us from an address your environment’s
Allowed IPs do not hold. error_description names the address we
saw, and never your list. A Live environment with no entries refuses every exchange this way,
so this is the error a new Live integration meets first.
It is only ever sent for an assertion that your own key signed, after we verified the signature. So when it arrives, the rest of your setup is right: add the address in the console, and the next exchange from it goes through. The console’s Suggested addresses lists it, a click from added. The address is the one your traffic leaves your network from, such as a NAT gateway’s, not the server’s private address.
Reading invalid_grant
Section titled “Reading invalid_grant”Configuration mistakes get a verbose error_description naming the problem: a wrong
aud, an expired assertion, a lifetime over the ceiling, a privileged claim. Those are
the ones you hit while integrating, and they are meant to be debuggable from the
response alone.
Cryptographic failures are deliberately vague. Every signature problem comes back as
one coarse “signature verification failed”, with no detail about which check failed. A
more helpful message here would be an oracle for someone probing our verifier, so it is
not one we can offer. If your signature is being rejected and your kid resolves, the
usual causes are in the signature_invalid row above.
The intermittent one
Section titled “The intermittent one”An invalid_grant that appears sometimes and passes on retry is nearly always
clock skew.
What we never tell you
Section titled “What we never tell you”The exchange finds or creates the member, so there is no “unknown member” rejection and no way to learn from an error whether a person has used the coach before. That is by design, and it is not a gap in this table.