Ir al contenido

Webhooks

Flashpi avisa a tus sistemas lo que pasó con tus comprobantes y certificados mediante webhooks: solicitudes HTTPS POST firmadas a las URL que registras. Siguen la especificación Standard Webhooks, así que cualquier librería de Standard Webhooks puede verificarlos, y la verificación también es corta de escribir a mano.

  • Registra un endpoint con POST /v1/webhook-endpoints (o en la consola, en Webhooks): una URL HTTPS pública y los tipos de evento que quiere recibir.
  • Antes de recibir eventos, el endpoint pasa una verificación (handshake): responde un desafío que Flashpi le envía.
  • Cada solicitud trae webhook-id, webhook-timestamp y webhook-signature. Verifica la firma con el secreto whsec_ del endpoint antes de confiar en el cuerpo.
  • Responde cualquier 2xx en menos de 10 segundos. Todo lo demás se reintenta con espera creciente durante 72 horas.
  • La entrega es al menos una vez y sin un orden garantizado: elimina duplicados con webhook-id.
  • Cada intento queda en el registro de entregas y se puede reenviar. GET /v1/events lista los mismos eventos, para ponerte al día después de una caída.

Administrar endpoints requiere una llave read_write con alcance account. Importa el ambiente de la llave: un endpoint creado con una llave de prueba solo recibe eventos de prueba, y uno creado con una llave de producción solo eventos de producción.

Ventana de terminal
curl https://api.flashpi.com.ec/v1/webhook-endpoints \
-H "Authorization: Bearer $FLASHPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://erp.example.com/webhooks/flashpi",
"description": "ERP production receiver",
"enabledEvents": ["invoice.authorized", "invoice.rejected", "certificate.blocked"],
"payloadMode": "full"
}'
Campo Notas
url Una URL HTTPS pública, sin credenciales ni fragmento, de hasta 2048 caracteres. Flashpi rechaza http://, localhost y otros nombres locales, y las direcciones privadas, de loopback o de enlace local (422 con el error de campo url_not_allowed). Lo vuelve a verificar en cada entrega, con cada dirección a la que resuelve el host.
enabledEvents Los tipos de evento que recibe el endpoint, o ["*"] para todos, incluidos los que se agreguen después.
payloadMode full (por defecto): el evento con una foto del recurso. thin: solo el ID y el tipo del evento. Consulta Contenido.
description Una etiqueta para ti, de hasta 500 caracteres.

La respuesta es 201 Created con el endpoint en estado pending_verification, y Flashpi inicia la verificación de inmediato. Lee su secreto de firma con GET /v1/webhook-endpoints/{endpointId}/secret:

{ "secret": "whsec_Zmxhc2hwaS1kb2NzLWV4YW1wbGUtc2VjcmV0LTMyLWI=", "previousSecretExpiresAt": null }
Operación Endpoint
Listar (del más nuevo al más antiguo), leer GET /v1/webhook-endpoints, GET /v1/webhook-endpoints/{endpointId}
Cambiar url, description, enabledEvents, payloadMode o disabled PATCH /v1/webhook-endpoints/{endpointId}
Eliminarlo junto con su registro de entregas (los eventos se quedan) DELETE /v1/webhook-endpoints/{endpointId}
Leer el secreto de firma GET /v1/webhook-endpoints/{endpointId}/secret
Rotar el secreto POST /v1/webhook-endpoints/{endpointId}/rotate-secret
Iniciar una nueva verificación POST /v1/webhook-endpoints/{endpointId}/verify
Registro de entregas, una entrega con todos sus intentos GET .../{endpointId}/deliveries, GET .../deliveries/{deliveryId}
Reenviar una entrega POST .../deliveries/{deliveryId}/replay

