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

Webhooks

Recibe un aviso firmado cuando SUNAT acepta, observa o rechaza un comprobante. Eventos, verificación de la firma en PHP, Node.js y Python, reintentos y reenvíos.

Actualizado el

En vez de preguntar cada minuto «¿ya lo aceptó SUNAT?», registra una dirección de tu sistema y te avisamos cuando cambia el estado. Funciona para lo que emites por la API y para lo que la empresa emite desde la web.

Eventos

Evento Cuándo
comprobante.aceptado SUNAT aceptó un comprobante (también cada boleta cuando se procesa su resumen diario)
comprobante.observado SUNAT lo aceptó con observaciones
comprobante.rechazado SUNAT lo rechazó
comprobante.anulado Quedó anulado (baja o resumen de anulación)
guia.aceptada / guia.rechazada Respuesta de SUNAT a una guía de remisión
resumen.procesado SUNAT procesó un resumen diario o una comunicación de baja

La API de socios tiene sus propios eventos (empresa.lista, saldo.bajo, cuota.vencida…, ver API de socios) y «Conectar con Sistema Facturación Perú» avisa conexion.revocada (ver). Todos se firman igual.

Registra tu webhook

En Configuración › Sistema › Integraciones › API › Webhooks, o por la API con el alcance webhooks.gestionar:

curl -X POST https://pruebas.sistemafacturacionperu.com/api/v1/webhooks \
  -H "Authorization: Bearer $SFP_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: webhook-mi-sistema" \
  -d '{"url": "https://mi-sistema.pe/avisos-sfp", "eventos": ["comprobante.aceptado", "comprobante.rechazado"]}'
<?php
$ch = curl_init('https://pruebas.sistemafacturacionperu.com/api/v1/webhooks');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('SFP_API_KEY'),
        'Content-Type: application/json',
        'Idempotency-Key: webhook-mi-sistema',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'url' => 'https://mi-sistema.pe/avisos-sfp',
        'eventos' => ['comprobante.aceptado', 'comprobante.rechazado'],
    ]),
]);
$webhook = json_decode(curl_exec($ch), true)['data'];
// Guarda $webhook['secreto'] (whsec_…) en tu servidor: no se vuelve a mostrar.
const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/webhooks', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SFP_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'webhook-mi-sistema',
  },
  body: JSON.stringify({ url: 'https://mi-sistema.pe/avisos-sfp', eventos: ['comprobante.aceptado', 'comprobante.rechazado'] }),
});
const { data: webhook } = await res.json();
// Guarda webhook.secreto (whsec_…) en tu servidor: no se vuelve a mostrar.
r = requests.post(
    "https://pruebas.sistemafacturacionperu.com/api/v1/webhooks",
    json={"url": "https://mi-sistema.pe/avisos-sfp", "eventos": ["comprobante.aceptado", "comprobante.rechazado"]},
    headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}", "Idempotency-Key": "webhook-mi-sistema"},
    timeout=30,
)
webhook = r.json()["data"]  # guarda webhook["secreto"] (whsec_…): no se vuelve a mostrar
  • La dirección tiene que ser https y de un dominio público.
  • La respuesta trae el secreto whsec_… una sola vez. Con él verificas cada aviso.
  • Hasta 10 webhooks por empresa (422 limite_webhooks si te pasas). GET /webhooks los lista junto con los eventos disponibles; DELETE /webhooks/{id} borra uno.
  • Con una clave de pruebas, sus webhooks avisan solo de lo que emitió esa clave.

Qué te llega

Un POST con un JSON y tres cabeceras:

POST /avisos-sfp HTTP/1.1
Content-Type: application/json
X-SFP-Event: comprobante.aceptado
X-SFP-Delivery: 0b8f3c1e-2d4a-4f7e-9a61-6c5d2e1f0a9b
X-SFP-Signature: t=1762269309,v1=5f2c0b7e9d…
{
  "id": "0b8f3c1e-2d4a-4f7e-9a61-6c5d2e1f0a9b",
  "evento": "comprobante.aceptado",
  "creado": "2026-11-04T10:15:09-05:00",
  "data": {
    "tipo_documento": "comprobante",
    "id": 1520,
    "external_id": "6f1c7d0e-8f7e-4a1c-9d5b-2a2f8e1c9b10",
    "tipo": "01",
    "numero_completo": "F001-120",
    "fecha_emision": "2026-11-04",
    "total": 1180,
    "moneda": "PEN",
    "estado": { "codigo": "05", "clave": "aceptado", "descripcion": "Aceptado" }
  }
}

