Saltar al contenido
Tarifa anual desde S/ 37 al mes + IGV. Pide tu demo sin costo. Reserva tu demo
Sistema Facturación Perú
Menú del portal

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-After y reintenta con la misma Idempotency-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_distinta no 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 code de 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).

Portal para desarrolladores · ¿Algo no está claro o te falta un ejemplo? Escríbenos y lo mejoramos.