# OAuth 2.0 Integration Guide

How an operator grants your app access to their Monta data, and how to obtain and use tokens.

## Start the authorization flow

The operator starts the flow from Monta Hub. They find your app in the catalogue, select **Connect**, and Monta builds the authorization request before redirecting their browser to Monta's identity provider.

Do not construct the authorization URL or add a “Connect with Monta” button in your product. Your only inbound surface is your registered `redirect_uri`.

:::info{title="Why this flow does not use PKCE"}

Monta initiates the authorization request and you redeem the code, so no single party holds both halves of a PKCE challenge. Confidential clients authenticate with `client_secret_post` and do not send `code_challenge`. Public clients must use PKCE and are not eligible for this flow.

:::

## End-to-end sequence

![](/images/partner/d20b57c96959cd10824ad9241433d088db2a66883cd6c59f4339d6ab44537425-Screenshot_2026-07-30_at_21.06.48.png)

When the operator approves access, Monta creates the install and its consumer before redirecting to your app. Monta also binds the install, operator, and consumer identifiers to the token session, allowing the Monta API to resolve the token to the correct operator.

## Redeem the code

Redeem the authorization code immediately in your callback handler:

```bash
curl -X POST {issuer}/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=ory_ac_... \
  -d redirect_uri=https://your.app/callback \
  -d client_id=<your_client_id> \
  -d client_secret=<your_client_secret>
```

A successful response includes an access token and its granted scopes:

```json
{
  "access_token": "ory_at_...",
  "refresh_token": "ory_rt_...",
  "token_type": "bearer",
  "expires_in": 3599,
  "scope": "charge-points:read charge-transactions:read offline_access"
}
```

| Item                    | Lifetime                                                                                    |
| ----------------------- | ------------------------------------------------------------------------------------------- |
| Authorization code      | **1 minute**, single use                                                                    |
| Access token            | 1 hour                                                                                      |
| Refresh token           | 30 days, **rotating**—each refresh returns a new token, so always persist the latest value. |
| Login / consent request | 30 minutes                                                                                  |

:::warning{title="Redeem the code in the callback handler"}

The authorization code is valid for one minute and can only be used once. Do not send it to a background queue. The `redirect_uri` in the token request must exactly match the URI used in the authorization request.

:::

## Refresh the token

Refresh before the access token's one-hour expiry:

```bash
curl -X POST {issuer}/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=refresh_token \
  -d refresh_token=ory_rt_... \
  -d client_id=<your_client_id> \
  -d client_secret=<your_client_secret>
```

Refresh tokens rotate: each response contains a new `refresh_token` and invalidates the previous one. Always persist the newest value. Replaying an already-used refresh token revokes the whole chain, and the operator must then re-install.

`offline_access` is added by Monta rather than requested by your app. It appears on the operator's consent screen and in your token response's scope, and it is what allows Monta to issue a refresh token. On its own it grants no access to data.

## Call the Monta API

```bash
curl {partnerApiBaseUrl}/charge-points?perPage=50 \
  -H "Authorization: Bearer ory_at_..."
```

* Use `/api/v1` as the base path. See the API Reference for available endpoints.
* Treat access tokens as opaque bearer strings. They have an `ory_at_` prefix, but are not JWTs and must not be decoded or validated locally.
* Monta introspects each token, resolves it to the operator's consumer, and enforces consumer status, re-authorization requirements, and granted scopes.

**Scope format:** `resource` or `resource:permission`, where permission is `read`, `write`, or `delete`. For example: `charge-points:read` and `teams:write`. A bare resource is read-only. Request `resource:write` or `resource:delete` explicitly if you need more. Request the narrowest scopes you need because the operator sees the exact list during consent.

**Rate limits:** Each install has its own budget of **1,000 requests per 60 seconds** by default. Traffic from one operator cannot exhaust another operator's limit.

## Handle scope changes

If you later request additional scopes, existing installs are not upgraded automatically. Monta flags each affected install, and API calls return HTTP `403` with `NEEDS_REAUTH`:

```json
{
  "errorCode": "NEEDS_REAUTH",
  "message": "Re-authorisation required for install 41 after a scope change",
  "context": {
    "install_id": 41,
    "reauth_url": "https://…/41/reauth"
  }
}
```

Handle `NEEDS_REAUTH` separately from other `403` responses: stop calling the API for that install, show the re-authorization state in your product, and send the operator to `reauth_url`. After the operator grants consent again, your existing tokens work with the widened scopes. Narrowing scopes does not trigger re-authorization.

## Verify webhooks

