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.posteffect, 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.
| Capability | Use it for | Result committed to app state? |
|---|---|---|
| Finalized-block webhook sink | Projection/indexing of every finalized block | No |
webhook.post effect | One business action after a deterministic transition | Yes, 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-blocksEach 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/yanoyano.app-chain.effects.executors.webhook.timeout-ms: 10000It sends:
Idempotency-Key: the hex hash of the effect id, identical on every attempt;X-App-Chain-Id; andX-Effect-Id,X-Effect-Type, andX-Effect-Scope.
Response semantics are:
2xx→ confirmed, with theLocationheader as the external reference when present;4xx→ failed without automatic retry; and- anything else, including
5xxand 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:
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.
- Emit Inside
apply(), the state machine callseffects.emit(...)with typewebhook.post, a scope,ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returnsEffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.Nothing reaches you yet. The effect is only a record in a block that is not final. - 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.Still nothing. The authorization is now final and provable. - Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (
app-finalis 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.The executor node has the effect queued for you. - Deliver The
webhook.postexecutor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unlessallow-payload-urlis set. TheIdempotency-Keyheader is the hex Blake2b-256 hash of the effect id, the same on every attempt.X-Effect-Idcarriesorders-chain/120/0.You receive a POST. Store theIdempotency-Keywith the work you do. - Answer The receiver answers 2xx. The executor records the effect as done and confirmed, with the
Locationheader, orHTTP 200, as the external reference. A 4xx means FAILED with no retry; a 5xx or a network error is retried.Answer 2xx only after you have durably done the work. - Report For a
CHAINeffect, the executor node signs a~fx/resultmessage 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.signerscan limit which members may report.Your answer is on its way back into the ledger. - Incorporate In the block that contains it, the framework accepts the first valid result before the state machine runs, records the outcome, and calls
onEffectResulton 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.The ledger now records CONFIRMED and the reference you returned.
What if: Result policy NONE
Without .result(ResultPolicy.CHAIN), the policy is the default, NONE.
- Emit without a result The intent does not call
.result(...), so its policy is the defaultResultPolicy.NONE. A NONE effect cannot take an expiry, and its outcome never returns to the ledger.Nothing reaches you yet. The effect is only a record in a block that is not final. - 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.Still nothing. The authorization is now final and provable. - Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (
app-finalis 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.The executor node has the effect queued for you. - Deliver The
webhook.postexecutor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unlessallow-payload-urlis set. TheIdempotency-Keyheader is the hex Blake2b-256 hash of the effect id, the same on every attempt.X-Effect-Idcarriesorders-chain/120/0.You receive a POST. Store theIdempotency-Keywith the work you do. - Done locally The receiver answers 2xx and the executor marks the effect done. That is the end: no
~fx/result, noonEffectResult, 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.You answered 2xx. The ledger never learns the result.
What if: Receiver answers 4xx
A definitive “no” from the target is recorded as FAILED.
- Emit Inside
apply(), the state machine callseffects.emit(...)with typewebhook.post, a scope,ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returnsEffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.Nothing reaches you yet. The effect is only a record in a block that is not final. - 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.Still nothing. The authorization is now final and provable. - Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (
app-finalis 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.The executor node has the effect queued for you. - Deliver The
webhook.postexecutor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unlessallow-payload-urlis set. TheIdempotency-Keyheader is the hex Blake2b-256 hash of the effect id, the same on every attempt.X-Effect-Idcarriesorders-chain/120/0.You receive a POST. Store theIdempotency-Keywith the work you do. - Rejected The receiver answers 400. Retrying the same bytes cannot succeed, so the executor does not retry. For a
CHAINeffect it records FAILED with the reasonHTTP 400.You rejected the request. You will not get it again. - Report For a
CHAINeffect, the executor node signs a~fx/resultmessage 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.signerscan limit which members may report.Your answer is on its way back into the ledger. - Incorporate FAILED Every member calls
onEffectResultwith outcome FAILED: the target answered no. The state machine decides what that means for the order.The ledger records that you said no.
What if: Receiver answers 5xx
Retries are local and bounded; the expiry closes the effect deterministically.
- Emit Inside
apply(), the state machine callseffects.emit(...)with typewebhook.post, a scope,ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returnsEffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.Nothing reaches you yet. The effect is only a record in a block that is not final. - 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.Still nothing. The authorization is now final and provable. - Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (
app-finalis 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.The executor node has the effect queued for you. - Deliver The
webhook.postexecutor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unlessallow-payload-urlis set. TheIdempotency-Keyheader is the hex Blake2b-256 hash of the effect id, the same on every attempt.X-Effect-Idcarriesorders-chain/120/0.You receive a POST. Store theIdempotency-Keywith the work you do. - 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.You keep receiving the same POST, with the same key.
- 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.The ledger stopped waiting for you.
What if: Executor crashes after the POST
Execution is at least once, so the receiver must deduplicate.
- Emit Inside
apply(), the state machine callseffects.emit(...)with typewebhook.post, a scope,ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returnsEffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.Nothing reaches you yet. The effect is only a record in a block that is not final. - 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.Still nothing. The authorization is now final and provable. - Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (
app-finalis 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.The executor node has the effect queued for you. - Deliver The
webhook.postexecutor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unlessallow-payload-urlis set. TheIdempotency-Keyheader is the hex Blake2b-256 hash of the effect id, the same on every attempt.X-Effect-Idcarriesorders-chain/120/0.You receive a POST. Store theIdempotency-Keywith the work you do. - 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.You did the work and answered 200. The executor never heard it.
- 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.The same key arrives twice. Answer 200 without doing the work again. - Report For a
CHAINeffect, the executor node signs a~fx/resultmessage 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.signerscan limit which members may report.Your answer is on its way back into the ledger. - Incorporate In the block that contains it, the framework accepts the first valid result before the state machine runs, records the outcome, and calls
onEffectResulton 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.The ledger now records CONFIRMED and the reference you returned.
- 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”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()PYIt prints nothing until the first POST arrives.
2. Create private per-node overrides
Section titled “2. Create private per-node overrides”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=275yano.app-chain.chains[0].state-machine=approvalsyano.app-chain.chains[0].effects.enabled=trueyano.app-chain.chains[0].machines.approvals.on-approved-effect.enabled=trueyano.app-chain.chains[0].machines.approvals.activations.on-approved-effect=1yano.app-chain.chains[0].machines.approvals.on-approved-effect.type=webhook.postyano.app-chain.chains[0].machines.approvals.on-approved-effect.gate=app-finalThese 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=trueyano.app-chain.chains[0].effects.executor.types=webhook.postyano.app-chain.chains[0].effects.executors.webhook.url=http://127.0.0.1:8099/yanoyano.app-chain.chains[0].effects.executors.webhook.timeout-ms=5000The distribution includes those exact files under
docs/appchain/tutorials/config/webhook/. Install owner-only copies and start
a fresh cluster:
install -d -m 700 /tmp/yano-tutorial-webhook-configinstall -m 600 \ docs/appchain/tutorials/config/webhook/node*.properties \ /tmp/yano-tutorial-webhook-config/
export YANO_CLUSTER_DIR=/tmp/yano-tutorial-webhookexport YANO_CLUSTER_NODE_CONFIG_DIR=/tmp/yano-tutorial-webhook-config./yano.sh appchain cluster start 3The 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:
HTTP_BASE=7070The chain id remains orders-chain, but its fresh deterministic profile is
now approvals. Never apply this override to retained ordered-log state.
3. Encode and submit a proposal
Section titled “3. Encode and submit a proposal”Encode the supplied HTTP body and the canonical stock commands with the tutorial helper:
TOOL=docs/appchain/tutorials/tools/stdlib_command.pyWEBHOOK_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.
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"}4. Watch the delivery
Section titled “4. Watch the delivery”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:
STATUS=http://127.0.0.1:$HTTP_BASE/api/v1/app-chain/chains/orders-chain/statusuntil curl -s "$STATUS" | jq -e '.effects.executor.executionTotals.confirmed >= 1 and .effects.executor.openOnChain == 0' >/dev/null; do sleep 1done
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.
5. Clean up
Section titled “5. Clean up”./yano.sh appchain cluster cleanunset YANO_CLUSTER_DIR YANO_CLUSTER_NODE_CONFIG_DIRrm -rf /tmp/yano-tutorial-webhook-configStop the receiver with Ctrl-C. Remove the private override directory after
the cluster is stopped.
Security and product boundary
Section titled “Security and product boundary”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.
Go deeper
Section titled “Go deeper”- 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.