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_webhookssi te pasas).GET /webhookslos 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=fallidolas lista (con el código de respuesta y el error) yPOST /webhooks/entregas/{id}/reenviarvuelve 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.