Skip to content

Tutorial 6 — Invoke an External HTTP Endpoint Safely

Open this approval-to-webhook workflow in App-Chain Studio

  • Level: intermediate to advanced
  • Time: about 20 minutes
  • Outcome: understand when to use a finalized-block webhook sink versus an acknowledged webhook.post effect, and run the latter from a stock approval transition against a local receiver.

Before you start: finish Tutorial 1 and stop or clean its cluster. You need the extracted Yano X JVM distribution, Python 3, curl, and jq. Every command runs from the distribution’s top-level directory, the one that contains yano.sh; the tutorial files ship under docs/ there. In Studio, add the effects:on-approved and executor:webhook capabilities; Studio then asks for the effect type and the webhook URL, which the exercise sets to webhook.post and http://127.0.0.1:8099/yano.

Yano provides two HTTP delivery shapes with different guarantees.

CapabilityUse it forResult committed to app state?
Finalized-block webhook sinkProjection/indexing of every finalized blockNo
webhook.post effectOne business action after a deterministic transitionYes, for CHAIN results

The snippets in Options A and B use the single-chain form, yano.app-chain.<key>. On a node with several chains, such as the local cluster, the same keys go under yano.app-chain.chains[<i>].<key>; the exercise below uses chains[0].

Option A — observe every finalized block

Section titled “Option A — observe every finalized block”

Configure a cursor-backed sink:

yano.app-chain.webhooks: https://projection.example/yano/finalized-blocks

Each finalized block is POSTed as JSON, in height order, with X-App-Chain-Id and X-App-Chain-Height headers. Delivery is at-least-once, and a persistent sink cursor resumes after restart; a failing sink halts and retries rather than skipping a block. This is the simplest option when the receiver wants all finalized history and does not need a per-action result fed back into the state machine.

Option B — execute one acknowledged action

Section titled “Option B — execute one acknowledged action”

The built-in executor supports webhook.post. The target URL lives in the executor’s configuration, not in the replicated payload:

yano.app-chain.effects.executors.webhook.url: https://erp.example/hooks/yano
yano.app-chain.effects.executors.webhook.timeout-ms: 10000

It sends:

  • Idempotency-Key: the hex hash of the effect id, identical on every attempt;
  • X-App-Chain-Id; and
  • X-Effect-Id, X-Effect-Type, and X-Effect-Scope.

Response semantics are:

  • 2xx → confirmed, with the Location header as the external reference when present;
  • 4xx → failed without automatic retry; and
  • anything else, including 5xx and transport failures → retried with bounded backoff, then parked for an operator.

The receiver must deduplicate by Idempotency-Key, because execution is at-least-once even though outcome incorporation is exactly once. Step through the whole path, including a crash after the POST:

Life of an effectIllustrative data

What you're seeing: an order approval that calls a webhook, with the default app-final gate and a CHAIN result. Try the “What if” scenarios, or switch to your receiver’s view.

  1. Emit Inside apply(), the state machine calls effects.emit(...) with type webhook.post, a scope, ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returns EffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.
  2. Finalize The block becomes final like any other. Every member stores the same effect record, and the block’s effects root, written under ~fx/root/120, commits it in the state root. The effect is provable from now on. What a transition emits is consensus data.
  3. Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (app-final is eligible at once), and never dispatches an effect within 2 blocks of its expiry. Its progress, such as PENDING, RETRY, DONE, or PARKED, is node-local and never part of a root.
  4. Deliver The webhook.post executor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unless allow-payload-url is set. The Idempotency-Key header is the hex Blake2b-256 hash of the effect id, the same on every attempt. X-Effect-Id carries orders-chain/120/0.
  5. Answer The receiver answers 2xx. The executor records the effect as done and confirmed, with the Location header, or HTTP 200, as the external reference. A 4xx means FAILED with no retry; a 5xx or a network error is retried.
  6. Report For a CHAIN effect, the executor node signs a ~fx/result message with its member key and submits it like any other message. It repeats this every 60 seconds until the ledger closes the effect. effects.result.signers can limit which members may report.
  7. Incorporate In the block that contains it, the framework accepts the first valid result before the state machine runs, records the outcome, and calls onEffectResult on every member. A duplicate, late, or unknown result is a no-op, so a result can never stall the ledger. A result is a member’s attestation, not proof of what the receiver did.

What if: Result policy NONE

