SDKs
Los SDK oficiales envuelven la API de Flashpi con tipos generados del documento OpenAPI, reintentos seguros con Idempotency-Key, errores tipados (RFC 9457), paginación y verificación de firmas de webhooks. Hay siete: @flashpi/node para Node.js y TypeScript, flashpi para Python, flashpi/flashpi-php para PHP, ec.com.flashpi:flashpi-java para Java y Kotlin, Flashpi para C# y .NET, github.com/flashpi1/flashpi-go para Go y flashpi para Ruby. No son obligatorios: las guías usan HTTP simple, que funciona desde cualquier lenguaje, incluidos los sistemas de escritorio en Visual FoxPro y Delphi. En un lenguaje sin SDK, verifica los webhooks con cualquier librería de Standard Webhooks o a mano (consulta Webhooks).
Cada sección muestra los siete SDK en pestañas. Elige tu lenguaje en cualquiera de ellas y toda la página lo sigue.
Instalación
Sección titulada «Instalación»@flashpi/node es el SDK oficial para Node.js y TypeScript. Su código está en GitHub, con licencia MIT.
- Cubre todas las operaciones de la API: los seis comprobantes, la consulta de comprobantes (XML, RIDE y correo), los emisores (incluida su creación desde el .p12) con sus establecimientos, puntos de emisión y certificados, los endpoints de webhooks, los eventos y el estado de la API.
- Funciona en Node.js 22 o superior, Bun, Deno y Cloudflare Workers, con
importorequire, y no tiene dependencias. - Corre solo en el servidor: tu API key es secreta, así que el SDK se niega a correr en un navegador.
npm install @flashpi/nodeTambién con pnpm add @flashpi/node, yarn add @flashpi/node o bun add @flashpi/node.
flashpi es el SDK oficial para Python. Su código está en GitHub, con licencia MIT.
- Cubre todas las operaciones de la API: los seis comprobantes, la consulta de comprobantes (XML, RIDE y correo), los emisores (incluida su creación desde el .p12) con sus establecimientos, puntos de emisión y certificados, los endpoints de webhooks, los eventos y el estado de la API.
- Tiene dos clientes con los mismos recursos y métodos:
Flashpi, síncrono, yAsyncFlashpi, para asyncio. - Funciona en Python 3.11 o superior, y su única dependencia es httpx. Sus tipos son
TypedDictgenerados del documento OpenAPI de la API. - Úsalo solo en tus servidores: tu API key es secreta.
pip install flashpiTambién con uv add flashpi o poetry add flashpi.
flashpi/flashpi-php es el SDK oficial para PHP. Su código está en GitHub, con licencia MIT.
- Cubre todas las operaciones de la API: los seis comprobantes, la consulta de comprobantes (XML, RIDE y correo), los emisores (incluida su creación desde el .p12) con sus establecimientos, puntos de emisión y certificados, los endpoints de webhooks, los eventos y el estado de la API.
- Funciona en PHP 8.2 o superior, con Laravel, Symfony o sin framework. Su única dependencia es Guzzle, 7.15.2 o superior u 8.3 o superior, así que convive con Laravel 11, 12 y 13.
- Las solicitudes y las respuestas son arrays, tipados con formas de arrays de PHPStan generadas del documento OpenAPI de la API.
- Úsalo solo en tus servidores: tu API key es secreta. En producción, instala la extensión
curlde PHP, con la que el tiempo límite de cada intento cubre toda la respuesta.
composer require flashpi/flashpi-phpec.com.flashpi:flashpi-java es el SDK oficial para Java y Kotlin, en Maven Central. Su código está en GitHub, con licencia MIT.
- Cubre todas las operaciones de la API: los seis comprobantes, la consulta de comprobantes (XML, RIDE y correo), los emisores (incluida su creación desde el .p12) con sus establecimientos, puntos de emisión y certificados, los endpoints de webhooks, los eventos y el estado de la API.
- Sus modelos se generan del documento OpenAPI de la API, con builders (
CreateInvoice.builder()), getters tipados, enums y fechas dejava.time, así que tu IDE conoce cada campo. Desde Kotlin se usa igual. - Funciona en Java 11 o superior, con el
HttpClientdel JDK. Su única dependencia es Jackson 2 (jackson-databind2.13 o superior), que Spring Boot 2.7 y 3 ya traen; con Spring Boot 4, que usa Jackson 3, el SDK trae su propio Jackson 2, que convive con él. - Úsalo solo en tus servidores: tu API key es secreta, así que nunca va en una app Android ni en código que corre en los dispositivos de tus usuarios.
Con Maven, en tu pom.xml:
<dependency> <groupId>ec.com.flashpi</groupId> <artifactId>flashpi-java</artifactId> <version>0.1.0</version></dependency>Con Gradle, en build.gradle.kts (Kotlin DSL):
dependencies { implementation("ec.com.flashpi:flashpi-java:0.1.0")}O en build.gradle (Groovy):
dependencies { implementation 'ec.com.flashpi:flashpi-java:0.1.0'}Flashpi es el SDK oficial para C# y .NET. Su código y su README están en GitHub, con licencia MIT.
- Cubre todas las operaciones de la API: los seis comprobantes, la consulta de comprobantes (XML, RIDE y correo), los emisores (incluida su creación desde el .p12) con sus establecimientos, puntos de emisión y certificados, los endpoints de webhooks, los eventos y el estado de la API.
- Sus métodos son asíncronos (
Task, con unCancellationTokenopcional al final) y sus modelos sonrecordgenerados del documento OpenAPI de la API, con propiedades tipadas: el compilador te pide los campos obligatorios. - Funciona en .NET 10 sin dependencias, y en .NET 8 y 9 y .NET Framework 4.6.2 o superior con System.Text.Json. En los proyectos de .NET Framework y las bibliotecas
netstandard2.0, usa C# 11 o superior (<LangVersion>latest</LangVersion>), porque los modelos tienen propiedadesrequired. - Úsalo solo en tus servidores: tu API key es secreta.
dotnet add package FlashpiTambién desde el administrador de paquetes NuGet de Visual Studio o Rider, o con Install-Package Flashpi en la consola del administrador de paquetes.
github.com/flashpi1/flashpi-go es el SDK oficial para Go. Su código está en GitHub, con licencia MIT, y su README documenta cada operación.
- Cubre todas las operaciones de la API: los seis comprobantes, la consulta de comprobantes (XML, RIDE y correo), los emisores (incluida su creación desde el .p12) con sus establecimientos, puntos de emisión y certificados, los endpoints de webhooks, los eventos y el estado de la API.
- Sigue las convenciones de Go: un
context.Contexten cada llamada, listas que recorres conrangey errores que revisas conerrors.As. Un mismo cliente sirve a varias goroutines a la vez. - Funciona en Go 1.26 o superior y no tiene dependencias: solo la biblioteca estándar. Sus structs se generan del documento OpenAPI de la API.
- Úsalo solo en tus servidores: tu API key es secreta.
go get github.com/flashpi1/flashpi-goEl paquete se llama flashpi: lo importas con import "github.com/flashpi1/flashpi-go" y lo usas como flashpi.NewClient().
flashpi es el SDK oficial para Ruby, una gema de RubyGems. Su código está en GitHub, con licencia MIT.
- Cubre todas las operaciones de la API: los seis comprobantes, la consulta de comprobantes (XML, RIDE y correo), los emisores (incluida su creación desde el .p12) con sus establecimientos, puntos de emisión y certificados, los endpoints de webhooks, los eventos y el estado de la API.
- Tiene un solo cliente,
Flashpi::Client, que puedes compartir entre hilos y que reutiliza sus conexiones. - Funciona en Ruby 3.3 o superior y no tiene dependencias: solo usa la biblioteca estándar (net/http). Trae firmas RBS generadas del documento OpenAPI de la API, así que Steep y los editores que leen RBS conocen cada campo.
- Úsalo solo en tus servidores: tu API key es secreta.
gem install flashpiEn un proyecto con Bundler, como una app de Rails: bundle add flashpi.
Emite una factura
Sección titulada «Emite una factura»Crea en la consola una API key de prueba con alcance Toda la cuenta (account) y acceso de lectura y escritura, y guárdala en la variable de entorno FLASHPI_API_KEY (consulta Autenticación y API keys). Con el emisor demo emites en el ambiente de pruebas del SRI sin un certificado propio: copia su ID del Resumen de la consola (consulta Sandbox y emisor demo).
import { Flashpi } from '@flashpi/node'
// Reads FLASHPI_API_KEY. You can also pass the key: new Flashpi('fp_test_...')const flashpi = new Flashpi()
const invoice = await flashpi.invoices.create({ issuerId: '<demo issuer id>', establishment: '001', emissionPoint: '001', customer: { idType: 'ruc', id: '1790000000001', name: 'Supermercados La Pradera S.A.', }, items: [ { code: 'SRV-001', description: 'Monthly accounting services', quantity: '1', unitPrice: '450.00', taxes: [{ type: 'vat', rate: '15' }], }, ], payments: [{ method: 'other_financial_system', amount: '517.50' }], options: { waitForAuthorization: true },})
console.log(invoice.status, invoice.accessKey)En Cloudflare Workers no hay variables de entorno globales: pasa la clave desde env, con new Flashpi(env.FLASHPI_API_KEY). En Deno, leer la variable necesita el permiso --allow-env=FLASHPI_API_KEY.
Los montos y las cantidades van como texto ('450.00'), nunca como números, para no perder decimales. Con options.waitForAuthorization, la solicitud espera la respuesta del SRI y devuelve authorized, rejected o failed; si el SRI tarda más, devuelve processing o pending_sri y el comprobante sigue su curso. Sin esa opción, la respuesta llega enseguida con el estado processing y la decisión del SRI llega por webhook (invoice.authorized o invoice.rejected). Un comprobante failed todavía no genera un evento: búscalo con flashpi.documents.list({ status: 'failed' }). Consulta Modo síncrono y asíncrono y Ciclo de vida del comprobante.
Los demás comprobantes siguen el mismo patrón, cada uno con su create(body): creditNotes, debitNotes, purchaseSettlements, waybills y withholdings.
from flashpi import Flashpi
# Reads FLASHPI_API_KEY. You can also pass the key: Flashpi("fp_test_...")flashpi = Flashpi()
invoice = flashpi.invoices.create( { "issuerId": "<demo issuer id>", "establishment": "001", "emissionPoint": "001", "customer": { "idType": "ruc", "id": "1790000000001", "name": "Supermercados La Pradera S.A.", }, "items": [ { "code": "SRV-001", "description": "Monthly accounting services", "quantity": "1", "unitPrice": "450.00", "taxes": [{"type": "vat", "rate": "15"}], } ], "payments": [{"method": "other_financial_system", "amount": "517.50"}], "options": {"waitForAuthorization": True}, })
print(invoice["status"], invoice["accessKey"])Las solicitudes y las respuestas son diccionarios con las claves de la API, en camelCase (invoice["accessKey"]), tipados con los TypedDict de flashpi.types (como CreateInvoice y Document), así que tu editor y tu verificador de tipos conocen cada campo. Un cliente mantiene un grupo de conexiones: crea uno y reutilízalo, y ciérralo con flashpi.close() o úsalo en un bloque with.
Los montos y las cantidades van como texto ("450.00"), nunca como números, para no perder decimales. Con "options": {"waitForAuthorization": True}, la solicitud espera la respuesta del SRI y devuelve authorized, rejected o failed; si el SRI tarda más, devuelve processing o pending_sri y el comprobante sigue su curso. Sin esa opción, la respuesta llega enseguida con el estado processing y la decisión del SRI llega por webhook (invoice.authorized o invoice.rejected). Un comprobante failed todavía no genera un evento: búscalo con flashpi.documents.list({"status": "failed"}). Consulta Modo síncrono y asíncrono y Ciclo de vida del comprobante.
Los demás comprobantes siguen el mismo patrón, cada uno con su create(body): credit_notes, debit_notes, purchase_settlements, waybills y withholdings.
Con asyncio, usa AsyncFlashpi: tiene los mismos recursos y métodos, que esperas con await.
import asyncio
from flashpi import AsyncFlashpi
async def main() -> None: async with AsyncFlashpi() as flashpi: invoice = await flashpi.invoices.create(body) # the same dictionary as above print(invoice["status"], invoice["accessKey"])
asyncio.run(main())Para cancelar una llamada, cancela su tarea (por ejemplo con asyncio.timeout()). Sin async with, cierra el cliente con await flashpi.aclose().
<?php
use Flashpi\Flashpi;
// Reads FLASHPI_API_KEY. You can also pass the key: new Flashpi(apiKey: 'fp_test_...')$flashpi = new Flashpi();
$invoice = $flashpi->invoices->create([ 'issuerId' => '<demo issuer id>', 'establishment' => '001', 'emissionPoint' => '001', 'customer' => [ 'idType' => 'ruc', 'id' => '1790000000001', 'name' => 'Supermercados La Pradera S.A.', ], 'items' => [ [ 'code' => 'SRV-001', 'description' => 'Monthly accounting services', 'quantity' => '1', 'unitPrice' => '450.00', 'taxes' => [['type' => 'vat', 'rate' => '15']], ], ], 'payments' => [['method' => 'other_financial_system', 'amount' => '517.50']], 'options' => ['waitForAuthorization' => true],]);
echo $invoice['status'], ' ', $invoice['accessKey'], "\n";Las solicitudes y las respuestas son arrays asociativos con las claves de la API, en camelCase ($invoice['accessKey']), tipados con las formas de arrays de PHPStan de Flashpi\Types\ApiTypes (como CreateInvoice y Document), así que tu editor y PHPStan conocen cada campo. Para que PHPStan reporte también una clave opcional mal escrita, incluye vendor/phpstan/phpstan/conf/bleedingEdge.neon en tu phpstan.neon. La clave se lee de $_ENV, $_SERVER o getenv(). Crea un cliente y reutilízalo.
Los montos y las cantidades van como texto ('450.00'), nunca como números, para no perder decimales. Con 'options' => ['waitForAuthorization' => true], la solicitud espera la respuesta del SRI y devuelve authorized, rejected o failed; si el SRI tarda más, devuelve processing o pending_sri y el comprobante sigue su curso. Sin esa opción, la respuesta llega enseguida con el estado processing y la decisión del SRI llega por webhook (invoice.authorized o invoice.rejected). Un comprobante failed todavía no genera un evento: búscalo con $flashpi->documents->list(['status' => 'failed']). Consulta Modo síncrono y asíncrono y Ciclo de vida del comprobante.
Los demás comprobantes siguen el mismo patrón, cada uno con su create($body): creditNotes, debitNotes, purchaseSettlements, waybills y withholdings.
En Laravel, registra el cliente como singleton en un service provider y recíbelo como Flashpi $flashpi en tus controladores y jobs. Pásale la clave desde tu configuración: con la configuración en caché (php artisan config:cache), Laravel no carga el .env.
<?php
namespace App\Providers;
use Flashpi\Flashpi;use Illuminate\Support\Facades\Config;use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider{ public function register(): void { // In config/services.php: // 'flashpi' => ['key' => env('FLASHPI_API_KEY'), 'webhook_secret' => env('FLASHPI_WEBHOOK_SECRET')], $this->app->singleton(Flashpi::class, fn(): Flashpi => new Flashpi( apiKey: Config::string('services.flashpi.key'), )); }}import ec.com.flashpi.Flashpi;import ec.com.flashpi.models.CreateInvoice;import ec.com.flashpi.models.Document;import ec.com.flashpi.models.DocumentItem;import ec.com.flashpi.models.DocumentOptions;import ec.com.flashpi.models.DocumentParty;import ec.com.flashpi.models.DocumentPayment;import ec.com.flashpi.models.DocumentTax;import ec.com.flashpi.models.IdType;import ec.com.flashpi.models.PaymentMethod;import ec.com.flashpi.models.TaxType;import java.util.List;
// Reads FLASHPI_API_KEY. You can also pass the key: new Flashpi("fp_test_...")Flashpi flashpi = new Flashpi();
CreateInvoice body = CreateInvoice.builder() .issuerId("<demo issuer id>") .establishment("001") .emissionPoint("001") .customer(DocumentParty.builder() .idType(IdType.RUC) .id("1790000000001") .name("Supermercados La Pradera S.A.") .build()) .items(List.of(DocumentItem.builder() .code("SRV-001") .description("Monthly accounting services") .quantity("1") .unitPrice("450.00") .taxes(List.of(DocumentTax.builder().type(TaxType.VAT).rate("15").build())) .build())) .payments(List.of(DocumentPayment.builder() .method(PaymentMethod.OTHER_FINANCIAL_SYSTEM) .amount("517.50") .build())) .options(DocumentOptions.builder().waitForAuthorization(true).build()) .build();
Document invoice = flashpi.invoices().create(body);
System.out.println(invoice.getStatus() + " " + invoice.getAccessKey());Los modelos están en ec.com.flashpi.models y usan los nombres de la API: los armas con builders y los lees con getters (invoice.getAccessKey()). Los valores fijos son enums (IdType.RUC, DocumentStatus.AUTHORIZED), con UNKNOWN para un valor que esta versión del SDK todavía no conoce, y las fechas son LocalDate y OffsetDateTime. Un cliente es seguro entre hilos y mantiene un grupo de conexiones: crea uno y compártelo (por ejemplo, como un bean de Spring), y ciérralo con flashpi.close() o úsalo en un try (Flashpi flashpi = new Flashpi()) { ... }.
Los montos y las cantidades van como texto ("450.00"), nunca como números, para no perder decimales. Con DocumentOptions.builder().waitForAuthorization(true), la solicitud espera la respuesta del SRI y devuelve AUTHORIZED, REJECTED o FAILED; si el SRI tarda más, devuelve PROCESSING o PENDING_SRI y el comprobante sigue su curso. Sin esa opción, la respuesta llega enseguida con el estado PROCESSING y la decisión del SRI llega por webhook (invoice.authorized o invoice.rejected). Un comprobante FAILED todavía no genera un evento: búscalo con flashpi.documents().list(DocumentsListParams.builder().status(DocumentStatus.FAILED).build()). Consulta Modo síncrono y asíncrono y Ciclo de vida del comprobante.
Los demás comprobantes siguen el mismo patrón, cada uno con su create(body) y el builder de su cuerpo: creditNotes() (CreateCreditNote), debitNotes() (CreateDebitNote), purchaseSettlements() (CreatePurchaseSettlement), waybills() (CreateWaybill) y withholdings() (CreateWithholding).
Desde Kotlin usas el mismo SDK: los builders se escriben igual, con listOf(...) en lugar de List.of(...), los getters se leen como propiedades y los enums funcionan con when.
import ec.com.flashpi.Flashpiimport ec.com.flashpi.models.CreateInvoiceimport ec.com.flashpi.models.DocumentStatus
val flashpi = Flashpi() // one client for the whole app
fun issue(body: CreateInvoice) { // the same CreateInvoice as above val invoice = flashpi.invoices().create(body) when (invoice.status) { DocumentStatus.AUTHORIZED -> println("Authorized: ${invoice.accessKey}") DocumentStatus.REJECTED -> println("Rejected: ${invoice.sriMessages}") else -> println("Status: ${invoice.status}") }}using Flashpi;using Flashpi.Models;
// Reads FLASHPI_API_KEY. You can also pass the key: new FlashpiClient("fp_test_...")using var flashpi = new FlashpiClient();
var invoice = await flashpi.Invoices.CreateAsync(new CreateInvoice{ IssuerId = "<demo issuer id>", Establishment = "001", EmissionPoint = "001", Customer = new DocumentParty { IdType = IdType.Ruc, Id = "1790000000001", Name = "Supermercados La Pradera S.A.", }, Items = [ new DocumentItem { Code = "SRV-001", Description = "Monthly accounting services", Quantity = "1", UnitPrice = "450.00", Taxes = [new DocumentTax { Type = TaxType.Vat, Rate = "15" }], }, ], Payments = [new DocumentPayment { Method = PaymentMethod.OtherFinancialSystem, Amount = "517.50" }], Options = new DocumentOptions { WaitForAuthorization = true },});
Console.WriteLine($"{invoice.Status} {invoice.AccessKey}");Los modelos están en Flashpi.Models y sus propiedades son las de la API en PascalCase (invoice.AccessKey es accessKey); el compilador te pide las obligatorias. Los valores fijos, como IdType.Ruc o DocumentStatus.Authorized, también aceptan los que esta versión del SDK aún no conoce: compáralos con ==. Un cliente mantiene un grupo de conexiones: crea uno y reutilízalo, y libéralo con using o Dispose(). En ASP.NET Core, regístralo como singleton: builder.Services.AddSingleton(new FlashpiClient()).
Los montos y las cantidades van como texto ("450.00"), nunca como números, para no perder decimales. Con Options = new DocumentOptions { WaitForAuthorization = true }, la solicitud espera la respuesta del SRI y devuelve authorized, rejected o failed; si el SRI tarda más, devuelve processing o pending_sri y el comprobante sigue su curso. Sin esa opción, la respuesta llega enseguida con el estado processing y la decisión del SRI llega por webhook (invoice.authorized o invoice.rejected). Un comprobante failed todavía no genera un evento: búscalo con flashpi.Documents.ListAsync(new DocumentListParams { Status = DocumentStatus.Failed }). Consulta Modo síncrono y asíncrono y Ciclo de vida del comprobante.
Los demás comprobantes siguen el mismo patrón, cada uno con su CreateAsync(body): CreditNotes, DebitNotes, PurchaseSettlements, Waybills y Withholdings.
Cada método recibe al final un CancellationToken opcional: cancelarlo lanza OperationCanceledException.
package main
import ( "context" "fmt" "log"
"github.com/flashpi1/flashpi-go")
func main() { // Reads FLASHPI_API_KEY. You can also pass the key: flashpi.NewClient(flashpi.WithAPIKey("fp_test_...")) client, err := flashpi.NewClient() if err != nil { log.Fatal(err) }
invoice, err := client.Invoices.Create(context.Background(), flashpi.CreateInvoice{ IssuerID: "<demo issuer id>", Establishment: "001", EmissionPoint: "001", Customer: flashpi.DocumentParty{ IDType: flashpi.IDTypeRUC, ID: new("1790000000001"), Name: new("Supermercados La Pradera S.A."), }, Items: []flashpi.DocumentItem{{ Code: new("SRV-001"), Description: "Monthly accounting services", Quantity: "1", UnitPrice: "450.00", Taxes: []flashpi.DocumentTax{{Type: flashpi.TaxTypeVAT, Rate: "15"}}, }}, Payments: []flashpi.DocumentPayment{ {Method: flashpi.PaymentMethodOtherFinancialSystem, Amount: "517.50"}, }, Options: &flashpi.DocumentOptions{WaitForAuthorization: new(true)}, }) if err != nil { log.Fatal(err) }
fmt.Println(invoice.Status, invoice.AccessKey)}Las solicitudes y las respuestas son structs con los campos de la API (invoice.AccessKey es accessKey), generados del documento OpenAPI, así que tu editor y el compilador conocen cada campo. Los campos opcionales son punteros, que creas con new("valor"). Un cliente mantiene sus conexiones abiertas y se puede usar desde varias goroutines: crea uno y reutilízalo.
Los montos y las cantidades van como texto ("450.00"), nunca como números, para no perder decimales. Con Options: &flashpi.DocumentOptions{WaitForAuthorization: new(true)}, la solicitud espera la respuesta del SRI y devuelve authorized, rejected o failed; si el SRI tarda más, devuelve processing o pending_sri y el comprobante sigue su curso. Sin esa opción, la respuesta llega enseguida con el estado processing y la decisión del SRI llega por webhook (invoice.authorized o invoice.rejected). Un comprobante failed todavía no genera un evento: búscalo con client.Documents.List(ctx, &flashpi.DocumentListParams{Status: new(flashpi.DocumentStatusFailed)}). Consulta Modo síncrono y asíncrono y Ciclo de vida del comprobante.
Los demás comprobantes siguen el mismo patrón, cada uno con su Create(ctx, body): CreditNotes, DebitNotes, PurchaseSettlements, Waybills y Withholdings.
El ctx de cada llamada limita la llamada entera, reintentos incluidos: para cancelarla o darle un plazo, pásale un contexto de context.WithCancel o context.WithTimeout. Si se cancela o vence, la llamada se detiene sin reintentar.
require "flashpi"
# Reads FLASHPI_API_KEY. You can also pass the key: Flashpi::Client.new(api_key: "fp_test_...")flashpi = Flashpi::Client.new
invoice = flashpi.invoices.create( { issuerId: "<demo issuer id>", establishment: "001", emissionPoint: "001", customer: { idType: "ruc", id: "1790000000001", name: "Supermercados La Pradera S.A." }, items: [ { code: "SRV-001", description: "Monthly accounting services", quantity: "1", unitPrice: "450.00", taxes: [{ type: "vat", rate: "15" }] } ], payments: [{ method: "other_financial_system", amount: "517.50" }], options: { waitForAuthorization: true } })
puts invoice[:status], invoice[:accessKey]Las solicitudes y las respuestas son hashes con las claves de la API, en camelCase y como símbolos (invoice[:accessKey]), con tipos RBS en Flashpi::Types (como Flashpi::Types::create_invoice y Flashpi::Types::document), así que Steep y tu editor conocen cada campo. Escribe el cuerpo entre llaves, create({ ... }): sin ellas, Ruby lo tomaría como las opciones de la llamada. Un cliente mantiene abiertas sus conexiones y se puede compartir entre hilos: crea uno y reutilízalo (en Rails, por ejemplo, en un initializer). flashpi.close cierra esas conexiones.
Los montos y las cantidades van como texto ("450.00"), nunca como números, para no perder decimales; si los tienes en un BigDecimal (como las columnas decimal de Active Record), conviértelos con to_s("F"), porque el SDK no acepta un BigDecimal y lanza un TypeError. Con options: { waitForAuthorization: true }, la solicitud espera la respuesta del SRI y devuelve authorized, rejected o failed; si el SRI tarda más, devuelve processing o pending_sri y el comprobante sigue su curso. Sin esa opción, la respuesta llega enseguida con el estado processing y la decisión del SRI llega por webhook (invoice.authorized o invoice.rejected). Un comprobante failed todavía no genera un evento: búscalo con flashpi.documents.list({ status: "failed" }). Consulta Modo síncrono y asíncrono y Ciclo de vida del comprobante.
Los demás comprobantes siguen el mismo patrón, cada uno con su create(body): credit_notes, debit_notes, purchase_settlements, waybills y withholdings.
Errores
Sección titulada «Errores»Cada error de la API se lanza como un APIError, con la subclase de su estado HTTP cuando la hay (por ejemplo UnprocessableEntityError para 422). Decide según code, que es estable y en inglés. El mensaje sigue el idioma que pidas: new Flashpi({ language: 'es' }) da los mensajes en español.
import { APIError, UnprocessableEntityError } from '@flashpi/node'
try { await flashpi.invoices.create(body)} catch (error) { if (error instanceof UnprocessableEntityError) { console.log(error.code) // 'validation_failed', 'total_mismatch', ... for (const field of error.errors) console.log(field.field, field.message) } else if (error instanceof APIError) { console.log(error.status, error.code, error.requestId) // share requestId with support } throw error}Cada error de la API se lanza como un APIError, con la subclase de su estado HTTP cuando la hay (por ejemplo UnprocessableEntityError para 422). Decide según code, que es estable y en inglés. El mensaje sigue el idioma que pidas: Flashpi(language="es") da los mensajes en español.
from flashpi import APIError, UnprocessableEntityError
try: flashpi.invoices.create(body)except UnprocessableEntityError as error: print(error.code) # "validation_failed", "total_mismatch", ... for field in error.errors: print(field["field"], field["message"]) raiseexcept APIError as error: print(error.status, error.code, error.request_id) # share request_id with support raiseLas subclases son BadRequestError (400), AuthenticationError (401), PermissionDeniedError (403), NotFoundError (404), ConflictError (409), UnprocessableEntityError (422), RateLimitError (429) e InternalServerError (5xx). Sin una respuesta, por ejemplo tras una falla de red, el error es un APIConnectionError, o un APITimeoutError cuando se agotó el tiempo. Todos los errores del SDK derivan de FlashpiError.
Cada error de la API se lanza como una ApiException, con la subclase de su estado HTTP cuando la hay (por ejemplo UnprocessableEntityException para 422), todas en Flashpi\Exception. Decide según errorCode, que es estable y en inglés (getCode() da el estado HTTP). El mensaje sigue el idioma que pidas: new Flashpi(language: 'es') da los mensajes en español.
<?php
use Flashpi\Exception\ApiException;use Flashpi\Exception\UnprocessableEntityException;
try { $flashpi->invoices->create($body);} catch (UnprocessableEntityException $error) { echo $error->errorCode, "\n"; // 'validation_failed', 'total_mismatch', ... foreach ($error->errors as $field) { echo $field['field'], ' ', $field['message'], "\n"; } throw $error;} catch (ApiException $error) { echo $error->status, ' ', $error->errorCode, ' ', $error->requestId, "\n"; // share requestId with support throw $error;}Las subclases son BadRequestException (400), AuthenticationException (401), PermissionDeniedException (403), NotFoundException (404), ConflictException (409), UnprocessableEntityException (422), RateLimitException (429) e InternalServerException (5xx). Sin una respuesta, por ejemplo tras una falla de red, el error es una ConnectionException, o una TimeoutException cuando se agotó el tiempo. Algunos errores traen datos extra en $error->problem, como documentId, limit o resetsAt. Todas las excepciones del SDK derivan de FlashpiException, salvo las de un argumento mal usado (una opción desconocida, por ejemplo), que son \InvalidArgumentException o \TypeError.
Cada error de la API se lanza como una ApiException, con la subclase de su estado HTTP cuando la hay (por ejemplo UnprocessableEntityException para 422); todas son excepciones no verificadas (unchecked). Decide según getCode(), que es estable y en inglés. El mensaje sigue el idioma que pidas: Flashpi.builder().language(Language.ES).build() da los mensajes en español.
import ec.com.flashpi.exceptions.ApiException;import ec.com.flashpi.exceptions.UnprocessableEntityException;import ec.com.flashpi.models.FieldError;
try { flashpi.invoices().create(body);} catch (UnprocessableEntityException error) { System.out.println(error.getCode()); // "validation_failed", "total_mismatch", ... for (FieldError field : error.getErrors()) { System.out.println(field.getField() + ": " + field.getMessage()); } throw error;} catch (ApiException error) { // Share getRequestId() with support. System.out.println(error.getStatus() + " " + error.getCode() + " " + error.getRequestId()); throw error;}Las subclases son BadRequestException (400), AuthenticationException (401), PermissionDeniedException (403), NotFoundException (404), ConflictException (409), UnprocessableEntityException (422), RateLimitException (429) e InternalServerException (5xx), todas en ec.com.flashpi.exceptions. Sin una respuesta, por ejemplo tras una falla de red, el error es una ApiConnectionException, o una ApiTimeoutException cuando se agotó el tiempo. Todos los errores del SDK derivan de FlashpiException, que extiende RuntimeException. Algunos traen datos extra en getProblem(), como getDocumentId() o getResetsAt().
Cada error de la API se lanza como un ApiException, con la subclase de su estado HTTP cuando la hay (por ejemplo UnprocessableEntityException para 422). Decide según Code, que es estable y en inglés. El mensaje sigue el idioma que pidas: new FlashpiClient(new FlashpiClientOptions { Language = Language.Spanish }) da los mensajes en español.
using Flashpi;
try{ await flashpi.Invoices.CreateAsync(body);}catch (UnprocessableEntityException error){ Console.WriteLine(error.Code); // validation_failed, total_mismatch, ... foreach (var field in error.Errors) { Console.WriteLine($"{field.Field}: {field.Message}"); } throw;}catch (ApiException error){ Console.WriteLine($"{error.StatusCode} {error.Code} {error.RequestId}"); // share RequestId with support throw;}Las subclases son BadRequestException (400), AuthenticationException (401), PermissionDeniedException (403), NotFoundException (404), ConflictException (409), UnprocessableEntityException (422), RateLimitException (429, con la espera en RetryAfter) e InternalServerException (5xx). Sin una respuesta, por ejemplo tras una falla de red, el error es un ApiConnectionException, o un ApiTimeoutException cuando se agotó el tiempo. Todos los errores del SDK derivan de FlashpiException. Para un código concreto, usa un filtro: catch (ConflictException error) when (error.Code == ErrorCode.SequentialAlreadyUsed). Algunos errores traen datos extra en error.Problem, como DocumentId o ResetsAt.
Cada error de la API es un *flashpi.APIError, envuelto en el tipo de su estado HTTP cuando lo hay (por ejemplo *flashpi.UnprocessableEntityError para 422). Revísalo con errors.As y decide según Code, que es estable y en inglés. El mensaje sigue el idioma que pidas: flashpi.NewClient(flashpi.WithLanguage(flashpi.LanguageSpanish)) da los mensajes en español.
_, err := client.Invoices.Create(ctx, body)if err != nil { var invalid *flashpi.UnprocessableEntityError var apiErr *flashpi.APIError switch { case errors.As(err, &invalid): fmt.Println(invalid.Code) // "validation_failed", "total_mismatch", ... for _, field := range invalid.Errors { fmt.Println(field.Field, field.Message) } case errors.As(err, &apiErr): fmt.Println(apiErr.Status, apiErr.Code, apiErr.RequestID) // share RequestID with support } return err}Los tipos por estado son BadRequestError (400), AuthenticationError (401), PermissionDeniedError (403), NotFoundError (404), ConflictError (409), UnprocessableEntityError (422), RateLimitError (429) e InternalServerError (5xx); los demás estados llegan como *flashpi.APIError. Sin una respuesta, por ejemplo tras una falla de red, el error es un *flashpi.ConnectionError, o un *flashpi.TimeoutError cuando se agotó el tiempo. Todos los errores del SDK se leen también como *flashpi.Error con errors.As.
Cada error de la API se lanza como un Flashpi::APIError, con la subclase de su estado HTTP cuando la hay (por ejemplo Flashpi::UnprocessableEntityError para 422). Decide según code, que es estable y en inglés. El mensaje sigue el idioma que pidas: Flashpi::Client.new(language: "es") da los mensajes en español.
begin flashpi.invoices.create(body)rescue Flashpi::UnprocessableEntityError => e puts e.code # "validation_failed", "total_mismatch", ... e.errors.each { |field| puts "#{field[:field]}: #{field[:message]}" } raiserescue Flashpi::APIError => e puts e.status, e.code, e.request_id # share request_id with support raiseendLas subclases son Flashpi::BadRequestError (400), Flashpi::AuthenticationError (401), Flashpi::PermissionDeniedError (403), Flashpi::NotFoundError (404), Flashpi::ConflictError (409), Flashpi::UnprocessableEntityError (422), Flashpi::RateLimitError (429) y Flashpi::InternalServerError (5xx). Algunos errores traen datos extra en e.problem, como documentId, limit o resetsAt. Sin una respuesta, por ejemplo tras una falla de red, el error es un Flashpi::APIConnectionError, o un Flashpi::APITimeoutError cuando se agotó el tiempo. Todos los errores del SDK derivan de Flashpi::Error; un argumento mal usado, como en cualquier método de Ruby, lanza un ArgumentError o un TypeError.
Consulta los códigos y qué hacer con cada uno en Errores.
Reintentos e idempotencia
Sección titulada «Reintentos e idempotencia»Cada SDK reintenta hasta dos veces, con una espera creciente, los errores de red, los tiempos agotados y las respuestas 408, 429 y 5xx, salvo el 501 y cuando la API pide esperar más de 60 segundos (como en los límites diarios). Tampoco reintenta el 503 demo_issuer_unavailable: un reintento segundos después no lo resuelve.
Cada emisión lleva una Idempotency-Key que se repite en sus reintentos, así que un reintento nunca emite dos veces: si el primer intento ya creó el comprobante, el SDK te devuelve ese comprobante.
Para usar tu propia clave, por ejemplo el ID de tu pedido, pásala en las opciones de la llamada:
await flashpi.invoices.create(body, { idempotencyKey: `order-${order.id}` })flashpi.invoices.create(body, idempotency_key=f"order-{order.id}")Cada método recibe las opciones de su llamada como argumentos con nombre: idempotency_key, timeout (en segundos), max_retries, language, correlation_id (tu propio ID, que Flashpi registra) y extra_headers. Si la emisión falla de todos modos, el error lleva la clave que usó en error.idempotency_key: volver a llamar con idempotency_key=error.idempotency_key nunca emite un segundo comprobante.
<?php
$flashpi->invoices->create($body, ['idempotencyKey' => "order-{$order->id}"]);Cada método recibe las opciones de su llamada en un array, como último argumento: idempotencyKey, timeout (en segundos), maxRetries, language, correlationId (tu propio ID, que Flashpi registra) y extraHeaders. Una opción desconocida lanza una \InvalidArgumentException. Si la emisión falla de todos modos, la excepción lleva la clave que usó en $error->idempotencyKey: volver a llamar con ['idempotencyKey' => $error->idempotencyKey] nunca emite un segundo comprobante.
import ec.com.flashpi.RequestOptions;
RequestOptions options = RequestOptions.builder().idempotencyKey("order-" + order.getId()).build();flashpi.invoices().create(body, options);Cada método recibe, como último argumento, las opciones de su llamada, armadas con RequestOptions.builder(): idempotencyKey, timeout (un Duration), maxRetries, language, correlationId (tu propio ID, que Flashpi registra) y encabezados extra (header o extraHeaders, solo ASCII). Si la emisión falla de todos modos, la excepción lleva la clave que usó en getIdempotencyKey(): volver a llamar con RequestOptions.builder().idempotencyKey(error.getIdempotencyKey()) nunca emite un segundo comprobante. Si interrumpes el hilo de una llamada, el SDK deja de esperar, lanza una FlashpiException con la clave y conserva el estado de interrupción del hilo.
await flashpi.Invoices.CreateAsync(body, new RequestOptions { IdempotencyKey = $"order-{order.Id}" });Cada método recibe las opciones de su llamada en un RequestOptions, antes del CancellationToken: IdempotencyKey, Timeout (un TimeSpan), MaxRetries, Language, CorrelationId (tu propio ID, que Flashpi registra) y ExtraHeaders. Si la emisión falla de todos modos, el error (un FlashpiException) lleva la clave que usó en error.IdempotencyKey: volver a llamar con IdempotencyKey = error.IdempotencyKey nunca emite un segundo comprobante. Cancelar la llamada con su CancellationToken lanza OperationCanceledException, que no lleva la clave: si cancelas emisiones, pasa tu propio IdempotencyKey.
_, err := client.Invoices.Create(ctx, body, flashpi.WithIdempotencyKey("order-"+order.ID))if err != nil { return err}Cada método recibe al final las opciones de su llamada: flashpi.WithIdempotencyKey, flashpi.WithTimeout (un time.Duration por intento), flashpi.WithMaxRetries, flashpi.WithLanguage, flashpi.WithCorrelationID (tu propio ID, que Flashpi registra) y flashpi.WithHeader. WithTimeout, WithMaxRetries, WithLanguage y WithHeader también se pasan a flashpi.NewClient para todas las llamadas.
Si la emisión falla de todos modos, el error lleva la clave que usó en IdempotencyKey: volver a llamar con flashpi.WithIdempotencyKey(key) nunca emite un segundo comprobante.
_, err := client.Invoices.Create(ctx, body)var flashpiErr *flashpi.Errorif errors.As(err, &flashpiErr) && flashpiErr.IdempotencyKey != "" { saveForRetry(body, flashpiErr.IdempotencyKey) // your retry queue}flashpi.invoices.create(body, idempotency_key: "order-#{order.id}")Cada método recibe las opciones de su llamada como argumentos con nombre: idempotency_key, timeout (en segundos), max_retries, language, correlation_id (tu propio ID, que Flashpi registra) y extra_headers. Si la emisión falla de todos modos, el error (un Flashpi::Error) lleva la clave que usó en error.idempotency_key: volver a llamar con idempotency_key: error.idempotency_key nunca emite un segundo comprobante. Una llamada que interrumpes desde fuera (con Timeout.timeout, o al detener el proceso o el hilo) no lanza un Flashpi::Error ni trae la clave: si lo haces con emisiones, pasa tu propia idempotency_key.
Un límite diario, como la cuota del emisor demo, no se reintenta: llega como un Flashpi::RateLimitError con retry_after en segundos.
Consulta Idempotencia.
Una lista devuelve una página.
Con for await recorres todas las páginas, que el SDK pide a medida que avanzas:
for await (const document of flashpi.documents.list({ status: 'authorized' })) { console.log(document.accessKey)}Con for recorres todas las páginas, que el SDK pide a medida que avanzas. Los filtros son un diccionario con los nombres de la API, en camelCase:
for document in flashpi.documents.list({"status": "authorized", "issueDateFrom": "2026-11-01"}): print(document["accessKey"])Con AsyncFlashpi, async for recorre todas las páginas.
Con foreach recorres todas las páginas, que el SDK pide a medida que avanzas. Los filtros son un array con los nombres de la API, en camelCase:
<?php
foreach ($flashpi->documents->list(['status' => 'authorized', 'issueDateFrom' => '2026-11-01']) as $document) { echo $document['accessKey'], "\n";}Para avanzar de una página a la siguiente, usa $page->data, $page->hasNextPage() y $page->nextPage().
Con for recorres todas las páginas, que el SDK pide a medida que avanzas, y stream() hace lo mismo de forma perezosa. Los filtros se arman con DocumentsListParams.builder(), con enums y fechas LocalDate:
import ec.com.flashpi.models.Document;import ec.com.flashpi.models.DocumentStatus;import ec.com.flashpi.models.DocumentsListParams;import java.time.LocalDate;
DocumentsListParams params = DocumentsListParams.builder() .status(DocumentStatus.AUTHORIZED) .issueDateFrom(LocalDate.of(2026, 11, 1)) .build();for (Document document : flashpi.documents().list(params)) { System.out.println(document.getAccessKey());}Para avanzar una página a la vez, usa getData(), hasNextPage() y nextPage() del Page que devuelve list.
Con await foreach recorres todas las páginas, que el SDK pide a medida que avanzas. Los filtros van en un DocumentListParams:
await foreach (var document in flashpi.Documents.ListAsync(new DocumentListParams{ Status = DocumentStatus.Authorized, IssueDateFrom = "2026-11-01",})){ Console.WriteLine(document.AccessKey);}Con await obtienes solo la primera página: un Page<Document> con Data, HasNextPage y GetNextPageAsync().
Con range recorres todas las páginas, que el SDK pide a medida que avanzas; si una página falla, recibes su error en err. Los filtros son un *flashpi.DocumentListParams con los nombres de la API, y nil significa sin filtros:
params := &flashpi.DocumentListParams{ Status: new(flashpi.DocumentStatusAuthorized), IssueDateFrom: new("2026-11-01"),}for document, err := range client.Documents.List(ctx, params) { if err != nil { return err } fmt.Println(document.AccessKey)}Para una página a la vez, ListPage devuelve un *flashpi.Page con los documentos en Data, y page.NextPage(ctx) la página siguiente (nil después de la última).
Con each recorres todas las páginas, que el SDK pide a medida que avanzas. Los filtros son un hash entre llaves con los nombres de la API, en camelCase, antes de las opciones de la llamada:
flashpi.documents.list({ status: "authorized", issueDateFrom: "2026-11-01" }).each do |document| puts document[:accessKey]endLa página es Enumerable, así que first(10) solo pide las páginas que necesita. Para avanzar página por página, usa data, has_next_page? y next_page.
Consulta Paginación.
Webhooks
Sección titulada «Webhooks»unwrapWebhook verifica la firma con el cuerpo tal como llegó, sin interpretarlo, y el secreto whsec_ del endpoint, y te devuelve el evento con su tipo. No necesita API key. Funciona en Next.js, Hono, Cloudflare Workers y cualquier entorno con Request; con Express, lee el cuerpo con express.raw({ type: 'application/json' }).
import { unwrapWebhook, WebhookVerificationError } from '@flashpi/node'
export async function POST(request: Request): Promise<Response> { let event try { event = await unwrapWebhook( await request.text(), request.headers, process.env.FLASHPI_WEBHOOK_SECRET ?? '', ) } catch (error) { if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 }) throw error }
switch (event.type) { case 'webhook_endpoint.verification': // The endpoint's activation: answer the challenge. return Response.json({ challenge: event.data.challenge }) case 'invoice.authorized': console.log('Authorized', event.data.accessKey) // event.data is the document break } return new Response(null, { status: 204 })}Guarda el event.id (el mismo valor del encabezado webhook-id) y, si se repite, responde 2xx sin volver a hacer el trabajo: un evento puede llegar más de una vez.
El ejemplo supone el modo full del endpoint, el predeterminado. En el modo thin los eventos, salvo la verificación, llegan sin data: usa unwrapWebhook<ThinWebhookEvent>(...) (el tipo también se importa de @flashpi/node) y lee el evento con flashpi.events.get(event.id). Consulta Webhooks y su Contenido.
unwrap_webhook verifica la firma con el cuerpo tal como llegó, sin interpretarlo, y el secreto whsec_ del endpoint, y te devuelve el evento. No necesita API key y no hace E/S, así que también lo llamas sin await en las vistas asíncronas. Con Django:
import os
from django.http import HttpRequest, HttpResponse, JsonResponsefrom django.views.decorators.csrf import csrf_exemptfrom django.views.decorators.http import require_POST
from flashpi import WebhookVerificationError, unwrap_webhook
@csrf_exempt@require_POSTdef flashpi_webhook(request: HttpRequest) -> HttpResponse: try: event = unwrap_webhook(request.body, request.headers, os.environ["FLASHPI_WEBHOOK_SECRET"]) except WebhookVerificationError: return HttpResponse(status=400)
if event["type"] == "webhook_endpoint.verification": # The endpoint's activation: answer the challenge. return JsonResponse({"challenge": event["data"]["challenge"]}) if event["type"] == "invoice.authorized": print("Authorized", event["data"]["accessKey"]) # event["data"] is the document elif event["type"] == "invoice.rejected": print("Rejected", event["data"]["sriMessages"]) return HttpResponse(status=204)Con Flask, cuyo request.headers no es un Mapping, pasa dict(request.headers):
import os
from flask import Flask, request
from flashpi import WebhookVerificationError, unwrap_webhook
app = Flask(__name__)
@app.post("/webhooks/flashpi")def flashpi_webhook(): try: event = unwrap_webhook( request.get_data(), dict(request.headers), os.environ["FLASHPI_WEBHOOK_SECRET"] ) except WebhookVerificationError: return "", 400
if event["type"] == "webhook_endpoint.verification": return {"challenge": event["data"]["challenge"]} handle_event(event) # your code, like the branches of the Django example return "", 204Con FastAPI:
import os
from fastapi import FastAPI, Request, Responsefrom fastapi.responses import JSONResponse
from flashpi import WebhookVerificationError, unwrap_webhook
app = FastAPI()
@app.post("/webhooks/flashpi")async def flashpi_webhook(request: Request) -> Response: try: event = unwrap_webhook( await request.body(), request.headers, os.environ["FLASHPI_WEBHOOK_SECRET"] ) except WebhookVerificationError: return Response(status_code=400)
if event["type"] == "webhook_endpoint.verification": return JSONResponse({"challenge": event["data"]["challenge"]}) handle_event(event) # your code, like the branches of the Django example return Response(status_code=204)Guarda el event["id"] (el mismo valor del encabezado webhook-id) y, si se repite, responde 2xx sin volver a hacer el trabajo: un evento puede llegar más de una vez.
Los ejemplos suponen el modo full del endpoint, el predeterminado. En el modo thin los eventos, salvo la verificación, llegan sin data: usa cast(ThinWebhookEvent, unwrap_webhook(...)) (el tipo se importa de flashpi.types) y lee el evento con flashpi.events.get(event["id"]). Consulta Webhooks y su Contenido.
Flashpi::unwrapWebhook() verifica la firma con el cuerpo tal como llegó, sin interpretarlo, y el secreto whsec_ del endpoint, y te devuelve el evento. Es estático y no necesita API key, y recibe los encabezados como te los entrega tu framework: el HeaderBag de Laravel y Symfony, el getHeaders() de PSR-7 o getallheaders(). Con Laravel, registra la ruta en routes/api.php, que no verifica CSRF (si tu app no lo tiene, php artisan install:api lo crea), con Route::post('/webhooks/flashpi', FlashpiWebhookController::class); la URL queda en /api/webhooks/flashpi:
<?php
namespace App\Http\Controllers;
use Flashpi\Exception\WebhookVerificationException;use Flashpi\Flashpi;use Illuminate\Http\JsonResponse;use Illuminate\Http\Request;use Illuminate\Http\Response;use Illuminate\Support\Facades\Config;use Illuminate\Support\Facades\Log;
final class FlashpiWebhookController{ public function __invoke(Request $request): Response|JsonResponse { try { $event = Flashpi::unwrapWebhook( $request->getContent(), $request->headers, Config::string('services.flashpi.webhook_secret'), ); } catch (WebhookVerificationException) { return new Response('', 400); }
if ($event['type'] === 'webhook_endpoint.verification') { // The endpoint's activation: answer the challenge. return new JsonResponse(['challenge' => $event['data']['challenge']]); } if ($event['type'] === 'invoice.authorized') { Log::info('Authorized', ['accessKey' => $event['data']['accessKey']]); // $event['data'] is the document } elseif ($event['type'] === 'invoice.rejected') { Log::warning('Rejected', ['sriMessages' => $event['data']['sriMessages']]); }
return new Response('', 204); }}Con Symfony:
<?php
namespace App\Controller;
use Flashpi\Exception\WebhookVerificationException;use Flashpi\Flashpi;use Symfony\Component\DependencyInjection\Attribute\Autowire;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Attribute\Route;
final class FlashpiWebhookController{ public function __construct( #[Autowire(env: 'FLASHPI_WEBHOOK_SECRET')] private readonly string $webhookSecret, ) {}
#[Route('/webhooks/flashpi', methods: ['POST'])] public function __invoke(Request $request): Response { try { $event = Flashpi::unwrapWebhook($request->getContent(), $request->headers, $this->webhookSecret); } catch (WebhookVerificationException) { return new Response('', 400); }
if ($event['type'] === 'webhook_endpoint.verification') { return new JsonResponse(['challenge' => $event['data']['challenge']]); } handleEvent($event); // your code, like the branches of the Laravel example
return new Response('', 204); }}Sin framework:
<?php
use Flashpi\Exception\WebhookVerificationException;use Flashpi\Flashpi;
try { $event = Flashpi::unwrapWebhook( (string) file_get_contents('php://input'), getallheaders(), (string) getenv('FLASHPI_WEBHOOK_SECRET'), );} catch (WebhookVerificationException) { http_response_code(400); exit;}
if ($event['type'] === 'webhook_endpoint.verification') { header('Content-Type: application/json'); echo json_encode(['challenge' => $event['data']['challenge']]); exit;}handleEvent($event); // your code, like the branches of the Laravel examplehttp_response_code(204);Con Guzzle 7, PHPStan lee getallheaders() como mixed: ahí pasa (array) getallheaders().
Guarda el $event['id'] (el mismo valor del encabezado webhook-id) y, si se repite, responde 2xx sin volver a hacer el trabajo: un evento puede llegar más de una vez.
Los ejemplos suponen el modo full del endpoint, el predeterminado. En el modo thin los eventos, salvo la verificación, llegan sin data: tipa el resultado como ThinWebhookEvent (@phpstan-import-type ThinWebhookEvent from \Flashpi\Types\SdkTypes) y lee el evento con $flashpi->events->get($event['id']). Consulta Webhooks y su Contenido.
El método unwrap de Webhooks (new Webhooks()) verifica la firma con el cuerpo tal como llegó, sin interpretarlo, y el secreto whsec_ del endpoint, y te devuelve el evento, cuya clase revisas con instanceof. Webhooks no necesita API key (también lo tienes en flashpi.webhooks()), no hace E/S y es seguro entre hilos. Con Spring Boot, recibe el cuerpo como byte[]:
import ec.com.flashpi.Webhooks;import ec.com.flashpi.exceptions.WebhookVerificationException;import ec.com.flashpi.models.DocumentEvent;import ec.com.flashpi.models.DocumentEventType;import ec.com.flashpi.models.WebhookEndpointVerificationEvent;import ec.com.flashpi.models.WebhookEvent;import java.util.Map;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestHeader;import org.springframework.web.bind.annotation.RestController;
@RestControllerpublic class FlashpiWebhookController { private final Webhooks webhooks = new Webhooks(); private final String secret = System.getenv("FLASHPI_WEBHOOK_SECRET");
@PostMapping("/webhooks/flashpi") public ResponseEntity<?> receive( @RequestBody byte[] body, @RequestHeader Map<String, String> headers) { WebhookEvent event; try { event = webhooks.unwrap(body, headers, secret); } catch (WebhookVerificationException error) { return ResponseEntity.badRequest().build(); }
if (event instanceof WebhookEndpointVerificationEvent) { // The endpoint's activation: answer the challenge. String challenge = ((WebhookEndpointVerificationEvent) event).getData().getChallenge(); return ResponseEntity.ok(Map.of("challenge", challenge)); } if (event instanceof DocumentEvent) { DocumentEvent documentEvent = (DocumentEvent) event; // getData() is the document if (documentEvent.getType() == DocumentEventType.INVOICE_AUTHORIZED) { System.out.println("Authorized " + documentEvent.getData().getAccessKey()); } else if (documentEvent.getType() == DocumentEventType.INVOICE_REJECTED) { System.out.println("Rejected " + documentEvent.getData().getSriMessages()); } } return ResponseEntity.noContent().build(); }}Con un servlet o cualquier otro framework, pasa los encabezados como una función: webhooks.unwrap(body, request::getHeader, secret); con los HttpHeaders de Spring, headers::getFirst. También sirve cualquier Map cuyos valores sean texto o listas de texto.
Guarda el event.getId() (el mismo valor del encabezado webhook-id) y, si se repite, responde 2xx sin volver a hacer el trabajo: un evento puede llegar más de una vez.
El ejemplo supone el modo full del endpoint, el predeterminado. En el modo thin los eventos, salvo la verificación, llegan con su clase pero sin datos (getData() devuelve null): lee el evento completo con flashpi.events().get(event.getId()). Un evento de un tipo que esta versión del SDK todavía no conoce llega como EventThin. Consulta Webhooks y su Contenido.
Webhooks.Unwrap verifica la firma con el cuerpo tal como llegó, sin interpretarlo, y el secreto whsec_ del endpoint, y te devuelve el evento con su tipo: WebhookEndpointVerificationEvent (la activación del endpoint), DocumentEvent, CertificateEvent, AccountEvent o UnknownEvent (un tipo que esta versión del SDK aún no conoce). No necesita API key y no hace E/S. Con ASP.NET Core, en una minimal API:
using Flashpi;using Flashpi.Models;
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();var webhookSecret = app.Configuration["FLASHPI_WEBHOOK_SECRET"] ?? throw new InvalidOperationException("Set FLASHPI_WEBHOOK_SECRET");
app.MapPost("/webhooks/flashpi", async (HttpRequest request) =>{ using var reader = new StreamReader(request.Body); var payload = await reader.ReadToEndAsync(); WebhookEvent webhook; try { webhook = Webhooks.Unwrap(payload, request.Headers, webhookSecret); } catch (WebhookVerificationException) { return Results.BadRequest(); }
switch (webhook) { case WebhookEndpointVerificationEvent verification: // The endpoint's activation: answer the challenge. return Results.Json(new { challenge = verification.Data.Challenge }); case DocumentEvent authorized when authorized.Type == EventType.InvoiceAuthorized: Console.WriteLine($"Authorized {authorized.Data.AccessKey}"); // Data is the document break; case DocumentEvent rejected when rejected.Type == EventType.InvoiceRejected: foreach (var message in rejected.Data.SriMessages) { Console.WriteLine($"Rejected {message.Code}: {message.Message}"); } break; } return Results.NoContent();});
app.Run();Con un controlador de ASP.NET Core (registra los controladores con builder.Services.AddControllers() y app.MapControllers()):
using Flashpi;using Flashpi.Models;using Microsoft.AspNetCore.Mvc;
[ApiController][Route("webhooks/flashpi")]public sealed class FlashpiWebhookController(IConfiguration configuration) : ControllerBase{ [HttpPost] public async Task<IActionResult> Receive() { var secret = configuration["FLASHPI_WEBHOOK_SECRET"] ?? throw new InvalidOperationException("Set FLASHPI_WEBHOOK_SECRET"); using var reader = new StreamReader(Request.Body); var payload = await reader.ReadToEndAsync(); WebhookEvent webhook; try { webhook = Webhooks.Unwrap(payload, Request.Headers, secret); } catch (WebhookVerificationException) { return BadRequest(); }
if (webhook is WebhookEndpointVerificationEvent verification) { return Ok(new { challenge = verification.Data.Challenge }); } HandleEvent(webhook); return NoContent(); }
// Your code, like the branches of the minimal API example. private static void HandleEvent(WebhookEvent webhook) => Console.WriteLine(webhook.Type);}Los dos ejemplos leen el secreto de la configuración de ASP.NET Core, que incluye las variables de entorno. Webhooks.Unwrap también acepta los encabezados como HttpHeaders, como un Dictionary<string, string> o como el NameValueCollection de ASP.NET en .NET Framework, y el cuerpo como byte[].
Guarda el webhook.Id (el mismo valor del encabezado webhook-id) y, si se repite, responde 2xx sin volver a hacer el trabajo: un evento puede llegar más de una vez.
Los ejemplos suponen el modo full del endpoint, el predeterminado. En el modo thin, Webhooks.Unwrap devuelve los eventos, salvo la verificación, como EventThin, sin Data: lee el evento con flashpi.Events.GetAsync(webhook.Id). Consulta Webhooks y su Contenido.
flashpi.UnwrapWebhook verifica la firma con el cuerpo tal como llegó, sin interpretarlo, y el secreto whsec_ del endpoint, y te devuelve el evento, que distingues con un switch de tipos. No necesita API key y no hace E/S. Con net/http:
package main
import ( "encoding/json" "errors" "io" "log" "net/http" "os"
"github.com/flashpi1/flashpi-go")
func main() { http.HandleFunc("POST /webhooks/flashpi", flashpiWebhook) log.Fatal(http.ListenAndServe(":8080", nil))}
func flashpiWebhook(w http.ResponseWriter, r *http.Request) { // The body arrives before the signature is checked: limit its size. body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20)) if err != nil { w.WriteHeader(http.StatusRequestEntityTooLarge) return } event, err := flashpi.UnwrapWebhook(body, r.Header, os.Getenv("FLASHPI_WEBHOOK_SECRET")) var invalid *flashpi.WebhookVerificationError switch { case errors.As(err, &invalid) && invalid.Reason == flashpi.WebhookInvalidSecret: // An empty or mistyped secret is your configuration, not a forged webhook. log.Println(err) w.WriteHeader(http.StatusInternalServerError) return case errors.As(err, &invalid): w.WriteHeader(http.StatusBadRequest) return case err != nil: w.WriteHeader(http.StatusInternalServerError) return }
switch e := event.(type) { case *flashpi.WebhookEndpointVerificationEvent: // The endpoint's activation: answer the challenge. w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"challenge": e.Data.Challenge}) return case *flashpi.DocumentEvent: // e.Data is the document switch e.Type { case flashpi.DocumentEventTypeInvoiceAuthorized: log.Println("Authorized", e.Data.AccessKey) case flashpi.DocumentEventTypeInvoiceRejected: log.Println("Rejected", e.Data.SRIMessages) } } w.WriteHeader(http.StatusNoContent)}Con chi, el mismo handler sirve tal cual; con Gin, Echo o Fiber, pasa igual el cuerpo sin procesar y los encabezados de la solicitud. Responde 400 a un webhook que no pasa la verificación, salvo flashpi.WebhookInvalidSecret: un secreto vacío o mal copiado es un error de tu configuración, no del webhook, así que regístralo y responde 500 (Flashpi lo reintentará).
Guarda event.Info().ID (el mismo valor del encabezado webhook-id) y, si se repite, responde 2xx sin volver a hacer el trabajo: un evento puede llegar más de una vez.
El ejemplo supone el modo full del endpoint, el predeterminado. En el modo thin los eventos, salvo la verificación, llegan sin data como un *flashpi.ThinEvent: lee el evento con client.Events.Get(ctx, e.ID). Un tipo de evento que esta versión del SDK no conoce llega como *flashpi.UnknownEvent. Consulta Webhooks y su Contenido.
Flashpi::Webhooks.unwrap verifica la firma con el cuerpo tal como llegó, sin interpretarlo, y el secreto whsec_ del endpoint, y te devuelve el evento, con claves como símbolos. No necesita API key (también está en flashpi.webhooks). Acepta las cabeceras como un hash, el env de Rack o el request.headers de Rails. Con Rails:
# Route in config/routes.rb: post "/webhooks/flashpi", to: "flashpi_webhooks#create"class FlashpiWebhooksController < ApplicationController skip_forgery_protection
def create event = Flashpi::Webhooks.unwrap(request.raw_post, request.headers, ENV.fetch("FLASHPI_WEBHOOK_SECRET"))
case event[:type] when "webhook_endpoint.verification" # The endpoint's activation: answer the challenge. return render(json: { challenge: event[:data][:challenge] }) when "invoice.authorized" Rails.logger.info("Authorized #{event[:data][:accessKey]}") # event[:data] is the document when "invoice.rejected" Rails.logger.info("Rejected #{event[:data][:sriMessages]}") end head :no_content rescue Flashpi::WebhookVerificationError head :bad_request endendEn una app solo API (ActionController::API), quita skip_forgery_protection: esas apps no tienen protección CSRF y el método no existe.
Con Sinatra, o cualquier app Rack con su env:
require "sinatra"require "flashpi"
post "/webhooks/flashpi" do event = Flashpi::Webhooks.unwrap(request.body.read, request.env, ENV.fetch("FLASHPI_WEBHOOK_SECRET")) if event[:type] == "webhook_endpoint.verification" content_type :json JSON.generate({ challenge: event[:data][:challenge] }) else handle_event(event) # your code, like the branches of the Rails example 204 endrescue Flashpi::WebhookVerificationError 400endGuarda el event[:id] (el mismo valor del encabezado webhook-id) y, si se repite, responde 2xx sin volver a hacer el trabajo: un evento puede llegar más de una vez.
Los ejemplos suponen el modo full del endpoint, el predeterminado. En el modo thin los eventos, salvo la verificación, llegan sin data: lee el evento con flashpi.events.get(event[:id]). Consulta Webhooks y su Contenido.
El README del SDK documenta el resto: la consulta de comprobantes (XML, RIDE en PDF y reenvío por correo), la creación de emisores desde su .p12 y sus certificados, las opciones de cada llamada y cómo leer la respuesta HTTP completa.
El README del SDK documenta el resto: la consulta de comprobantes (XML, RIDE en PDF y reenvío por correo), la creación de emisores desde su .p12 y sus certificados, las opciones del cliente, el envío de las solicitudes con tu propio cliente de httpx y cómo leer la respuesta HTTP completa (flashpi.documents.with_response.get(document_id)).
El README del SDK documenta el resto: la consulta de comprobantes (XML, RIDE en PDF y reenvío por correo), la creación de emisores desde su .p12 y sus certificados, las opciones del cliente (new Flashpi(apiKey: ..., timeout: ..., maxRetries: ...)), el envío de las solicitudes con tu propio cliente de Guzzle (httpClient) y cómo leer la respuesta HTTP completa ($flashpi->documents->withResponse()->get($documentId)).
El README del SDK documenta el resto: la consulta de comprobantes (XML, RIDE en PDF y reenvío por correo), la creación de emisores desde su .p12 y sus certificados, las opciones del cliente, el envío de las solicitudes con tu propio java.net.http.HttpClient (por ejemplo, con un proxy), cómo leer la respuesta HTTP completa (flashpi.documents().withResponse().get(documentId)) y cómo usar los modelos con Spring Boot 4, que usa Jackson 3.
El README del SDK documenta el resto: la consulta de comprobantes (XML, RIDE en PDF y reenvío por correo), la creación de emisores desde su .p12 y sus certificados, las opciones del cliente, el envío de las solicitudes con tu propio HttpClient (por ejemplo, con un proxy) y cómo leer la respuesta HTTP completa (flashpi.Documents.WithResponse.GetAsync(documentId)).
El README del SDK documenta el resto: la consulta de comprobantes (XML, RIDE en PDF y reenvío por correo), la creación de emisores desde su .p12 y sus certificados, los campos que se pueden enviar como null (flashpi.Nullable), las opciones del cliente, el envío de las solicitudes con tu propio *http.Client y cómo leer la respuesta HTTP completa (flashpi.WithResponseInto(&response)). La referencia de cada tipo y método está en pkg.go.dev.
El README del SDK documenta el resto: la consulta de comprobantes (XML, RIDE en PDF y reenvío por correo), la creación de emisores desde su .p12 y sus certificados, las opciones del cliente, los tipos RBS con Steep, cómo responder en lugar de la API en tus pruebas con WebMock o VCR y cómo leer la respuesta HTTP completa (flashpi.documents.with_response.get(document_id)).
Planificado
Sección titulada «Planificado»- Un paquete para Laravel, aparte del SDK de PHP. Mientras tanto,
flashpi/flashpi-phpfunciona en Laravel tal como es: la pestaña PHP de esta página muestra cómo registrarlo en un service provider y cómo recibir los webhooks en un controlador.