Skip to content

OAuth 2.1

How an application gets a token on behalf of a person, what that token can do, and every error it can get back.

Pingo Notify runs its own OAuth 2.1 authorization server. It issues the credential for software that acts on behalf of a person — an AI assistant, a third-party integration, anything you would rather not hand an API key to.

Which credential to use

API keyOAuth 2.1 token
Sent asapikey: sk_live_…Authorization: Bearer …
Identitythe member who created itthe person who approved the consent screen
Workspacethe key's own; X-Account-Id switches itfixed when consent was given; X-Account-Id is refused
Permissionseverything its creator can doonly the approved scopes, intersected with that person's role
Expirynever — revoked by hand1 hour, renewed with a refresh token
Right foryour own backendsoftware that is not yours, or that many people install

Never send both headers. On an endpoint that expects one, the other is ignored, not merged.

The flow

Five calls. 1 and 2 happen once per application, 3 once per person who authorizes, 4 on every request, 5 about once an hour.

Your applicationyour serverThe persontheir browserPingo Notify api.pingonotify.com once, before anything elseregister the client → get client_id1send them to /v3/oauth/authorize2they sign in and approve the scopes3302 with code · 60 s, single use4lands on your redirect_uri5exchange the code — POST /v3/oauth/token6access_token (1 h) + refresh_token7call the API with Authorization: Bearer8the answer, within the approved scopethe password only exists in herefrom here on, without the person
The password never crosses into the left lane. What crosses is a 60-second code and then a token — bound to the workspace the person chose and limited to the scopes they approved.

1 — Register the client

POST /v3/oauth/register (RFC 7591). Open, no credential needed, answers 201.

curl -X POST https://api.pingonotify.com/v3/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Acme Assistant",
    "redirect_uris": ["https://acme.example/oauth/callback"],
    "token_endpoint_auth_method": "none",
    "grant_types": ["authorization_code", "refresh_token"],
    "scope": "profile connections:read messages:send"
  }'
