Skip to content

Webhooks

Flashpi tells your systems what happened to your documents and certificates by sending webhooks: signed HTTPS POST requests to the URLs you register. They follow the Standard Webhooks specification, so any Standard Webhooks library can verify them, and the check is also short to write by hand.

  • Register an endpoint with POST /v1/webhook-endpoints (or in the console, under Webhooks): a public HTTPS URL and the event types it wants.
  • Before it receives events, the endpoint passes a verification handshake: it answers a challenge Flashpi sends to it.
  • Every request carries webhook-id, webhook-timestamp and webhook-signature. Verify the signature with the endpoint’s whsec_ secret before trusting the body.
  • Answer any 2xx within 10 seconds. Anything else is retried with backoff for 72 hours.
  • Delivery is at least once and in no guaranteed order: deduplicate on webhook-id.
  • Every attempt is in the delivery log and can be replayed. GET /v1/events lists the same events, to catch up after an outage.

Managing endpoints needs an account-scoped read_write key. The key’s environment matters: an endpoint created with a test key receives only test events, and one created with a live key only live events.

Terminal window
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"
}'
Field Notes
url A public HTTPS URL, without credentials or a fragment, up to 2048 characters. Flashpi refuses http://, localhost and other local names, and private, loopback or link-local addresses (422 with the field error url_not_allowed). It checks again on every delivery, against every address the host resolves to.
enabledEvents The event types the endpoint receives, or ["*"] for every type, including types added later.
payloadMode full (default): the event with a snapshot of the resource. thin: only the event’s ID and type. See Payloads.
description A label for yourself, up to 500 characters.

The answer is 201 Created with the endpoint in status pending_verification, and Flashpi starts the handshake right away. Read its signing secret with GET /v1/webhook-endpoints/{endpointId}/secret:

{ "secret": "whsec_Zmxhc2hwaS1kb2NzLWV4YW1wbGUtc2VjcmV0LTMyLWI=", "previousSecretExpiresAt": null }
Operation Endpoint
List (newest first), read GET /v1/webhook-endpoints, GET /v1/webhook-endpoints/{endpointId}
Change url, description, enabledEvents, payloadMode or disabled PATCH /v1/webhook-endpoints/{endpointId}
Delete it and its delivery log (the events stay) DELETE /v1/webhook-endpoints/{endpointId}
Read the signing secret GET /v1/webhook-endpoints/{endpointId}/secret
Rotate the secret POST /v1/webhook-endpoints/{endpointId}/rotate-secret
Start a new handshake POST /v1/webhook-endpoints/{endpointId}/verify
Delivery log, one delivery with every attempt GET .../{endpointId}/deliveries, GET .../deliveries/{deliveryId}
Replay a delivery POST .../deliveries/{deliveryId}/replay

An endpoint is pending_verification (receives nothing yet), active or disabled. Changing its URL sends it back to pending_verification with a new handshake. "disabled": true stops its deliveries (pending ones end with endpoint_disabled) and "disabled": false enables it again. Events that happen while an endpoint is pending or disabled are not queued for it; they stay in GET /v1/events. Flashpi never disables a failing endpoint on its own.

When an endpoint is created, when its URL changes, and when you call POST .../verify, Flashpi sends it a signed request (with the same headers as every webhook) with this body:

{
"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"
}
}

Answer with any 2xx whose body is {"challenge": "<data.challenge>"}, or the challenge alone as plain text. The endpoint becomes active.

If the attempt fails, Flashpi tries again after 1 minute, 5 minutes and 30 minutes. After that the endpoint stays pending_verification and its verification.lastError says why: challenge_mismatch, unexpected_status, timeout, connection_failed, dns_resolution_failed, tls_error, address_not_allowed or invalid_url. Fix the receiver and call POST /v1/webhook-endpoints/{endpointId}/verify.

