OAuth 2.0 for partners

OAuth lets your application act on behalf of other Phone.inc customers without ever holding their credentials. Use this when you're shipping a product — a Slack bot, a CRM connector, a workflow tool — that hundreds of Phone.inc customers might install.

If you're an end customer building your own internal scripts or server-to-server integrations, you don't need OAuth. Use an API key — it's faster to set up and easier to operate.

We support a single grant type: authorization code with PKCE. It works for native apps, single-page apps, and traditional server-side web apps. The base URL for everything below is https://app.phone.inc.

Discovery metadata

MCP clients and other OAuth libraries discover our authorization server automatically. Fetch either document from your app's origin:

  • GET /.well-known/oauth-authorization-server — authorization, token, revoke, and registration endpoints
  • GET /.well-known/oauth-protected-resource — the protected resource identifier and linked authorization server
  • GET /.well-known/oauth-protected-resource/mcp — the same metadata scoped to the MCP endpoint (resource: https://app.phone.inc/mcp)

Both return JSON. The issuer field matches the host you called (https://app.phone.inc in production). The root protected-resource document uses that host as resource; the MCP sub-path document uses https://app.phone.inc/mcp.

Authorization server metadata

{
  "issuer": "https://app.phone.inc",
  "authorization_endpoint": "https://app.phone.inc/oauth/authorize",
  "token_endpoint": "https://app.phone.inc/oauth/token",
  "revocation_endpoint": "https://app.phone.inc/oauth/revoke",
  "registration_endpoint": "https://app.phone.inc/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "scopes_supported": ["api"]
}

Register an OAuth client

Native apps installed side by side should use distinct callback schemes and register each exact redirect URI with their client. Phone.inc Android previews use inc.phone.preview.pr<PR>://oauth/callback, accepted only for our first-party public mobile client; standard mobile builds keep phoneinc://oauth/callback. These custom schemes are not accepted by dynamic client registration. Token exchange must use the same redirect URI as the authorization request. Android preview token exchanges require an exact string match, including rejecting added query parameters.

POST /oauth/register implements RFC 7591 dynamic client registration. MCP connectors call this before starting the authorization flow. Send application/json:

Registration request

POST
/oauth/register
curl -X POST https://app.phone.inc/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My MCP connector",
    "redirect_uris": ["http://127.0.0.1:8765/callback"],
    "token_endpoint_auth_method": "none"
  }'

Response

{
  "client_id": "a1b2c3d4e5f6g7h8i9j0",
  "client_id_issued_at": 1730895600,
  "redirect_uris": ["http://127.0.0.1:8765/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "client_name": "My MCP connector",
  "scope": "api"
}

Registered clients are always public (token_endpoint_auth_method: none), always scoped to api, and always show the consent screen. There is no client_secret.

Allowed redirect URIs

  • Name
    https://
    Type
    allowed
    Description

    Production callbacks on your own domain.

  • Name
    http://127.0.0.1, http://localhost, http://[::1]
    Type
    allowed
    Description

    Loopback HTTP with any port — for Claude Desktop, Cursor, and other local MCP clients.

  • Name
    Custom schemes (myapp://)
    Type
    not via DCR
    Description

    Native app deep links are only available for manually registered first-party clients.


Overview

The flow has three steps:

  1. Authorize — redirect the user to /oauth/authorize. They sign in to Phone.inc and approve your app.
  2. Exchange — your app receives an authorization code on the redirect URI and exchanges it for an access_token at /oauth/token.
  3. Call the API — send the access token as Authorization: Bearer <token> on every API request.

Access tokens are scoped to the employee who authorized your app. They expire after 1 hour, and you can use the long-lived refresh_token to mint new ones for up to 30 days without prompting the user again.


Step 1: Build the authorization URL

Generate a PKCE code verifier and challenge, then redirect the user's browser to:

Authorization request

GET
/oauth/authorize
# This is a browser redirect, not a direct API call. Open in the user's browser:
https://app.phone.inc/oauth/authorize\
  ?response_type=code\
  &client_id=your_client_id\
  &redirect_uri=https%3A%2F%2Fyour-app.com%2Fcallback\
  &scope=api\
  &state=random_csrf_token\
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM\
  &code_challenge_method=S256

Required parameters

  • Name
    response_type
    Type
    string
    Description

    Must be code.

  • Name
    client_id
    Type
    string
    Description

    The client ID issued when you registered your app.

  • Name
    redirect_uri
    Type
    string
    Description

    Where to send the user after they approve your app. Must match a redirect URI registered with your app exactly. HTTPS callbacks and loopback HTTP (http://127.0.0.1, http://localhost, http://[::1] with any port) are supported for web and MCP clients. Custom schemes (e.g. myapp://callback) are only available for manually registered native apps.

  • Name
    scope
    Type
    string
    Description

    Space-separated list of scopes. Use api for full access on behalf of the authenticated employee.

  • Name
    code_challenge
    Type
    string
    Description

    The PKCE challenge — base64url-encoded SHA-256 of the code verifier. Required for all public clients; requests without it are rejected.

  • Name
    code_challenge_method
    Type
    string
    Description

    Must be S256.

  • Name
    state
    Type
    string
    Description

    Opaque value that is echoed back on the redirect. Use it to prevent CSRF — generate it per request and validate it on the callback.

After the user signs in and approves the app, the browser is redirected to:

https://your-app.com/callback?code=AbCdEf123456&state=random_csrf_token

If the user denies the request, you'll get ?error=access_denied&state=... instead.


Step 2: Exchange the code for an access token

Within 10 minutes of receiving the authorization code, exchange it for tokens by POSTing to /oauth/token. Send the request body as application/x-www-form-urlencoded.

Token request

POST
/oauth/token
curl -X POST https://app.phone.inc/oauth/token \
  -d grant_type=authorization_code \
  -d code=AbCdEf123456 \
  -d redirect_uri=https://your-app.com/callback \
  -d client_id=your_client_id \
  -d code_verifier=the_original_pkce_verifier

Response

{
  "access_token": "Pj7vXq2L0aN4mYtRk8sGdH9eW6cZbF1uIoP3xKjQ",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "Rt8sFq1MaZ3oV9pKjXhB4nW2yL7eC5uIdN6gHbPwE",
  "scope": "api",
  "created_at": 1730895600
}

Store both tokens securely. Treat the refresh token like a password — never expose it to the browser if you can avoid it.


Step 3: Call the API

Pass the access token in the Authorization header on every API request. The same /api/v1/* endpoints that accept API keys also accept OAuth bearer tokens — Phone.inc auto-detects which is which.

Authenticated request

GET
/api/v1/business_numbers
curl https://app.phone.inc/api/v1/business_numbers \
  -H "Authorization: Bearer Pj7vXq2L0aN4mYtRk8sGdH9eW6cZbF1uIoP3xKjQ"

If the token is missing, malformed, expired, or revoked, you'll get a 401 Unauthorized with a JSON body describing the problem. Time to refresh.


Refreshing tokens

Access tokens last 1 hour. To get a new one without prompting the user, exchange your refresh token at the same /oauth/token endpoint with grant_type=refresh_token. Refresh tokens are valid for 30 days from issue, and we issue a new refresh token on every refresh — store the latest one and discard the previous.

Refresh request

POST
/oauth/token
curl -X POST https://app.phone.inc/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token=Rt8sFq1MaZ3oV9pKjXhB4nW2yL7eC5uIdN6gHbPwE \
  -d client_id=your_client_id

Response

{
  "access_token": "QmJ0wYr3Mb...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "St9tGr2Nb...",
  "scope": "api",
  "created_at": 1730899200
}

Revoking tokens

Call /oauth/revoke to invalidate an access or refresh token immediately — for sign-out, "log out everywhere", or after a suspected leak. The endpoint always returns 200 OK (even for unknown tokens) per RFC 7009.

Revoke request

POST
/oauth/revoke
curl -X POST https://app.phone.inc/oauth/revoke \
  -d token=Pj7vXq2L0aN4mYtRk8sGdH9eW6cZbF1uIoP3xKjQ \
  -d client_id=your_client_id

Scopes

  • Name
    api
    Type
    default
    Description

    Full access to /api/v1/* on behalf of the authenticated employee. This is the only scope third-party apps should request.

  • Name
    web
    Type
    reserved
    Description

    Reserved for first-party browser sessions. Don't request it from a third-party app — it won't be granted.


Error responses

The OAuth endpoints return errors in the standard OAuth 2.0 shape:

OAuth error

{
  "error": "invalid_grant",
  "error_description": "The provided authorization grant is invalid, expired, revoked, or does not match the redirection URI."
}

Common error codes:

  • Name
    invalid_request
    Type
    400
    Description

    Required parameter missing, malformed, or repeated.

  • Name
    invalid_client
    Type
    401
    Description

    The client_id is unknown.

  • Name
    invalid_grant
    Type
    400
    Description

    The authorization code or refresh token is invalid, expired, or already used.

  • Name
    invalid_scope
    Type
    400
    Description

    The requested scope is unknown or not allowed for your app.

  • Name
    unauthorized_client
    Type
    400
    Description

    Your app isn't authorized for this grant type.

  • Name
    access_denied
    Type
    302
    Description

    The user denied your authorization request. Returned as a redirect parameter, not a JSON body.

When you call /api/v1/* with a bad token, you'll get a 401 with WWW-Authenticate: Bearer error="invalid_token". Treat it as a signal to refresh and retry once.


Security checklist

  • Use a fresh PKCE verifier and state value for every authorization request.
  • Validate state on the callback before exchanging the code.
  • Never embed your client ID in a public web app and assume it's secret — it isn't. Security comes from PKCE plus the registered redirect URIs.
  • Store refresh tokens server-side or in secure platform storage (Keychain on iOS, EncryptedSharedPreferences on Android). Never put them in localStorage.
  • Revoke tokens on sign-out.