Pular para o conteúdo principal

Webhooks

A Ishtaran entrega webhooks como notificações HTTP POST em tempo real sempre que algo muda na plataforma. Esta página documenta o contrato exato e atual — headers, algoritmo de assinatura, formato do payload, catálogo de eventos e semântica de entrega — extraído diretamente da implementação real, não é aspiracional.

1. Configuração

Registre um WebhookEndpoint via POST /v1/organizations/{organizationId}/webhook-endpoints/ (Member JWT, Permissions.WebhookEndpointManage). O corpo da requisição é só { "url": "..." }.

A resposta inclui um secretmostrado por completo exatamente uma vez, nessa resposta. Nenhum GET subsequente no endpoint volta a retorná-lo (GET /v1/webhook-endpoints/{id} omite o campo deliberadamente). Guarde-o imediatamente no seu próprio gerenciador de segredos.

Para rotacionar o secret, chame POST /v1/webhook-endpoints/{webhookEndpointId}/rotate-secret. O novo secret é retornado uma vez, da mesma forma. A rotação é um overwrite imediato — o secret anterior para de funcionar no instante em que a rotação é concluída. Não existe hoje período de graça nem janela de dois secrets válidos simultaneamente: planeje sua rotação como um corte atômico único (atualize seu verificador com o novo secret antes ou imediatamente depois de chamar rotate, nunca em um cronograma preguiçoso).

Um WebhookEndpoint é escopado para a Organization inteira, não para um tipo de evento específico — todo endpoint ativo recebe todos os eventos do catálogo abaixo (não existe hoje filtro de assinatura por tipo de evento por endpoint). Desative um endpoint via POST /v1/webhook-endpoints/{webhookEndpointId}/deactivate.

2. Headers

Toda entrega de webhook carrega exatamente estes três headers:

HeaderSignificado
X-Webhook-SignatureAssinatura HMAC-SHA256, hex minúsculo (ver abaixo).
X-Webhook-TimestampHora Unix em segundos, como string, no momento em que a entrega foi assinada.
X-Webhook-Delivery-IdO ID único desta tentativa de entrega — use para deduplicação (§8).

Não existe header X-Webhook-Event-Type — veja §6 para como descobrir o tipo do evento, e a lacuna que isso representa.

3. Assinatura

Algoritmo: HMAC-SHA256. O conteúdo assinado é o timestamp e o corpo bruto da requisição unidos por um ponto:

signedContent = "{unixTimestampSeconds}.{rawBody}"
signature = lowercase_hex(HMAC_SHA256(secret, signedContent))
  • rawBody precisa ser exatamente os bytes que você recebeu — nunca reanalise e re-serialize o JSON antes de validar. Re-serializar pode mudar silenciosamente a ordem das chaves ou o espaçamento e quebrar a comparação mesmo que o "significado" do payload não tenha mudado.
  • A codificação de saída é hexadecimal minúsculo (não base64).
  • Não existe versionamento de esquema de assinatura hoje (nenhum prefixo v1=/t= como em alguns outros provedores) — X-Webhook-Signature é o digest hex bruto.
  • Sempre compare usando uma comparação de tempo constante (crypto.timingSafeEqual, hmac.compare_digest, MessageDigest.isEqual/loop manual de tempo constante, crypto/subtle, dependendo da sua linguagem) — nunca ==/.equals() nas duas strings.

Os 4 SDKs oficiais já implementam isso exatamente assim — veja §10.

4. Timestamp / proteção contra replay

X-Webhook-Timestamp é Unix em segundos. A Ishtaran não impõe uma janela de tolerância do lado de quem envia — impor a janela de frescor/replay é responsabilidade sua como receptor, da mesma forma que na maioria dos provedores de webhook. Os 4 SDKs oficiais usam por padrão uma tolerância de 300 segundos (5 minutos) ao validar, um valor razoável para padronizar caso você implemente sua própria checagem fora dos SDKs. Rejeite uma entrega cujo timestamp seja mais antigo (ou, para se proteger contra clock skew, mais no futuro) que sua tolerância, mesmo que a assinatura em si seja válida.

5. Payload

Contrato atual: o corpo HTTP é os dados do evento serializados diretamente — não existe envelope. Concretamente:

  • Sem wrapper { "id": ..., "type": ..., "data": {...} }. O corpo é os campos do evento.
  • Sem campo type dentro do corpo (veja §6).
  • Sem campo created_at dentro do corpo — use X-Webhook-Timestamp para o horário da entrega, ou busque o recurso para seu próprio campo de timestamp autoritativo.
  • O casing dos campos é PascalCase (ex.: SettlementId, TransactionId) — isso difere do camelCase usado no resto das respostas JSON da API REST pública. Não presuma camelCase ao fazer parse de um corpo de webhook.

Para encontrar o ID relevante de um evento, olhe o formato de payload desse evento no catálogo abaixo — todo evento carrega pelo menos o ID do aggregate a que se refere (SettlementId, WithdrawalId, DepositId, TransactionId, PaymentIntentId, RefundId, ou WithdrawalDestinationId, dependendo do evento).

