Saltar al contenido principal

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 secretmostrado 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:

HeaderSignificado
X-Webhook-SignatureFirma HMAC-SHA256, hex en minúsculas (ver abajo).
X-Webhook-TimestampHora Unix en segundos, como string, en el momento en que se firmó la entrega.
X-Webhook-Delivery-IdEl 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))
  • rawBody debe 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-Signature es 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 type dentro del cuerpo (ver §6).
  • Sin campo created_at dentro del cuerpo — usa X-Webhook-Timestamp para 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

EventoCuándoCampos principalesID del aggregate
transaction.createdSe crea una Transaction.ApplicationId, ParticipantAccountIdsTransactionId
transaction.fundedUna Transaction queda totalmente financiada.TransactionId
transaction.reservedSe reserva saldo para una Transaction.AmountTransactionId
transaction.cancelledSe cancela una Transaction.ReasonTransactionId
transaction.frozenSe congela una Transaction (acción de Member).Reason, ActorMemberIdTransactionId
transaction.unfrozenSe descongela una Transaction congelada.ActorMemberIdTransactionId
transaction.settledUna Transaction llega a un Settlement (total o parcial).SettlementId, IsTotalTransactionId
transaction.refundedUna Transaction es reembolsada (total o parcialmente).RefundId, IsTotalTransactionId

PaymentIntent

EventoCuándoCampos principalesID del aggregate
payment_intent.createdSe crea un PaymentIntent.TransactionId, Amount, ExpiresAtPaymentIntentId
payment_intent.cancelledSe cancela un PaymentIntent.PaymentIntentId
payment_intent.expiredUn PaymentIntent expira sin ser financiado.PaymentIntentId
payment_intent.late_deposit_receivedLlega un depósito después de que el PaymentIntent ya expiró.TransactionId, DepositId, Amount, ExpiredAt, ReceivedAtPaymentIntentId

Deposit

EventoCuándoCampos principalesID del aggregate
deposit.address_generatedSe asigna una dirección de depósito para un PaymentIntent.Address, AssetNetworkIdPaymentIntentId
deposit.detectedSe ve por primera vez un depósito on-chain, aún sin confirmar.PaymentIntentId, AmountDepositId
deposit.confirmingEl conteo de confirmaciones está avanzando.ConfirmationCountDepositId
deposit.confirmedEl depósito alcanza la profundidad de confirmación requerida.PaymentIntentId, TransactionId, AmountDepositId
deposit.rejectedSe rechaza el depósito.ReasonDepositId
deposit.reorg_frozenUn reorg de la chain pone en duda un depósito ya visto.PaymentIntentId, AmountDepositId
deposit.under_reviewEl depósito se marca para revisión manual.ReasonDepositId

Settlement / Refund

EventoCuándoCampos principalesID del aggregate
settlement.executedUn Settlement (total o parcial) se completa con éxito, incluso cuando hay asignaciones retenidas.TransactionId, AssetNetworkId, GrossAmount, DistributableAmount, PlatformFeeAmount, ExecutedAtSettlementId
settlement.failedFalla un intento de Settlement.TransactionId, Reason, FailedAtSettlementId
settlement.fee_appliedSe aplica el Platform Fee de un Settlement.PricingPolicyId, FeeAmount, FeePercentageAppliedSettlementId
settlement.split_portion_retainedUna asignación de Split se retiene en vez de liberarse.AllocationId, ParticipantId, AccountId, Amount, ReasonSettlementId
settlement.split_portion_releasedUna asignación de Split se libera a su Account beneficiaria.AllocationId, AccountId, AmountSettlementId
refund.executedSe completa un Refund.TransactionId, Amount, ExecutedAtRefundId
refund.rejectedSe rechaza un intento de Refund.TransactionId, ReasonRefundId

Withdrawal

EventoCuándoCampos principalesID del aggregate
withdrawal.requestedSe solicita un Withdrawal.AccountId, Amount, WithdrawalDestinationIdWithdrawalId
withdrawal.approvedSe aprueba un Withdrawal.ActorMemberIdWithdrawalId
withdrawal.rejectedSe rechaza un Withdrawal.ReasonWithdrawalId
withdrawal.cancelledSe cancela un Withdrawal.WithdrawalId
withdrawal.broadcastLa transacción de retiro se transmite on-chain.TechnicalReferenceWithdrawalId
withdrawal.broadcast_failedFalla el intento de broadcast.ReasonWithdrawalId
withdrawal.confirmedLa transacción transmitida alcanza las confirmaciones requeridas.TechnicalReferenceWithdrawalId
withdrawal.failedEl Withdrawal falla de forma terminal.ReasonWithdrawalId
withdrawal.requires_reconciliationEl Withdrawal necesita reconciliación manual.WithdrawalId
withdrawal_destination.registeredSe registra un WithdrawalDestination para una Organization.OrganizationId, AddressWithdrawalDestinationId

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 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 2xx marca 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 propio X-Webhook-Delivery-Id nuevo, 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ía GET /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 GET sobre 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.

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

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.