Webhooks
Ishtaran delivers webhooks as real-time HTTP POST notifications whenever something changes on
the platform. This page documents the exact, current contract — headers, signature algorithm,
payload shape, event catalog, and delivery semantics — extracted directly from the real
implementation, not aspirational.
1. Configuration
Register a WebhookEndpoint via POST /v1/organizations/{organizationId}/webhook-endpoints/
(Member JWT, Permissions.WebhookEndpointManage). The request body is just { "url": "..." }.
The response includes a secret — shown in full exactly once, in this response. No
subsequent GET on the endpoint ever returns it again (GET /v1/webhook-endpoints/{id}
deliberately omits the field). Store it immediately in your own secret manager.
To rotate the secret, call POST /v1/webhook-endpoints/{webhookEndpointId}/rotate-secret.
The new secret is returned once, the same way. Rotation is an immediate overwrite — the
previous secret stops working the instant rotation succeeds. There is no grace period or
dual-secret window today: plan your rotation as a single atomic cutover (update your verifier
with the new secret before or immediately after calling rotate, not on a lazy schedule).
A WebhookEndpoint is scoped to the whole Organization, not to a specific event type — every
active endpoint receives every event in the catalog below (there is no per-endpoint event-type
subscription filter today). Deactivate an endpoint via
POST /v1/webhook-endpoints/{webhookEndpointId}/deactivate.
2. Headers
Every webhook delivery carries exactly these three headers:
| Header | Meaning |
|---|---|
X-Webhook-Signature | HMAC-SHA256 signature, lowercase hex (see below). |
X-Webhook-Timestamp | Unix time in seconds, as a string, at the moment the delivery was signed. |
X-Webhook-Delivery-Id | The unique ID of this delivery attempt — use it for dedup (§8). |
There is no X-Webhook-Event-Type header — see §6 for how to find the event type, and the
gap this represents.
3. Signature
Algorithm: HMAC-SHA256. Signed content is the timestamp and the raw request body joined by a dot:
signedContent = "{unixTimestampSeconds}.{rawBody}"
signature = lowercase_hex(HMAC_SHA256(secret, signedContent))
rawBodymust be exactly the bytes you received — never re-parse and re-serialize the JSON before verifying. Re-serializing can silently change key order or spacing and break the comparison even though the "meaning" of the payload is unchanged.- Output encoding is lowercase hexadecimal (not base64).
- There is no signature-scheme versioning today (no
v1=/t=prefix scheme like some other providers) —X-Webhook-Signatureis the raw hex digest. - Always compare using a constant-time comparison (
crypto.timingSafeEqual,hmac.compare_digest,MessageDigest.isEqual/manual constant-time loop,crypto/subtle, depending on your language) — never==/.equals()on the two strings.
All 4 official SDKs already implement this exactly — see §10.
4. Timestamp / replay protection
X-Webhook-Timestamp is Unix seconds. Ishtaran does not enforce a tolerance window on the
sending side — freshness/replay-window enforcement is your responsibility as the receiver, the
same way it is with most webhook providers. All 4 official SDKs default to a 300-second (5
minute) tolerance when verifying, which is a reasonable value to standardize on if you're
implementing your own check outside the SDKs. Reject a delivery whose timestamp is older (or, to
guard against clock skew, more in the future) than your tolerance, even if the signature itself
is valid.
5. Payload
Current contract: the HTTP body is the event's data serialized directly — there is no envelope. Concretely:
- No
{ "id": ..., "type": ..., "data": {...} }wrapper. The body is the event's fields. - No
typefield inside the body (see §6). - No
created_atfield inside the body — useX-Webhook-Timestampfor delivery time, or fetch the resource for its own authoritative timestamp field. - Field casing is PascalCase (e.g.
SettlementId,TransactionId) — this differs from the camelCase used elsewhere in the public REST API's JSON responses. Don't assume camelCase when parsing a webhook body.
To find the relevant ID for a given event, look at that event's payload shape in the catalog
below — every event carries at least the ID of the aggregate it's about (SettlementId,
WithdrawalId, DepositId, TransactionId, PaymentIntentId, RefundId, or
WithdrawalDestinationId, depending on the event).
6. Event type
CURRENT CONTRACT: the event type string (e.g. settlement.executed) is not included
anywhere in the delivery itself — not in a header, not in the body. To find out what kind of
event a delivery represents, fetch its metadata from the platform:
GET /v1/webhook-deliveries/{webhookDeliveryId} (the
X-Webhook-Delivery-Id header gives you this ID) returns an EventType field, or list/filter
deliveries for an endpoint via
GET /v1/webhook-endpoints/{webhookEndpointId}/deliveries?eventType=....
In practice, most integrators infer the event from which ID fields are present in the payload
(e.g. a body with SettlementId and no WithdrawalId is a Settlement event) combined with
knowing which endpoint/environment it arrived on — workable, but not ergonomic.
PRODUCT/DX GAP: a X-Webhook-Event-Type header (or a versioned envelope carrying type)
would remove the need for that extra API call or field-sniffing. This is a real, registered gap
— not implemented in this pass. If you're building against webhooks today, treat needing to look
up the event type as a known limitation, not a bug in your integration.
7. Event catalog
Only events that are actually dispatched through the delivery pipeline (traced to a live handler
that creates a WebhookDelivery per active endpoint, confirmed in source — not inferred from an
event registry) are listed here. Every one of these is <aggregate>.<event> in snake_case.
Transaction
| Event | When | Key fields | Aggregate ID |
|---|---|---|---|
transaction.created | A Transaction is created. | ApplicationId, ParticipantAccountIds | TransactionId |
transaction.funded | A Transaction becomes fully funded. | — | TransactionId |
transaction.reserved | Balance is reserved for a Transaction. | Amount | TransactionId |
transaction.cancelled | A Transaction is cancelled. | Reason | TransactionId |
transaction.frozen | A Transaction is frozen (Member action). | Reason, ActorMemberId | TransactionId |
transaction.unfrozen | A frozen Transaction is unfrozen. | ActorMemberId | TransactionId |
transaction.settled | A Transaction reaches a Settlement (full or partial). | SettlementId, IsTotal | TransactionId |
transaction.refunded | A Transaction is refunded (full or partial). | RefundId, IsTotal | TransactionId |
PaymentIntent
| Event | When | Key fields | Aggregate ID |
|---|---|---|---|
payment_intent.created | A PaymentIntent is created. | TransactionId, Amount, ExpiresAt | PaymentIntentId |
payment_intent.cancelled | A PaymentIntent is cancelled. | — | PaymentIntentId |
payment_intent.expired | A PaymentIntent expires unfunded. | — | PaymentIntentId |
payment_intent.late_deposit_received | A deposit arrives after the PaymentIntent already expired. | TransactionId, DepositId, Amount, ExpiredAt, ReceivedAt | PaymentIntentId |
Deposit
| Event | When | Key fields | Aggregate ID |
|---|---|---|---|
deposit.address_generated | A deposit address is allocated for a PaymentIntent. | Address, AssetNetworkId | PaymentIntentId |
deposit.detected | An on-chain deposit is first seen, unconfirmed. | PaymentIntentId, Amount | DepositId |
deposit.confirming | Confirmation count is progressing. | ConfirmationCount | DepositId |
deposit.confirmed | The deposit reaches the required confirmation depth. | PaymentIntentId, TransactionId, Amount | DepositId |
deposit.rejected | The deposit is rejected. | Reason | DepositId |
deposit.reorg_frozen | A chain reorg puts a previously-seen deposit in doubt. | PaymentIntentId, Amount | DepositId |
deposit.under_review | The deposit is flagged for manual review. | Reason | DepositId |
Settlement / Refund
| Event | When | Key fields | Aggregate ID |
|---|---|---|---|
settlement.executed | A Settlement (full or partial) completes successfully, even when some allocations are retained. | TransactionId, AssetNetworkId, GrossAmount, DistributableAmount, PlatformFeeAmount, ExecutedAt | SettlementId |
settlement.failed | A Settlement attempt fails. | TransactionId, Reason, FailedAt | SettlementId |
settlement.fee_applied | The Platform Fee is applied for a Settlement. | PricingPolicyId, FeeAmount, FeePercentageApplied | SettlementId |
settlement.split_portion_retained | A Split allocation is retained instead of released. | AllocationId, ParticipantId, AccountId, Amount, Reason | SettlementId |
settlement.split_portion_released | A Split allocation is released to its beneficiary Account. | AllocationId, AccountId, Amount | SettlementId |
refund.executed | A Refund completes. | TransactionId, Amount, ExecutedAt | RefundId |
refund.rejected | A Refund attempt is rejected. | TransactionId, Reason | RefundId |
Withdrawal
| Event | When | Key fields | Aggregate ID |
|---|---|---|---|
withdrawal.requested | A Withdrawal is requested. | AccountId, Amount, WithdrawalDestinationId | WithdrawalId |
withdrawal.approved | A Withdrawal is approved. | ActorMemberId | WithdrawalId |
withdrawal.rejected | A Withdrawal is rejected. | Reason | WithdrawalId |
withdrawal.cancelled | A Withdrawal is cancelled. | — | WithdrawalId |
withdrawal.broadcast | The withdrawal transaction is broadcast on-chain. | TechnicalReference | WithdrawalId |
withdrawal.broadcast_failed | The broadcast attempt fails. | Reason | WithdrawalId |
withdrawal.confirmed | The broadcast transaction reaches the required confirmations. | TechnicalReference | WithdrawalId |
withdrawal.failed | The Withdrawal fails terminally. | Reason | WithdrawalId |
withdrawal.requires_reconciliation | The Withdrawal needs manual reconciliation. | — | WithdrawalId |
withdrawal_destination.registered | A WithdrawalDestination is registered for an Organization. | OrganizationId, Address | WithdrawalDestinationId |
There is no signing_request.* event, and no settlement.confirming event — self-custody
signing is not itself part of the webhook catalog (see the note below).
A note on SelfCustody: under SelfCustody execution, signing happens locally by you (or your
integrator's device) against a SigningRequest the platform hands you — that flow is
request/response (POST/GET on SigningRequest), not webhook-driven. What is webhook-driven
is the outcome once execution confirms — e.g. withdrawal.broadcast/withdrawal.confirmed for a
Withdrawal, or settlement.executed once a SelfCustody Settlement's signed transactions confirm.
Don't wait on a webhook to know a SigningRequest needs signing — that's a synchronous step in
your own flow, described in SelfCustody.
8. Delivery semantics
- At-least-once, never exactly-once. The same event can produce more than one delivery (e.g.
if the underlying Outbox event is redelivered internally) — always dedupe on
X-Webhook-Delivery-Id, not on event content. - 2xx = success. Any
2xxresponse marks the delivery as delivered; anything else (including a network failure or timeout) is treated as a failure and retried. - Retry/backoff: 30 seconds base delay, doubling per attempt, capped at 24 hours, with ±20% jitter applied to each delay.
- Max attempts: 10. After the 10th failed attempt, the delivery moves to a dead-letter state and stops retrying automatically.
- Manual redelivery: a dead-lettered delivery can be redelivered via
POST /v1/webhook-deliveries/{webhookDeliveryId}/redeliver, which creates a fresh delivery (its own newX-Webhook-Delivery-Id, attempt count reset to 0, linked back to the original). - Inspect any delivery's status/attempt history via
GET /v1/webhook-deliveries/{webhookDeliveryId}, or list an endpoint's history viaGET /v1/webhook-endpoints/{webhookEndpointId}/deliveries.
9. Ordering
Deliveries are not guaranteed to arrive in order. A per-endpoint SequenceNumber is included
in delivery metadata as a hint only, never a strict guarantee — retries and backoff mean a
later event can be delivered before an earlier one's retry finally succeeds.
Treat every webhook as a notification to go re-check state, not as the state itself:
- If the current state of an Aggregate matters to your logic (not just "something happened"),
GETthe Aggregate (Settlement, Withdrawal, Transaction, ...) after receiving its webhook and treat that response as the source of truth, not the webhook payload's snapshot. - Design handlers to be safe if the same conceptual event's webhook arrives twice, or if a
"later" event (e.g.
withdrawal.confirmed) arrives before an "earlier" one you haven't finished processing yet (e.g.withdrawal.broadcast).
10. Verifying a delivery — full example
All 4 official SDKs implement identical HMAC-SHA256 verification with a 300-second default tolerance and constant-time comparison — use it rather than reimplementing §3/§4 yourself.
- 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 a real store (DB/cache) in production
const app = express();
app.post('/webhooks/ishtaran', express.text({ type: '*/*' }), (req, res) => {
const rawBody = req.body as string; // raw text, never pre-parsed as 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 -- still 2xx
}
seenDeliveryIds.add(deliveryId);
const payload = JSON.parse(rawBody); // parse only after verifying
// ... look up event type via GET /v1/webhook-deliveries/{deliveryId} if needed (see §6),
// or branch on which ID field is present; then re-fetch the Aggregate as source of truth.
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 a real store (DB/cache) in production
app = Flask(__name__)
@app.post("/webhooks/ishtaran")
def receive_webhook():
raw_body = request.get_data(as_text=True) # raw text, never pre-parsed as 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 -- still 2xx
seen_delivery_ids.add(delivery_id)
payload = json.loads(raw_body) # parse only after verifying
# ... look up event type via GET /v1/webhook-deliveries/{delivery_id} if needed (see §6),
# or branch on which ID field is present; then re-fetch the Aggregate as source of truth.
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 a real store (DB/cache) in production
// Inside your HTTP handler (Spring/Javalin/plain servlet -- shown as pseudocode for the framework part):
String rawBody = readBodyExactlyAsReceived(request); // raw text, never pre-parsed as 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 -- still 2xx
return;
}
seenDeliveryIds.add(deliveryId);
var payload = JsonCodec.mapper().readTree(rawBody); // parse only after verifying
// ... look up event type via GET /v1/webhook-deliveries/{deliveryId} if needed (see §6),
// or branch on which ID field is present; then re-fetch the Aggregate as source of truth.
response.setStatus(200);
import (
"io"
"net/http"
"sync"
ishtaran "github.com/taylorjeftedasilva/ishtaran-go"
)
var seenDeliveryIDs sync.Map // use a real store (DB/cache) in production
func handleWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body) // raw bytes, never pre-parsed as 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 -- still 2xx
return
}
var payload map[string]any
json.Unmarshal(rawBody, &payload) // parse only after verifying
// ... look up event type via GET /v1/webhook-deliveries/{deliveryId} if needed (see §6),
// or branch on which ID field is present; then re-fetch the Aggregate as source of truth.
w.WriteHeader(http.StatusOK)
}
For a signature-only demo with no HTTP server (useful for tests), see each SDK's own
WEBHOOKS.md and the 10-webhook-verification example.