Socios y conexión
Conectar con Sistema Facturación Perú
Pon un botón en tu sistema para que tus clientes conecten su cuenta de Sistema Facturación Perú sin copiar claves: OAuth 2.0 con PKCE, ventana emergente y tokens.
Actualizado el
Si tu sistema (ERP, tienda, app) tiene clientes que ya usan Sistema Facturación Perú, ponles un botón «Conectar con Sistema Facturación Perú». El cliente escribe su RUC, entra con su usuario de siempre y pulsa «Permitir»: tú recibes un token para emitir con su cuenta, sin pedirle claves de API y sin que su contraseña pase por tu sistema.
- Solo conecta una cuenta que ya existe: nunca crea empresas ni usuarios. Para dar de alta empresas está la API de socios.
- Es gratis para tu sistema. La empresa paga su plan de siempre.
- Es OAuth 2.0 con authorization code y PKCE S256 obligatorio (RFC 6749, 7636, 7009 y 9207).
- Tu servidor genera
stateycode_verifier, los guarda en la sesión del usuario y arma la URL de/conectar. - Tu botón abre esa URL en una ventana emergente, con nuestra dirección visible.
- El usuario escribe el RUC de su empresa, entra con su correo y contraseña en el subdominio de su empresa (con su segundo factor, si lo tiene) y pulsa «Permitir».
- Volvemos a tu
redirect_uriconcode,stateeiss. - Tu servidor comprueba el
statey canjea el código enPOST /oauth/token. - Emites en la API v1 de esa empresa con el token de acceso, y lo renuevas cada hora.
1. Registra tu aplicación
Escríbenos con el nombre y el logo de tu sistema, tu web, un correo de soporte, tus direcciones de retorno
(redirect_uri exactas: solo https://, o http://localhost y http://127.0.0.1 para desarrollar) y los permisos que
vas a pedir. Te damos:
client_id(sfp_app_…), que es público;client_secret(sfp_secret_…), que se muestra una sola vez: guárdalo en tu servidor, nunca en el navegador;- si nos das una URL de webhook, su secreto
whsec_…para verificar el avisoconexion.revocada.
Sin ese registro no hay botón: así nadie se hace pasar por tu aplicación.
2. El botón y la ventana emergente
No metas nuestro inicio de sesión dentro de un <iframe>: el navegador bloquea las cookies, tu usuario no puede ver que
escribe su contraseña en nuestro sitio y nuestras pantallas responden X-Frame-Options: DENY. Abre una ventana
emergente (o redirige la página).
<button id="sfp-conectar" type="button">Conectar con Sistema Facturación Perú</button>
<script>
// 1. Tu servidor genera state y code_verifier, los guarda en la sesión y devuelve la URL:
// GET /sfp/conectar-url → { "url": "https://api.sistemafacturacionperu.com/conectar?..." }
document.getElementById('sfp-conectar').addEventListener('click', async () => {
const { url } = await (await fetch('/sfp/conectar-url', { credentials: 'same-origin' })).json();
const w = 520, h = 680;
const popup = window.open(url, 'sfp-conectar',
`width=${w},height=${h},left=${(screen.width - w) / 2},top=${(screen.height - h) / 2}`);
if (!popup) { window.location.href = url; return; } // Si bloquean la ventana, redirige.
// 3. Tu página de vuelta (redirect_uri) termina el canje en tu servidor y avisa con postMessage.
window.addEventListener('message', (e) => {
if (e.origin !== window.location.origin || e.data?.tipo !== 'sfp-conectado') return;
popup.close();
location.reload(); // Ya está conectado: muestra «Conectado · RUC …».
});
});
</script>
<!-- En tu redirect_uri (https://tusistema.com/sfp/vuelta), después de canjear el código en el servidor: -->
<script>
if (window.opener) { window.opener.postMessage({ tipo: 'sfp-conectado' }, window.location.origin); window.close(); }
else { window.location.href = '/configuracion/facturacion'; }
</script>
La URL de la ventana:
https://api.sistemafacturacionperu.com/conectar
?response_type=code
&client_id=sfp_app_…
&redirect_uri=https%3A%2F%2Ftusistema.com%2Fsfp%2Fvuelta
&state=<al azar, guardado en la sesión>
&code_challenge=<BASE64URL(SHA256(code_verifier))>
&code_challenge_method=S256
&scope=comprobantes.emitir%20comprobantes.leer%20consultas
Así generas el code_verifier, el code_challenge y el state:
import { randomBytes, createHash } from 'node:crypto';
const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const codeVerifier = b64url(randomBytes(48)); // 64 caracteres
const codeChallenge = b64url(createHash('sha256').update(codeVerifier).digest());
const state = b64url(randomBytes(24));
// Guarda codeVerifier y state en la sesión del usuario.
<?php
$b64url = fn (string $bin) => rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
$codeVerifier = $b64url(random_bytes(48));
$codeChallenge = $b64url(hash('sha256', $codeVerifier, true));
$state = $b64url(random_bytes(24));
$_SESSION['sfp'] = ['verifier' => $codeVerifier, 'state' => $state];
$url = 'https://api.sistemafacturacionperu.com/conectar?' . http_build_query([
'response_type' => 'code',
'client_id' => getenv('SFP_APP_CLIENT_ID'),
'redirect_uri' => 'https://tusistema.com/sfp/vuelta',
'state' => $state,
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
'scope' => 'comprobantes.emitir comprobantes.leer consultas',
], '', '&', PHP_QUERY_RFC3986);
import base64, hashlib, secrets
from urllib.parse import urlencode
b64url = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
code_verifier = b64url(secrets.token_bytes(48))
code_challenge = b64url(hashlib.sha256(code_verifier.encode()).digest())
state = b64url(secrets.token_bytes(24))
# Guarda code_verifier y state en la sesión del usuario.
url = "https://api.sistemafacturacionperu.com/conectar?" + urlencode({
"response_type": "code",
"client_id": os.environ["SFP_APP_CLIENT_ID"],
"redirect_uri": "https://tusistema.com/sfp/vuelta",
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"scope": "comprobantes.emitir comprobantes.leer consultas",
})
- Si el
client_ido laredirect_urino están registrados, mostramos el error en la pantalla y nunca redirigimos. - Con la dirección ya válida, los errores vuelven a tu
redirect_uri:?error=invalid_request|invalid_scope|access_denied…&error_description=…&state=…&iss=https://api.sistemafacturacionperu.com. - Si todo va bien:
?code=sfp_oac_…&state=…&iss=https://api.sistemafacturacionperu.com. Comprueba questatees el tuyo antes de canjear.
3. Canjea el código (desde tu servidor)
El código vale 5 minutos y un solo uso: si se canjea dos veces, revocamos todo lo emitido con él.
curl -s https://api.sistemafacturacionperu.com/oauth/token \
-u "$SFP_APP_CLIENT_ID:$SFP_APP_CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code=sfp_oac_… \
-d redirect_uri=https://tusistema.com/sfp/vuelta \
-d code_verifier=EL_QUE_GUARDASTE
<?php
if (!hash_equals($_SESSION['sfp']['state'] ?? '', $_GET['state'] ?? '')) {
http_response_code(400);
exit('state no coincide');
}
$ch = curl_init('https://api.sistemafacturacionperu.com/oauth/token');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERPWD => getenv('SFP_APP_CLIENT_ID') . ':' . getenv('SFP_APP_CLIENT_SECRET'),
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'authorization_code',
'code' => $_GET['code'],
'redirect_uri' => 'https://tusistema.com/sfp/vuelta',
'code_verifier' => $_SESSION['sfp']['verifier'],
]),
]);
$tokens = json_decode(curl_exec($ch), true);
// Guarda $tokens['refresh_token'], $tokens['empresa']['api_url'] y $tokens['conexion_id'] para esa empresa.
const basic = Buffer.from(`${process.env.SFP_APP_CLIENT_ID}:${process.env.SFP_APP_CLIENT_SECRET}`).toString('base64');
const res = await fetch('https://api.sistemafacturacionperu.com/oauth/token', {
method: 'POST',
headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: req.query.code,
redirect_uri: 'https://tusistema.com/sfp/vuelta',
code_verifier: req.session.sfp.verifier,
}),
});
const tokens = await res.json();
// Guarda tokens.refresh_token, tokens.empresa.api_url y tokens.conexion_id para esa empresa.
r = requests.post(
"https://api.sistemafacturacionperu.com/oauth/token",
auth=(os.environ["SFP_APP_CLIENT_ID"], os.environ["SFP_APP_CLIENT_SECRET"]),
data={
"grant_type": "authorization_code",
"code": request.args["code"],
"redirect_uri": "https://tusistema.com/sfp/vuelta",
"code_verifier": session["sfp"]["verifier"],
},
timeout=30,
)
tokens = r.json()
{
"access_token": "sfp_oat_…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "sfp_ort_…",
"scope": "comprobantes.emitir comprobantes.leer consultas",
"conexion_id": 42,
"empresa": {
"ruc": "20601030013",
"razon_social": "EMPRESA CONECTADA S.A.C.",
"api_url": "https://miempresa.sistemafacturacionperu.com/api/v1"
}
}
Las credenciales van por Authorization: Basic o como client_id y client_secret en el cuerpo (no las dos).
Guarda empresa.api_url: el token solo vale en la API de esa empresa.
curl -s https://miempresa.sistemafacturacionperu.com/api/v1/yo -H "Authorization: Bearer sfp_oat_…"
4. Renueva y revoca
El token de acceso dura 1 hora. Renuévalo con el de renovación: cada uso te da uno nuevo y el anterior deja de valer. Si alguien reusa uno ya usado, revocamos toda la cadena y la empresa tiene que volver a pulsar «Permitir»: guarda siempre el último.
curl -s https://api.sistemafacturacionperu.com/oauth/token -u "$SFP_APP_CLIENT_ID:$SFP_APP_CLIENT_SECRET" \
-d grant_type=refresh_token -d refresh_token=sfp_ort_…
POST /oauth/revoke(RFC 7009) contoken=(ytoken_type_hintsi quieres): un token de acceso deja de servir; un token de renovación desconecta la empresa (úsalo cuando el usuario quite la conexión en tu sistema). Siempre responde 200.- Límites: 30 llamadas por minuto por IP y 120 por
client_id(429temporarily_unavailableconRetry-After).
5. Cuando la empresa desconecta
En Configuración › Sistema › Integraciones › Aplicaciones conectadas la empresa ve tu aplicación, quién la
autorizó, cuándo, sus permisos, su local y el último uso, y puede Desconectar. Al instante tus tokens responden 401
conexion_revocada y, si registraste un webhook, te llega conexion.revocada firmado como los
webhooks:
{
"id": "7b0e3c8a-5d1f-4f0e-9a51-2a8c7c9e1f30",
"evento": "conexion.revocada",
"creado": "2026-12-26T10:15:00-05:00",
"data": {
"conexion_id": 42,
"empresa": { "ruc": "20601030013", "razon_social": "EMPRESA CONECTADA S.A.C." },
"motivo": "empresa",
"revocada": "2026-12-26T10:15:00-05:00"
}
}
La conexión es de la empresa: si quien autorizó cambia su contraseña o deja la empresa, sigue funcionando.
Quién puede dar permiso
El administrador (titular) de la empresa y quien tenga la función «Gestionar tokens de API». Un vendedor o un cajero sin
esa función ve «Tu usuario no puede conectar sistemas». Ni nuestro soporte ni el portal del contador pueden autorizar en
nombre de la empresa. Con la cuenta suspendida o el pago vencido, la empresa no puede conectar y la API responde 423
cuenta_bloqueada.
Plan Básico frente a PRO
| Básico | PRO y Empresa | |
|---|---|---|
| Conectar | Sí, solo para emitir | Sí, con todos los permisos |
| Qué puede hacer tu sistema | Emitir facturas, boletas, notas y guías (comprobantes.emitir, guias.emitir); ver su estado y bajar PDF, XML y CDR (comprobantes.leer, guias.leer); anular (comprobantes.anular); consultar RUC y DNI (consultas) |
Además: clientes, productos, stock, cotizaciones, notas de venta, retenciones, percepciones, liquidaciones, cobros y webhooks |
| Comprobantes al mes | Los 300 del Básico, contados como en la web (422 limite_plan al pasarte) |
Sin límite |
| Claves de API propias y modo pruebas | No (403 plan_sin_api) |
Sí |
- Puedes pedir todos los permisos: en la pantalla «Permitir», un Básico ve tachados los que su plan no cubre, con
«Incluido desde PRO», y el
scopede la respuesta trae solo los que sirven hoy. - Se revisa en cada llamada con el plan de ese momento: si la empresa baja a Básico, lo que no es de emitir responde
403
plan_insuficiente(errors.plan: ["PRO"]); si sube a PRO, funciona sin volver a conectar. Para pedir permisos nuevos, el usuario vuelve a pasar por «Permitir» (se actualiza la misma conexión).
Para desarrollar, registra también una
redirect_uridehttp://localhost. Recuerda que la conexión es siempre con una cuenta real: lo que emitas con ese token sigue la configuración de esa empresa (si está en el entorno «Demo», va a la beta de SUNAT; si está en «Producción», es válido ante SUNAT).
Los errores del servidor de autorización están en el catálogo de errores.