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 secret — mostrado 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:
| Header | Significado |
|---|---|
X-Webhook-Signature | Assinatura HMAC-SHA256, hex minúsculo (ver abaixo). |
X-Webhook-Timestamp | Hora Unix em segundos, como string, no momento em que a entrega foi assinada. |
X-Webhook-Delivery-Id | O 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))
rawBodyprecisa 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
typedentro do corpo (veja §6). - Sem campo
created_atdentro do corpo — useX-Webhook-Timestamppara 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
| Evento | Quando | Campos principais | ID do aggregate |
|---|---|---|---|
transaction.created | Uma Transaction é criada. | ApplicationId, ParticipantAccountIds | TransactionId |
transaction.funded | Uma Transaction fica totalmente financiada. | — | TransactionId |
transaction.reserved | Saldo é reservado para uma Transaction. | Amount | TransactionId |
transaction.cancelled | Uma Transaction é cancelada. | Reason | TransactionId |
transaction.frozen | Uma Transaction é congelada (ação de Member). | Reason, ActorMemberId | TransactionId |
transaction.unfrozen | Uma Transaction congelada é descongelada. | ActorMemberId | TransactionId |
transaction.settled | Uma Transaction chega a um Settlement (total ou parcial). | SettlementId, IsTotal | TransactionId |
transaction.refunded | Uma Transaction é reembolsada (total ou parcialmente). | RefundId, IsTotal | TransactionId |
PaymentIntent
| Evento | Quando | Campos principais | ID do aggregate |
|---|---|---|---|
payment_intent.created | Um PaymentIntent é criado. | TransactionId, Amount, ExpiresAt | PaymentIntentId |
payment_intent.cancelled | Um PaymentIntent é cancelado. | — | PaymentIntentId |
payment_intent.expired | Um PaymentIntent expira sem ser financiado. | — | PaymentIntentId |
payment_intent.late_deposit_received | Um depósito chega depois de o PaymentIntent já ter expirado. | TransactionId, DepositId, Amount, ExpiredAt, ReceivedAt | PaymentIntentId |
Deposit
| Evento | Quando | Campos principais | ID do aggregate |
|---|---|---|---|
deposit.address_generated | Um endereço de depósito é alocado para um PaymentIntent. | Address, AssetNetworkId | PaymentIntentId |
deposit.detected | Um depósito on-chain é visto pela primeira vez, ainda sem confirmação. | PaymentIntentId, Amount | DepositId |
deposit.confirming | A contagem de confirmações está progredindo. | ConfirmationCount | DepositId |
deposit.confirmed | O depósito atinge a profundidade de confirmação exigida. | PaymentIntentId, TransactionId, Amount | DepositId |
deposit.rejected | O depósito é rejeitado. | Reason | DepositId |
deposit.reorg_frozen | Um reorg de chain coloca um depósito já visto em dúvida. | PaymentIntentId, Amount | DepositId |
deposit.under_review | O depósito é sinalizado para revisão manual. | Reason | DepositId |
Settlement / Refund
| Evento | Quando | Campos principais | ID do aggregate |
|---|---|---|---|
settlement.executed | Um Settlement (total ou parcial) é concluído com sucesso, mesmo quando há alocações retidas. | TransactionId, AssetNetworkId, GrossAmount, DistributableAmount, PlatformFeeAmount, ExecutedAt | SettlementId |
settlement.failed | Uma tentativa de Settlement falha. | TransactionId, Reason, FailedAt | SettlementId |
settlement.fee_applied | O Platform Fee é aplicado para um Settlement. | PricingPolicyId, FeeAmount, FeePercentageApplied | SettlementId |
settlement.split_portion_retained | Uma alocação de Split é retida em vez de liberada. | AllocationId, ParticipantId, AccountId, Amount, Reason | SettlementId |
settlement.split_portion_released | Uma alocação de Split é liberada para a Account beneficiária. | AllocationId, AccountId, Amount | SettlementId |
refund.executed | Um Refund é concluído. | TransactionId, Amount, ExecutedAt | RefundId |
refund.rejected | Uma tentativa de Refund é rejeitada. | TransactionId, Reason | RefundId |
Withdrawal
| Evento | Quando | Campos principais | ID do aggregate |
|---|---|---|---|
withdrawal.requested | Um Withdrawal é solicitado. | AccountId, Amount, WithdrawalDestinationId | WithdrawalId |
withdrawal.approved | Um Withdrawal é aprovado. | ActorMemberId | WithdrawalId |
withdrawal.rejected | Um Withdrawal é rejeitado. | Reason | WithdrawalId |
withdrawal.cancelled | Um Withdrawal é cancelado. | — | WithdrawalId |
withdrawal.broadcast | A transação de saque é transmitida on-chain. | TechnicalReference | WithdrawalId |
withdrawal.broadcast_failed | A tentativa de broadcast falha. | Reason | WithdrawalId |
withdrawal.confirmed | A transação transmitida atinge as confirmações exigidas. | TechnicalReference | WithdrawalId |
withdrawal.failed | O Withdrawal falha de forma terminal. | Reason | WithdrawalId |
withdrawal.requires_reconciliation | O Withdrawal precisa de reconciliação manual. | — | WithdrawalId |
withdrawal_destination.registered | Um WithdrawalDestination é registrado para uma Organization. | OrganizationId, Address | WithdrawalDestinationId |
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
2xxmarca 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óprioX-Webhook-Delivery-Idnovo, 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 viaGET /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
GETno 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.
- 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>(); // 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');
});
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() # use um armazenamento real (DB/cache) em produção
app = Flask(__name__)
@app.post("/webhooks/ishtaran")
def receive_webhook():
raw_body = request.get_data(as_text=True) # texto bruto, nunca pré-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 -- ainda 2xx
seen_delivery_ids.add(delivery_id)
payload = json.loads(raw_body) # parse só depois de validar
# ... busque o tipo do evento via GET /v1/webhook-deliveries/{delivery_id} se precisar (§6),
# ou decida pelo campo de ID presente; depois busque o Aggregate como fonte da verdade.
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(); // use um armazenamento real (DB/cache) em produção
// Dentro do seu handler HTTP (Spring/Javalin/servlet puro -- mostrado como pseudocódigo para a parte de framework):
String rawBody = readBodyExactlyAsReceived(request); // texto bruto, nunca pré-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 -- ainda 2xx
return;
}
seenDeliveryIds.add(deliveryId);
var payload = JsonCodec.mapper().readTree(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.
response.setStatus(200);
import (
"io"
"net/http"
"sync"
ishtaran "github.com/taylorjeftedasilva/ishtaran-go"
)
var seenDeliveryIDs sync.Map // use um armazenamento real (DB/cache) em produção
func handleWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body) // bytes brutos, nunca pré-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 -- ainda 2xx
return
}
var payload map[string]any
json.Unmarshal(rawBody, &payload) // 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.
w.WriteHeader(http.StatusOK)
}
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.