| Event                    | Meaning                                                                                                                         |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `install.created`        | An operator installed your app. It carries `install_id`, `operator_id`, and `slug`. Your credentials come from the reveal link. |
| `install.revoked`        | The operator uninstalled your app. Stop using tokens for that install.                                                          |
| `install.config.updated` | The operator changed your app's per-install configuration.                                                                      |

Verify every delivery before parsing its body:

* Read `X-Monta-Signature: sha256=<hex>`.
* Calculate HMAC-SHA256 over the **raw request body** with your `webhook_secret`.
* Compare signatures in constant time.
* Use the signed `delivered_at` epoch timestamp to reject stale or replayed deliveries.

```python
import hashlib
import hmac

expected = "sha256=" + hmac.new(
    secret.encode(), raw_body, hashlib.sha256
).hexdigest()

if not hmac.compare_digest(expected, request.headers["X-Monta-Signature"]):
    abort(401)
```

Your endpoint must use HTTPS, be publicly resolvable, and return a `2xx` response within 10 seconds.

:::warning{title="Webhook delivery is best-effort"}

Monta does not automatically retry a timeout, non-`2xx` response, or transport error. Monta can replay deliveries for your app on request. Treat webhooks as an accelerator and reconcile authoritative state from the API; do not make `install.created` your only source of credentials because you already receive them through the reveal link.

:::

## Handle revocation and uninstall

* An operator can uninstall at any time. Monta revokes the install, disables its consumer, and sends `install.revoked`.
* Existing access tokens for that install stop working.
* Your app-level `client_id` and `client_secret` remain valid, and other operators' installs continue to work.
* Only one active install can exist for an app and operator. A re-install creates a new install with a new identifier.

## Security summary

| Concern               | How it is handled                                                                            |
| --------------------- | -------------------------------------------------------------------------------------------- |
| Client authentication | `client_secret_post`; Monta stores the secret only as a hash.                                |
| Token format          | Opaque and introspected server-side, enabling immediate revocation without key distribution. |
| Redirect URIs         | Exact-match allowlist; a removed URI stops being accepted immediately.                       |
| Consent               | Per operator and displays the exact scope list; widening scopes forces re-consent.           |
| Webhook authenticity  | HMAC-SHA256 over the raw body with a per-app secret and a signed timestamp.                  |
| Blast radius          | Credentials are per-app; data access is per-install and independently revocable.             |

## Endpoints

The OAuth handshake exists in production only. See the sandbox guidance at the top of this page before you begin certification.

| Purpose             | Production                                  |
| ------------------- | ------------------------------------------- |
| Issuer / OAuth base | `On Request`                                |
| Authorize           | `{issuer}/oauth2/auth` (Monta builds this)  |
| Token               | `{issuer}/oauth2/token`                     |
| Discovery           | `{issuer}/.well-known/openid-configuration` |
| Monta API           | `https://partner-api.monta.com/api/v1`      |

For sandbox endpoints and the credential exchange used there, see Building in the sandbox.

## Troubleshooting

| Symptom                                                            | Cause and fix                                                                                                                                       |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_client` — “The requested OAuth 2.0 Client does not exist” | Your client is not registered with the identity provider or was removed. Contact Monta to re-issue it; this rotates your secret.                    |
| `invalid_grant` during token exchange                              | The code expired, was already used, or the `redirect_uri` differs from the authorization request.                                                   |
| `401` on API calls                                                 | The access token expired or the install was revoked. Refresh the token; if refresh fails, the install was revoked and the operator must re-install. |
| `403` with `NEEDS_REAUTH`                                          | Your app's scopes widened. Follow the re-authorization flow described in [Handle scope changes](#handle-scope-changes).                             |
| No `install.created` received                                      | Delivery is best-effort. Confirm that your HTTPS endpoint is reachable and returns `2xx` within 10 seconds, then ask Monta to replay the event.     |

Monta performs webhook replays, credential re-issues, and operator-allowlist expansions for you. Contact [partners@monta.com](mailto:partners@monta.com) to request these operations.

## Integration checklist

* [ ] Store the client credentials and webhook secret from the reveal link.
* [ ] Register the deployed HTTPS `redirect_uri` exactly.
* [ ] Redeem the authorization code immediately within the one-minute window.
* [ ] Handle refresh-token rotation and always persist the newest refresh token.
* [ ] Treat access tokens as opaque strings.
* [ ] Verify each webhook signature in constant time before parsing its body, and check `delivered_at`.
* [ ] Return `2xx` from the webhook endpoint in under 10 seconds.
* [ ] Handle `403 NEEDS_REAUTH` separately from other `403` errors.
* [ ] Stop all use of an install after `install.revoked`.
* [ ] Respect the 1,000 requests per 60 seconds, per-install limit and apply backoff.
* [ ] Reconcile state without depending solely on webhook delivery.
