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.
At a glance
Section titled “At a glance”- 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-timestampandwebhook-signature. Verify the signature with the endpoint’swhsec_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/eventslists the same events, to catch up after an outage.
Register an endpoint
Section titled “Register an endpoint”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.
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.
Verification handshake
Section titled “Verification handshake”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.
Event types
Section titled “Event types”| 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.
Payloads
Section titled “Payloads”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" }}idis the event’s ID and thewebhook-idheader: the same on every attempt and replay.environmentistestorlive.datais the resource as it was when the event happened (a document or a certificate), with English texts. It is not the current state: readGET /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.
Headers
Section titled “Headers”| 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.
Responding and retries
Section titled “Responding and retries”- 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.
Delivery log and replays
Section titled “Delivery log and replays”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.
Verify signatures
Section titled “Verify signatures”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.
- Read
webhook-id,webhook-timestampandwebhook-signature. Refuse the request if one is missing. - Refuse a
webhook-timestampmore than 5 minutes away from your clock. This stops replays of an old request. - Build the signed content:
<webhook-id>.<webhook-timestamp>.<raw body>. - Decode the secret: the part after
whsec_, base64-decoded, is the HMAC key. - Compute the HMAC-SHA256 of the signed content with that key, and encode it in base64.
- Split
webhook-signatureon spaces; each entry is<version>,<signature>. Accept the request if an entry with versionv1matches, comparing in constant time.
Then deduplicate on webhook-id and answer 2xx.
With a library
Section titled “With a library”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 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)Without a library
Section titled “Without a library”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()})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 FalseWith Flask, request.get_data() is the raw body and request.headers is case-insensitive: pass {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; }}Call it with Instant.now().getEpochSecond() as 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; }}Call it with DateTimeOffset.UtcNow (.NET 6 or later, for 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}Call it with time.Now(); read the body with io.ReadAll(request.Body) before decoding it.
Test vector
Section titled “Test vector”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= |
Secret rotation
Section titled “Secret rotation”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:
- Rotate, then read the new secret with
GET .../secret. - Deploy it to your receiver. Your verification keeps passing with either secret, because the request carries both signatures.
- 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).
Events API
Section titled “Events API”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.
A robust receiver
Section titled “A robust receiver”- 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
statusandupdatedAt, or read the document, instead of the arrival order. - Watch the delivery log in the console, and replay deliveries once your receiver is fixed.