Type data When
invoice.authorized, credit_note.authorized, debit_note.authorized, withholding.authorized, waybill.authorized, purchase_settlement.authorized Document The SRI authorized the document.
invoice.rejected, credit_note.rejected, debit_note.rejected, withholding.rejected, waybill.rejected, purchase_settlement.rejected Document The SRI rejected it; sriMessages says why.
certificate.verified Certificate It was verified not revoked: when it is accepted, or when a pending status is verified.
certificate.pending Certificate Its revocation status could not be verified: it signs until revocation.blockAt. Raised again with each reminder, on days 3 and 6.
certificate.blocked Certificate It stopped signing: still unverified at blockAt, or its chain is no longer trusted.
certificate.revoked Certificate Its certification authority revoked it; it never signs again.
certificate.reactivated Certificate A blocked certificate was verified good and signs again.
document.ride.generated, document.ride.failed Document The RIDE was generated, or could not be.
document.email.sent, document.email.delivered, document.email.bounced, document.email.complained, document.email.failed Document The email to the buyer changed state.
account.email_paused, account.email_resumed Email sending status The account’s document emails in the event’s environment were paused (too many bounces or complaints, or Flashpi’s team paused them), or Flashpi’s team resumed them. See Paused emails.

The data of the account.* events is the account’s email sending status in the event’s environment: status (active or paused), pausedAt, pauseReason (bounce_rate, complaint_rate or staff) and window, with the last 7 days’ sent, hardBounces and complaints. They have no issuer, so an issuer-scoped key does not see them in GET /v1/events.

The certificate.* events of a certificate that serves both environments (allModes) are recorded once per environment, so your test endpoints and your live ones both get them.

A document that ends failed raises no event yet. See Document lifecycle and Certificates.

The body is the event, the same object GET /v1/events/{eventId} returns:

{
"id": "01a14116-278e-7c99-b3ce-adb0dc1e7960",
"type": "invoice.authorized",
"environment": "test",
"createdAt": "2026-10-15T19:42:13.390Z",
"data": {
"id": "01a14116-1c50-73e7-8aab-349c93e541da",
"type": "invoice",
"environment": "test",
"status": "authorized",
"issuerId": "01a0f7fa-bd80-7d58-8d62-73a4adc597cb",
"establishment": "001",
"emissionPoint": "001",
"sequential": 4821,
"number": "001-001-000004821",
"accessKey": "1510202601099341137200110010010000048217014625315",
"issueDate": "2026-10-15",
"xmlVersion": "2.1.0",
"counterparty": { "id": "1790000000001", "name": "Supermercados La Pradera S.A." },
"totals": {
"subtotal": "450.00",
"discount": "0.00",
"taxes": [{ "type": "vat", "rate": "15", "taxableBase": "450.00", "amount": "67.50" }],
"tip": "0.00",
"total": "517.50"
},
"authorization": {
"number": "1510202601099341137200110010010000048217014625315",
"authorizedAt": "2026-10-15T19:42:13.000Z"
},
"ride": { "status": "pending", "template": null, "generatedAt": null },
"email": { "requested": true, "status": "queued", "deliveries": [], "warnings": [] },
"sriMessages": [
{
"code": "test_environment",
"message": "The document was processed in the SRI test environment and has no tax validity.",
"severity": "warning"
}
],
"failure": null,
"voidedAt": null,
"createdAt": "2026-10-15T19:42:10.512Z",
"updatedAt": "2026-10-15T19:42:13.204Z"
}
}
  • id is the event’s ID and the webhook-id header: the same on every attempt and replay.
  • environment is test or live.
  • data is the resource as it was when the event happened (a document or a certificate), with English texts. It is not the current state: read GET /v1/documents/{id} for that.

Thin mode ("payloadMode": "thin") sends only the event’s envelope:

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

Fetch the snapshot with GET /v1/events/{eventId} and your API key. Thin mode suits receivers that want every read of your data to be authenticated with a key.