6. Tipo do evento

CONTRATO ATUAL: a string do tipo de evento (ex.: settlement.executed) não é incluída em nenhum lugar da entrega em si — nem em header, nem no corpo. Para descobrir que tipo de evento uma entrega representa, busque seus metadados na plataforma: GET /v1/webhook-deliveries/{webhookDeliveryId} (o header X-Webhook-Delivery-Id te dá esse ID) retorna um campo EventType, ou liste/filtre entregas de um endpoint via GET /v1/webhook-endpoints/{webhookEndpointId}/deliveries?eventType=....

Na prática, a maioria dos integradores infere o evento a partir de quais campos de ID estão presentes no payload (ex.: um corpo com SettlementId e sem WithdrawalId é um evento de Settlement) combinado com saber em qual endpoint/environment ele chegou — funciona, mas não é ergonômico.

LACUNA DE PRODUTO/DX: um header X-Ishtaran-Event-Type (ou um envelope versionado carregando type) eliminaria a necessidade dessa chamada extra ou de inferir pelo formato dos campos. Esta é uma lacuna real, registrada — não implementada nesta rodada. Se você está construindo contra webhooks hoje, trate precisar buscar o tipo do evento como uma limitação conhecida, não um bug na sua integração.

7. Catálogo de eventos

Só entram aqui os eventos que são de fato despachados pelo pipeline de entrega (rastreados até um handler ativo que cria uma WebhookDelivery por endpoint ativo, confirmado no código-fonte — não inferido a partir de um registro de eventos). Todos seguem o padrão <aggregate>.<evento> em snake_case.

Transaction

EventoQuandoCampos principaisID do aggregate
transaction.createdUma Transaction é criada.ApplicationId, ParticipantAccountIdsTransactionId
transaction.fundedUma Transaction fica totalmente financiada.TransactionId
transaction.reservedSaldo é reservado para uma Transaction.AmountTransactionId
transaction.cancelledUma Transaction é cancelada.ReasonTransactionId
transaction.frozenUma Transaction é congelada (ação de Member).Reason, ActorMemberIdTransactionId
transaction.unfrozenUma Transaction congelada é descongelada.ActorMemberIdTransactionId
transaction.settledUma Transaction chega a um Settlement (total ou parcial).SettlementId, IsTotalTransactionId
transaction.refundedUma Transaction é reembolsada (total ou parcialmente).RefundId, IsTotalTransactionId

PaymentIntent

EventoQuandoCampos principaisID do aggregate
payment_intent.createdUm PaymentIntent é criado.TransactionId, Amount, ExpiresAtPaymentIntentId
payment_intent.cancelledUm PaymentIntent é cancelado.PaymentIntentId
payment_intent.expiredUm PaymentIntent expira sem ser financiado.PaymentIntentId
payment_intent.late_deposit_receivedUm depósito chega depois de o PaymentIntent já ter expirado.TransactionId, DepositId, Amount, ExpiredAt, ReceivedAtPaymentIntentId

Deposit

EventoQuandoCampos principaisID do aggregate
deposit.address_generatedUm endereço de depósito é alocado para um PaymentIntent.Address, AssetNetworkIdPaymentIntentId
deposit.detectedUm depósito on-chain é visto pela primeira vez, ainda sem confirmação.PaymentIntentId, AmountDepositId
deposit.confirmingA contagem de confirmações está progredindo.ConfirmationCountDepositId
deposit.confirmedO depósito atinge a profundidade de confirmação exigida.PaymentIntentId, TransactionId, AmountDepositId
deposit.rejectedO depósito é rejeitado.ReasonDepositId
deposit.reorg_frozenUm reorg de chain coloca um depósito já visto em dúvida.PaymentIntentId, AmountDepositId
deposit.under_reviewO depósito é sinalizado para revisão manual.ReasonDepositId

Settlement / Refund

EventoQuandoCampos principaisID do aggregate
settlement.executedUm Settlement (total ou parcial) é concluído com sucesso, mesmo quando há alocações retidas.TransactionId, AssetNetworkId, GrossAmount, DistributableAmount, PlatformFeeAmount, ExecutedAtSettlementId
settlement.failedUma tentativa de Settlement falha.TransactionId, Reason, FailedAtSettlementId
settlement.fee_appliedO Platform Fee é aplicado para um Settlement.PricingPolicyId, FeeAmount, FeePercentageAppliedSettlementId
settlement.split_portion_retainedUma alocação de Split é retida em vez de liberada.AllocationId, ParticipantId, AccountId, Amount, ReasonSettlementId
settlement.split_portion_releasedUma alocação de Split é liberada para a Account beneficiária.AllocationId, AccountId, AmountSettlementId
refund.executedUm Refund é concluído.TransactionId, Amount, ExecutedAtRefundId
refund.rejectedUma tentativa de Refund é rejeitada.TransactionId, ReasonRefundId

Withdrawal

