# Authentication How MCP clients and API callers authenticate with Savi Finance: OAuth 2.1 with PKCE, dynamic client registration, refresh tokens, scopes and bearer tokens. Last updated October 1, 2026 ## Which credentials do I need? | You are building | Use | What you provide | | --- | --- | --- | | An AI assistant or any MCP client | The [MCP server](https://financesavi.com/developers/mcp) with OAuth 2.1 | Nothing to copy. The client opens a Savi sign-in page and the user approves access. | | A script or app that calls the JSON-RPC API | The [JSON-RPC API](https://financesavi.com/developers/api) with a bearer token | A token from the two-call email sign-in [below](https://financesavi.com/developers/authentication#get-a-token). | ## Get an API token Savi signs you in with an emailed 6-digit code, so there is no password. Two calls give you a bearer token, and a third confirms it works. Each panel below can run against the live API from this page, or you can copy a snippet in any of the ten supported languages. **Treat the token like a password.** Anyone who has it can read and change your Savi data. Keep it out of source control and screenshots. This page keeps it in memory for this tab and sends it only to `api-v2.financesavi.com`. Reloading clears it. MCP OAuth tokens and API tokens are not interchangeable. Raw API sign-in requests and examples ### 1\. Request a sign-in code Enter your email in the request params. Savi emails you a 6-digit code. Codes can be requested once per minute per address. ## sendLoginCodeEmail request POST https://api-v2.financesavi.com/rpc · Public ### cURL ```bash curl -sS --max-time 30 -X POST 'https://api-v2.financesavi.com/rpc' \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","method":"sendLoginCodeEmail","params":{"email":"YOUR_EMAIL_ADDRESS","loginCodeType":"SHORT_LIVED","emailType":"LOGIN_CODE"},"id":1}' ``` ### JavaScript ```javascript const response = await fetch("https://api-v2.financesavi.com/rpc", { method: 'POST', signal: AbortSignal.timeout(30_000), headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ "jsonrpc": "2.0", "method": "sendLoginCodeEmail", "params": { "email": "YOUR_EMAIL_ADDRESS", "loginCodeType": "SHORT_LIVED", "emailType": "LOGIN_CODE" }, "id": 1 }), }); console.log(await response.json()); ``` ### TypeScript ```typescript const response = await fetch("https://api-v2.financesavi.com/rpc", { method: 'POST', signal: AbortSignal.timeout(30_000), headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ "jsonrpc": "2.0", "method": "sendLoginCodeEmail", "params": { "email": "YOUR_EMAIL_ADDRESS", "loginCodeType": "SHORT_LIVED", "emailType": "LOGIN_CODE" }, "id": 1 }), }); console.log(await response.json()); ``` ### Python ```python import os import requests response = requests.post( "https://api-v2.financesavi.com/rpc", json={ "jsonrpc": "2.0", "method": "sendLoginCodeEmail", "params": { "email": "YOUR_EMAIL_ADDRESS", "loginCodeType": "SHORT_LIVED", "emailType": "LOGIN_CODE", }, "id": 1, }, timeout=30, ) print(response.json()) ``` ### Go ```go package main import ( "fmt" "io" "net/http" "strings" "time" ) func main() { payload := strings.NewReader("{\"jsonrpc\":\"2.0\",\"method\":\"sendLoginCodeEmail\",\"params\":{\"email\":\"YOUR_EMAIL_ADDRESS\",\"loginCodeType\":\"SHORT_LIVED\",\"emailType\":\"LOGIN_CODE\"},\"id\":1}") req, err := http.NewRequest("POST", "https://api-v2.financesavi.com/rpc", payload) if err != nil { panic(err) } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} res, err := client.Do(req) if err != nil { panic(err) } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { panic(err) } fmt.Println(res.Status, string(body)) } ``` ### C# ```csharp // .NET 6 or later using System; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; using var content = new StringContent(@"{""jsonrpc"":""2.0"",""method"":""sendLoginCodeEmail"",""params"":{""email"":""YOUR_EMAIL_ADDRESS"",""loginCodeType"":""SHORT_LIVED"",""emailType"":""LOGIN_CODE""},""id"":1}", Encoding.UTF8, "application/json"); using var response = await client.PostAsync(@"https://api-v2.financesavi.com/rpc", content); Console.WriteLine($"HTTP {(int)response.StatusCode}"); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` ### Java ```java // Java 11+: save as SaviExample.java, then run java SaviExample.java import java.net.URI; import java.net.http.*; import java.time.Duration; class SaviExample { public static void main(String[] args) throws Exception { var client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(); var builder = HttpRequest.newBuilder(URI.create("https://api-v2.financesavi.com/rpc")) .timeout(Duration.ofSeconds(30)) .header("Content-Type", "application/json"); var request = builder.POST(HttpRequest.BodyPublishers.ofString("{\"jsonrpc\":\"2.0\",\"method\":\"sendLoginCodeEmail\",\"params\":{\"email\":\"YOUR_EMAIL_ADDRESS\",\"loginCodeType\":\"SHORT_LIVED\",\"emailType\":\"LOGIN_CODE\"},\"id\":1}")).build(); var response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println("HTTP " + response.statusCode()); System.out.println(response.body()); } } ``` ### PHP ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 30, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => '{"jsonrpc":"2.0","method":"sendLoginCodeEmail","params":{"email":"YOUR_EMAIL_ADDRESS","loginCodeType":"SHORT_LIVED","emailType":"LOGIN_CODE"},"id":1}', ]); $body = curl_exec($curl); if ($body === false) { throw new RuntimeException(curl_error($curl)); } echo "HTTP " . curl_getinfo($curl, CURLINFO_HTTP_CODE) . PHP_EOL; echo $body . PHP_EOL; curl_close($curl); ``` ### Ruby ```ruby require 'net/http' require 'uri' uri = URI('https://api-v2.financesavi.com/rpc') request = Net::HTTP::Post.new(uri) request['Content-Type'] = 'application/json' request.body = '{"jsonrpc":"2.0","method":"sendLoginCodeEmail","params":{"email":"YOUR_EMAIL_ADDRESS","loginCodeType":"SHORT_LIVED","emailType":"LOGIN_CODE"},"id":1}' response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 30, read_timeout: 30, write_timeout: 30 ) { |http| http.request(request) } puts "HTTP #{response.code}" puts response.body ``` ### Swift ```swift // Swift 5.5+: save as SaviExample.swift, then run swift SaviExample.swift import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif var request = URLRequest(url: URL(string: "https://api-v2.financesavi.com/rpc")!) request.httpMethod = "POST" request.timeoutInterval = 30 request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = Data("{\"jsonrpc\":\"2.0\",\"method\":\"sendLoginCodeEmail\",\"params\":{\"email\":\"YOUR_EMAIL_ADDRESS\",\"loginCodeType\":\"SHORT_LIVED\",\"emailType\":\"LOGIN_CODE\"},\"id\":1}".utf8) let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForResource = 30 let session = URLSession(configuration: configuration) let (data, response) = try await session.data(for: request) if let http = response as? HTTPURLResponse { print("HTTP", http.statusCode) } print(String(decoding: data, as: UTF8.self)) ``` ### Local API capture (scrubbed) (HTTP 200) ```json { "jsonrpc": "2.0", "result": { "message": "ok" }, "id": 1 } ``` ### 2\. Exchange the code for a token Enter the code from the email. On success the response contains your `token` and `userId`. This page stores both for you; in your own code, save the token. ## loginByEmailVerification request POST https://api-v2.financesavi.com/rpc · Public ### cURL ```bash curl -sS --max-time 30 -X POST 'https://api-v2.financesavi.com/rpc' \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","method":"loginByEmailVerification","params":{"email":"YOUR_EMAIL_ADDRESS","code":"123456","authenticationMethod":"EMAIL_CODE"},"id":1}' ``` ### JavaScript ```javascript const response = await fetch("https://api-v2.financesavi.com/rpc", { method: 'POST', signal: AbortSignal.timeout(30_000), headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ "jsonrpc": "2.0", "method": "loginByEmailVerification", "params": { "email": "YOUR_EMAIL_ADDRESS", "code": "123456", "authenticationMethod": "EMAIL_CODE" }, "id": 1 }), }); console.log(await response.json()); ``` ### TypeScript ```typescript const response = await fetch("https://api-v2.financesavi.com/rpc", { method: 'POST', signal: AbortSignal.timeout(30_000), headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ "jsonrpc": "2.0", "method": "loginByEmailVerification", "params": { "email": "YOUR_EMAIL_ADDRESS", "code": "123456", "authenticationMethod": "EMAIL_CODE" }, "id": 1 }), }); console.log(await response.json()); ``` ### Python ```python import os import requests response = requests.post( "https://api-v2.financesavi.com/rpc", json={ "jsonrpc": "2.0", "method": "loginByEmailVerification", "params": { "email": "YOUR_EMAIL_ADDRESS", "code": "123456", "authenticationMethod": "EMAIL_CODE", }, "id": 1, }, timeout=30, ) print(response.json()) ``` ### Go ```go package main import ( "fmt" "io" "net/http" "strings" "time" ) func main() { payload := strings.NewReader("{\"jsonrpc\":\"2.0\",\"method\":\"loginByEmailVerification\",\"params\":{\"email\":\"YOUR_EMAIL_ADDRESS\",\"code\":\"123456\",\"authenticationMethod\":\"EMAIL_CODE\"},\"id\":1}") req, err := http.NewRequest("POST", "https://api-v2.financesavi.com/rpc", payload) if err != nil { panic(err) } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} res, err := client.Do(req) if err != nil { panic(err) } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { panic(err) } fmt.Println(res.Status, string(body)) } ``` ### C# ```csharp // .NET 6 or later using System; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; using var content = new StringContent(@"{""jsonrpc"":""2.0"",""method"":""loginByEmailVerification"",""params"":{""email"":""YOUR_EMAIL_ADDRESS"",""code"":""123456"",""authenticationMethod"":""EMAIL_CODE""},""id"":1}", Encoding.UTF8, "application/json"); using var response = await client.PostAsync(@"https://api-v2.financesavi.com/rpc", content); Console.WriteLine($"HTTP {(int)response.StatusCode}"); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` ### Java ```java // Java 11+: save as SaviExample.java, then run java SaviExample.java import java.net.URI; import java.net.http.*; import java.time.Duration; class SaviExample { public static void main(String[] args) throws Exception { var client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(); var builder = HttpRequest.newBuilder(URI.create("https://api-v2.financesavi.com/rpc")) .timeout(Duration.ofSeconds(30)) .header("Content-Type", "application/json"); var request = builder.POST(HttpRequest.BodyPublishers.ofString("{\"jsonrpc\":\"2.0\",\"method\":\"loginByEmailVerification\",\"params\":{\"email\":\"YOUR_EMAIL_ADDRESS\",\"code\":\"123456\",\"authenticationMethod\":\"EMAIL_CODE\"},\"id\":1}")).build(); var response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println("HTTP " + response.statusCode()); System.out.println(response.body()); } } ``` ### PHP ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 30, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => '{"jsonrpc":"2.0","method":"loginByEmailVerification","params":{"email":"YOUR_EMAIL_ADDRESS","code":"123456","authenticationMethod":"EMAIL_CODE"},"id":1}', ]); $body = curl_exec($curl); if ($body === false) { throw new RuntimeException(curl_error($curl)); } echo "HTTP " . curl_getinfo($curl, CURLINFO_HTTP_CODE) . PHP_EOL; echo $body . PHP_EOL; curl_close($curl); ``` ### Ruby ```ruby require 'net/http' require 'uri' uri = URI('https://api-v2.financesavi.com/rpc') request = Net::HTTP::Post.new(uri) request['Content-Type'] = 'application/json' request.body = '{"jsonrpc":"2.0","method":"loginByEmailVerification","params":{"email":"YOUR_EMAIL_ADDRESS","code":"123456","authenticationMethod":"EMAIL_CODE"},"id":1}' response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 30, read_timeout: 30, write_timeout: 30 ) { |http| http.request(request) } puts "HTTP #{response.code}" puts response.body ``` ### Swift ```swift // Swift 5.5+: save as SaviExample.swift, then run swift SaviExample.swift import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif var request = URLRequest(url: URL(string: "https://api-v2.financesavi.com/rpc")!) request.httpMethod = "POST" request.timeoutInterval = 30 request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = Data("{\"jsonrpc\":\"2.0\",\"method\":\"loginByEmailVerification\",\"params\":{\"email\":\"YOUR_EMAIL_ADDRESS\",\"code\":\"123456\",\"authenticationMethod\":\"EMAIL_CODE\"},\"id\":1}".utf8) let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForResource = 30 let session = URLSession(configuration: configuration) let (data, response) = try await session.data(for: request) if let http = response as? HTTPURLResponse { print("HTTP", http.statusCode) } print(String(decoding: data, as: UTF8.self)) ``` ### Local API capture (scrubbed) (HTTP 200) ```json { "jsonrpc": "2.0", "result": { "token": "", "userId": "user_8f3a1c2d9b" }, "id": 1 } ``` ### 3\. Confirm the token works Send the token as `Authorization: Bearer `. A good token returns your profile: ## getCurrentUser request POST https://api-v2.financesavi.com/rpc · Bearer token required ### cURL ```bash curl -sS --max-time 30 -X POST 'https://api-v2.financesavi.com/rpc' \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $SAVI_TOKEN" \ -d '{"jsonrpc":"2.0","method":"getCurrentUser","params":{},"id":1}' ``` ### JavaScript ```javascript const response = await fetch("https://api-v2.financesavi.com/rpc", { method: 'POST', signal: AbortSignal.timeout(30_000), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.SAVI_TOKEN}`, }, body: JSON.stringify({ "jsonrpc": "2.0", "method": "getCurrentUser", "params": {}, "id": 1 }), }); console.log(await response.json()); ``` ### TypeScript ```typescript const response = await fetch("https://api-v2.financesavi.com/rpc", { method: 'POST', signal: AbortSignal.timeout(30_000), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.SAVI_TOKEN}`, }, body: JSON.stringify({ "jsonrpc": "2.0", "method": "getCurrentUser", "params": {}, "id": 1 }), }); console.log(await response.json()); ``` ### Python ```python import os import requests response = requests.post( "https://api-v2.financesavi.com/rpc", headers={"Authorization": f"Bearer {os.environ['SAVI_TOKEN']}"}, json={ "jsonrpc": "2.0", "method": "getCurrentUser", "params": {}, "id": 1, }, timeout=30, ) print(response.json()) ``` ### Go ```go package main import ( "fmt" "io" "net/http" "os" "strings" "time" ) func main() { payload := strings.NewReader("{\"jsonrpc\":\"2.0\",\"method\":\"getCurrentUser\",\"params\":{},\"id\":1}") req, err := http.NewRequest("POST", "https://api-v2.financesavi.com/rpc", payload) if err != nil { panic(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer " + os.Getenv("SAVI_TOKEN")) client := &http.Client{Timeout: 30 * time.Second} res, err := client.Do(req) if err != nil { panic(err) } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { panic(err) } fmt.Println(res.Status, string(body)) } ``` ### C# ```csharp // .NET 6 or later using System; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("SAVI_TOKEN")); using var content = new StringContent(@"{""jsonrpc"":""2.0"",""method"":""getCurrentUser"",""params"":{},""id"":1}", Encoding.UTF8, "application/json"); using var response = await client.PostAsync(@"https://api-v2.financesavi.com/rpc", content); Console.WriteLine($"HTTP {(int)response.StatusCode}"); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` ### Java ```java // Java 11+: save as SaviExample.java, then run java SaviExample.java import java.net.URI; import java.net.http.*; import java.time.Duration; class SaviExample { public static void main(String[] args) throws Exception { var client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(); var builder = HttpRequest.newBuilder(URI.create("https://api-v2.financesavi.com/rpc")) .timeout(Duration.ofSeconds(30)) .header("Content-Type", "application/json"); builder.header("Authorization", "Bearer " + System.getenv("SAVI_TOKEN")); var request = builder.POST(HttpRequest.BodyPublishers.ofString("{\"jsonrpc\":\"2.0\",\"method\":\"getCurrentUser\",\"params\":{},\"id\":1}")).build(); var response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println("HTTP " + response.statusCode()); System.out.println(response.body()); } } ``` ### PHP ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 30, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . getenv('SAVI_TOKEN'), ], CURLOPT_POSTFIELDS => '{"jsonrpc":"2.0","method":"getCurrentUser","params":{},"id":1}', ]); $body = curl_exec($curl); if ($body === false) { throw new RuntimeException(curl_error($curl)); } echo "HTTP " . curl_getinfo($curl, CURLINFO_HTTP_CODE) . PHP_EOL; echo $body . PHP_EOL; curl_close($curl); ``` ### Ruby ```ruby require 'net/http' require 'uri' uri = URI('https://api-v2.financesavi.com/rpc') request = Net::HTTP::Post.new(uri) request['Content-Type'] = 'application/json' request['Authorization'] = 'Bearer ' + ENV.fetch('SAVI_TOKEN') request.body = '{"jsonrpc":"2.0","method":"getCurrentUser","params":{},"id":1}' response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 30, read_timeout: 30, write_timeout: 30 ) { |http| http.request(request) } puts "HTTP #{response.code}" puts response.body ``` ### Swift ```swift // Swift 5.5+: save as SaviExample.swift, then run swift SaviExample.swift import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif var request = URLRequest(url: URL(string: "https://api-v2.financesavi.com/rpc")!) request.httpMethod = "POST" request.timeoutInterval = 30 request.setValue("application/json", forHTTPHeaderField: "Content-Type") guard let token = ProcessInfo.processInfo.environment["SAVI_TOKEN"] else { fatalError("Set SAVI_TOKEN first") } request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") request.httpBody = Data("{\"jsonrpc\":\"2.0\",\"method\":\"getCurrentUser\",\"params\":{},\"id\":1}".utf8) let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForResource = 30 let session = URLSession(configuration: configuration) let (data, response) = try await session.data(for: request) if let http = response as? HTTPURLResponse { print("HTTP", http.statusCode) } print(String(decoding: data, as: UTF8.self)) ``` ### Local API capture (scrubbed) (HTTP 200) ```json { "jsonrpc": "2.0", "result": { "credentials": { "id": "uc_1790000000000000000", "email": "you@example.com", "username": "u_1790000000000000000", "isVerified": false, "lastLoginByEmailCodeType": "SHORT_LIVED", "createdAt": "2026-10-01T15:48:48.426Z" }, "users": [ { "id": "user_8f3a1c2d9b", "email": "you@example.com", "userType": "PERSONAL", "username": "u_1790000000000000000", "createdAt": "2026-10-01T15:48:48.428Z", "businessTier": "FREE", "referralId": "Ab12Cd34", "referralLink": "https://app.financesavi.com/refer/Ab12Cd34", "referralCount": 0, "hasRedeemedReferral": false, "totalStorageUsedInBytes": 0, "storageLimitInBytes": 1000000000, "numFiles": 0, "widgetIds": [], "notificationSettings": { "insightSummary": { "enabled": true } } } ] }, "id": 1 } ``` ### Use the token in your own code ```bash # Save the token once, then reuse it in every request. export SAVI_TOKEN='' curl -sS -X POST https://api-v2.financesavi.com/rpc \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $SAVI_TOKEN" \ -d '{"jsonrpc":"2.0","method":"getCurrentUser","params":{},"id":1}' ``` Use the explicit _Copy token_ control after sign-in; live response previews redact it. Store credentials securely in your own application. The browser helper is for manual exploration, and does not persist a session or provide a sandbox. Without a bearer token, production `getCurrentUser` returned the response below. If your request returns this code, sign in again to get a token: Production response to `getCurrentUser` without a bearer token. Captured at Oct 2, 2026, 3:44:13 p.m. UTC. ```json { "jsonrpc": "2.0", "method": "getCurrentUser", "params": {}, "id": 4 } ``` ```json { "jsonrpc": "2.0", "error": { "code": -32002, "message": "missing or invalid auth token" }, "id": 4 } ``` ## MCP: OAuth 2.1 flow The MCP server at `https://mcp.financesavi.com` follows the MCP authorization specification. Compliant clients complete these steps automatically: 1. Request `/.well-known/oauth-protected-resource` to find the authorization server. 2. Request `/.well-known/oauth-authorization-server` for endpoint metadata. 3. Register with `POST /register` (`client_name`, `redirect_uris`) to receive a `client_id`. Clients are public: there is no client secret, so `token_endpoint_auth_method` must be `none`. 4. Open `/authorize` in a browser with `response_type=code`, `client_id`, `redirect_uri`, `state` and a PKCE `code_challenge` with `code_challenge_method=S256`. Optionally pass `scope` and `resource`. 5. The user enters their email, then the 6-digit code Savi sends to it. 6. The user sees a consent page that names the app, where they will be sent back to and what it is asking to do, and approves or denies. Approving without _Allow changes_ grants read-only access. For apps that registered themselves through `/register`, _Allow changes_ starts unticked, so the user has to opt in to write access. 7. Savi redirects to the `redirect_uri` with a single-use authorization `code`, valid for about 60 seconds. If the user denies, it redirects with `error=access_denied`. 8. Exchange the code at `POST /token` with `grant_type=authorization_code`, `code`, `client_id`, `redirect_uri` and `code_verifier`. The response contains an `access_token` of type `Bearer`, `expires_in`, a `refresh_token` and the granted `scope`. 9. Call `https://mcp.financesavi.com/mcp` with `Authorization: Bearer `. PKCE with `S256` is required. A 401 response includes a `WWW-Authenticate` header with a `resource_metadata` parameter that points clients at the discovery document. ## Registering a client Redirect URIs must be absolute and must be one of: an `https` URL, an `http` URL on `localhost` or another loopback address, or a custom app scheme such as `myapp://callback`. URIs with a fragment, embedded credentials or a wildcard are rejected, as are schemes that can run code (`javascript:`, `data:`, `file:`). A client can register up to 10 redirect URIs, and the `redirect_uri` sent to `/authorize` must match one exactly. Supported grant types are `authorization_code` and `refresh_token`. The consent page notes that Savi has not reviewed clients registered this way. ## Token lifetime, refresh and revocation * Access tokens last 1 hour. When one expires the server answers `401` and the client should refresh. * Refresh tokens last 30 days and rotate. Send `POST /token` with `grant_type=refresh_token`, `refresh_token` and `client_id` to receive a new access token and a new refresh token. A refresh request can narrow `scope` but never widen it. * Refreshing never extends a sign-in past 90 days from its first token: tokens issued near the end expire at that limit, and after it the user must sign in again. * Using a refresh token or an authorization code a second time is treated as theft: every token from that sign-in is revoked and the user must sign in again. * `POST /revoke` with `token` (an access or refresh token) revokes the sign-in. It always answers `200`, whether or not the token existed. * If you send the `resource` parameter it must be `https://mcp.financesavi.com` or `https://mcp.financesavi.com/mcp`; anything else is rejected with `invalid_target`. ## MCP scopes | Scope | Grants | | --- | --- | | `read` | Every read-only tool. | | `write` | Every tool that creates, changes or deletes data. These tools also need `read`, so a request for `write` alone is granted `read write`. | | `read:profile`, `read:accounts`, `read:transactions`, `read:categories`, `read:tags`, `read:analytics` | A single read tool each. | | `mcp` | All tools. Used when a client requests no scope. | The user can always lower the grant on the consent page by unticking _Allow changes_. A tool call without the required scope returns a tool result with `isError` set and an `insufficient scope` message. See the [tool reference](https://financesavi.com/developers/mcp/tools) for the scope each tool needs. ## JSON-RPC API Methods marked _required_ in the [API reference](https://financesavi.com/developers/api) need an `Authorization: Bearer ` header on requests to `https://api-v2.financesavi.com/rpc`. Methods marked _public_ need no token. MCP access tokens work only on the MCP server and cannot be used with the JSON-RPC API. Source: https://financesavi.com/developers/authentication