Sign UpLog In

Connect with Play Nice

Docs

  • Connect with Play Nice
    • Introduction
    • Credentials
    • How it works
    • Start the link
    • Handle the callback
    • Exchange the code
    • Errors
    • Local development
    • Popup window
    • Full example
  • Convoke game submissions
  • Opening hands

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.

Endpoints
Authorize   https://www.playnicemtg.com/oauth/authorize
Token       https://api.playnicemtg.com/v1/oauth/token

Credentials

CredentialDescription
client_ide.g. convoke. Not secret.
client_secretYour partner API key, the same one you use for your data endpoints. Server-side only: never ship it in a browser or mobile app.
OriginsYour 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

  1. Your app redirects the user to /oauth/authorize.
  2. The user signs in to Play Nice (or registers) and clicks Link Account.
  3. Play Nice redirects back to your redirect_uri with a code.
  4. Your server exchanges the code at /v1/oauth/token for the user's Play Nice user id.

Start the link

GET/oauth/authorize

Create a random state and a PKCE code_verifier, store both in the signed-in user's session, then redirect. All fields are required; scope is ignored.

FieldTypeDescription
response_typestringcode
client_idstringYour client id.
redirect_uristringYour callback URL: any https path on a registered origin, or localhost.
statestringRandom value stored in the user's session. Returned unchanged.
code_challengestringbase64url(SHA-256(code_verifier)), no padding.
code_challenge_methodstringS256
Redirect the browser to
https://www.playnicemtg.com/oauth/authorize
  ?response_type=code
  &client_id=convoke
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fplay-nice%2Fcallback
  &state=af0ifjsldkj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

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.

FieldTypeDescription
codestringOn success. Valid for 5 minutes.
statestringYour value, unchanged. Omitted if state itself was invalid.
errorstringOn 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.

Success
https://app.example.com/play-nice/callback?code=eyJ1aWQiOi…&state=af0ifjsldkj
Cancelled
https://app.example.com/play-nice/callback?error=access_denied&state=af0ifjsldkj

Exchange 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.

FieldTypeDescription
grant_typestringauthorization_code
codestringFrom the callback.
redirect_uristringExactly the value sent to /oauth/authorize.
code_verifierstringThe verifier from your session. 43–128 characters.
partner_user_idstringYour 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.

Request
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
Response · 200
{
  "access_token": "q8Jt0mH3pXv…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "user_id": "Xy12AbCdEfGh34IjKlMn56OpQr78"
}

Errors

StatusErrorWhat to do
400invalid_grantCode expired or doesn't match the request. Start a new link.
400invalid_requestA required field is missing.
400unsupported_grant_typeSend grant_type=authorization_code.
401invalid_clientCheck your client id and secret.
500server_errorTemporary. Retry.
Response · 400
{
  "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.

Accepted without registration
redirect_uri=http://localhost:3000/play-nice/callback
redirect_uri=http://127.0.0.1:8080/auth/play-nice

Popup 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.
JavaScript
// 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.

Node.js
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();
};
  • Play Nice
  • About Us
  • Pricing
  • BubbleCut
  • Middle Age Magic
  • Support
  • FAQ
  • Contact Us
  • Legal
  • Privacy Policy
  • Terms of Service
Wizards of the Coast, Magic: The Gathering, and their logos are trademarks of Wizards of the Coast LLC in the United States and other countries. © 1993-2026 Wizards of the Coast LLC. All Rights Reserved.

Play Nice and Middle Age Magic LLC is not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC. Play Nice is using the trademarks and other intellectual property of Wizards of the Coast LLC as permitted under the Wizard's Fan Content Policy. © 2024-2026 Middle Age Magic LLC. All Rights Reserved.

"Mountain of Skulls" artwork by Kathleen Neeley for Play Nice.