EventoQuandoCampos principaisID do aggregate
withdrawal.requestedUm Withdrawal é solicitado.AccountId, Amount, WithdrawalDestinationIdWithdrawalId
withdrawal.approvedUm Withdrawal é aprovado.ActorMemberIdWithdrawalId
withdrawal.rejectedUm Withdrawal é rejeitado.ReasonWithdrawalId
withdrawal.cancelledUm Withdrawal é cancelado.WithdrawalId
withdrawal.broadcastA transação de saque é transmitida on-chain.TechnicalReferenceWithdrawalId
withdrawal.broadcast_failedA tentativa de broadcast falha.ReasonWithdrawalId
withdrawal.confirmedA transação transmitida atinge as confirmações exigidas.TechnicalReferenceWithdrawalId
withdrawal.failedO Withdrawal falha de forma terminal.ReasonWithdrawalId
withdrawal.requires_reconciliationO Withdrawal precisa de reconciliação manual.WithdrawalId
withdrawal_destination.registeredUm WithdrawalDestination é registrado para uma Organization.OrganizationId, AddressWithdrawalDestinationId

Não existe evento signing_request.*, nem evento settlement.confirming — a assinatura em SelfCustody não faz parte do catálogo de webhook em si (veja a nota abaixo).

Uma nota sobre SelfCustody: em execução SelfCustody, a assinatura acontece localmente por você (ou pelo device do seu integrador) contra um SigningRequest que a plataforma te entrega — esse fluxo é de requisição/resposta (POST/GET sobre SigningRequest), não orientado a webhook. O que é orientado a webhook é o resultado depois que a execução confirma — ex.: withdrawal.broadcast/withdrawal.confirmed para um Withdrawal, ou settlement.executed assim que as transações assinadas de um Settlement SelfCustody confirmam. Não espere um webhook para saber que um SigningRequest precisa ser assinado — isso é um passo síncrono no seu próprio fluxo, descrito em SelfCustody.

8. Semântica de entrega

  • At-least-once, nunca exactly-once. O mesmo evento pode produzir mais de uma entrega (ex.: se o evento de Outbox subjacente for reentregue internamente) — sempre deduplique pelo X-Webhook-Delivery-Id, nunca pelo conteúdo do evento.
  • 2xx = sucesso. Qualquer resposta 2xx marca a entrega como entregue; qualquer outra coisa (incluindo falha de rede ou timeout) é tratada como falha e reenviada.
  • Retry/backoff: 30 segundos de delay base, dobrando a cada tentativa, com teto de 24 horas, com jitter de ±20% aplicado a cada delay.
  • Máximo de tentativas: 10. Depois da 10ª tentativa falha, a entrega passa para um estado de dead-letter e para de reenviar automaticamente.
  • Redelivery manual: uma entrega em dead-letter pode ser reenviada via POST /v1/webhook-deliveries/{webhookDeliveryId}/redeliver, que cria uma entrega nova (com seu próprio X-Webhook-Delivery-Id novo, contagem de tentativas zerada, vinculada de volta à original).
  • Consulte o status/histórico de tentativas de qualquer entrega via GET /v1/webhook-deliveries/{webhookDeliveryId}, ou liste o histórico de um endpoint via GET /v1/webhook-endpoints/{webhookEndpointId}/deliveries.

9. Ordenação

Entregas não têm garantia de chegar em ordem. Um SequenceNumber por endpoint é incluído nos metadados de entrega como apenas uma dica, nunca uma garantia estrita — retries e backoff significam que um evento posterior pode ser entregue antes de o retry de um evento anterior finalmente ter sucesso.

Trate todo webhook como uma notificação para ir reconsultar o estado, não como o próprio estado:

  • Se o estado atual de um Aggregate importa para sua lógica (não só "algo aconteceu"), faça GET no Aggregate (Settlement, Withdrawal, Transaction, ...) depois de receber o webhook dele e trate essa resposta como a fonte da verdade, não o snapshot do payload do webhook.
  • Projete handlers para serem seguros caso o webhook do mesmo evento conceitual chegue duas vezes, ou caso um evento "posterior" (ex.: withdrawal.confirmed) chegue antes de um "anterior" que você ainda não terminou de processar (ex.: withdrawal.broadcast).

10. Validando uma entrega — exemplo completo

Os 4 SDKs oficiais implementam verificação HMAC-SHA256 idêntica, com tolerância padrão de 300 segundos e comparação de tempo constante — use-a em vez de reimplementar §3/§4 você mesmo.

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>(); // use um armazenamento real (DB/cache) em produção

const app = express();
app.post('/webhooks/ishtaran', express.text({ type: '*/*' }), (req, res) => {
const rawBody = req.body as string; // texto bruto, nunca pré-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 -- ainda 2xx
}
seenDeliveryIds.add(deliveryId);

const payload = JSON.parse(rawBody); // parse só depois de validar
// ... busque o tipo do evento via GET /v1/webhook-deliveries/{deliveryId} se precisar (§6),
// ou decida pelo campo de ID presente; depois busque o Aggregate como fonte da verdade.

res.status(200).send('ok');
});

Para uma demonstração só de assinatura, sem servidor HTTP (útil para testes), veja o WEBHOOKS.md de cada SDK e o exemplo 10-webhook-verification.