Vesopa OAuth for developers
Vesopa OAuth is a standard OpenID Connect provider. If your language has a
certified OIDC library, point it at the issuer below and it will configure
itself — there is nothing of ours to learn. Everything here is
curl, so you can see exactly what goes over the wire.
The only URL you need
curl https://auth.vesopa.com/.well-known/openid-configuration
Every endpoint, the supported algorithms and the signing keys are described there. Hard-coding any of the other URLs is how an integration breaks the day one of them moves.
| Issuer | https://auth.vesopa.com |
|---|---|
| Authorization | /oauth/authorize |
| Token | /oauth/token |
| User info | /oauth/userinfo |
| Keys | /jwks.json |
| Revoke | /oauth/revoke |
| Introspect | /oauth/introspect |
| Log out | /oauth/logout |
What we support, and what we refuse
| Grant | |
|---|---|
authorization_code | Yes — with PKCE, always. |
refresh_token | Yes — rotated on every use. |
client_credentials | Yes — machine to machine only. |
implicit | No. It leaks tokens through the URL. |
password | No. It teaches applications to collect passwords, and it cannot do a second factor. |
PKCE is required of every client, including confidential
ones with a secret. It costs you two extra fields and closes
authorisation-code interception for everybody.
code_challenge_method must be S256;
plain is refused.
Signing somebody in, in four steps
1. Make a PKCE pair
VERIFIER=$(openssl rand -base64 60 | tr -d '=+/' | cut -c1-64)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 \
| openssl base64 | tr '+/' '-_' | tr -d '=')
Keep the verifier; you will need it in step 3. Never send it in step 2.
2. Send the person to Vesopa
https://auth.vesopa.com/oauth/authorize
?client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fyour.app%2Fcallback
&response_type=code
&scope=openid%20profile%20email
&state=RANDOM_PER_REQUEST
&nonce=RANDOM_PER_REQUEST
&code_challenge=$CHALLENGE
&code_challenge_method=S256
They come back to your redirect_uri with
?code=…&state=…. Check the state matches the one
you sent — that check is what stops somebody else's
authorisation code being fed to your callback.
The redirect_uri must match one you registered, character
for character; we do not forgive a trailing slash.
3. Swap the code for tokens
curl -X POST https://auth.vesopa.com/oauth/token \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri="https://your.app/callback" \
-d code_verifier="$VERIFIER" \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET"
Public clients — a single-page app, a desktop app — send no
client_secret. A secret shipped inside something the user
holds is not a secret, and we will refuse one if your application is
registered as public.
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6…",
"token_type": "Bearer",
"expires_in": 600,
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6…",
"scope": "openid profile email"
}
4. Read who they are
curl https://auth.vesopa.com/oauth/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"
Better still, verify the id_token yourself against
/jwks.json and skip the round trip. Tokens are
RS256 and carry a kid; cache the key set
and re-fetch when you meet a kid you do not know.
Scopes
openid | Required. Gets you an ID token. |
|---|---|
profile | Name, picture, date of birth, locale. |
email | Email address and whether it is confirmed. |
phone | Phone number and whether it is confirmed. |
roles | The person's roles in your application only. |
offline_access | A refresh token. Ask only if you need to act while they are away. |
Asking for a scope you were not granted is not an error — it is dropped,
and the rest of the request succeeds. The scope in the
response tells you what you actually got, so read it rather than
assuming.
Refresh tokens rotate. Handle it properly.
curl -X POST https://auth.vesopa.com/oauth/token \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN" \
-d client_id="$CLIENT_ID"
Every refresh returns a new refresh token, and the old one is spent. Store the new one before you do anything else. If a spent token is presented again we assume it was stolen and revoke the whole chain — because we cannot tell a thief from a client that kept a copy.
We make one allowance: the same device retrying within a few seconds,
after a dropped response, gets 409 and should simply try
once more rather than treating it as a failure.
Roles and permissions
A role says what a person may do inside your application. Ask
for the roles scope and they arrive in the access token:
{
"sub": "01M21A5GMCA3838KSRAAXMFRJA",
"aud": "your-client-id",
"scope": "openid profile roles",
"roles": ["till.operator"],
"amr": ["otp"],
"acr": "aal1"
}
You only ever see roles from your own application — never another
developer's, and never Vesopa's. Role changes reach you when the access
token is next refreshed, within ten minutes; call
/oauth/introspect if you need to know sooner.
Two claims worth using
amr says how somebody proved themselves —
pwd, otp, sms,
webauthn — and acr summarises how strong that
is. If an action in your app deserves more than a password, send
acr_values=aal2 on the authorisation request and we will
ask for the missing factor without signing the person out.
Errors you will actually meet
invalid_grant | The code expired, was used already, or the code_verifier does not match. Codes last sixty seconds and work once. |
|---|---|
invalid_client | Wrong client id or secret — or a secret sent by a client registered as public. |
invalid_request | Usually a missing code_challenge. PKCE is not optional here. |
access_denied | The person said no, or has no access to your application. |
| A page instead of a redirect | Your client_id or redirect_uri is not registered. We will not redirect to an address we cannot vouch for. |
Webhooks
Signing somebody in tells you who they are at that moment. Webhooks tell you what happened afterwards — that they changed their name, disconnected you, or deleted their account and you must erase your copy of their data. Add an endpoint in the developer portal and pick the events you want.
user.created | Somebody used your application for the first time. |
|---|---|
user.updated | A name, picture or date of birth changed. |
user.deleted | The account was deleted. Erase your copy of their data. |
user.suspended | An administrator suspended the account. |
identity.linked | An email address, phone number or provider was added. |
identity.unlinked | One was removed — it may now belong to somebody else. |
consent.revoked | The person disconnected your application. Stop using their tokens. |
session.revoked | They signed out everywhere, or a session was ended for them. |
What arrives
POST /your/endpoint HTTP/1.1
Content-Type: application/json
Vesopa-Signature: t=1757370000,v1=6f1c…
Vesopa-Event: user.updated
Vesopa-Event-Id: 01M21Z4RP1MXNKM2HFESAV3H29
Vesopa-Delivery: 01M21Z4RP2QK8CJ4W0YT7BNXME
Vesopa-Attempt: 1
{
"id": "01M21Z4RP1MXNKM2HFESAV3H29",
"type": "user.updated",
"created": 1757370000,
"data": { "sub": "01M21YEY6BPPC048E6J3CR6MSB", "name": "Jane Bell" }
}
Check the signature. Every time.
An unverified webhook endpoint is a public API that writes to your
database, and its address is not a secret — it is in our outbound
connection logs, your access logs and probably a screenshot somewhere.
The signature is HMAC-SHA256 over
timestamp + "." + raw body, so you must hash the bytes
you received, before any JSON parsing and
re-serialising: a re-encoded body is a different string and will never
match.
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=')),
);
// Reject anything old, or a genuine delivery captured today can be
// replayed at your endpoint tomorrow. The signature alone does not
// stop that — it covers the timestamp precisely so you can.
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!(age < 300)) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`, 'utf8')
.digest('hex');
// Constant time, so a comparison cannot be timed to guess the digest.
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(parts.v1, 'hex'),
);
}
Delivery
Answer 2xx and quickly — anything else is a failure and we
try again. Retries back off
10s → 1m → 5m → 15m → 1h → 3h → 6h → 12h, eight attempts in
all; an endpoint that has failed twenty times in a row is switched off
and we email you. Delivery is at least once, so the same
event can arrive twice — make your handler idempotent on
Vesopa-Event-Id, which is stable across every retry while
Vesopa-Delivery changes.
Endpoints must be https:// and reachable from the public
internet — an address that resolves to a private range is refused when
you add it, because an endpoint pointing back inside our network would
turn your webhook into a way of reading it. Redirects are not followed,
for the same reason.
The portal shows every attempt with the status and the first part of the response body, and will replay any delivery on demand — so a deploy that was down for an hour costs nothing.
Getting a client id
Sign in and open the developer portal. Create an application, register your redirect URI, choose your scopes, and the client id and secret are issued straight away. Secrets can be rotated there too — add the new one, deploy, then revoke the old, so a rotation is never an outage.