> ## Documentation Index
> Fetch the complete documentation index at: https://docs.grantex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Troubleshooting

> Common issues and solutions when working with the Grantex API and SDKs.

## "Token invalid" or Verification Fails

If `verifyGrantToken()` or `tokens.verify()` returns invalid results, work through this checklist:

<Steps>
  <Step title="Check token expiry">
    Decode the JWT and inspect the `exp` claim. If `exp` is in the past, the token has expired.

    ```bash theme={null}
    # Decode a JWT (base64 payload)
    echo 'YOUR_JWT' | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
    ```
  </Step>

  <Step title="Check revocation status">
    Call `POST /v1/tokens/verify` with the token. If `valid: false`, the token or its grant has been revoked.
  </Step>

  <Step title="Verify the JWKS URI">
    Make sure you're using the correct JWKS endpoint for your environment:

    * **Production:** `https://api.grantex.dev/.well-known/jwks.json`
    * **Self-hosted:** `https://your-domain/.well-known/jwks.json`
  </Step>

  <Step title="Check clock skew">
    JWT verification compares `exp` and `iat` against your server's clock. If your server clock is more than a few seconds off, tokens may fail verification. Use NTP to keep clocks synchronized.
  </Step>

  <Step title="Check audience mismatch">
    If you set `audience` in `VerifyGrantTokenOptions`, the JWT's `aud` claim must match. Remove the audience check or ensure the authorization request included the same audience.
  </Step>
</Steps>

## "Authorization Stuck" — Consent Flow Not Completing

If the authorization request stays in `pending` status:

1. **Check the consent URL** — ensure the user was redirected to `consentUrl` from the authorization response
2. **Check the auth request status** — query `GET /v1/consent/:id` to see the current state
3. **Check expiry** — auth requests expire after the `expiresIn` window (default 24h). If expired, create a new authorization request
4. **Check the redirect URI** — if provided, the code is delivered via redirect. Ensure your callback handler is working
5. **Sandbox mode** — in sandbox mode, consent is auto-approved and a code is returned directly in the response

## Rate Limited (429)

When you receive a `429 Too Many Requests` response:

```json theme={null}
{
  "message": "Rate limit exceeded, retry in 42 seconds",
  "code": "BAD_REQUEST",
  "requestId": "a1b2c3d4-e5f6-..."
}
```

**What to do:**

1. Read the `Retry-After` header for the minimum wait time
2. Implement exponential backoff with jitter (see [Rate Limits guide](/guides/rate-limits))
3. Reduce request frequency:
   * **Use local JWKS-based verification** instead of `POST /v1/tokens/verify`
     when a revocation lookup is not required. The standalone helpers still
     fetch JWKS per call, but that endpoint is exempt from rate limits.
   * **Use webhooks** instead of polling for grant status changes
   * **Cache tokens** — don't re-exchange or re-verify tokens unnecessarily

**Rate limits by endpoint:**

| Endpoint | Limit |
| - | - |
| Default Fastify IP policy (routes without an override) | 5,000 req/min |
| Standard API-key - Free plan | 100 req/min per developer |
| Standard API-key - Pro plan | 500 req/min per developer |
| Standard API-key - Enterprise plan | 2,000 req/min per developer |
| Containment (revoke a grant, token, passport or consent bundle; emergency stop) | 2,000 req/min per developer, every plan |
| Revocation feed and status reads | 6,000 req/min per developer, every plan |
| `POST /v1/authorize` | 10 req/min |
| `POST /v1/token` | 20 req/min |
| `POST /v1/token/refresh` | 20 req/min |
| `GET /.well-known/jwks.json` | Exempt |

A route-specific Fastify limit replaces the 5,000 req/min Fastify default on that route. The Redis-backed standard developer plan remains an additional policy wherever standard API-key authentication applies.

Standard developer API-key plan exhaustion returns `429` with `RATE_LIMIT_EXCEEDED`. If the Redis-backed plan counter is unavailable, the request fails closed with `503` and `RATE_LIMIT_UNAVAILABLE`. Revoking and the emergency stop do not consume the plan budget and are not refused when the counter is unavailable; the revocation feed and status reads have a budget of their own and still answer `503` then. See [Rate Limits](/guides/rate-limits). Commerce, the SCIM Bearer data-plane routes under `/scim/v2/*`, admin, and other custom-auth routes do not consume this plan bucket; the standard API-key-authenticated `/v1/scim/tokens` management routes do.

## Plan Limit Exceeded (402)

A `402 Payment Required` response means you've hit a resource limit for your current plan:

```json theme={null}
{
  "message": "Agent limit reached (free plan: 500)",
  "code": "PLAN_LIMIT_EXCEEDED",
  "requestId": "..."
}
```

**Options:**

* **Upgrade your plan** — use the billing portal to switch to Pro or Enterprise
* **Clean up unused resources** — delete inactive agents, revoke expired grants
* **Check your current usage** via the developer dashboard

## Issuance Frozen (403)

`403` with `ISSUANCE_FROZEN` means an emergency stop placed a lockout over
something this request would issue under. That can be your whole developer
account, the agent, the principal, or a grant above the one you are
refreshing or delegating from.

```json theme={null}
{
  "message": "Issuance is frozen for this agent by an emergency stop",
  "code": "ISSUANCE_FROZEN",
  "requestId": "..."
}
```

The OAuth profile's endpoints answer `403` with `access_denied`. Nothing is
issued until the lockout is lifted. An authorization code or refresh token
refused this way is not used up, so it works again afterwards.
`GET /v1/emergency-stops` lists the lockouts in force under `freezes`, 50 to a
page; `freezesTotal` says how many there are, and `?page=2` fetches the next.
`POST /v1/emergency-stop/unfreeze` lifts one you placed; a lockout placed by
the platform operator can only be lifted by the operator.