Un endpoint está pending_verification (todavía no recibe nada), active o disabled. Cambiar su URL lo devuelve a pending_verification con una nueva verificación. "disabled": true detiene sus entregas (las pendientes terminan con endpoint_disabled) y "disabled": false lo vuelve a habilitar. Los eventos que ocurren mientras un endpoint está pendiente o deshabilitado no se le encolan; quedan en GET /v1/events. Flashpi nunca deshabilita por su cuenta un endpoint que falla.

Cuando se crea un endpoint, cuando cambia su URL y cuando llamas a POST .../verify, Flashpi le envía una solicitud firmada (con los mismos encabezados que todo webhook) con este cuerpo:

{
"id": "019a1b2c-4f10-7a8b-9c0d-1e2f3a4b5c6d",
"type": "webhook_endpoint.verification",
"environment": "test",
"createdAt": "2026-10-16T17:20:00.000Z",
"data": {
"webhookEndpointId": "019a1b2c-3a00-7b11-8c22-3d44e55f6677",
"challenge": "lC3hyY0f2bXq7m9Zr8V1u4kN6pW5sT2oE0aJdGiHfQx"
}
}

Responde con cualquier 2xx cuyo cuerpo sea {"challenge": "<data.challenge>"}, o solo el desafío como texto plano. El endpoint pasa a active.

Si el intento falla, Flashpi vuelve a intentar después de 1 minuto, 5 minutos y 30 minutos. Después el endpoint se queda en pending_verification y su verification.lastError dice por qué: challenge_mismatch, unexpected_status, timeout, connection_failed, dns_resolution_failed, tls_error, address_not_allowed o invalid_url. Corrige el receptor y llama a POST /v1/webhook-endpoints/{endpointId}/verify.

Tipo data Cuándo
invoice.authorized, credit_note.authorized, debit_note.authorized, withholding.authorized, waybill.authorized, purchase_settlement.authorized Comprobante El SRI autorizó el comprobante.
invoice.rejected, credit_note.rejected, debit_note.rejected, withholding.rejected, waybill.rejected, purchase_settlement.rejected Comprobante El SRI lo rechazó; sriMessages dice por qué.
certificate.verified Certificado Se verificó que no está revocado: al aceptarlo, o cuando se verifica un estado pendiente.
certificate.pending Certificado No se pudo verificar su estado de revocación: firma hasta revocation.blockAt. Se genera de nuevo con cada recordatorio, los días 3 y 6.
certificate.blocked Certificado Dejó de firmar: sigue sin verificarse en blockAt, o su cadena ya no es confiable.
certificate.revoked Certificado Su entidad de certificación lo revocó; nunca vuelve a firmar.
certificate.reactivated Certificado Un certificado bloqueado se verificó como bueno y vuelve a firmar.
document.ride.generated, document.ride.failed Comprobante Se generó el RIDE, o no se pudo generar.
document.email.sent, document.email.delivered, document.email.bounced, document.email.complained, document.email.failed Comprobante El correo al comprador cambió de estado.
account.email_paused, account.email_resumed Estado de envío de correos Los correos de comprobantes de la cuenta en el ambiente del evento se pausaron (demasiados rebotes o quejas, o el equipo de Flashpi los pausó), o el equipo de Flashpi los reanudó. Mira Correos pausados.

El data de los eventos account.* es el estado de envío de correos de la cuenta en el ambiente del evento: status (active o paused), pausedAt, pauseReason (bounce_rate, complaint_rate o staff) y window, con los sent, hardBounces y complaints de los últimos 7 días. No tienen emisor, así que una llave de un emisor no los ve en GET /v1/events.

Los eventos certificate.* de un certificado que sirve a ambos ambientes (allModes) se generan una vez por ambiente, así que los reciben tus endpoints de pruebas y los de producción.

Un comprobante que termina failed todavía no genera un evento. Consulta Ciclo de vida del comprobante y Certificados.

El cuerpo es el evento, el mismo objeto que devuelve GET /v1/events/{eventId}:

{
"id": "01a14116-278e-7c99-b3ce-adb0dc1e7960",
"type": "invoice.authorized",
"environment": "test",
"createdAt": "2026-10-15T19:42:13.390Z",
"data": {
"id": "01a14116-1c50-73e7-8aab-349c93e541da",
"type": "invoice",
"environment": "test",
"status": "authorized",
"issuerId": "01a0f7fa-bd80-7d58-8d62-73a4adc597cb",
"establishment": "001",
"emissionPoint": "001",
"sequential": 4821,
"number": "001-001-000004821",
"accessKey": "1510202601099341137200110010010000048217014625315",
"issueDate": "2026-10-15",
"xmlVersion": "2.1.0",
"counterparty": { "id": "1790000000001", "name": "Supermercados La Pradera S.A." },
"totals": {
"subtotal": "450.00",
"discount": "0.00",
"taxes": [{ "type": "vat", "rate": "15", "taxableBase": "450.00", "amount": "67.50" }],
"tip": "0.00",
"total": "517.50"
},
"authorization": {
"number": "1510202601099341137200110010010000048217014625315",
"authorizedAt": "2026-10-15T19:42:13.000Z"
},
"ride": { "status": "pending", "template": null, "generatedAt": null },
"email": { "requested": true, "status": "queued", "deliveries": [], "warnings": [] },
"sriMessages": [
{
"code": "test_environment",
"message": "The document was processed in the SRI test environment and has no tax validity.",
"severity": "warning"
}
],
"failure": null,
"voidedAt": null,
"createdAt": "2026-10-15T19:42:10.512Z",
"updatedAt": "2026-10-15T19:42:13.204Z"
}
}
  • id es el ID del evento y el encabezado webhook-id: es el mismo en cada intento y reenvío.
  • environment es test o live.
  • data es el recurso tal como estaba cuando ocurrió el evento (un comprobante o un certificado), con textos en inglés. No es el estado actual: para eso consulta GET /v1/documents/{id}.

El modo thin ("payloadMode": "thin") envía solo el sobre del evento:

{
"id": "01a14116-278e-7c99-b3ce-adb0dc1e7960",
"type": "invoice.authorized",
"environment": "test",
"createdAt": "2026-10-15T19:42:13.390Z"
}

Obtén la foto con GET /v1/events/{eventId} y tu API key. El modo thin sirve a receptores que quieren que cada lectura de tus datos se autentique con una llave.

