Skip to content

Call an external service

Task: call a service outside the cluster (a captcha check, a payment API, a model, any HTTPS API) and write what it answers into state.

A call may ask its node for one HTTPS request. The node makes it only when every gate below holds and answers with its reason otherwise. Outbound requests are off by default: an app that needs them runs on a cluster whose operator turned them on, for its hosts or for any host an app declares.

Gate Who sets it
[outbound] enabled = true, and the request’s host is in hosts (or hosts holds "*": any host an installed app declares) The cluster’s operator
The request’s URL is under a prefix in the app’s signed release manifest (outbound): the same host, and the prefix’s path or one below it at a /, with any query string The app’s publisher
A prefix covering the URL is approved for the app’s publisher The organization’s owner (or the account, for an app outside an organization); the operator where the cluster was deployed with --outbound-approvals operator
The run is a call that writes: not a view, an init, a migration, or a call by a read-only member The node
One request per run; GET, POST, PUT, PATCH or DELETE; https on port 443 to a DNS name that resolves only to public addresses; no fragment; no redirect followed; no proxy The node
Request body ≤ 64 KiB, URL ≤ 2 KiB, ≤ 8 headers of ≤ 1 KiB, response body ≤ 1 MiB (a longer one is refused, not cut), 10 s for the whole request The node (calimero_primitives::outbound)
Host, Cookie, Connection, Content-Length, Transfer-Encoding, Proxy-* and X-Forwarded-* headers are refused The node

A refusal is a reason the method can handle (Err(String)). A refusal for a missing approval starts with a stable word an app can match on:

  • NotApproved: nobody approved the prefix the URL is under. Nothing was ever approved for this publisher, or the owner saw the prefix and left it out or revoked it. Tell a member to ask the owner.
  • AwaitingApproval: a later release of the app declares the prefix and the owner has not been asked about it yet. The prefixes approved before keep working. Show the owner what to approve.

OutboundRefusal::of reads the word for you:

use calimero_sdk::outbound::OutboundRefusal;
match env::http_request(&request) {
Ok(response) => { /* decide, and store the decision */ }
Err(reason) => match OutboundRefusal::of(&reason) {
Some(OutboundRefusal::NotApproved) => { /* ask the organization's owner */ }
Some(OutboundRefusal::AwaitingApproval) => { /* the owner approves the release's new prefix */ }
_ => { /* anything else is a bug in the app or its deployment */ }
},
}

A run with no outbound client at all (outbound is off, the app is not a signed release declaring a prefix, or the run is not a writing call) and a second request in one run trap the run.

The app never holds a secret. A request names one as {{secret:NAME}} in a header value or the body; the node fills it in from its operator’s secrets, for this app only (the package and signer of the installed release, never anything the app says), and only into a request to a host the operator bound that secret to. Any copy of it in the response is replaced with [redacted] before the app sees the response. So a method cannot write the secret into state, where it would replicate to every member.

  1. Declare the URL prefixes in Cargo.toml. They are signed into the release’s manifest and shown to whoever approves them, so declare the narrowest that works:

    [package.metadata.calimero]
    outbound = [
    "https://challenges.cloudflare.com/turnstile/v0/siteverify",
    "https://api.stripe.com/v1/", # every path below /v1/
    ]
  2. Make the request from a method that writes, and store the decision:

    use calimero_sdk::env;
    use calimero_sdk::outbound::{OutboundMethod, OutboundRequest};
    pub fn submit(&mut self, answer: String, captcha_token: String) -> app::Result<()> {
    let response = env::http_request(&OutboundRequest {
    method: OutboundMethod::Post,
    url: "https://challenges.cloudflare.com/turnstile/v0/siteverify".to_owned(),
    headers: vec![(
    "Content-Type".to_owned(),
    "application/x-www-form-urlencoded".to_owned(),
    )],
    body: format!("secret={{{{secret:TURNSTILE_SECRET}}}}&response={captcha_token}")
    .into_bytes(),
    })
    .map_err(|reason| app::err!("captcha check failed: {reason}"))?;
    if response.status != 200 || !String::from_utf8_lossy(&response.body).contains(r#""success":true"#) {
    app::bail!("captcha not solved");
    }
    self.answers.push(answer.into())?;
    Ok(())
    }
  3. Have the owner approve the prefixes (see approving), and the operator allow the host and supply the secret (see the operator’s side).