`503` with `FREEZE_STATE_UNAVAILABLE` (`temporarily_unavailable` on the OAuth
endpoints) means the service could not read whether a lockout applies, and
refused rather than risk issuing under one. Retry shortly.

## Delegation Failed

If `POST /v1/grants/delegate` returns an error:

| Error | Cause | Fix |
| - | - | - |
| "Scopes must be a subset" | Sub-agent scopes exceed parent grant scopes | Narrow the requested scopes |
| "Delegation depth exceeded" | Too many levels of delegation | Reduce the chain or increase the limit |
| "Parent grant expired" | The parent grant token has expired | Obtain a new grant token first |
| "Parent grant revoked" | The parent grant was revoked | Re-authorize the parent agent |
| "Invalid parent grant token" | JWT signature verification failed | Check the token is well-formed |

## Refresh Token Rejected

If `POST /v1/token/refresh` returns 400:

| Error message | Cause | Fix |
| - | - | - |
| "Refresh token already used" | Token was already used and the idempotency key changed or was lost, the five-minute window expired, its child was used, or the grant expired | Retry only with the same old token and persisted idempotency key; otherwise use the newest stored token or re-authorize |
| "Refresh token expired" | The refresh token's own expiry elapsed; refresh tokens last up to 30 days and are capped by grant expiration | Re-authorize to get a fresh token pair |
| "Grant has been revoked" | The underlying grant was revoked | Re-authorize the agent |
| "Agent mismatch" | `agentId` doesn't match the grant's agent | Use the correct agent ID |
| "Invalid refresh token" | Token ID not found | Check you're passing the correct refresh token value |

If a refresh request times out or the connection drops, retry the same refresh token immediately. Grantex can recover the already-rotated refresh token for five minutes while the grant remains active, without extending the rotation chain or grant lifetime.

## Error Codes Reference

All Grantex API errors include a `code` field. SDKs expose this as `error.code`:

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { GrantexApiError } from '@grantex/sdk';

  try {
    await grantex.tokens.exchange({ code, agentId });
  } catch (err) {
    if (err instanceof GrantexApiError) {
      console.log(err.code);       // 'BAD_REQUEST'
      console.log(err.statusCode); // 400
      console.log(err.message);    // 'Invalid code'
      console.log(err.requestId);  // 'a1b2c3d4-...'
    }
  }
  ```

  ```python Python theme={null}
  from grantex import GrantexApiError

  try:
      client.tokens.exchange(params)
  except GrantexApiError as err:
      print(err.code)        # 'BAD_REQUEST'
      print(err.status_code) # 400
      print(str(err))        # 'Invalid code'
      print(err.request_id)  # 'a1b2c3d4-...'
  ```
</CodeGroup>

| Code | HTTP Status | Description |
| - | - | - |
| `BAD_REQUEST` | 400 | Invalid input, missing fields, or validation failure |
| `UNAUTHORIZED` | 401 | Invalid or missing API key |
| `FORBIDDEN` | 403 | Valid API key but insufficient permissions |
| `NOT_FOUND` | 404 | Resource does not exist |
| `CONFLICT` | 409 | Resource already exists (e.g., duplicate agent name) |
| `GONE` | 410 | Resource was deleted |
| `VALIDATION_ERROR` | 422 | Schema validation failure |
| `PLAN_LIMIT_EXCEEDED` | 402 | Resource count exceeds plan threshold |
| `POLICY_DENIED` | 403 | An access policy blocked the operation |
| `ISSUANCE_FROZEN` | 403 | An emergency stop lockout covers what the request would issue |
| `FREEZE_STATE_UNAVAILABLE` | 503 | The lockout state could not be read, so issuance was refused |
| `SSO_ERROR` | 400 | SSO/OIDC configuration or callback error |
| `SERVICE_UNAVAILABLE` | 503 | Service temporarily unavailable |
| `INTERNAL_ERROR` | 500 | Unexpected server error |

## Inspecting JWT Claims Locally

You can decode a Grantex grant token without verification to inspect its claims:

```bash theme={null}
# Using jq (if installed)
echo 'YOUR_JWT' | cut -d. -f2 | base64 -d 2>/dev/null | jq .

# Using Python
python3 -c "import sys, json, base64; print(json.dumps(json.loads(base64.urlsafe_b64decode(sys.argv[1].split('.')[1] + '==')), indent=2))" 'YOUR_JWT'
```

**Grant token claims:**

| Claim | Description |
| - | - |
| `iss` | Issuer (Grantex auth service URL) |
| `sub` | Principal ID (the user who authorized) |
| `agt` | Agent DID |
| `dev` | Developer ID |
| `scp` | Scopes array |
| `grnt` | Grant record ID |
| `jti` | Token ID (unique per token) |
| `iat` | Issued at (Unix timestamp) |
| `exp` | Expires at (Unix timestamp) |
| `aud` | Audience (optional) |
| `parentAgt` | Parent agent DID (delegation only) |
| `parentGrnt` | Parent grant ID (delegation only) |
| `delegationDepth` | Delegation depth (delegation only) |

## Getting Help

If none of the above resolves your issue:

* Check the [GitHub Issues](https://github.com/mishrasanjeev/grantex/issues) for known problems
* Open a new issue with your error message, `requestId`, and reproduction steps
* For Enterprise support, use the [contact form](https://grantex.dev/#enterprise)

## Ownership

Grantex is owned by Orchestrum Technologies LLP. Inventor and owner: Sanjeev Kumar. Ownership contact: [sanjeev@orchestrum.in](mailto:sanjeev@orchestrum.in) or [mishra.sanjeev@gmail.com](mailto:mishra.sanjeev@gmail.com).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.