Skip to content

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.

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/x-www-form-urlencoded. 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:ietf:params:oauth:grant-type:jwt-bearer. 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-After, 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.

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.

Reasonerrorerror_description startsFix
grant_type_missinginvalid_requestgrant_type is requiredSend grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer in the form body.
grant_type_unsupportedunsupported_grant_typethe only supported grant_type is urn:ietf:params:oauth:grant-type:jwt-bearerSet grant_type to urn:ietf:params:oauth:grant-type:jwt-bearer, the only grant the token exchange serves.
assertion_missinginvalid_requestassertion is requiredSend the signed assertion in the form body's assertion field.
assertion_malformedinvalid_requestassertion is not a well-formed JWTSend the compact JWT your library signs: three base64url parts joined by dots, and nothing around them.
request_body_malformedinvalid_requestrequest body must be application/x-www-form-urlencodedForm-encode the body as application/x-www-form-urlencoded. A JSON body is refused.
assertion_iss_missinginvalid_clientassertion has no iss claimPut the iss from Setup values in every assertion.
issuer_unknowninvalid_clientiss is not a registered issuerCopy iss from Setup values exactly. Live and Test each have their own.
issuer_revokedinvalid_clientthis issuer's registration is pausedYour integration is paused. Resume it in Settings > Integration once your keys are safe.
issuer_audience_misconfiguredinvalid_clientthis issuer is registered against a different exchange endpoint — contact Doshi supportNothing to change on your side. Email hello@doshi.app and we will correct your registration.
kid_missinginvalid_clientassertion header has no kid; every assertion must name the key that signed itPut the kid of the key that signed the assertion in its header.
kid_unknowninvalid_clientno active key with this kid is registered for you or published at your JWKSSign with a kid listed under Signing keys, or re-enable the key if you disabled it.
alg_unsupportedinvalid_clientunsupported alg; the exchange accepts RS256 and ES256Sign with ES256 or RS256.
alg_mismatchinvalid_clientalg does not match the algorithm registered for this keySign with the algorithm the key was added with, shown beside it under Signing keys.
jwks_unavailableinvalid_clientyour JWKS could not be fetchedMake your JWKS URL answer a public HTTPS GET with your keys, then retry.
jwks_malformedinvalid_clientyour JWKS is not a valid JWK SetServe a JSON JWK Set at your JWKS URL: an object whose keys array holds your public keys.
signature_invalidinvalid_grantsignature verification failedIt 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_mismatchinvalid_grantaud must be the exchange endpoint's URLUse the exchange URL from Setup values as aud. Live and Test share it.
sub_missinginvalid_grantassertion has no sub claimPut your own ID for the customer in sub.
jti_missinginvalid_grantassertion has no jti claimGive every assertion a fresh jti, such as a random UUID.
iat_missinginvalid_grantassertion has no iat claimSet iat to the time you sign, in seconds since the epoch.
exp_missinginvalid_grantassertion has no exp claimSet exp no more than 300 s after iat.
assertion_expiredinvalid_grantassertion has expiredCheck your server clock. Assertions live at most 300 s, so sign each one just before you send it.
assertion_not_yet_validinvalid_grantassertion 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_futureinvalid_grantassertion iat is in the future beyond the permitted clock skew — check NTPSync your server clock with NTP, and set iat to the time you sign.
assertion_ttl_exceeds_ceilinginvalid_grantassertion lifetime exceeds the permitted ceilingSet exp at most 300 s after iat.
privileged_claim_presentinvalid_grantassertion carries a privileged claim; these are rejected outright, never filteredLeave out the claims Doshi sets itself, such as role and tenant_id.
unknown_editioninvalid_grantedition is not one of your editionsSend an edition's key exactly as Setup values lists it, or leave edition out to keep the member where they are.
ip_not_allowedunauthorized_clientyour server's IP address is not in this environment's Allowed IPsAdd the address your server calls from in the Console, under Integration > Allowed IPs. Live refuses every exchange until its list has one.
mint_failedserver_errorthe mint could not be completedSomething failed on our side. Retry once, and if it keeps failing, email hello@doshi.app with the Reference from the Console.
rate_limitedslow_downthis client is sending requests faster than its limit — retry after the Retry-After headerWait as many seconds as Retry-After says before you retry, and reuse each member's token until it expires.

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.

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.

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.

An invalid_grant that appears sometimes and passes on retry is nearly always clock skew.

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.