Authorization code flow
The standard server-side flow for confidential clients, from consent screen to access token.
For confidential clients — apps with a backend that can hold a
client_secret.
1. Send the user to the consent screen#
<a href="https://app.portmodels.com/connect/authorize?client_id=acme%2Fchatbot&redirect_uri=https%3A%2F%2Fyourapp.com%2Fcallback&scope=models.run%20kv.read%20kv.write&state=RANDOM_STATE">
Connect with PortModels
</a>| Parameter | Required | Notes |
|---|---|---|
client_id | yes | Your app id, slash URL-encoded as %2F |
redirect_uri | yes | Must exactly match a registered URI |
scope | yes | Space-separated |
state | recommended | Your CSRF token — random per attempt, verified on return |
The user signs in if needed, sees a consent screen listing exactly what you asked for, and on approval is redirected to:
https://yourapp.com/callback?code=pmac_...&state=RANDOM_STATEOn deny you get ?error=access_denied&state=... instead.
Important
Always verify state matches the value you generated for this attempt before
you exchange the code. Skipping it leaves you open to CSRF on the callback.
2. Exchange the code for a token#
The code is single-use and expires in 10 minutes. Exchange it from your
backend — never expose your client_secret to a browser.
curl -X POST https://api.portmodels.com/connect/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"client_id": "acme/chatbot",
"client_secret": "pmcs_...",
"code": "pmac_...",
"redirect_uri": "https://yourapp.com/callback"
}'{
"access_token": "pmc_...",
"token_type": "bearer",
"scope": "models.run kv.read kv.write",
"connection_id": "..."
}Note that client_id in the JSON body is not URL-encoded — the %2F
encoding applies only to the query string of the authorize link.
3. Store the token for this login#
Store access_token securely for the corresponding app user, session, or
device installation. Every successful code exchange creates an independent
token, so connecting on device B leaves a token already held by device A
working. Each token keeps the scopes approved in its own grant.
Connect tokens currently have no expiry timer. A token stops working when the user disconnects the app, the account becomes unavailable, or a future scope policy gives that token an expiry. Disconnecting from Settings → Connected apps revokes every token for that app and user.
If your app intentionally keeps one shared server-side session per user, it may
replace its own stored token when a new login completes. That is your storage
policy; PortModels does not invalidate the older token. Treat a 401 as
"reconnect required" for that token rather than as a transient failure. See
Revocation and token lifetime.
Complete example#
// 1. Start the flow
app.get('/connect', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
req.session.pmState = state;
const url = new URL('https://app.portmodels.com/connect/authorize');
url.searchParams.set('client_id', 'acme/chatbot');
url.searchParams.set('redirect_uri', 'https://yourapp.com/callback');
url.searchParams.set('scope', 'models.run kv.read kv.write');
url.searchParams.set('state', state);
res.redirect(url.toString());
});
// 2. Handle the callback
app.get('/callback', async (req, res) => {
if (req.query.error) return res.status(400).send('Connection declined');
if (req.query.state !== req.session.pmState) return res.status(400).send('Bad state');
const response = await fetch('https://api.portmodels.com/connect/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
client_id: 'acme/chatbot',
client_secret: process.env.PM_CLIENT_SECRET,
code: req.query.code,
redirect_uri: 'https://yourapp.com/callback',
}),
});
const token = await response.json();
await saveConnection({
userId: req.session.userId,
appId: 'acme/chatbot',
accessToken: token.access_token,
scopes: token.scope,
});
res.redirect('/');
});