Connect with Play Nice
Introduction
Link a user's Play Nice account to your app. The user approves on Play Nice, and your server receives their Play Nice user id.
It's a standard OAuth 2.0 authorization code flow with PKCE, for identity only: no refresh tokens, scopes or userinfo endpoint.
Authorize https://www.playnicemtg.com/oauth/authorize
Token https://api.playnicemtg.com/v1/oauth/tokenCredentials
| Credential | Description |
|---|---|
client_id | e.g. convoke. Not secret. |
client_secret | Your partner API key, the same one you use for your data endpoints. Server-side only: never ship it in a browser or mobile app. |
| Origins | Your production and staging origins, e.g. https://app.example.com. Send them to us to register. |
Use the hosts exactly as shown. The authorize page is on www.playnicemtg.com, where the user is signed in; server-to-server calls go to api.playnicemtg.com/v1.
How it works
- Your app redirects the user to /oauth/authorize.
- The user signs in to Play Nice (or registers) and clicks Link Account.
- Play Nice redirects back to your
redirect_uriwith acode. - Your server exchanges the code at /v1/oauth/token for the user's Play Nice user id.
Handle the callback
Check state first. Reject the request unless it matches the value in this user's session. Keep the verifier in that session too; never look it up by state.
| Field | Type | Description |
|---|---|---|
code | string | On success. Valid for 5 minutes. |
state | string | Your value, unchanged. Omitted if state itself was invalid. |
error | string | On failure. access_denied: the user cancelled. invalid_request, unsupported_response_type: your start request was malformed. |
An unknown client_id or unregistered redirect_uri doesn't redirect back: the user sees an error on Play Nice.
https://app.example.com/play-nice/callback?code=eyJ1aWQiOi…&state=af0ifjsldkjhttps://app.example.com/play-nice/callback?error=access_denied&state=af0ifjsldkjExchange the code
POST/v1/oauth/token
Server to server, body application/x-www-form-urlencoded. Authenticate with HTTP Basic (client_id:client_secret), or send client_id and client_secret in the body.
| Field | Type | Description |
|---|---|---|
grant_type | string | authorization_code |
code | string | From the callback. |
redirect_uri | string | Exactly the value sent to /oauth/authorize. |
code_verifier | string | The verifier from your session. 43–128 characters. |
partner_user_id | string | Your id for this user. The link is bound to it. |
Store user_id. Send it with every game submission (see Convoke game submissions). Ignore access_token: OAuth requires the field, but no Play Nice endpoint accepts it.
Retrying with the same code returns the same result until the code expires.
curl -X POST https://api.playnicemtg.com/v1/oauth/token \
-u 'convoke:YOUR_PARTNER_KEY' \
-d grant_type=authorization_code \
-d code=eyJ1aWQiOi… \
--data-urlencode redirect_uri=https://app.example.com/play-nice/callback \
-d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk \
-d partner_user_id=convoke-user-123{
"access_token": "q8Jt0mH3pXv…",
"token_type": "Bearer",
"expires_in": 3600,
"user_id": "Xy12AbCdEfGh34IjKlMn56OpQr78"
}Errors
| Status | Error | What to do |
|---|---|---|
| 400 | invalid_grant | Code expired or doesn't match the request. Start a new link. |
| 400 | invalid_request | A required field is missing. |
| 400 | unsupported_grant_type | Send grant_type=authorization_code. |
| 401 | invalid_client | Check your client id and secret. |
| 500 | server_error | Temporary. Retry. |
{
"error": "invalid_grant",
"error_description": "The code is invalid or expired, or doesn't match this request. Start the link again."
}Local development
Any port on localhost, 127.0.0.1 or [::1] works against production Play Nice, with your normal client secret.
Use test accounts. A Play Nice account has one link per app, so linking a real account locally replaces its production link.
redirect_uri=http://localhost:3000/play-nice/callback
redirect_uri=http://127.0.0.1:8080/auth/play-nicePopup window
On desktop, you can open the flow in a 420 × 640 popup. The Play Nice pages fit that size.
- Open it from the click handler, or the browser blocks it.
- Do the exchange on your callback page, message the opener, then close.
- Treat a popup closed without a message as cancelled.
- Mobile: use a full-page redirect. In-app browsers often block popups.
- Iframes aren't supported.
// your page: open the flow from the click handler
window.open(authorizeUrl, 'playnice-link', 'popup,width=420,height=640');
window.addEventListener('message', (event) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === 'playnice-linked') {
// refresh the user's connection state
}
});
// your callback page, after the exchange
window.opener?.postMessage({ type: 'playnice-linked' }, window.location.origin);
window.close();Full example
Both steps with plain fetch. Assumes a session store and a signed-in user.
import crypto from 'node:crypto';
const PLAY_NICE = 'https://www.playnicemtg.com'; // the authorize page, in the user's browser
const PLAY_NICE_API = 'https://api.playnicemtg.com/v1'; // server-to-server calls
const CLIENT_ID = 'convoke';
const REDIRECT_URI = 'https://app.example.com/play-nice/callback'; // any path on your registered origin
// 1. Start: GET /play-nice/connect (user must be signed in to your app)
export const startPlayNiceLink = async (req, res) => {
const state = crypto.randomBytes(32).toString('base64url');
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
req.session.playNiceLink = { state, codeVerifier };
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
res.redirect(`${PLAY_NICE}/oauth/authorize?${params}`);
};
// 2. Callback: GET /play-nice/callback
export const finishPlayNiceLink = async (req, res) => {
const saved = req.session.playNiceLink;
// required: only finish a link this user's own session started
if (!saved || req.query.state !== saved.state) {
// reject the request
}
// if req.query.error is set (access_denied = the user cancelled), stop here
const response = await fetch(`${PLAY_NICE_API}/oauth/token`, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code: req.query.code,
redirect_uri: REDIRECT_URI,
code_verifier: saved.codeVerifier,
client_id: CLIENT_ID,
client_secret: process.env.PLAY_NICE_PARTNER_KEY,
partner_user_id: req.user.id // your own user id, from your session
})
});
// if !response.ok, the body is { error, error_description }
// otherwise { user_id, ... }: store user_id as this user's Play Nice id
const playNiceUser = await response.json();
};