Without .result(ResultPolicy.CHAIN), the policy is the default, NONE.

  1. Emit without a result The intent does not call .result(...), so its policy is the default ResultPolicy.NONE. A NONE effect cannot take an expiry, and its outcome never returns to the ledger.
  2. Finalize The block becomes final like any other. Every member stores the same effect record, and the block’s effects root, written under ~fx/root/120, commits it in the state root. The effect is provable from now on. What a transition emits is consensus data.
  3. Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (app-final is eligible at once), and never dispatches an effect within 2 blocks of its expiry. Its progress, such as PENDING, RETRY, DONE, or PARKED, is node-local and never part of a root.
  4. Deliver The webhook.post executor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unless allow-payload-url is set. The Idempotency-Key header is the hex Blake2b-256 hash of the effect id, the same on every attempt. X-Effect-Id carries orders-chain/120/0.
  5. Done locally The receiver answers 2xx and the executor marks the effect done. That is the end: no ~fx/result, no onEffectResult, no expiry. Only this node’s REST status and metrics show the outcome. A NONE effect that fails for good is PARKED for an operator.

What if: Receiver answers 4xx

A definitive “no” from the target is recorded as FAILED.

  1. Emit Inside apply(), the state machine calls effects.emit(...) with type webhook.post, a scope, ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returns EffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.
  2. Finalize The block becomes final like any other. Every member stores the same effect record, and the block’s effects root, written under ~fx/root/120, commits it in the state root. The effect is provable from now on. What a transition emits is consensus data.
  3. Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (app-final is eligible at once), and never dispatches an effect within 2 blocks of its expiry. Its progress, such as PENDING, RETRY, DONE, or PARKED, is node-local and never part of a root.
  4. Deliver The webhook.post executor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unless allow-payload-url is set. The Idempotency-Key header is the hex Blake2b-256 hash of the effect id, the same on every attempt. X-Effect-Id carries orders-chain/120/0.
  5. Rejected The receiver answers 400. Retrying the same bytes cannot succeed, so the executor does not retry. For a CHAIN effect it records FAILED with the reason HTTP 400.
  6. Report For a CHAIN effect, the executor node signs a ~fx/result message with its member key and submits it like any other message. It repeats this every 60 seconds until the ledger closes the effect. effects.result.signers can limit which members may report.
  7. Incorporate FAILED Every member calls onEffectResult with outcome FAILED: the target answered no. The state machine decides what that means for the order.

What if: Receiver answers 5xx

Retries are local and bounded; the expiry closes the effect deterministically.

  1. Emit Inside apply(), the state machine calls effects.emit(...) with type webhook.post, a scope, ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returns EffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.
  2. Finalize The block becomes final like any other. Every member stores the same effect record, and the block’s effects root, written under ~fx/root/120, commits it in the state root. The effect is provable from now on. What a transition emits is consensus data.
  3. Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (app-final is eligible at once), and never dispatches an effect within 2 blocks of its expiry. Its progress, such as PENDING, RETRY, DONE, or PARKED, is node-local and never part of a root.
  4. Deliver The webhook.post executor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unless allow-payload-url is set. The Idempotency-Key header is the hex Blake2b-256 hash of the effect id, the same on every attempt. X-Effect-Id carries orders-chain/120/0.
  5. Retry The receiver answers 503. The executor retries after 2 seconds, doubling the wait up to 5 minutes. After 8 attempts the effect is PARKED, and an operator can requeue or cancel it. It is never dispatched within 2 blocks of its expiry.
  6. Expire No result arrives before height 1120, the emission height plus the expiry. At that height every member closes the effect as EXPIRED, without any message, and calls onEffectResult. EXPIRED means “nobody answered in time”, which is different from FAILED.

What if: Executor crashes after the POST

