Referencia
Límites, idempotencia, paginación y versiones
Cuántas llamadas por minuto acepta la API, cómo reintentar sin emitir dos veces con Idempotency-Key, cómo paginar los listados y cómo cuidamos la compatibilidad.
Actualizado el
Límites de uso
| API | Límite |
|---|---|
| API v1 | 120 llamadas por minuto por clave y 30 emisiones por minuto (los POST que crean documentos) |
| API de socios | 120 llamadas por minuto por token; 10 pedidos de token por minuto por IP y por client_id |
Conectar (/oauth/token, /oauth/revoke) |
30 llamadas por minuto por IP y 120 por client_id |
Cada respuesta trae X-RateLimit-Limit y X-RateLimit-Remaining. Al pasarte responde 429 con Retry-After (en
segundos): demasiadas_solicitudes o demasiadas_emisiones en la v1.
- Si emites en lote (por ejemplo, el cierre del día), reparte las emisiones: 30 por minuto es una cada dos segundos.
- Respeta
Retry-Aftery reintenta con la mismaIdempotency-Key. - Las consultas (RUC, DNI) también cuentan: guarda sus resultados un rato en tu sistema.
No emitir dos veces: Idempotency-Key
La red falla. Si tu sistema manda una factura, se corta la conexión y no sabes si llegó, reintentar a ciegas puede
emitirla dos veces. Para eso está la cabecera Idempotency-Key: manda en cada POST y PUT (y PATCH en la API
de socios) un valor único por operación, por ejemplo un UUID.
POST /api/v1/comprobantes HTTP/1.1
Authorization: Bearer sfp_live_…
Content-Type: application/json
Idempotency-Key: 3f1c2a1e-0b7d-4c55-9f3e-8a0c6d2b9e11
| Si reintentas… | Respuesta |
|---|---|
| Con la misma clave y el mismo cuerpo | La misma respuesta de la primera vez, con la cabecera Idempotent-Replayed: true. No se emite dos veces. |
| Con la misma clave y otro cuerpo | 409 idempotencia_distinta |
| Mientras la primera todavía corre | 409 idempotencia_en_proceso: reintenta en unos segundos |
| Después de un error nuestro (5xx) | La clave queda libre: puedes reintentar con la misma |
- La clave se recuerda 24 horas.
- Hasta 255 caracteres visibles. Un UUID nuevo por comprobante es lo más simple; también sirve algo tuyo y único, como
venta-<id de tu venta>. - Guárdala junto a tu venta antes de llamar: así, si tu sistema se cae en medio, al volver reintenta con la misma.
<?php
// Reintento seguro: la misma Idempotency-Key en cada intento.
function emitir(array $comprobante, string $idempotencia): array
{
for ($intento = 1; $intento <= 4; $intento++) {
$ch = curl_init('https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 90,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SFP_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: ' . $idempotencia,
],
CURLOPT_POSTFIELDS => json_encode($comprobante),
]);
$cuerpo = curl_exec($ch);
$estado = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$json = $cuerpo !== false ? json_decode($cuerpo, true) : null;
$enProceso = $estado === 409 && ($json['code'] ?? '') === 'idempotencia_en_proceso';
if ($json !== null && $estado < 500 && $estado !== 429 && !$enProceso) {
return $json; // 201, o un error que hay que corregir (4xx)
}
sleep(min(2 ** $intento, 30)); // red caída, 5xx, 429 o «en proceso»: espera y reintenta
}
throw new RuntimeException('No se pudo emitir: reintenta más tarde con la misma Idempotency-Key.');
}
// Reintento seguro: la misma Idempotency-Key en cada intento.
async function emitir(comprobante, idempotencia) {
for (let intento = 1; intento <= 4; intento++) {
try {
const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SFP_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencia,
},
body: JSON.stringify(comprobante),
signal: AbortSignal.timeout(90_000),
});
const json = await res.json().catch(() => null);
const enProceso = res.status === 409 && json?.code === 'idempotencia_en_proceso';
if (json && res.status < 500 && res.status !== 429 && !enProceso) return json; // 201, o un 4xx que hay que corregir
const espera = Number(res.headers.get('Retry-After')) || 2 ** intento;
await new Promise((r) => setTimeout(r, Math.min(espera, 30) * 1000));
} catch {
await new Promise((r) => setTimeout(r, Math.min(2 ** intento, 30) * 1000)); // red caída
}
}
throw new Error('No se pudo emitir: reintenta más tarde con la misma Idempotency-Key.');
}
import os, time, requests
def emitir(comprobante: dict, idempotencia: str) -> dict:
"""Reintento seguro: la misma Idempotency-Key en cada intento."""
for intento in range(1, 5):
try:
r = requests.post(
"https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes",
json=comprobante,
headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}", "Idempotency-Key": idempotencia},
timeout=90,
)
en_proceso = r.status_code == 409 and r.json().get("code") == "idempotencia_en_proceso"
if r.status_code < 500 and r.status_code != 429 and not en_proceso:
return r.json() # 201, o un 4xx que hay que corregir
espera = int(r.headers.get("Retry-After", 2 ** intento))
except requests.RequestException:
espera = 2 ** intento # red caída
time.sleep(min(espera, 30))
raise RuntimeError("No se pudo emitir: reintenta más tarde con la misma Idempotency-Key.")
Un 409
idempotencia_distintano se arregla reintentando: significa que usaste la misma clave para dos comprobantes distintos. Usa una nueva.
Paginación
Los listados (GET /comprobantes, /guias, /clientes, /productos, /stock, /webhooks/{id}/entregas y los de
socios) aceptan page y per_page (20 por defecto, hasta 100) y devuelven meta:
{
"success": true,
"data": [{ "id": 1520, "numero_completo": "B001-845" }],
"meta": { "page": 1, "per_page": 100, "total": 347, "last_page": 4 }
}
Recorre las páginas hasta last_page. Para sincronizar, filtra por fechas (desde, hasta) en vez de bajar todo.
curl "https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes?desde=2026-11-01&hasta=2026-11-04&estado=aceptado&per_page=100&page=2" \
-H "Authorization: Bearer $SFP_API_KEY"
Un usuario que no ve toda la empresa (por ejemplo, un vendedor) ve solo sus comprobantes, como en la web.
Versiones y compatibilidad
- La versión va en la ruta:
/api/v1. Dentro de la v1 solo agregamos: campos nuevos en las respuestas, endpoints nuevos, eventos nuevos y códigos de error nuevos. Tu integración debe ignorar los campos que no conoce. - Un cambio que rompa lo existente iría en una versión nueva (
/api/v2), con aviso y tiempo para migrar. - Los
codede error son estables: no cambian de significado. - Cada cambio queda en las novedades.
La API antigua
/api/documents, /api/dispatches y las demás rutas de antes siguen funcionando igual para quien ya las usa. No las
documentamos ni les agregamos funciones: todo lo nuevo está en la v1. Dentro de unos meses avisaremos de su retiro a
quien siga usándolas (lo sabemos por el registro de llamadas).