Encabezado Valor
webhook-id El ID del evento (UUIDv7). Elimina duplicados con él.
webhook-timestamp Tiempo Unix, en segundos, de este intento.
webhook-signature Una o más firmas v1,<base64> separadas por espacios: una por cada secreto válido (dos durante una rotación).
content-type application/json
user-agent Flashpi-Webhooks/1.0 (+https://flashpi.com.ec/docs/webhooks)

Flashpi no publica direcciones IP de origen fijas y no cifra los cuerpos más allá de HTTPS: la firma es lo que prueba que una solicitud viene de Flashpi.

  • Cualquier 2xx confirma la entrega. Responde rápido (el límite es 10 segundos) y haz el trabajo después, por ejemplo en una cola.
  • Las redirecciones nunca se siguen: un 3xx cuenta como fallo, igual que un 4xx, un 5xx, un tiempo de espera agotado o una conexión rechazada.
  • Un intento fallido se reintenta después de 5 segundos, 5 minutos, 30 minutos, 2 horas y 5 horas, y luego cada 10 horas, hasta 72 horas después del primer intento: máximo 13 intentos. Después la entrega queda failed.
  • Mientras un endpoint se verifica de nuevo (cambió su URL), sus entregas pendientes esperan.

GET /v1/webhook-endpoints/{endpointId}/deliveries lista las entregas de un endpoint, de la más nueva a la más antigua (filtros status, eventType, eventId), y la consola muestra el mismo registro. Una entrega está pending (hay un intento programado o en curso), succeeded o failed, con su attemptCount, nextAttemptAt, lastResponseStatus, lastError y retryUntil. GET .../deliveries/{deliveryId} agrega cada intento con su código de estado, duración y error. Los errores son unexpected_status (una respuesta distinta de 2xx), timeout, connection_failed, dns_resolution_failed, tls_error, address_not_allowed, invalid_url y, sin intento, endpoint_disabled y endpoint_not_verified.

POST /v1/webhook-endpoints/{endpointId}/deliveries/{deliveryId}/replay vuelve a enviar una vez, de inmediato, una entrega exitosa o fallida, con el mismo webhook-id y una nueva fecha y firma. Responde 409 con webhook_delivery_in_progress mientras la entrega sigue pendiente, y con webhook_endpoint_disabled o webhook_endpoint_not_verified cuando el endpoint no puede recibirla.

Verifica siempre antes de interpretar el cuerpo o actuar sobre él, con los bytes exactos que recibiste: un JSON interpretado y vuelto a serializar no coincide con la firma.

  1. Lee webhook-id, webhook-timestamp y webhook-signature. Rechaza la solicitud si falta alguno.
  2. Rechaza un webhook-timestamp que se aleje más de 5 minutos de tu reloj. Así evitas que alguien repita una solicitud antigua.
  3. Arma el contenido firmado: <webhook-id>.<webhook-timestamp>.<cuerpo sin procesar>.
  4. Decodifica el secreto: la parte después de whsec_, decodificada en base64, es la llave del HMAC.
  5. Calcula el HMAC-SHA256 del contenido firmado con esa llave, y codifícalo en base64.
  6. Separa webhook-signature por espacios; cada elemento es <versión>,<firma>. Acepta la solicitud si un elemento con versión v1 coincide, comparando en tiempo constante.

Luego elimina duplicados con webhook-id y responde 2xx.

Los SDK oficiales hacen esta verificación: unwrapWebhook en @flashpi/node para Node.js y TypeScript, unwrap_webhook en flashpi para Python, Flashpi::unwrapWebhook en flashpi/flashpi-php para PHP, new Webhooks().unwrap (o flashpi.webhooks().unwrap) en ec.com.flashpi:flashpi-java para Java y Kotlin, Webhooks.Unwrap en Flashpi para C# y .NET, flashpi.UnwrapWebhook en github.com/flashpi1/flashpi-go para Go y Flashpi::Webhooks.unwrap en la gema flashpi para Ruby (consulta SDKs). En cualquier lenguaje, con SDK o sin él, también sirven las librerías oficiales de Standard Webhooks: Flashpi sigue la especificación al pie de la letra, así que funcionan sin cambios y aceptan el secreto whsec_ tal como lo entrega Flashpi.

// npm install standardwebhooks
import { Webhook } from 'standardwebhooks'
const webhook = new Webhook(process.env.FLASHPI_WEBHOOK_SECRET ?? '') // whsec_...
// Throws when the signature or the timestamp is not valid; returns the parsed body.
const event = webhook.verify(rawBody, {
'webhook-id': request.headers['webhook-id'],
'webhook-timestamp': request.headers['webhook-timestamp'],
'webhook-signature': request.headers['webhook-signature'],
})

Cada ejemplo usa solo la librería estándar del lenguaje. Los nombres de los encabezados van en minúsculas; pasa el cuerpo sin procesar.

import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 5 * 60
/**
* secret: the whsec_ secret of the endpoint.
* headers: lowercase header names.
* rawBody: the exact body received, before JSON.parse.
*/
export function verifyFlashpiWebhook(
secret: string,
headers: Record<string, string | undefined>,
rawBody: string,
nowMs: number = Date.now(),
): boolean {
const id = headers['webhook-id']
const timestamp = headers['webhook-timestamp']
const signatures = headers['webhook-signature']
if (!id || !timestamp || !signatures || !/^\d+$/.test(timestamp)) {
return false
}
if (Math.abs(Math.floor(nowMs / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) {
return false
}
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest()
return signatures.split(' ').some((entry) => {
const [version, signature] = entry.split(',', 2)
if (version !== 'v1' || !signature) {
return false
}
const received = Buffer.from(signature, 'base64')
return received.length === expected.length && timingSafeEqual(received, expected)
})
}

Un receptor con Express que también responde la verificación (handshake):

import express from 'express'
const app = express()
// Keep the raw body: the signature covers the exact bytes.
app.post('/webhooks/flashpi', express.raw({ type: 'application/json' }), (request, response) => {
const rawBody = (request.body as Buffer).toString('utf8')
const headers = {
'webhook-id': request.header('webhook-id'),
'webhook-timestamp': request.header('webhook-timestamp'),
'webhook-signature': request.header('webhook-signature'),
}
if (!verifyFlashpiWebhook(process.env.FLASHPI_WEBHOOK_SECRET ?? '', headers, rawBody)) {
response.status(400).end()
return
}
const event = JSON.parse(rawBody) as { id: string; type: string; data?: { challenge?: string } }
if (event.type === 'webhook_endpoint.verification') {
response.json({ challenge: event.data?.challenge })
return
}
// Deduplicate on event.id, queue the work, and acknowledge at once.
response.status(204).end()
})

Comprueba tu implementación con estos valores (un secreto inventado). Con tu reloj en webhook-timestamp, la firma debe ser válida; cambia un byte del cuerpo y ya no debe serlo.

Campo Valor
secreto whsec_Zmxhc2hwaS1kb2NzLWV4YW1wbGUtc2VjcmV0LTMyLWI=
webhook-id 019a1b2c-3d4e-7f60-8a1b-2c3d4e5f6a7b
webhook-timestamp 1792152000
cuerpo {"id":"019a1b2c-3d4e-7f60-8a1b-2c3d4e5f6a7b","type":"invoice.authorized","environment":"test","createdAt":"2026-10-16T17:20:00.000Z"}
webhook-signature v1,HbWOpDPWCo3wtq41t9GVHUpg69M1zgvveoDJmSWkA8U=

POST /v1/webhook-endpoints/{endpointId}/rotate-secret con {"previousSecretExpiresInHours": 24} crea un secreto nuevo. El anterior sigue firmando junto al nuevo durante esas horas (24 por defecto, máximo 168; 0 lo descarta de inmediato), así que mientras tanto cada solicitud trae una firma por cada secreto:

  1. Rota el secreto y luego lee el nuevo con GET .../secret.
  2. Despliégalo en tu receptor. Tu verificación sigue pasando con cualquiera de los dos secretos, porque la solicitud trae ambas firmas.
  3. El secreto anterior deja de firmar en previousSecretExpiresAt.

Rotar de nuevo durante una superposición descarta de inmediato el secreto más antiguo. En la consola, leer o rotar un secreto pide el código de tu app de autenticación (o tu contraseña, si no tienes app de autenticación).

GET /v1/events lista los eventos del ambiente de tu llave, del más nuevo al más antiguo, con los filtros type, issuerId, documentId y certificateId y paginación por cursor. GET /v1/events/{eventId} lee uno. Cualquier llave puede leerlos, incluidas las read_only; una llave de un emisor solo ve los eventos de su emisor. Úsalos para ponerte al día después de una caída, o para leer la foto de una entrega thin.

  • Verifica primero, con el cuerpo sin procesar, y rechaza todo lo que falle.
  • Elimina duplicados con webhook-id: guarda los ID que procesaste, y confirma uno repetido sin volver a hacer el trabajo.
  • Confirma rápido: pon el evento en una cola, responde 2xx y procésalo después.
  • No dependas del orden: los eventos pueden llegar desordenados. Usa el status y el updatedAt de la foto, o consulta el comprobante, en lugar del orden de llegada.
  • Revisa el registro de entregas en la consola, y reenvía las entregas cuando tu receptor esté corregido.