Skip to main content

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

HeaderMeaning
X-Webhook-SignatureHMAC-SHA256 signature, lowercase hex (see below).
X-Webhook-TimestampUnix time in seconds, as a string, at the moment the delivery was signed.
X-Webhook-Delivery-IdThe 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))
  • rawBody must 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-Signature is 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 type field inside the body (see §6).
  • No created_at field inside the body — use X-Webhook-Timestamp for 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

EventWhenKey fieldsAggregate ID
transaction.createdA Transaction is created.ApplicationId, ParticipantAccountIdsTransactionId
transaction.fundedA Transaction becomes fully funded.TransactionId
transaction.reservedBalance is reserved for a Transaction.AmountTransactionId
transaction.cancelledA Transaction is cancelled.ReasonTransactionId
transaction.frozenA Transaction is frozen (Member action).Reason, ActorMemberIdTransactionId
transaction.unfrozenA frozen Transaction is unfrozen.ActorMemberIdTransactionId
transaction.settledA Transaction reaches a Settlement (full or partial).SettlementId, IsTotalTransactionId
transaction.refundedA Transaction is refunded (full or partial).RefundId, IsTotalTransactionId

PaymentIntent

EventWhenKey fieldsAggregate ID
payment_intent.createdA PaymentIntent is created.TransactionId, Amount, ExpiresAtPaymentIntentId
payment_intent.cancelledA PaymentIntent is cancelled.PaymentIntentId
payment_intent.expiredA PaymentIntent expires unfunded.PaymentIntentId
payment_intent.late_deposit_receivedA deposit arrives after the PaymentIntent already expired.TransactionId, DepositId, Amount, ExpiredAt, ReceivedAtPaymentIntentId

Deposit

EventWhenKey fieldsAggregate ID
deposit.address_generatedA deposit address is allocated for a PaymentIntent.Address, AssetNetworkIdPaymentIntentId
deposit.detectedAn on-chain deposit is first seen, unconfirmed.PaymentIntentId, AmountDepositId
deposit.confirmingConfirmation count is progressing.ConfirmationCountDepositId
deposit.confirmedThe deposit reaches the required confirmation depth.PaymentIntentId, TransactionId, AmountDepositId
deposit.rejectedThe deposit is rejected.ReasonDepositId
deposit.reorg_frozenA chain reorg puts a previously-seen deposit in doubt.PaymentIntentId, AmountDepositId
deposit.under_reviewThe deposit is flagged for manual review.ReasonDepositId

Settlement / Refund

EventWhenKey fieldsAggregate ID
settlement.executedA Settlement (full or partial) completes successfully, even when some allocations are retained.TransactionId, AssetNetworkId, GrossAmount, DistributableAmount, PlatformFeeAmount, ExecutedAtSettlementId
settlement.failedA Settlement attempt fails.TransactionId, Reason, FailedAtSettlementId
settlement.fee_appliedThe Platform Fee is applied for a Settlement.PricingPolicyId, FeeAmount, FeePercentageAppliedSettlementId
settlement.split_portion_retainedA Split allocation is retained instead of released.AllocationId, ParticipantId, AccountId, Amount, ReasonSettlementId
settlement.split_portion_releasedA Split allocation is released to its beneficiary Account.AllocationId, AccountId, AmountSettlementId
refund.executedA Refund completes.TransactionId, Amount, ExecutedAtRefundId
refund.rejectedA Refund attempt is rejected.TransactionId, ReasonRefundId

Withdrawal

EventWhenKey fieldsAggregate ID
withdrawal.requestedA Withdrawal is requested.AccountId, Amount, WithdrawalDestinationIdWithdrawalId
withdrawal.approvedA Withdrawal is approved.ActorMemberIdWithdrawalId
withdrawal.rejectedA Withdrawal is rejected.ReasonWithdrawalId
withdrawal.cancelledA Withdrawal is cancelled.WithdrawalId
withdrawal.broadcastThe withdrawal transaction is broadcast on-chain.TechnicalReferenceWithdrawalId
withdrawal.broadcast_failedThe broadcast attempt fails.ReasonWithdrawalId
withdrawal.confirmedThe broadcast transaction reaches the required confirmations.TechnicalReferenceWithdrawalId
withdrawal.failedThe Withdrawal fails terminally.ReasonWithdrawalId
withdrawal.requires_reconciliationThe Withdrawal needs manual reconciliation.WithdrawalId
withdrawal_destination.registeredA WithdrawalDestination is registered for an Organization.OrganizationId, AddressWithdrawalDestinationId

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 2xx response 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 new X-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 via GET /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"), GET the 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.

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

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.