The organization’s data is what a call can send, so its owner agrees to the hosts too: the operator allowing a host and the publisher declaring it are not enough. The owner approves with a passkey step-up over the exact change:

POST /orgs/{org}/outbound
{ "package": "com.acme.forms", "prefixes": "manifest" }

"manifest" (the default) approves every prefix the package’s latest release declares; a list approves those. An app that declares no outbound needs and asks for nothing.

  • Per publisher. The approval is keyed by the package’s pinned signer, so a release signed by someone else starts with nothing approved.
  • Per organization. It holds for every app of the package in the organization. An app outside any organization uses its owner’s own approval (POST /auth/outbound, same body).
  • Releases only add. A prefix a later release declares that the owner was never shown answers AwaitingApproval until approved; the rest keep working.
  • Revoke by setting the list. "replace": true sets the publisher’s list exactly; an empty list with it revokes everything. It holds from the next call.
  • Operator-governed clusters. A cluster deployed with --outbound-approvals operator takes approvals from its operator instead, for every app: POST /ops/outbound with the same body, over the Ops API. The owner’s route then answers that the operator holds them.

With meroctl: namespace outbound <org> --package <pkg> [--prefix <p>]… [--replace], and account outbound --package <pkg> … for apps outside an organization.

The [outbound] table of merod’s configuration, or of the file --settings names (which replaces it). Give every node of a cluster the same table: the call runs on the app’s leader, whichever node that is.

[outbound]
enabled = true
hosts = ["challenges.cloudflare.com", "api.stripe.com"] # or ["*"]
# Secrets from environment variables, read once at start: a missing one
# fails the start.
[outbound.env_secrets.TURNSTILE_SECRET]
env = "MERO_TURNSTILE_SECRET"
packages = ["com.calimero.mero-forms"]
signers = ["did:key:z6Mk…"] # the package's signer
hosts = ["challenges.cloudflare.com"]

hosts = ["*"] lets every installed app reach the hosts its own signed manifest declares and its owner approved, so a new app needs no config change. Every other gate still holds, and a secret still goes only to the hosts bound to it.

Instead of env_secrets, a node can ask a secrets service beside it (exclusive with env_secrets):

[outbound.sidecar]
url = "http://127.0.0.1:7070/v1/secret"
token_env = "MERO_SECRETS_TOKEN"

The node POSTs {"package", "signer", "name"} with the token as a bearer and expects 200 {"value", "hosts"} or 404. The URL must be http on a loopback address. A managed deployment answers there with the secrets a deployer set for their app, so the secret lives on the nodes that serve the app and nowhere else.

The node logs every request it makes or refuses, with the app and the URL, never the secret, and counts the requests it sends (calimero_app_outbound_requests_total).

A secret is matched on both the package and the release’s signer: anyone can name a package, only its publisher can sign it.

env::http_request needs a node: under TestHost it returns Err("outbound requests need a node"), so unit-test the logic around the answer by passing the answer in.

End to end, build merod with --features calimero-node/outbound-testing. Its [outbound.testing] table, which a release build refuses, answers .test hosts in-process and takes literal secrets:

[outbound]
enabled = true
hosts = ["echo.outbound.test"]
[outbound.testing]
echo = true # .test hosts are answered in-process
[outbound.testing.secrets.ECHO_KEY]
value = "s3cr3t-e2e"
packages = ["com.calimero.outbound-probe"]
signers = ["did:key:z6Mk…"]
hosts = ["echo.outbound.test"]

The echo answers 200 with the request the node sent, as JSON: method, URL, path, query, headers, body and the number of secrets filled in. It answers only after every gate has passed, and its answer is redacted like any other, so a test sees exactly what left the node. apps/outbound-probe and its workflows/outbound.yml cover every gate this way.

  • A request never runs in a view. Read the answer back from state.
  • Redaction removes the secret as sent. A service that answers with it encoded (base64, URL-escaped) defeats it, so bind a secret only to hosts that do not echo it.
  • The response is not authenticated beyond TLS: a service you call can answer anything, so call only services you trust for the decision you store.