Execution is at least once, so the receiver must deduplicate.

  1. Emit Inside apply(), the state machine calls effects.emit(...) with type webhook.post, a scope, ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returns EffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.
  2. Finalize The block becomes final like any other. Every member stores the same effect record, and the block’s effects root, written under ~fx/root/120, commits it in the state root. The effect is provable from now on. What a transition emits is consensus data.
  3. Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (app-final is eligible at once), and never dispatches an effect within 2 blocks of its expiry. Its progress, such as PENDING, RETRY, DONE, or PARKED, is node-local and never part of a root.
  4. Deliver The webhook.post executor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unless allow-payload-url is set. The Idempotency-Key header is the hex Blake2b-256 hash of the effect id, the same on every attempt. X-Effect-Id carries orders-chain/120/0.
  5. Crash The receiver does the work and answers 200, but the executor node stops before it records the outcome. Its local status still says the effect needs to run.
  6. Deliver again After the restart, the executor POSTs the same payload again with the same Idempotency-Key. The receiver finds the key, does nothing new, and answers 200 again.
  7. Report For a CHAIN effect, the executor node signs a ~fx/result message with its member key and submits it like any other message. It repeats this every 60 seconds until the ledger closes the effect. effects.result.signers can limit which members may report.
  8. Incorporate In the block that contains it, the framework accepts the first valid result before the state machine runs, records the outcome, and calls onEffectResult on every member. A duplicate, late, or unknown result is a no-op, so a result can never stall the ledger. A result is a member’s attestation, not proof of what the receiver did.
  • deterministic, on every member
  • app ledger members
  • node-local execution
  • your system
  • final
  • failed or refused

Effects explains result policies, expiry, and executors in more depth.

Runnable local approval-to-webhook exercise

Section titled “Runnable local approval-to-webhook exercise”

The stock approvals machine can emit a configured effect when a proposal reaches its threshold. This avoids writing a custom state machine for the tutorial.

1. Start a receiver in a separate terminal

Section titled “1. Start a receiver in a separate terminal”
Terminal window
python3 - <<'PY'
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
seen = set()
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
key = self.headers.get("Idempotency-Key", "")
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
duplicate = key in seen
seen.add(key)
print({"idempotencyKey": key, "duplicate": duplicate,
"contentType": self.headers.get("Content-Type"),
"body": body.decode("utf-8", errors="replace")}, flush=True)
self.send_response(200)
self.send_header("Location", f"local://receipt/{key}")
self.end_headers()
def log_message(self, *_):
pass
ThreadingHTTPServer(("127.0.0.1", 8099), Receiver).serve_forever()
PY

It prints nothing until the first POST arrives.

Start from a clean tutorial cluster and create three owner-only property files. All members receive the same consensus settings; only node 0 runs the executor. Each file must begin with config_ordinal=275.

The local cluster launcher loads these files as per-node overlays when the YANO_CLUSTER_NODE_CONFIG_DIR environment variable names their directory. They are not generated-project configuration: generated app ledger projects and appchain config use YAML.

Common settings for node0.properties, node1.properties, and node2.properties:

config_ordinal=275
yano.app-chain.chains[0].state-machine=approvals
yano.app-chain.chains[0].effects.enabled=true
yano.app-chain.chains[0].machines.approvals.on-approved-effect.enabled=true
yano.app-chain.chains[0].machines.approvals.activations.on-approved-effect=1
yano.app-chain.chains[0].machines.approvals.on-approved-effect.type=webhook.post
yano.app-chain.chains[0].machines.approvals.on-approved-effect.gate=app-final

These settings attach one generic action to the stock approval transition. webhook.post is only the executor routing type; the approvals machine does not interpret the payload as a payment or webhook. The approval decision stays APPROVED, while the separate ae/s/<itemId> record tracks the effect as PENDING, CONFIRMED, or FAILED. Change type to another packaged or custom executor contract without changing approval semantics. The effect uses a CHAIN result, and with no expiry-blocks setting its expiry is the chain’s default.

Append these node-local settings only to node0.properties:

yano.app-chain.chains[0].effects.executor.enabled=true
yano.app-chain.chains[0].effects.executor.types=webhook.post
yano.app-chain.chains[0].effects.executors.webhook.url=http://127.0.0.1:8099/yano
yano.app-chain.chains[0].effects.executors.webhook.timeout-ms=5000

The distribution includes those exact files under docs/appchain/tutorials/config/webhook/. Install owner-only copies and start a fresh cluster:

Terminal window
install -d -m 700 /tmp/yano-tutorial-webhook-config
install -m 600 \
docs/appchain/tutorials/config/webhook/node*.properties \
/tmp/yano-tutorial-webhook-config/
export YANO_CLUSTER_DIR=/tmp/yano-tutorial-webhook
export YANO_CLUSTER_NODE_CONFIG_DIR=/tmp/yano-tutorial-webhook-config
./yano.sh appchain cluster start 3

The commands below reach node n on HTTP port HTTP_BASE + n. Set HTTP_BASE to the first port of the range the launcher printed. It is 7070 unless the launcher printed a line such as HTTP range 7070-7072 unavailable; using 7073-7075:

Terminal window
HTTP_BASE=7070