Header Value
webhook-id The event’s ID (UUIDv7). Deduplicate on it.
webhook-timestamp Unix time, in seconds, of this attempt.
webhook-signature One or more v1,<base64> signatures separated by spaces: one per valid secret (two during a rotation).
content-type application/json
user-agent Flashpi-Webhooks/1.0 (+https://flashpi.com.ec/docs/webhooks)

Flashpi publishes no fixed source IP addresses and does not encrypt bodies beyond HTTPS: the signature is what proves a request comes from Flashpi.

  • Any 2xx acknowledges the delivery. Answer fast (the limit is 10 seconds) and do the work afterwards, for example in a queue.
  • Redirects are never followed: a 3xx counts as a failure, like a 4xx, a 5xx, a timeout or a refused connection.
  • A failed attempt is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours and 5 hours, then every 10 hours, until 72 hours after the first attempt: 13 attempts at most. The delivery is then failed.
  • While an endpoint is being verified again (its URL changed), its pending deliveries wait.

GET /v1/webhook-endpoints/{endpointId}/deliveries lists the deliveries of an endpoint, newest first (filters status, eventType, eventId), and the console shows the same log. A delivery is pending (an attempt is scheduled or running), succeeded or failed, with its attemptCount, nextAttemptAt, lastResponseStatus, lastError and retryUntil. GET .../deliveries/{deliveryId} adds every attempt with its status code, duration and error. The errors are unexpected_status (an answer other than 2xx), timeout, connection_failed, dns_resolution_failed, tls_error, address_not_allowed, invalid_url, and, without an attempt, endpoint_disabled and endpoint_not_verified.

POST /v1/webhook-endpoints/{endpointId}/deliveries/{deliveryId}/replay sends a delivery that succeeded or failed once more, right away, with the same webhook-id and a new timestamp and signature. It answers 409 with webhook_delivery_in_progress while the delivery is still pending, and webhook_endpoint_disabled or webhook_endpoint_not_verified when the endpoint cannot receive it.

Always verify before parsing or acting on the body, against the raw bytes you received: JSON parsed and serialized again does not match the signature.

  1. Read webhook-id, webhook-timestamp and webhook-signature. Refuse the request if one is missing.
  2. Refuse a webhook-timestamp more than 5 minutes away from your clock. This stops replays of an old request.
  3. Build the signed content: <webhook-id>.<webhook-timestamp>.<raw body>.
  4. Decode the secret: the part after whsec_, base64-decoded, is the HMAC key.
  5. Compute the HMAC-SHA256 of the signed content with that key, and encode it in base64.
  6. Split webhook-signature on spaces; each entry is <version>,<signature>. Accept the request if an entry with version v1 matches, comparing in constant time.

Then deduplicate on webhook-id and answer 2xx.

The official SDKs do this check: unwrapWebhook in @flashpi/node for Node.js and TypeScript, unwrap_webhook in flashpi for Python, Flashpi::unwrapWebhook in flashpi/flashpi-php for PHP, new Webhooks().unwrap (or flashpi.webhooks().unwrap) in ec.com.flashpi:flashpi-java for Java and Kotlin, Webhooks.Unwrap in Flashpi for C# and .NET, flashpi.UnwrapWebhook in github.com/flashpi1/flashpi-go for Go and Flashpi::Webhooks.unwrap in the flashpi gem for Ruby (see SDKs). In any language, with an SDK or without one, the official Standard Webhooks libraries work too: Flashpi follows the specification exactly, so they work unchanged and accept the whsec_ secret as Flashpi gives it.

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

Every example below uses only the language’s standard library. Header names are lowercase; pass the raw body.

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)
})
}

A receiver with Express that also answers the verification 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()
})

Check your implementation with these values (a made-up secret). With your clock set to webhook-timestamp, the signature must be valid; change one byte of the body and it must not.

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

POST /v1/webhook-endpoints/{endpointId}/rotate-secret with {"previousSecretExpiresInHours": 24} creates a new secret. The previous one keeps signing next to it for that many hours (24 by default, at most 168; 0 drops it at once), so every request carries one signature per secret meanwhile:

  1. Rotate, then read the new secret with GET .../secret.
  2. Deploy it to your receiver. Your verification keeps passing with either secret, because the request carries both signatures.
  3. The previous secret stops signing at previousSecretExpiresAt.

Rotating again during an overlap drops the oldest secret at once. In the console, reading or rotating a secret asks for your authenticator code (or your password, if you have no authenticator).

GET /v1/events lists the events of your key’s environment, newest first, with the filters type, issuerId, documentId and certificateId and cursor pagination. GET /v1/events/{eventId} reads one. Any key can read them, read_only included; an issuer-scoped key sees only its issuer’s events. Use them to catch up after downtime, or to read the snapshot of a thin delivery.

  • Verify first, against the raw body, and refuse anything that fails.
  • Deduplicate on webhook-id: store the IDs you processed, and acknowledge a repeated one without doing the work again.
  • Acknowledge fast: put the event in a queue, answer 2xx, and process it afterwards.
  • Do not rely on the order: events can arrive out of order. Use the snapshot’s status and updatedAt, or read the document, instead of the arrival order.
  • Watch the delivery log in the console, and replay deliveries once your receiver is fixed.