id es igual a X-SFP-Delivery: guárdalo y no proceses dos veces el mismo aviso (un reintento trae el mismo id).

Verifica la firma

X-SFP-Signature tiene la hora del envío (t, segundos Unix) y la firma (v1): un HMAC-SHA256, en hexadecimal, con tu secreto whsec_… sobre "<t>.<cuerpo crudo>". Calcula la firma sobre el cuerpo tal como llegó (no sobre el JSON ya decodificado), compárala en tiempo constante y rechaza los avisos con más de 5 minutos.

<?php
// PHP puro (o dentro de tu controlador de Laravel/Symfony con el cuerpo crudo).
$cuerpo = file_get_contents('php://input');
$cabecera = $_SERVER['HTTP_X_SFP_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $cabecera), $partes);   // ['t' => '…', 'v1' => '…']

$esperada = hash_hmac('sha256', ($partes['t'] ?? '') . '.' . $cuerpo, getenv('SFP_WEBHOOK_SECRET'));
if (!hash_equals($esperada, $partes['v1'] ?? '') || abs(time() - (int) ($partes['t'] ?? 0)) > 300) {
    http_response_code(400);
    exit;
}

$aviso = json_decode($cuerpo, true);
// Encola el trabajo y responde rápido: $aviso['id'], $aviso['evento'], $aviso['data']['external_id']…
http_response_code(200);
// Node.js con Express: lee el cuerpo crudo solo en esta ruta.
import crypto from 'node:crypto';
import express from 'express';

const app = express();
app.post('/avisos-sfp', express.raw({ type: 'application/json' }), (req, res) => {
  const partes = Object.fromEntries(String(req.get('X-SFP-Signature') || '').split(',').map((p) => p.split('=')));
  const esperada = crypto
    .createHmac('sha256', process.env.SFP_WEBHOOK_SECRET)
    .update(`${partes.t}.`)
    .update(req.body)
    .digest('hex');
  const ok =
    typeof partes.v1 === 'string' &&
    partes.v1.length === esperada.length &&
    crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(partes.v1)) &&
    Math.abs(Date.now() / 1000 - Number(partes.t)) <= 300;
  if (!ok) return res.sendStatus(400);

  const aviso = JSON.parse(req.body.toString('utf8'));
  // Encola el trabajo y responde rápido: aviso.id, aviso.evento, aviso.data.external_id…
  res.sendStatus(200);
});
# Python con Flask
import hashlib, hmac, json, os, time
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/avisos-sfp")
def avisos_sfp():
    cuerpo = request.get_data()  # bytes, tal como llegaron
    partes = dict(p.split("=", 1) for p in request.headers.get("X-SFP-Signature", "").split(",") if "=" in p)
    esperada = hmac.new(os.environ["SFP_WEBHOOK_SECRET"].encode(),
                        partes.get("t", "").encode() + b"." + cuerpo, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(esperada, partes.get("v1", "")) or abs(time.time() - int(partes.get("t", 0))) > 300:
        abort(400)

    aviso = json.loads(cuerpo)
    # Encola el trabajo y responde rápido: aviso["id"], aviso["evento"], aviso["data"]["external_id"]…
    return "", 200

Responde rápido y reintentamos

  • Responde 2xx en menos de 10 segundos. Si tienes trabajo pesado, encólalo y responde primero.
  • No seguimos redirecciones: un 301 o 302 cuenta como fallo.
  • Si no responde 2xx, reintentamos a 1 min, 5 min, 30 min, 2 h, 6 h y 24 h. Después la entrega queda fallida.
  • Las entregas se guardan 30 días. GET /webhooks/{id}/entregas?estado=fallido las lista (con el código de respuesta y el error) y POST /webhooks/entregas/{id}/reenviar vuelve a mandar una ya mismo. También puedes reenviar desde la pantalla.

Prueba en tu equipo

Para recibir avisos mientras desarrollas, expón tu servidor local con un túnel https (por ejemplo ngrok o cloudflared) y registra esa dirección en un webhook de tu clave de pruebas. Emite una factura en el modo pruebas y mira llegar comprobante.aceptado.

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