OAuth 2.0 con PKCE
En corto
Redirige al Dueño a /oauth/autorizar con PKCE S256, canjea el code por
tokens en menos de 60 segundos, guarda el refresh_token cifrado (es
rotativo: siempre el más nuevo) y valida state en tu callback — es tu única
defensa contra CSRF.
Las apps públicas usan OAuth 2.0 con código de autorización y PKCE (obligatorio: no existe el método plain, solo S256). El Dueño del comercio acepta tus permisos en una pantalla de consentimiento y tu app recibe sus propios tokens, por instalación.
Cómo llega el comercio a tu app
Sección titulada «Cómo llega el comercio a tu app»En la práctica, el Dueño llega a tu install_url (la que registraste al pedir el alta) haciendo clic en “Instalar” desde el catálogo de apps de Wink POS (Ajustes → Integraciones § Explorar apps). Wink POS abre tu install_url con ?business_id=<id> como pista del comercio, para que la preselecciones; el Dueño igual elige y aprueba el comercio en la pantalla de consentimiento, sin importar esa pista. Desde install_url, tu app arranca el flujo normal descrito abajo: redirige a /oauth/autorizar con tu propio state y PKCE.
- 1
Tu servidor genera
code_verifierycode_challengeCon S256, antes de redirigir a nadie.
- 2
Tu servidor redirige al Dueño a
/oauth/autorizarCon
code_challenge,statey los demás parámetros en la URL. - 3El Dueño inicia sesión en Wink POS y acepta los permisos
Todo esto pasa en el navegador del Dueño, nunca en tu servidor.
- 4
Wink POS redirige de vuelta a tu
redirect_uriCon
codeystateen la URL. - 5
Tu servidor canjea el code en
POST /oauth/tokenCon
codeycode_verifier, antes de que pasen 60 segundos. - 6Wink POS responde con los tokens
access_token(1 hora) yrefresh_token(30 días). - 7
Tu servidor usa el
access_tokenen/public/v1En cada llamada a la API pública, para ese comercio.
- 8Antes de que venza, tu servidor rota el refresh
Guarda siempre el
refresh_tokennuevo: el anterior deja de servir.
Paso a paso, en Node (crypto y fetch, sin librerías)
Sección titulada «Paso a paso, en Node (crypto y fetch, sin librerías)»import crypto from 'node:crypto';
// 1. Verifier y challenge S256. El verifier se guarda en tu servidor (sesión o caché de corta// vida) hasta que el callback llegue: nunca viaja al navegador del Dueño.const verifier = crypto.randomBytes(48).toString('base64url');const challenge = crypto .createHash('sha256') .update(verifier) .digest('base64url');
// 2. Arma la URL de autorización y redirige al Dueño ahí (no hay SDK: es una URL).const authorizeUrl = new URL('https://app.wink-pos.com/oauth/autorizar');authorizeUrl.searchParams.set('response_type', 'code');authorizeUrl.searchParams.set('client_id', process.env.WINK_CLIENT_ID);authorizeUrl.searchParams.set( 'redirect_uri', 'https://mi-app.example.com/callback',);authorizeUrl.searchParams.set('scope', 'catalog.view sales.create');authorizeUrl.searchParams.set('state', crypto.randomUUID());authorizeUrl.searchParams.set('code_challenge', challenge);authorizeUrl.searchParams.set('code_challenge_method', 'S256');// res.redirect(authorizeUrl.toString())
// 3. En tu endpoint de callback (?code=...&state=...): compara `state` con el que guardaste y// canjea el code por tokens.async function exchangeCode(code, verifier) { const res = await fetch('https://api.wink-pos.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: process.env.WINK_CLIENT_ID, client_secret: process.env.WINK_CLIENT_SECRET, code, redirect_uri: 'https://mi-app.example.com/callback', code_verifier: verifier, }), }); if (!res.ok) throw new Error(`token exchange failed: ${res.status}`); return res.json(); // { access_token, token_type: "Bearer", expires_in: 3600, refresh_token, scope }}
// 4. Guarda los tokens cifrados, asociados a la instalación (un comercio puede tener varias apps// y una app puede estar instalada en varios comercios: guarda siempre por par app+comercio).async function saveTokens(businessId, tokens) { // encrypt(tokens.access_token), encrypt(tokens.refresh_token), ...}
// 5. Renueva antes de que venza (1 h). El refresh es rotativo: guarda siempre el que te devuelve// la respuesta, nunca reutilices uno viejo.async function refresh(refreshToken) { const res = await fetch('https://api.wink-pos.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id: process.env.WINK_CLIENT_ID, client_secret: process.env.WINK_CLIENT_SECRET, refresh_token: refreshToken, }), }); if (!res.ok) throw new Error(`refresh failed: ${res.status}`); return res.json(); // nuevo access_token + nuevo refresh_token: guarda los dos}
// 6. Revoca un token concreto cuando ya no lo necesitas. OJO: esto NO desinstala la app.// Para desinstalar desde tu propia app usa DELETE /public/v1/installation (sección de abajo).async function revoke(token) { await fetch('https://api.wink-pos.com/oauth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: process.env.WINK_CLIENT_ID, client_secret: process.env.WINK_CLIENT_SECRET, token, }), });}Desinstalar la app desde tu app
Sección titulada «Desinstalar la app desde tu app»Cuando el comercio desvincula Wink POS desde tu propia pantalla (por ejemplo, «Desconectar Wink POS»), llama a DELETE /public/v1/installation con el access_token de esa instalación. Es el equivalente a que el Dueño pulse «Desinstalar» en Ajustes → Integraciones: no hay nada más que hacer de tu lado.
curl -X DELETE https://api.wink-pos.com/public/v1/installation \ -H "Authorization: Bearer $ACCESS_TOKEN"# 204 No ContentQué pasa al recibir la llamada:
- La instalación queda desinstalada: deja de aparecer como «Activa» en Ajustes → Integraciones.
- Se revocan todos sus tokens (access y refresh). Para volver a conectar, el Dueño tiene que repetir el consentimiento.
- Se envía el webhook
app.uninstalleda tus suscripciones y después se borran, igual que cuando desinstala el Dueño. - La bitácora del comercio registra que la desinstaló la app (no una persona).
No pide ningún permiso (scope), igual que GET /public/v1/business.
| Respuesta | Cuándo |
|---|---|
204 |
La instalación se desinstaló. |
401 invalid_token |
Token ausente, vencido o ya revocado. Si repites la llamada tras un 204, el token ya no sirve y da 401. |
409 private_app_not_uninstallable |
Usaste la llave de una app privada (wpa_…): no tiene instalación. El Dueño la revoca en Ajustes → Integraciones. |
DELETE /public/v1/installation o POST /oauth/revoke
Sección titulada «DELETE /public/v1/installation o POST /oauth/revoke»DELETE /public/v1/installation |
POST /oauth/revoke |
|
|---|---|---|
| Autenticación | Authorization: Bearer con el access token |
client_id + client_secret con el token a revocar |
| Revoca | Todos los tokens de la instalación | El token indicado y su familia (access + refresh) |
| Estado en Ajustes → Integraciones | Pasa a desinstalada | Sigue «Activa» |
Webhook app.uninstalled |
Sí | No |
Usa /oauth/revoke solo para invalidar un token (por ejemplo, tras una sospecha de filtración) sin dejar la integración. Para desvincular al comercio, usa siempre DELETE /public/v1/installation.
Errores comunes
Sección titulada «Errores comunes»| Causa | Qué pasa |
|---|---|
redirect_uri no coincide exacto con el registrado |
No redirige: Wink POS muestra el error directamente, porque para entonces el redirect no se validó |
client_secret incorrecto o incompleto |
invalid_client al canjear el código |
El code venció (más de 60 segundos) |
invalid_grant |
El code ya se usó |
invalid_grant, y además revoca los tokens que se emitieron la primera vez con ese código |
PKCE plain |
No soportado: el esquema solo acepta code_challenge_method=S256 |
| Refresh token reusado fuera de su rotación | Se revoca toda la cadena de tokens de esa instalación (igual que una sesión de persona reutilizada) |
Lo que tu app tiene que construir
Sección titulada «Lo que tu app tiene que construir»Tres piezas de tu lado, nada más (no hay SDK oficial: son una URL, una ruta y un lugar donde guardar datos).
a. El botón Conectar. Arma la URL de /oauth/autorizar (paso 2 del código de arriba) y redirige ahí. Genera state nuevo en cada intento y guárdalo en la sesión del Dueño junto con el code_verifier.
b. La ruta de callback en tu servidor (la que registraste como redirect_uri, por ejemplo https://tu-app.com/wink-pos/callback). Recibe uno de estos dos casos por query string:
?code=...&state=...— éxito. Antes de canjear nada, comparastatecon el que guardaste: si no coincide, corta ahí (protección CSRF: alguien pudo estar intentando colarte uncodesuyo). Si coincide, canjea elcodeen/oauth/token— tienes 60 segundos antes de que venza.?error=...&error_description=...&state=...— el Dueño no completó el flujo. El caso más común eserror=access_denied(le dio “Cancelar” en la pantalla de consentimiento): no es una falla tuya, muéstrale una pantalla para reintentar, no un error genérico.
c. Dónde guardar los tokens. Guárdalos cifrados en tu base de datos, indexados por instalación (client_id + comercio, nunca solo por comercio: un mismo comercio puede tener varias apps y tu app puede estar instalada en varios comercios). Para saber qué comercio acaba de conectarse, llama a GET /public/v1/business con el access_token recién recibido — te devuelve el comercio dueño de esa instalación.
Ejemplo completo en Node/Express de las dos rutas (conectar + callback):
import crypto from 'node:crypto';import express from 'express';
const app = express();
app.get('/wink-pos/conectar', (req, res) => { const verifier = crypto.randomBytes(48).toString('base64url'); const state = crypto.randomUUID();
// Guarda ambos en la sesión del Dueño hasta que vuelva por el callback. req.session.oauth = { verifier, state };
const challenge = crypto .createHash('sha256') .update(verifier) .digest('base64url'); const url = new URL('https://app.wink-pos.com/oauth/autorizar'); url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', process.env.WINK_CLIENT_ID); url.searchParams.set('redirect_uri', process.env.WINK_REDIRECT_URI); url.searchParams.set('scope', 'catalog.view sales.create'); url.searchParams.set('state', state); url.searchParams.set('code_challenge', challenge); url.searchParams.set('code_challenge_method', 'S256');
res.redirect(url.toString());});
app.get('/wink-pos/callback', async (req, res) => { const { code, state, error, error_description: description } = req.query;
if (error) { // error === 'access_denied' cuando el Dueño cancela: no es un bug tuyo. return res.render('wink-pos-error', { error, description }); }
const saved = req.session.oauth; if (!saved || state !== saved.state) { return res .status(400) .send('state no coincide: posible CSRF, reintenta la conexión'); }
const tokenRes = await fetch('https://api.wink-pos.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: process.env.WINK_CLIENT_ID, client_secret: process.env.WINK_CLIENT_SECRET, code: String(code), redirect_uri: process.env.WINK_REDIRECT_URI, code_verifier: saved.verifier, }), }); if (!tokenRes.ok) return res.status(502).send('no se pudo canjear el code'); const tokens = await tokenRes.json();
// Con el access_token recién recibido, averigua qué comercio se conectó. const businessRes = await fetch( 'https://api.wink-pos.com/public/v1/business', { headers: { Authorization: `Bearer ${tokens.access_token}` }, }, ); const business = await businessRes.json();
await saveEncryptedTokens(business.id, tokens); // tu función, nunca en texto plano
delete req.session.oauth; res.redirect('/conectado');});Las redirect_uri coinciden exactas
Sección titulada «Las redirect_uri coinciden exactas»Las redirect_uri deben coincidir exactas con lo registrado (ver Pide tu alta): mismo esquema (https://), mismo dominio, misma ruta, sin barra final de más si no la registraste con ella (https://tu-app.com/callback y https://tu-app.com/callback/ son distintas).
Probar sin código, con Postman
Sección titulada «Probar sin código, con Postman»Para probar el flujo completo sin escribir nada:
- En tu colección o request, pestaña Authorization → tipo OAuth 2.0.
- Configuración del token: Grant Type →
Authorization Code (With PKCE), Callback URL → activa Authorize using browser (Postman abre tu navegador para el login del Dueño en vez de capturar el callback con su propio servidor). - Auth URL:
https://app.wink-pos.com/oauth/autorizar. Access Token URL:https://api.wink-pos.com/oauth/token. Client ID y Client Secret: los de tu app. Scope: los permisos que vas a probar. Code Challenge Method:S256. - Client Authentication:
Send as Basic Auth HeaderoSend client credentials in body— Wink POS acepta ambos, pero usa el mismo método que vas a usar en tu servidor para probar lo real. - Registra
https://oauth.pstmn.io/v1/browser-callbackcomo una de lasredirect_uriválidas de tu app (es el callback que usa el modo “Authorize using browser” de Postman) — sin esto, Postman completa el login pero el redirect falla. - Get New Access Token: inicia sesión como el Dueño del comercio de pruebas y acepta los permisos. Postman canjea el code solo y te deja el
access_tokenlisto para usar en el resto de la colección.
Error típico: pegar el client_secret incompleto (se corta un carácter al copiarlo) da invalid_client al pedir el token, no un error de formato — si te pasa, vuelve a copiar el secreto completo desde donde lo guardaste al crear la app (se muestra una sola vez).
Scopes y permisos
Sección titulada «Scopes y permisos»Qué scope pedir para cada acción está en Permisos (scopes). Pide solo lo que tu app usa: la pantalla de consentimiento se lo muestra al Dueño agrupado por módulo, y pedir de más retrasa tu revisión.