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.
En resumen
Sección titulada «En resumen»- 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-timestampywebhook-signature. Verifica la firma con el secretowhsec_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/eventslista los mismos eventos, para ponerte al día después de una caída.
Registrar un endpoint
Sección titulada «Registrar un endpoint»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.
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.
Verificación (handshake)
Sección titulada «Verificación (handshake)»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.
Tipos de evento
Sección titulada «Tipos de evento»| 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.
Contenido
Sección titulada «Contenido»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" }}ides el ID del evento y el encabezadowebhook-id: es el mismo en cada intento y reenvío.environmentestestolive.dataes 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 consultaGET /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.
Encabezados
Sección titulada «Encabezados»| 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.
Respuestas y reintentos
Sección titulada «Respuestas y reintentos»- 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.
Registro de entregas y reenvíos
Sección titulada «Registro de entregas y reenvíos»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.
Verificar firmas
Sección titulada «Verificar firmas»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.
- Lee
webhook-id,webhook-timestampywebhook-signature. Rechaza la solicitud si falta alguno. - Rechaza un
webhook-timestampque se aleje más de 5 minutos de tu reloj. Así evitas que alguien repita una solicitud antigua. - Arma el contenido firmado:
<webhook-id>.<webhook-timestamp>.<cuerpo sin procesar>. - Decodifica el secreto: la parte después de
whsec_, decodificada en base64, es la llave del HMAC. - Calcula el HMAC-SHA256 del contenido firmado con esa llave, y codifícalo en base64.
- Separa
webhook-signaturepor espacios; cada elemento es<versión>,<firma>. Acepta la solicitud si un elemento con versiónv1coincide, comparando en tiempo constante.
Luego elimina duplicados con webhook-id y responde 2xx.
Con una librería
Sección titulada «Con una librería»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 standardwebhooksimport { 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'],})# pip install standardwebhooksimport os
from standardwebhooks.webhooks import Webhook
webhook = Webhook(os.environ["FLASHPI_WEBHOOK_SECRET"]) # whsec_...# Raises WebhookVerificationError when the signature or the timestamp is not valid.event = webhook.verify(raw_body, dict(request.headers))<?php// composer require standard-webhooks/standard-webhooks$webhook = new \StandardWebhooks\Webhook(getenv('FLASHPI_WEBHOOK_SECRET')); // whsec_...// The library reads lowercase header names.$headers = array_change_key_case(getallheaders(), CASE_LOWER);// Throws WebhookVerificationException when the signature or the timestamp is not valid;// returns the decoded body.$event = $webhook->verify(file_get_contents('php://input'), $headers);// Maven: com.standardwebhooks:standardwebhooksimport com.standardwebhooks.Webhook;import java.util.List;import java.util.Map;
Webhook webhook = new Webhook(System.getenv("FLASHPI_WEBHOOK_SECRET")); // whsec_...// Throws WebhookVerificationException when the signature or the timestamp is not valid.webhook.verify(rawBody, Map.of( "webhook-id", List.of(webhookId), "webhook-timestamp", List.of(webhookTimestamp), "webhook-signature", List.of(webhookSignature)));// The official .NET library (namespace StandardWebhooks).using System.Net;using StandardWebhooks;
var webhook = new Webhook(Environment.GetEnvironmentVariable("FLASHPI_WEBHOOK_SECRET")); // whsec_...var headers = new WebHeaderCollection{ { "webhook-id", webhookId }, { "webhook-timestamp", webhookTimestamp }, { "webhook-signature", webhookSignature },};// Throws WebhookVerificationException when the signature or the timestamp is not valid.webhook.Verify(rawBody, headers);// go get github.com/standard-webhooks/standard-webhooks/libraries/goimport standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
webhook, err := standardwebhooks.NewWebhook(os.Getenv("FLASHPI_WEBHOOK_SECRET")) // whsec_...if err != nil { log.Fatal(err)}// Returns an error when the signature or the timestamp is not valid.err = webhook.Verify(rawBody, request.Header)Sin una librería
Sección titulada «Sin una librería»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()})import base64import hashlibimport hmacimport time
TOLERANCE_SECONDS = 5 * 60
def verify_flashpi_webhook(secret, headers, raw_body, now=None): """secret: the whsec_ secret. headers: lowercase names. raw_body: the exact bytes received.""" msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not msg_id or not timestamp or not signatures or not timestamp.isdigit(): return False now = time.time() if now is None else now if abs(int(now) - int(timestamp)) > TOLERANCE_SECONDS: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() for entry in signatures.split(" "): version, _, signature = entry.partition(",") if version == "v1" and hmac.compare_digest(signature, expected): return True return FalseCon Flask, request.get_data() es el cuerpo sin procesar y request.headers no distingue mayúsculas: pasa {name: request.headers.get(name) for name in ("webhook-id", "webhook-timestamp", "webhook-signature")}.
<?php
const FLASHPI_TOLERANCE_SECONDS = 300;
/** $secret: the whsec_ secret. $rawBody: the exact body received. */function verifyFlashpiWebhook(string $secret, array $headers, string $rawBody, ?int $now = null): bool{ $headers = array_change_key_case($headers, CASE_LOWER); $id = $headers['webhook-id'] ?? null; $timestamp = $headers['webhook-timestamp'] ?? null; $signatures = $headers['webhook-signature'] ?? null; if ($id === null || $timestamp === null || $signatures === null || !ctype_digit($timestamp)) { return false; } $now ??= time(); if (abs($now - (int) $timestamp) > FLASHPI_TOLERANCE_SECONDS) { return false; } $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true); if ($key === false) { return false; } $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true)); foreach (explode(' ', $signatures) as $entry) { [$version, $signature] = array_pad(explode(',', $entry, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $signature)) { return true; } } return false;}
// Plain PHP: the raw body and the request headers.$valid = verifyFlashpiWebhook(getenv('FLASHPI_WEBHOOK_SECRET'), getallheaders(), file_get_contents('php://input'));import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.util.Base64;import java.util.Map;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
public final class FlashpiWebhooks { private static final long TOLERANCE_SECONDS = 300;
private FlashpiWebhooks() {}
/** * secret: the whsec_ secret. headers: lowercase names. rawBody: the exact bytes received. */ public static boolean verify(String secret, Map<String, String> headers, byte[] rawBody, long nowSeconds) throws Exception { String id = headers.get("webhook-id"); String timestamp = headers.get("webhook-timestamp"); String signatures = headers.get("webhook-signature"); if (id == null || timestamp == null || signatures == null || !timestamp.matches("\\d{1,12}")) { return false; } if (Math.abs(nowSeconds - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) { return false; } byte[] key = Base64.getDecoder().decode(secret.startsWith("whsec_") ? secret.substring(6) : secret); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key, "HmacSHA256")); mac.update((id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8)); byte[] expected = mac.doFinal(rawBody); for (String entry : signatures.split(" ")) { String[] parts = entry.split(",", 2); if (parts.length != 2 || !parts[0].equals("v1")) { continue; } try { if (MessageDigest.isEqual(Base64.getDecoder().decode(parts[1]), expected)) { return true; } } catch (IllegalArgumentException notBase64) { // Not a signature of ours: try the next one. } } return false; }}Llámala con Instant.now().getEpochSecond() como nowSeconds.
using System;using System.Collections.Generic;using System.Security.Cryptography;using System.Text;
public static class FlashpiWebhooks{ private const long ToleranceSeconds = 300;
// secret: the whsec_ secret. headers: build the dictionary with // StringComparer.OrdinalIgnoreCase. rawBody: the exact bytes received. public static bool Verify( string secret, IReadOnlyDictionary<string, string> headers, byte[] rawBody, DateTimeOffset now) { if (!headers.TryGetValue("webhook-id", out var id) || !headers.TryGetValue("webhook-timestamp", out var timestamp) || !headers.TryGetValue("webhook-signature", out var signatures) || !long.TryParse(timestamp, out var seconds)) { return false; } if (Math.Abs(now.ToUnixTimeSeconds() - seconds) > ToleranceSeconds) { return false; } var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret[6..] : secret); var prefix = Encoding.UTF8.GetBytes($"{id}.{timestamp}."); var signed = new byte[prefix.Length + rawBody.Length]; prefix.CopyTo(signed, 0); rawBody.CopyTo(signed, prefix.Length); var expected = HMACSHA256.HashData(key, signed); foreach (var entry in signatures.Split(' ')) { var parts = entry.Split(',', 2); if (parts.Length != 2 || parts[0] != "v1") { continue; } var received = new byte[expected.Length]; if (Convert.TryFromBase64String(parts[1], received, out var written) && written == expected.Length && CryptographicOperations.FixedTimeEquals(received, expected)) { return true; } } return false; }}Llámala con DateTimeOffset.UtcNow (.NET 6 o posterior, por HMACSHA256.HashData).
package flashpi
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "net/http" "strconv" "strings" "time")
const toleranceSeconds = 300
// VerifyWebhook checks a Flashpi webhook. secret is the whsec_ secret; rawBody must be the// exact bytes received.func VerifyWebhook(secret string, header http.Header, rawBody []byte, now time.Time) bool { id := header.Get("webhook-id") timestamp := header.Get("webhook-timestamp") signatures := header.Get("webhook-signature") if id == "" || timestamp == "" || signatures == "" { return false } seconds, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } if diff := now.Unix() - seconds; diff > toleranceSeconds || diff < -toleranceSeconds { return false } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + timestamp + ".")) mac.Write(rawBody) expected := mac.Sum(nil) for _, entry := range strings.Split(signatures, " ") { version, signature, found := strings.Cut(entry, ",") if !found || version != "v1" { continue } received, err := base64.StdEncoding.DecodeString(signature) if err == nil && hmac.Equal(received, expected) { return true } } return false}Llámala con time.Now(); lee el cuerpo con io.ReadAll(request.Body) antes de decodificarlo.
Vector de prueba
Sección titulada «Vector de prueba»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= |
Rotación del secreto
Sección titulada «Rotación del secreto»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:
- Rota el secreto y luego lee el nuevo con
GET .../secret. - Despliégalo en tu receptor. Tu verificación sigue pasando con cualquiera de los dos secretos, porque la solicitud trae ambas firmas.
- 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).
API de eventos
Sección titulada «API de eventos»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.
Un receptor robusto
Sección titulada «Un receptor robusto»- 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
2xxy procésalo después. - No dependas del orden: los eventos pueden llegar desordenados. Usa el
statusy elupdatedAtde 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.