bash
FieldRequiredValue
redirect_urisyes1 to 10 URIs, matched exactly at /authorize. Absolute, no fragment, no credentials. https always; http only on loopback (localhost, 127.0.0.1, [::1]); a native-app scheme is fine if it contains a dot (com.acme.app:/oauth)
client_namenoShown on the consent screen, up to 255 characters. Omit it and your app appears as Aplicativo sem nome — always send it
client_urinoShown on the consent screen as the app's site
logo_urinoAn image URL. Shown as the app's face on the consent screen
scopenoSpace-separated ceiling for this client. Omitted grants every requestable scope
grant_typesnoauthorization_code, refresh_token. Omitted means authorization_code alone
response_typesnocode is the only accepted value
token_endpoint_auth_methodnonone, client_secret_basic or client_secret_post. Omitted makes a confidential client
software_id, software_versionnoAccepted and validated, then ignored: not stored, not returned
{
  "client_id": "xf3K9…",
  "client_id_issued_at": 1780000000,
  "client_secret_expires_at": 0,
  "client_name": "Acme Assistant",
  "redirect_uris": ["https://acme.example/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "profile connections:read messages:send"
}
json

client_secret is present only for a confidential client, and only in this response.

Two omissions cost an afternoon each.

  • Leave grant_types out and the client gets authorization_code alone — no refresh token is ever issued, and your integration dies one hour later needing a human.
  • Leave token_endpoint_auth_method out and you get a confidential client with a client_secret shown exactly once, which you must then send on every token call. Send "none" to be a public client that proves itself with PKCE.

scope omitted grants the client every requestable scope. Registering is not approving: a client registered this way has no owner and no workspace until a person authorizes it.

2 — Send the person to /authorize

GET /v3/oauth/authorize, in a browser. Not an API call — it always answers 302.

ParameterRequiredValue
response_typeyescode — the only value accepted
client_idyesfrom step 1
redirect_uriif the client registered more than onemust match one registered exactly
code_challengeyesbase64url of SHA-256(code_verifier)
code_challenge_methodyesS256 — must be explicit; plain does not exist here
scopenospace-separated; defaults to profile
staterecommendedechoed back untouched
resourcenothe exact URL the token is for — see Audience
promptnoconsent forces the screen; none forbids any interaction

PKCE is mandatory for every client, confidential ones included.

The person signs in, picks a workspace and approves. Then the browser lands on your redirect_uri with code, state and iss.

Only an Owner or an Admin can authorize an application for a workspace. The screen offers only the workspaces where the person's role allows it. Approving without choosing one binds the token to their personal account.

A person can approve fewer scopes than you asked for. Read the scope you get back in step 3 — it is the truth, your request was a wish.

3 — Exchange the code

POST /v3/oauth/token, application/x-www-form-urlencoded or JSON.

curl -X POST https://api.pingonotify.com/v3/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=aG9sYS4uLg \
  -d redirect_uri=https://acme.example/oauth/callback \
  -d client_id=xf3K9... \
  -d code_verifier=dBjftJeZ4CVP...
bash
{
  "access_token": "eyJ…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "8xLOxBt…",
  "scope": "profile connections:read messages:send"
}
json

refresh_token appears only if the client registered that grant. A public client must not send client_secret; a confidential one must, by HTTP Basic or as a form field.

The code is valid for 60 seconds and once. Replaying your own code revokes the whole grant — that is reuse detection, not a bug.

4 — Call the API

curl https://api.pingonotify.com/v3/connections \
  -H "Authorization: Bearer eyJ…"
bash

Three rules that have no equivalent on the API-key side:

  • The workspace is fixed. Sending X-Account-Id for a different workspace answers 403 — This access token is bound to a different workspace; re-authorize to switch.
  • The scope is a ceiling, never a grant. Effective permission is the intersection of the approved scopes and what that person's role allows. A token holding helpdesk:write in the hands of an Agent still does only what an Agent does.
  • resource binds the token to a path. Ask for https://api.pingonotify.com/v3/mcp and the token is accepted under /v3/mcp and answers 401 everywhere else. Omit resource and the token works across the API. Ask for the resource you actually intend to call.

Anything with no scope that covers it is closed to OAuth tokens entirely, and says so: this operation is not available to OAuth clients. Consent management is the notable case — no scope reaches it, so an app can never list or revoke another app's access.

5 — Refresh

curl -X POST https://api.pingonotify.com/v3/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=refresh_token \
  -d refresh_token=8xLOxBt... \
  -d client_id=xf3K9...
bash

The response has the same shape as step 3, with a new refresh token. Rotation is mandatory: the one you presented is dead the moment the new pair is issued. Store the new one before you use the new access token.

Presenting a refresh token twice revokes the entire grant — every token that client holds for that person and workspace, at once, with invalid_grant. That is the defence against a stolen token, and it cannot tell your retry loop apart from a thief. Never refresh concurrently from two processes, and never retry a refresh whose response you did not read.

Both scope and resource may only narrow on refresh. Asking for more returns invalid_scope or invalid_target.

Scopes

Twelve scopes can be requested. legacy exists but no client may ask for it — it belongs to the v1 API-key bridge.

ScopeGrants
profileWho authorized, and which workspace. The default when scope is omitted
connections:readRead connections and their status
connections:connectPair and disconnect a number (QR code and logout), without the power to create or delete connections
connections:writeCreate, edit, connect and disconnect connections. Also satisfies connections:read
messages:readRead message history and download received attachments
messages:sendSend messages: text, media, audio, sticker, template, list, buttons
contacts:readRead contacts
contacts:writeCreate and edit contacts
helpdesk:readRead helpdesk conversations, contacts and settings
helpdesk:writeReply to and manage the helpdesk. Needs the PRO plan on top of the scope
webhooks:readRead webhooks and their delivery history
webhooks:writeCreate, edit and delete webhooks, and read the signing secret
stats:readRead workspace statistics

Ask for the least you need. Every extra scope is a line on the consent screen that a person can refuse, and refusing narrows the whole approval.

If your application does not work without a specific permission, it can be marked as required: the checkbox is locked on the screen, with the reason next to it, and the authorization is refused if anyone tries to drop it underneath. This is configured on the client registration, not on /authorize — talk to support about your case.

How your app shows up on the screen

The user sees the name, logo and website you registered — plus a warning when the client registered itself through the dynamic endpoint, saying the displayed name was chosen by the app itself.

Applications Pingo has verified show a Verified by Pingo badge in place of that warning. Verification is granted by the platform and confirms who publishes the application — it does not change what the app can do and does not skip consent: permissions are still the user's choice, one by one. There is no way for an application to mark itself as verified.

Revoking

GoalCallCredential
The app drops its own tokenPOST /v3/oauth/revokethe client itself
The workspace lists who has accessGET /v3/oauth/consentsAPI key, or the dashboard
The workspace cuts an app offDELETE /v3/oauth/consents/{id}API key, or the dashboard

revoke answers 200 for any input, including a token that never existed. That is deliberate: a different answer would turn the endpoint into an oracle for guessing valid tokens.

Re-approving an app with fewer scopes also revokes the tokens already issued under the old approval, so the consent screen and reality never disagree.

Errors

Where an error arrives matters more than its code, because it decides what your client has to parse. There are three places.

As a JSON body

The body is exactly { "error": "…", "error_description": "…" } — no other envelope, no statusCode field.

errorHTTPEndpointCauseFix
invalid_client_metadata400registerA registration field is wrong, grant_types has an unsupported value, or dynamic registration is disabled on this servererror_description names the field
invalid_redirect_uri400register, authorizeA redirect URI is relative, carries a fragment or credentials, or is http outside loopbackUse an absolute https URI, or http://localhost
invalid_scope400register, tokenA scope is unknown, is legacy, is outside what the client registered, or a refresh tried to widenAsk for a subset of the client's scopes
invalid_request400authorize, token, revoke, introspectA parameter is missing, malformed or contradictoryRead error_description
invalid_client400 authorize · 401 token, revoke, introspectclient_id is unknown, or a confidential client's secret is missing or wrong, or a public client sent onePublic clients send no secret
unauthorized_client400tokenThe client did not register the grant it is usingRegister refresh_token before using it
unsupported_grant_type400tokengrant_type is neither authorization_code nor refresh_token
invalid_grant400tokenThe code expired (60 s), was already used, belongs to another client, or a refresh token was replayedOn a replay the whole grant is gone: start at /authorize again
invalid_target400tokenresource is not on the issuer's origin, has a fragment, or a refresh tried to widen itSend the exact resource URL, or nothing

On your redirect_uri

Once client_id and redirect_uri check out, every remaining authorize error becomes a 302 to your callback, never a JSON body — answering in the body before that point would make the endpoint an open redirect. Parse the query, not the status:

https://acme.example/oauth/callback?error=invalid_scope&error_description=…&state=…&iss=https://api.pingonotify.com
text

iss is there so you can tell which authorization server answered (RFC 9207). The codes that arrive this way: unsupported_response_type, unauthorized_client, invalid_request, invalid_scope, invalid_target, login_required, consent_required, access_denied, server_error.

access_denied means a person refused, or their role does not allow authorizing that workspace. There is nothing to retry.

On an API call

errorHTTPCauseFix
invalid_token401Missing, expired, revoked, or issued for a different resourceRefresh; if that fails, authorize again
insufficient_scope403The token lacks a scope, or nothing grants that operation to OAuth clients at allRe-authorize with the scope the response names

Both carry a WWW-Authenticate header, and on insufficient_scope it names the missing scope:

WWW-Authenticate: Bearer realm="pingo", error="invalid_token",
  error_description="the access token is invalid, expired or revoked",
  resource_metadata="https://api.pingonotify.com/.well-known/oauth-protected-resource/v3/mcp"
http

Follow resource_metadata instead of hardcoding endpoints — it is the thread back to the authorization server.

Rate limiting answers 429, and its body is not in the OAuth error shape. Treat a 429 as transport, not as a protocol error.

Two codes exist in the OAuth vocabulary but this server never returns them: interaction_required and temporarily_unavailable. Do not write handling for them.

Lifetimes and limits

Authorization code60 seconds, single use
Access token1 hour — unless the client turns expiry off
Refresh token30 days of inactivity, rotated on every use. Each renewal restarts the clock
Pending authorization request10 minutes
POST /v3/oauth/token, /revoke, /introspect120 requests per minute, per IP
GET /v3/oauth/authorize, POST /v3/oauth/register15 requests per minute, per IP
Discovery documentsno limit, cached 5 minutes

Expiry is a per-client setting. A client registered from the dashboard can turn Expire access tokens off — then its access token has no expiry, no expires_in is returned, and no refresh token is issued, since refresh exists to renew what expires. Dynamic registration (RFC 7591) cannot set it: a client that registers itself always gets expiring tokens.

With expiry off, revoking is the only way to cut access — from Connected apps, or POST /v3/oauth/revoke. Changing the setting affects new tokens; the ones already issued keep the lifetime they were born with.

Discovery

Do not hardcode the endpoints. Both documents are public, need no credential, and are the supported way to find everything above:

DocumentAnswers
/.well-known/oauth-authorization-serverissuer, every endpoint, supported scopes, grant types, code_challenge_methods_supported, prompt_values_supported
/.well-known/oauth-protected-resourcethe API as a whole, and which authorization server guards it
/.well-known/oauth-protected-resource/v3/mcpthe MCP endpoint specifically, and only the scopes it uses

The first thing built on all of this is the MCP server — an AI assistant connects with exactly this flow, and its page shows it end to end. For the API-key credential, see API Keys and Authentication.

© 2026 Pingo Notify. All rights reserved.

pingonotify.com ·Built with Nuxt and Scalar