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 key | OAuth 2.1 token | |
|---|---|---|
| Sent as | apikey: sk_live_… | Authorization: Bearer … |
| Identity | the member who created it | the person who approved the consent screen |
| Workspace | the key's own; X-Account-Id switches it | fixed when consent was given; X-Account-Id is refused |
| Permissions | everything its creator can do | only the approved scopes, intersected with that person's role |
| Expiry | never — revoked by hand | 1 hour, renewed with a refresh token |
| Right for | your own backend | software 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.
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"
}'
| Field | Required | Value |
|---|---|---|
redirect_uris | yes | 1 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_name | no | Shown on the consent screen, up to 255 characters. Omit it and your app appears as Aplicativo sem nome — always send it |
client_uri | no | Shown on the consent screen as the app's site |
logo_uri | no | An image URL. Shown as the app's face on the consent screen |
scope | no | Space-separated ceiling for this client. Omitted grants every requestable scope |
grant_types | no | authorization_code, refresh_token. Omitted means authorization_code alone |
response_types | no | code is the only accepted value |
token_endpoint_auth_method | no | none, client_secret_basic or client_secret_post. Omitted makes a confidential client |
software_id, software_version | no | Accepted 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"
}
client_secret is present only for a confidential client, and only in this response.
Two omissions cost an afternoon each.
- Leave
grant_typesout and the client getsauthorization_codealone — no refresh token is ever issued, and your integration dies one hour later needing a human. - Leave
token_endpoint_auth_methodout and you get a confidential client with aclient_secretshown 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.
| Parameter | Required | Value |
|---|---|---|
response_type | yes | code — the only value accepted |
client_id | yes | from step 1 |
redirect_uri | if the client registered more than one | must match one registered exactly |
code_challenge | yes | base64url of SHA-256(code_verifier) |
code_challenge_method | yes | S256 — must be explicit; plain does not exist here |
scope | no | space-separated; defaults to profile |
state | recommended | echoed back untouched |
resource | no | the exact URL the token is for — see Audience |
prompt | no | consent 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...
{
"access_token": "eyJ…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8xLOxBt…",
"scope": "profile connections:read messages:send"
}
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…"
Three rules that have no equivalent on the API-key side:
- The workspace is fixed. Sending
X-Account-Idfor 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:writein the hands of an Agent still does only what an Agent does. resourcebinds the token to a path. Ask forhttps://api.pingonotify.com/v3/mcpand the token is accepted under/v3/mcpand answers 401 everywhere else. Omitresourceand 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...
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.
| Scope | Grants |
|---|---|
profile | Who authorized, and which workspace. The default when scope is omitted |
connections:read | Read connections and their status |
connections:connect | Pair and disconnect a number (QR code and logout), without the power to create or delete connections |
connections:write | Create, edit, connect and disconnect connections. Also satisfies connections:read |
messages:read | Read message history and download received attachments |
messages:send | Send messages: text, media, audio, sticker, template, list, buttons |
contacts:read | Read contacts |
contacts:write | Create and edit contacts |
helpdesk:read | Read helpdesk conversations, contacts and settings |
helpdesk:write | Reply to and manage the helpdesk. Needs the PRO plan on top of the scope |
webhooks:read | Read webhooks and their delivery history |
webhooks:write | Create, edit and delete webhooks, and read the signing secret |
stats:read | Read 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
| Goal | Call | Credential |
|---|---|---|
| The app drops its own token | POST /v3/oauth/revoke | the client itself |
| The workspace lists who has access | GET /v3/oauth/consents | API key, or the dashboard |
| The workspace cuts an app off | DELETE /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.
error | HTTP | Endpoint | Cause | Fix |
|---|---|---|---|---|
invalid_client_metadata | 400 | register | A registration field is wrong, grant_types has an unsupported value, or dynamic registration is disabled on this server | error_description names the field |
invalid_redirect_uri | 400 | register, authorize | A redirect URI is relative, carries a fragment or credentials, or is http outside loopback | Use an absolute https URI, or http://localhost |
invalid_scope | 400 | register, token | A scope is unknown, is legacy, is outside what the client registered, or a refresh tried to widen | Ask for a subset of the client's scopes |
invalid_request | 400 | authorize, token, revoke, introspect | A parameter is missing, malformed or contradictory | Read error_description |
invalid_client | 400 authorize · 401 token, revoke, introspect | client_id is unknown, or a confidential client's secret is missing or wrong, or a public client sent one | Public clients send no secret | |
unauthorized_client | 400 | token | The client did not register the grant it is using | Register refresh_token before using it |
unsupported_grant_type | 400 | token | grant_type is neither authorization_code nor refresh_token | — |
invalid_grant | 400 | token | The code expired (60 s), was already used, belongs to another client, or a refresh token was replayed | On a replay the whole grant is gone: start at /authorize again |
invalid_target | 400 | token | resource is not on the issuer's origin, has a fragment, or a refresh tried to widen it | Send 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
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
error | HTTP | Cause | Fix |
|---|---|---|---|
invalid_token | 401 | Missing, expired, revoked, or issued for a different resource | Refresh; if that fails, authorize again |
insufficient_scope | 403 | The token lacks a scope, or nothing grants that operation to OAuth clients at all | Re-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"
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 code | 60 seconds, single use |
| Access token | 1 hour — unless the client turns expiry off |
| Refresh token | 30 days of inactivity, rotated on every use. Each renewal restarts the clock |
| Pending authorization request | 10 minutes |
POST /v3/oauth/token, /revoke, /introspect | 120 requests per minute, per IP |
GET /v3/oauth/authorize, POST /v3/oauth/register | 15 requests per minute, per IP |
| Discovery documents | no 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:
| Document | Answers |
|---|---|
/.well-known/oauth-authorization-server | issuer, every endpoint, supported scopes, grant types, code_challenge_methods_supported, prompt_values_supported |
/.well-known/oauth-protected-resource | the API as a whole, and which authorization server guards it |
/.well-known/oauth-protected-resource/v3/mcp | the 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.