The chain id remains orders-chain, but its fresh deterministic profile is now approvals. Never apply this override to retained ordered-log state.

Encode the supplied HTTP body and the canonical stock commands with the tutorial helper:

Terminal window
TOOL=docs/appchain/tutorials/tools/stdlib_command.py
WEBHOOK_HEX=$(python3 "$TOOL" webhook \
--body-file docs/appchain/tutorials/config/webhook/request.json \
--content-type application/json)
PROPOSE_HEX=$(python3 "$TOOL" approvals propose erp-release-001 \
--required 2 --payload-hex "$WEBHOOK_HEX")
APPROVE_HEX=$(python3 "$TOOL" approvals approve erp-release-001)

Propose through member 0, then approve through members 1 and 2. Proposing is not approving: the item needs two approvals from distinct members.

Terminal window
API=api/v1/app-chain/chains/orders-chain/messages
curl -s -X POST "http://127.0.0.1:$HTTP_BASE/$API" \
-H 'Content-Type: application/json' \
-d "{\"topic\":\"approvals\",\"bodyHex\":\"$PROPOSE_HEX\"}" | jq .
curl -s -X POST "http://127.0.0.1:$((HTTP_BASE + 1))/$API" \
-H 'Content-Type: application/json' \
-d "{\"topic\":\"approvals\",\"bodyHex\":\"$APPROVE_HEX\"}" | jq .
curl -s -X POST "http://127.0.0.1:$((HTTP_BASE + 2))/$API" \
-H 'Content-Type: application/json' \
-d "{\"topic\":\"approvals\",\"bodyHex\":\"$APPROVE_HEX\"}" | jq .

Each call answers 202 Accepted with a message id:

{
"messageId": "<64 hex characters>",
"chainId": "orders-chain",
"topic": "approvals"
}

After finality and the executor tick, the receiver terminal prints one line:

{'idempotencyKey': '<64 hex characters>', 'duplicate': False, 'contentType': 'application/json', 'body': '{"action":"release-order","orderId":"A-1001"}\n'}

A crash at the acknowledgement boundary may produce another physical POST with the same idempotency key, printed with 'duplicate': True. That is why the receiver keeps a deduplication set.

Wait until the result is incorporated, then inspect the effect record and the executor status:

Terminal window
STATUS=http://127.0.0.1:$HTTP_BASE/api/v1/app-chain/chains/orders-chain/status
until curl -s "$STATUS" | jq -e '.effects.executor.executionTotals.confirmed >= 1
and .effects.executor.openOnChain == 0' >/dev/null; do
sleep 1
done
curl -s \
"http://127.0.0.1:$HTTP_BASE/api/v1/app-chain/chains/orders-chain/effects?fromHeight=1&limit=20" \
| jq '.effects[] | {height, ordinal, type, scope}'
curl -s "$STATUS" \
| jq '.effects.executor | {executed, openOnChain, confirmed: .executionTotals.confirmed}'

Expected output, with your height:

{
"height": <h>,
"ordinal": 0,
"type": "webhook.post",
"scope": "approvals/on-approved/erp-release-001"
}
{
"executed": 1,
"openOnChain": 0,
"confirmed": 1
}

One confirmed execution and zero open on-chain effects mean the ~fx/result message has been incorporated. The approval item is still APPROVED; confirmation updates only its independently provable generic effect-state record.

Terminal window
./yano.sh appchain cluster clean
unset YANO_CLUSTER_DIR YANO_CLUSTER_NODE_CONFIG_DIR
rm -rf /tmp/yano-tutorial-webhook-config

Stop the receiver with Ctrl-C. Remove the private override directory after the cluster is stopped.

webhook.post is intentionally small: one POST to a configured endpoint. It does not yet provide named target aliases, OAuth/API-key/mTLS profiles, arbitrary methods, response-body contracts, or asynchronous operation polling. Keep allow-payload-url=false unless a separately reviewed allow-list and SSRF boundary exists.

For a product API such as uVerify, prefer a dedicated executor when durable acceptance requires several API calls, polling, reconciliation, or a typed receipt. Use the generic webhook only when one idempotent POST and its immediate status are the real business contract.

  • Read Effects §17 for quarantine, requeue, cancellation, proof, and external executor APIs.
  • Kill the receiver temporarily, observe retries and parking, restore it, and use the operator requeue endpoint.
  • Build a custom executor plugin with named target aliases and secret-backed authentication rather than putting URLs or credentials in replicated payloads.

Next: Tutorial 7 — anchors and verification.