Webhooks
Ishtaran entrega webhooks como notificaciones HTTP POST en tiempo real cada vez que algo cambia
en la plataforma. Esta página documenta el contrato exacto y actual — headers, algoritmo de
firma, formato del payload, catálogo de eventos y semántica de entrega — extraído directamente de
la implementación real, no es aspiracional.
1. Configuración
Registra un WebhookEndpoint vía POST /v1/organizations/{organizationId}/webhook-endpoints/
(Member JWT, Permissions.WebhookEndpointManage). El cuerpo de la solicitud es solo
{ "url": "..." }.
La respuesta incluye un secret — mostrado por completo exactamente una vez, en esa
respuesta. Ningún GET posterior sobre el endpoint vuelve a devolverlo (GET /v1/webhook-endpoints/{id} omite el campo deliberadamente). Guárdalo de inmediato en tu propio
gestor de secretos.
Para rotar el secret, llama a POST /v1/webhook-endpoints/{webhookEndpointId}/rotate-secret.
El nuevo secret se devuelve una vez, de la misma forma. La rotación es un sobrescritura
inmediata — el secret anterior deja de funcionar en el instante en que la rotación se completa.
Hoy no existe periodo de gracia ni ventana con dos secrets válidos a la vez: planifica tu
rotación como un corte atómico único (actualiza tu verificador con el nuevo secret antes o
inmediatamente después de llamar a rotate, nunca en un cronograma perezoso).
Un WebhookEndpoint está delimitado a toda la Organization, no a un tipo de evento específico —
todo endpoint activo recibe todos los eventos del catálogo de abajo (hoy no existe filtro de
suscripción por tipo de evento por endpoint). Desactiva un endpoint vía
POST /v1/webhook-endpoints/{webhookEndpointId}/deactivate.
2. Headers
Toda entrega de webhook lleva exactamente estos tres headers:
| Header | Significado |
|---|---|
X-Webhook-Signature | Firma HMAC-SHA256, hex en minúsculas (ver abajo). |
X-Webhook-Timestamp | Hora Unix en segundos, como string, en el momento en que se firmó la entrega. |
X-Webhook-Delivery-Id | El ID único de este intento de entrega — úsalo para deduplicación (§8). |
No existe el header X-Webhook-Event-Type — ver §6 para cómo descubrir el tipo de evento, y la
brecha que esto representa.
3. Firma
Algoritmo: HMAC-SHA256. El contenido firmado es el timestamp y el cuerpo bruto de la solicitud unidos por un punto:
signedContent = "{unixTimestampSeconds}.{rawBody}"
signature = lowercase_hex(HMAC_SHA256(secret, signedContent))
rawBodydebe ser exactamente los bytes que recibiste — nunca vuelvas a analizar y re-serializar el JSON antes de validar. Re-serializar puede cambiar silenciosamente el orden de las claves o el espaciado y romper la comparación aunque el "significado" del payload no haya cambiado.- La codificación de salida es hexadecimal en minúsculas (no base64).
- Hoy no existe versionado del esquema de firma (ningún prefijo
v1=/t=como en otros proveedores) —X-Webhook-Signaturees el digest hex crudo. - Compara siempre usando una comparación de tiempo constante (
crypto.timingSafeEqual,hmac.compare_digest,MessageDigest.isEqual/bucle manual de tiempo constante,crypto/subtle, según tu lenguaje) — nunca==/.equals()sobre las dos strings.
Los 4 SDKs oficiales ya implementan esto exactamente así — ver §10.
4. Timestamp / protección contra replay
X-Webhook-Timestamp es Unix en segundos. Ishtaran no impone una ventana de tolerancia del
lado de quien envía — hacer cumplir la ventana de frescura/replay es tu responsabilidad como
receptor, igual que con la mayoría de los proveedores de webhook. Los 4 SDKs oficiales usan por
defecto una tolerancia de 300 segundos (5 minutos) al validar, un valor razonable para
estandarizar si implementas tu propia verificación fuera de los SDKs. Rechaza una entrega cuyo
timestamp sea más antiguo (o, para protegerte del clock skew, más futuro) que tu tolerancia,
incluso si la firma en sí es válida.
5. Payload
Contrato actual: el cuerpo HTTP son los datos del evento serializados directamente — no existe envoltorio. Concretamente:
- Sin wrapper
{ "id": ..., "type": ..., "data": {...} }. El cuerpo es los campos del evento. - Sin campo
typedentro del cuerpo (ver §6). - Sin campo
created_atdentro del cuerpo — usaX-Webhook-Timestamppara la hora de entrega, o busca el recurso para su propio campo de timestamp autoritativo. - El casing de los campos es PascalCase (ej.:
SettlementId,TransactionId) — esto difiere del camelCase usado en el resto de las respuestas JSON de la API REST pública. No asumas camelCase al parsear el cuerpo de un webhook.
Para encontrar el ID relevante de un evento, mira el formato de payload de ese evento en el
catálogo de abajo — todo evento lleva al menos el ID del aggregate al que se refiere
(SettlementId, WithdrawalId, DepositId, TransactionId, PaymentIntentId, RefundId, o
WithdrawalDestinationId, según el evento).
6. Tipo de evento
CONTRATO ACTUAL: la string del tipo de evento (ej.: settlement.executed) no está
incluida en ninguna parte de la entrega en sí — ni en un header, ni en el cuerpo. Para saber qué
tipo de evento representa una entrega, busca sus metadatos en la plataforma:
GET /v1/webhook-deliveries/{webhookDeliveryId} (el header
X-Webhook-Delivery-Id te da este ID) devuelve un campo EventType, o lista/filtra entregas de
un endpoint vía
GET /v1/webhook-endpoints/{webhookEndpointId}/deliveries?eventType=....
En la práctica, la mayoría de los integradores infiere el evento a partir de qué campos de ID
están presentes en el payload (ej.: un cuerpo con SettlementId y sin WithdrawalId es un
evento de Settlement) combinado con saber en qué endpoint/environment llegó — funciona, pero no
es ergonómico.
BRECHA DE PRODUCTO/DX: un header X-Ishtaran-Event-Type (o un envoltorio versionado que
lleve type) eliminaría la necesidad de esa llamada extra o de inferir por los campos presentes.
Esta es una brecha real, registrada — no implementada en esta ronda. Si estás construyendo contra
webhooks hoy, trata el tener que buscar el tipo de evento como una limitación conocida, no un bug
en tu integración.
7. Catálogo de eventos
Solo entran aquí los eventos que efectivamente se despachan por el pipeline de entrega
(rastreados hasta un handler activo que crea una WebhookDelivery por endpoint activo,
confirmado en el código fuente — no inferido a partir de un registro de eventos). Todos siguen el
patrón <aggregate>.<evento> en snake_case.
Transaction
| Evento | Cuándo | Campos principales | ID del aggregate |
|---|---|---|---|
transaction.created | Se crea una Transaction. | ApplicationId, ParticipantAccountIds | TransactionId |
transaction.funded | Una Transaction queda totalmente financiada. | — | TransactionId |
transaction.reserved | Se reserva saldo para una Transaction. | Amount | TransactionId |
transaction.cancelled | Se cancela una Transaction. | Reason | TransactionId |
transaction.frozen | Se congela una Transaction (acción de Member). | Reason, ActorMemberId | TransactionId |
transaction.unfrozen | Se descongela una Transaction congelada. | ActorMemberId | TransactionId |
transaction.settled | Una Transaction llega a un Settlement (total o parcial). | SettlementId, IsTotal | TransactionId |
transaction.refunded | Una Transaction es reembolsada (total o parcialmente). | RefundId, IsTotal | TransactionId |
PaymentIntent
| Evento | Cuándo | Campos principales | ID del aggregate |
|---|---|---|---|
payment_intent.created | Se crea un PaymentIntent. | TransactionId, Amount, ExpiresAt | PaymentIntentId |
payment_intent.cancelled | Se cancela un PaymentIntent. | — | PaymentIntentId |
payment_intent.expired | Un PaymentIntent expira sin ser financiado. | — | PaymentIntentId |
payment_intent.late_deposit_received | Llega un depósito después de que el PaymentIntent ya expiró. | TransactionId, DepositId, Amount, ExpiredAt, ReceivedAt | PaymentIntentId |
Deposit
| Evento | Cuándo | Campos principales | ID del aggregate |
|---|---|---|---|
deposit.address_generated | Se asigna una dirección de depósito para un PaymentIntent. | Address, AssetNetworkId | PaymentIntentId |
deposit.detected | Se ve por primera vez un depósito on-chain, aún sin confirmar. | PaymentIntentId, Amount | DepositId |
deposit.confirming | El conteo de confirmaciones está avanzando. | ConfirmationCount | DepositId |
deposit.confirmed | El depósito alcanza la profundidad de confirmación requerida. | PaymentIntentId, TransactionId, Amount | DepositId |
deposit.rejected | Se rechaza el depósito. | Reason | DepositId |
deposit.reorg_frozen | Un reorg de la chain pone en duda un depósito ya visto. | PaymentIntentId, Amount | DepositId |
deposit.under_review | El depósito se marca para revisión manual. | Reason | DepositId |
Settlement / Refund
| Evento | Cuándo | Campos principales | ID del aggregate |
|---|---|---|---|
settlement.executed | Un Settlement (total o parcial) se completa con éxito, incluso cuando hay asignaciones retenidas. | TransactionId, AssetNetworkId, GrossAmount, DistributableAmount, PlatformFeeAmount, ExecutedAt | SettlementId |
settlement.failed | Falla un intento de Settlement. | TransactionId, Reason, FailedAt | SettlementId |
settlement.fee_applied | Se aplica el Platform Fee de un Settlement. | PricingPolicyId, FeeAmount, FeePercentageApplied | SettlementId |
settlement.split_portion_retained | Una asignación de Split se retiene en vez de liberarse. | AllocationId, ParticipantId, AccountId, Amount, Reason | SettlementId |
settlement.split_portion_released | Una asignación de Split se libera a su Account beneficiaria. | AllocationId, AccountId, Amount | SettlementId |
refund.executed | Se completa un Refund. | TransactionId, Amount, ExecutedAt | RefundId |
refund.rejected | Se rechaza un intento de Refund. | TransactionId, Reason | RefundId |
Withdrawal
| Evento | Cuándo | Campos principales | ID del aggregate |
|---|---|---|---|
withdrawal.requested | Se solicita un Withdrawal. | AccountId, Amount, WithdrawalDestinationId | WithdrawalId |
withdrawal.approved | Se aprueba un Withdrawal. | ActorMemberId | WithdrawalId |
withdrawal.rejected | Se rechaza un Withdrawal. | Reason | WithdrawalId |
withdrawal.cancelled | Se cancela un Withdrawal. | — | WithdrawalId |
withdrawal.broadcast | La transacción de retiro se transmite on-chain. | TechnicalReference | WithdrawalId |
withdrawal.broadcast_failed | Falla el intento de broadcast. | Reason | WithdrawalId |
withdrawal.confirmed | La transacción transmitida alcanza las confirmaciones requeridas. | TechnicalReference | WithdrawalId |
withdrawal.failed | El Withdrawal falla de forma terminal. | Reason | WithdrawalId |
withdrawal.requires_reconciliation | El Withdrawal necesita reconciliación manual. | — | WithdrawalId |
withdrawal_destination.registered | Se registra un WithdrawalDestination para una Organization. | OrganizationId, Address | WithdrawalDestinationId |
No existe evento signing_request.*, ni evento settlement.confirming — la firma en SelfCustody
no forma parte del catálogo de webhook en sí (ver la nota abajo).
Una nota sobre SelfCustody: en ejecución SelfCustody, la firma ocurre localmente por ti (o
por el dispositivo de tu integrador) contra un SigningRequest que la plataforma te entrega —
ese flujo es de solicitud/respuesta (POST/GET sobre SigningRequest), no impulsado por
webhook. Lo que sí está impulsado por webhook es el resultado una vez que la ejecución
confirma — ej.: withdrawal.broadcast/withdrawal.confirmed para un Withdrawal, o
settlement.executed en cuanto las transacciones firmadas de un Settlement SelfCustody
confirman. No esperes un webhook para saber que un SigningRequest necesita firmarse — eso es un
paso síncrono en tu propio flujo, descrito en SelfCustody.
8. Semántica de entrega
- At-least-once, nunca exactly-once. El mismo evento puede producir más de una entrega (ej.:
si el evento de Outbox subyacente se reentrega internamente) — deduplica siempre por
X-Webhook-Delivery-Id, nunca por el contenido del evento. - 2xx = éxito. Cualquier respuesta
2xxmarca la entrega como entregada; cualquier otra cosa (incluyendo fallo de red o timeout) se trata como fallo y se reintenta. - Retry/backoff: 30 segundos de delay base, duplicándose en cada intento, con un tope de 24 horas, con jitter de ±20% aplicado a cada delay.
- Máximo de intentos: 10. Después del décimo intento fallido, la entrega pasa a un estado de dead-letter y deja de reintentarse automáticamente.
- Reenvío manual: una entrega en dead-letter se puede reenviar vía
POST /v1/webhook-deliveries/{webhookDeliveryId}/redeliver, que crea una entrega nueva (con su propioX-Webhook-Delivery-Idnuevo, contador de intentos en cero, vinculada de vuelta a la original). - Consulta el estado/historial de intentos de cualquier entrega vía
GET /v1/webhook-deliveries/{webhookDeliveryId}, o lista el historial de un endpoint víaGET /v1/webhook-endpoints/{webhookEndpointId}/deliveries.
9. Ordenamiento
Las entregas no tienen garantía de llegar en orden. Se incluye un SequenceNumber por
endpoint en los metadatos de entrega solo como una pista, nunca una garantía estricta — los
reintentos y el backoff significan que un evento posterior puede entregarse antes de que el
reintento de uno anterior finalmente tenga éxito.
Trata todo webhook como una notificación para volver a consultar el estado, no como el estado en sí:
- Si el estado actual de un Aggregate importa para tu lógica (no solo "algo pasó"), haz
GETsobre el Aggregate (Settlement, Withdrawal, Transaction, ...) después de recibir su webhook y trata esa respuesta como la fuente de la verdad, no el snapshot del payload del webhook. - Diseña los handlers para que sean seguros si el webhook del mismo evento conceptual llega dos
veces, o si un evento "posterior" (ej.:
withdrawal.confirmed) llega antes de uno "anterior" que aún no terminaste de procesar (ej.:withdrawal.broadcast).
10. Validar una entrega — ejemplo completo
Los 4 SDKs oficiales implementan verificación HMAC-SHA256 idéntica, con tolerancia por defecto de 300 segundos y comparación de tiempo constante — úsala en vez de reimplementar §3/§4 tú mismo.
- Node.js/TypeScript
- Python
- Java
- Go
import express from 'express';
import { IshtaranClient, Environment } from '@ishtaran/sdk';
const client = IshtaranClient.create({ apiKey: process.env.ISHTARAN_API_KEY, environment: Environment.Sandbox });
const seenDeliveryIds = new Set<string>(); // usa un almacén real (DB/cache) en producción
const app = express();
app.post('/webhooks/ishtaran', express.text({ type: '*/*' }), (req, res) => {
const rawBody = req.body as string; // texto crudo, nunca pre-parseado como JSON
const signature = req.header('X-Webhook-Signature') ?? '';
const timestamp = req.header('X-Webhook-Timestamp') ?? '';
const deliveryId = req.header('X-Webhook-Delivery-Id') ?? '';
if (!client.verifyWebhookSignature(rawBody, signature, timestamp, process.env.WEBHOOK_SECRET!)) {
return res.status(401).send('invalid signature');
}
if (seenDeliveryIds.has(deliveryId)) {
return res.status(200).send('already processed'); // dedupe -- sigue siendo 2xx
}
seenDeliveryIds.add(deliveryId);
const payload = JSON.parse(rawBody); // parsea solo después de validar
// ... busca el tipo de evento vía GET /v1/webhook-deliveries/{deliveryId} si lo necesitas (§6),
// o decide por el campo de ID presente; luego busca el Aggregate como fuente de la verdad.
res.status(200).send('ok');
});
from flask import Flask, request, Response
from ishtaran import IshtaranClient, Environment
import json, os
client = IshtaranClient.create(api_key=os.environ["ISHTARAN_API_KEY"], environment=Environment.SANDBOX)
seen_delivery_ids = set() # usa un almacén real (DB/cache) en producción
app = Flask(__name__)
@app.post("/webhooks/ishtaran")
def receive_webhook():
raw_body = request.get_data(as_text=True) # texto crudo, nunca pre-parseado como JSON
signature = request.headers.get("X-Webhook-Signature", "")
timestamp = request.headers.get("X-Webhook-Timestamp", "")
delivery_id = request.headers.get("X-Webhook-Delivery-Id", "")
if not client.verify_webhook_signature(raw_body, signature, timestamp, os.environ["WEBHOOK_SECRET"]):
return Response("invalid signature", status=401)
if delivery_id in seen_delivery_ids:
return Response("already processed", status=200) # dedupe -- sigue siendo 2xx
seen_delivery_ids.add(delivery_id)
payload = json.loads(raw_body) # parsea solo después de validar
# ... busca el tipo de evento vía GET /v1/webhook-deliveries/{delivery_id} si lo necesitas (§6),
# o decide por el campo de ID presente; luego busca el Aggregate como fuente de la verdad.
return Response("ok", status=200)
import com.ishtaran.sdk.webhook.WebhookSignatureVerifier;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
Set<String> seenDeliveryIds = ConcurrentHashMap.newKeySet(); // usa un almacén real (DB/cache) en producción
// Dentro de tu handler HTTP (Spring/Javalin/servlet plano -- mostrado como pseudocódigo para la parte de framework):
String rawBody = readBodyExactlyAsReceived(request); // texto crudo, nunca pre-parseado como JSON
String signature = request.getHeader("X-Webhook-Signature");
String timestamp = request.getHeader("X-Webhook-Timestamp");
String deliveryId = request.getHeader("X-Webhook-Delivery-Id");
if (!WebhookSignatureVerifier.verify(rawBody, signature, timestamp, System.getenv("WEBHOOK_SECRET"))) {
response.setStatus(401);
return;
}
if (seenDeliveryIds.contains(deliveryId)) {
response.setStatus(200); // dedupe -- sigue siendo 2xx
return;
}
seenDeliveryIds.add(deliveryId);
var payload = JsonCodec.mapper().readTree(rawBody); // parsea solo después de validar
// ... busca el tipo de evento vía GET /v1/webhook-deliveries/{deliveryId} si lo necesitas (§6),
// o decide por el campo de ID presente; luego busca el Aggregate como fuente de la verdad.
response.setStatus(200);
import (
"io"
"net/http"
"sync"
ishtaran "github.com/taylorjeftedasilva/ishtaran-go"
)
var seenDeliveryIDs sync.Map // usa un almacén real (DB/cache) en producción
func handleWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body) // bytes crudos, nunca pre-parseados como JSON
signature := r.Header.Get("X-Webhook-Signature")
timestamp := r.Header.Get("X-Webhook-Timestamp")
deliveryID := r.Header.Get("X-Webhook-Delivery-Id")
if !ishtaran.VerifyWebhookSignature(string(rawBody), signature, timestamp, webhookSecret) {
w.WriteHeader(http.StatusUnauthorized)
return
}
if _, alreadySeen := seenDeliveryIDs.LoadOrStore(deliveryID, true); alreadySeen {
w.WriteHeader(http.StatusOK) // dedupe -- sigue siendo 2xx
return
}
var payload map[string]any
json.Unmarshal(rawBody, &payload) // parsea solo después de validar
// ... busca el tipo de evento vía GET /v1/webhook-deliveries/{deliveryId} si lo necesitas (§6),
// o decide por el campo de ID presente; luego busca el Aggregate como fuente de la verdad.
w.WriteHeader(http.StatusOK)
}
Para una demostración solo de firma, sin servidor HTTP (útil para tests), ve el WEBHOOKS.md de
cada SDK y el ejemplo 10-webhook-verification.