From 6e15e5f8ef4e0a914ea3289667e0fd58500357f4 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Fri, 13 Mar 2026 19:28:30 -0400 Subject: [PATCH 1/6] docs: HTTPS outcalls guide --- docs/guides/backends/https-outcalls.md | 191 +++++++++++++++++++++++-- 1 file changed, 179 insertions(+), 12 deletions(-) diff --git a/docs/guides/backends/https-outcalls.md b/docs/guides/backends/https-outcalls.md index 8e347f49..bbabe9d5 100644 --- a/docs/guides/backends/https-outcalls.md +++ b/docs/guides/backends/https-outcalls.md @@ -1,22 +1,189 @@ --- title: "HTTPS Outcalls" -description: "Make HTTP requests from canisters to external web APIs" +description: "Make HTTP GET and POST requests from canisters to external web APIs" sidebar: order: 2 icskills: [https-outcalls] --- -TODO: Write content for this page. +Canisters can make HTTP requests to external web services using HTTPS outcalls. This lets your canister fetch off-chain data, call REST APIs, or send notifications — all from onchain code. - -Show how to make HTTP GET and POST requests from canisters. Cover transform functions for consensus on responses, cycle costs for outcalls, response size limits, and idempotency requirements. Include inline code examples (~20 lines) for a basic GET request in both Rust and Motoko. Link to the exchange-rates example for a complete real-world use case. +HTTPS outcalls are available through the [IC management canister](../../reference/management-canister.md) (`aaaaa-aa`) via the `http_request` method. Both `GET`, `HEAD`, and `POST` methods are supported. Only HTTPS (not plain HTTP) is supported. - -- Portal: building-apps/integrations/https-outcalls/ (5 files: overview, GET, POST, technology, costs) -- icskills: https-outcalls -- Examples: send_http_get (both, inline ~20 lines), send_http_post (both, link), exchange-rates (Rust, link) +For how the consensus mechanism works for outcalls, see [Concepts: HTTPS Outcalls](../../concepts/https-outcalls.md). - -- concepts/https-outcalls -- how outcalls achieve consensus -- guides/chain-fusion/ethereum -- EVM RPC uses HTTPS outcalls under the hood -- reference/cycles-costs -- outcall pricing +## How HTTPS outcalls work + +Because a canister runs on a subnet of multiple replica nodes, every node independently makes the same HTTP request. All nodes must agree on the response before execution continues. To make responses deterministic across nodes: + +1. Every HTTPS outcall **must include a transform function** — a query method exported by your canister that strips non-deterministic fields (timestamps, request IDs, dynamic headers) from the response. +2. Cycles to cover the request cost **must be attached** at call time. If you use the CDK wrappers shown below, this is handled automatically. + +## GET request + +A minimal example that fetches a JSON price feed: + +**Motoko** + +```motoko +import Blob "mo:core/Blob"; +import Text "mo:core/Text"; +import IC "ic:aaaaa-aa"; +import Call "mo:ic/Call"; + +persistent actor { + + // Transform: strip headers so all replicas see the same response + public query func transform({ + context : Blob; + response : IC.http_request_result; + }) : async IC.http_request_result { + { response with headers = [] }; + }; + + public func getIcpPrice() : async Text { + let request : IC.http_request_args = { + url = "https://api.coingecko.com/api/v3/simple/price?ids=internet-computer&vs_currencies=usd"; + max_response_bytes = ?(10_000 : Nat64); + headers = [{ name = "User-Agent"; value = "ic-canister" }]; + body = null; + method = #get; + transform = ?{ function = transform; context = Blob.fromArray([]) }; + is_replicated = null; + }; + // Call.httpRequest auto-computes and attaches the required cycles + let response = await Call.httpRequest(request); + switch (Text.decodeUtf8(response.body)) { + case (?text) text; + case null "Response is not valid UTF-8"; + }; + }; +}; +``` + +**Rust** + +```rust +use ic_cdk::api::canister_self; +use ic_cdk::management_canister::{ + http_request, HttpHeader, HttpMethod, HttpRequestArgs, HttpRequestResult, + TransformArgs, TransformContext, TransformFunc, +}; +use ic_cdk::{query, update}; + +// Transform: strip headers so all replicas agree +#[query(hidden = true)] +fn transform(args: TransformArgs) -> HttpRequestResult { + HttpRequestResult { headers: vec![], ..args.response } +} + +#[update] +async fn get_icp_price() -> String { + let request = HttpRequestArgs { + url: "https://api.coingecko.com/api/v3/simple/price?ids=internet-computer&vs_currencies=usd".to_string(), + max_response_bytes: Some(10_000), + method: HttpMethod::GET, + headers: vec![HttpHeader { name: "User-Agent".to_string(), value: "ic-canister".to_string() }], + body: None, + transform: Some(TransformContext { + function: TransformFunc::new(canister_self(), "transform".to_string()), + context: vec![], + }), + is_replicated: None, + }; + // http_request auto-attaches the required cycles + match http_request(&request).await { + Ok(response) => String::from_utf8(response.body).unwrap_or_default(), + Err(err) => format!("Outcall failed: {:?}", err), + } +} +``` + +Add the following dependencies to `Cargo.toml`: + +```toml +[dependencies] +ic-cdk = "0.19" +candid = "0.10" +serde_json = "1" +``` + +For a complete working project, see the [send_http_get example](https://github.com/dfinity/examples/tree/master/rust/send_http_get) (Rust) or [Motoko version](https://github.com/dfinity/examples/tree/master/motoko/send_http_get). + +## POST request + +POST requests work the same way, with two additional considerations: + +- **Idempotency:** Because all replicas independently send the same request, a non-idempotent endpoint (e.g., "create order") will be called once per replica — typically 13 times on a 13-node subnet. Use an idempotency key header so the server can deduplicate. +- **Transform:** The POST transform often needs to strip the response body too, since some servers include per-request fields (like the caller's IP) in the response body. + +```motoko +public query func transformPost({ + context : Blob; + response : IC.http_request_result; +}) : async IC.http_request_result { + // Strip both headers and body — httpbin.org echoes sender IP in body + { response with headers = []; body = Blob.fromArray([]) }; +}; + +public func postData(payload : Text) : async Text { + let request : IC.http_request_args = { + url = "https://httpbin.org/post"; + max_response_bytes = ?(50_000 : Nat64); + headers = [ + { name = "Content-Type"; value = "application/json" }, + { name = "Idempotency-Key"; value = "unique-request-id-12345" }, + ]; + body = ?Text.encodeUtf8(payload); + method = #post; + transform = ?{ function = transformPost; context = Blob.fromArray([]) }; + is_replicated = null; + }; + let response = await Call.httpRequest(request); + if (response.status == 200) "POST successful" else "POST failed"; +}; +``` + +For a complete example, see [send_http_post](https://github.com/dfinity/examples/tree/master/rust/send_http_post). + +## Transform functions + +The transform function is mandatory for any outcall where the external server may return non-deterministic data. It runs on each replica before consensus and must be a `query` method. At minimum, strip all headers: + +- In Motoko: `{ response with headers = [] }` +- In Rust: `HttpRequestResult { headers: vec![], ..args.response }` + +If the response body also contains dynamic fields (timestamps, per-request IDs), parse and re-serialize the body to extract only the deterministic fields you need. + +**Debugging "no consensus" errors:** If you see `"No consensus could be reached"`, the transform is not making responses identical. Common culprits: response headers differ, JSON fields arrive in a different order, or the response body contains timestamps. Strip all headers first; if that doesn't resolve it, also normalize or strip the body. + +## Cycle costs + +HTTPS outcall costs are based on `max_response_bytes`, not the actual response size. If you omit `max_response_bytes`, the system assumes 2MB and charges approximately **21.5 billion cycles** — even for a 1KB response. Always set a tight upper bound. + +The CDK wrappers (`Call.httpRequest` in Motoko, `ic_cdk::management_canister::http_request` in Rust) compute and attach the exact cost automatically using the `ic0.cost_http_request` system API. You do not need to hard-code cycle amounts. + +For reference, on a 13-node subnet: +- Base cost: ~49 million cycles +- Per request byte: 5,200 cycles +- Per `max_response_bytes` byte: 10,400 cycles + +Unused cycles are refunded. See [Cycles Costs](../../reference/cycles-costs.md) for the full pricing table. + +## Testing locally + +```bash +icp network start -d +icp deploy backend +icp canister call backend getIcpPrice +``` + +HTTPS outcalls work on the local replica — icp-cli proxies requests through the local HTTP gateway. + +## What's next + +- [Concepts: HTTPS Outcalls](../../concepts/https-outcalls.md) — how consensus works for outcalls +- [Chain Fusion: Ethereum](../chain-fusion/ethereum.md) — the EVM RPC canister uses HTTPS outcalls under the hood +- [Cycles Costs](../../reference/cycles-costs.md) — outcall pricing details + + From 11d680f7c4c5e328fa7c0798a0c0cd733fa5db5e Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Sat, 14 Mar 2026 13:58:12 +0100 Subject: [PATCH 2/6] fix: use "offchain" spelling per project convention --- docs/guides/backends/https-outcalls.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/backends/https-outcalls.md b/docs/guides/backends/https-outcalls.md index bbabe9d5..47511ba1 100644 --- a/docs/guides/backends/https-outcalls.md +++ b/docs/guides/backends/https-outcalls.md @@ -6,7 +6,7 @@ sidebar: icskills: [https-outcalls] --- -Canisters can make HTTP requests to external web services using HTTPS outcalls. This lets your canister fetch off-chain data, call REST APIs, or send notifications — all from onchain code. +Canisters can make HTTP requests to external web services using HTTPS outcalls. This lets your canister fetch offchain data, call REST APIs, or send notifications — all from onchain code. HTTPS outcalls are available through the [IC management canister](../../reference/management-canister.md) (`aaaaa-aa`) via the `http_request` method. Both `GET`, `HEAD`, and `POST` methods are supported. Only HTTPS (not plain HTTP) is supported. From bfd9e4e3b64f684ee7da1f983fbd683378deadac Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Sat, 14 Mar 2026 14:03:29 +0100 Subject: [PATCH 3/6] docs: add local testing note about single-node consensus --- docs/guides/backends/https-outcalls.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/guides/backends/https-outcalls.md b/docs/guides/backends/https-outcalls.md index 47511ba1..c595ca74 100644 --- a/docs/guides/backends/https-outcalls.md +++ b/docs/guides/backends/https-outcalls.md @@ -180,6 +180,8 @@ icp canister call backend getIcpPrice HTTPS outcalls work on the local replica — icp-cli proxies requests through the local HTTP gateway. +> **Note:** The local replica runs a single node, so all responses reach consensus automatically — even without a transform function. Make sure to verify your transform produces identical output for varying inputs (different headers, timestamps) before deploying to a multi-node subnet, where mismatches cause "no consensus" errors. + ## What's next - [Concepts: HTTPS Outcalls](../../concepts/https-outcalls.md) — how consensus works for outcalls From f337ad102b7ba086339508bb844313ef7a74a94e Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Sat, 14 Mar 2026 14:17:54 +0100 Subject: [PATCH 4/6] fix: grammar, unused dep, and Rust function name in testing section --- docs/guides/backends/https-outcalls.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/guides/backends/https-outcalls.md b/docs/guides/backends/https-outcalls.md index c595ca74..e1bb2df9 100644 --- a/docs/guides/backends/https-outcalls.md +++ b/docs/guides/backends/https-outcalls.md @@ -8,7 +8,7 @@ icskills: [https-outcalls] Canisters can make HTTP requests to external web services using HTTPS outcalls. This lets your canister fetch offchain data, call REST APIs, or send notifications — all from onchain code. -HTTPS outcalls are available through the [IC management canister](../../reference/management-canister.md) (`aaaaa-aa`) via the `http_request` method. Both `GET`, `HEAD`, and `POST` methods are supported. Only HTTPS (not plain HTTP) is supported. +HTTPS outcalls are available through the [IC management canister](../../reference/management-canister.md) (`aaaaa-aa`) via the `http_request` method. The `GET`, `HEAD`, and `POST` methods are supported. Only HTTPS (not plain HTTP) is supported. For how the consensus mechanism works for outcalls, see [Concepts: HTTPS Outcalls](../../concepts/https-outcalls.md). @@ -105,7 +105,6 @@ Add the following dependencies to `Cargo.toml`: [dependencies] ic-cdk = "0.19" candid = "0.10" -serde_json = "1" ``` For a complete working project, see the [send_http_get example](https://github.com/dfinity/examples/tree/master/rust/send_http_get) (Rust) or [Motoko version](https://github.com/dfinity/examples/tree/master/motoko/send_http_get). @@ -175,7 +174,7 @@ Unused cycles are refunded. See [Cycles Costs](../../reference/cycles-costs.md) ```bash icp network start -d icp deploy backend -icp canister call backend getIcpPrice +icp canister call backend getIcpPrice # Rust: get_icp_price ``` HTTPS outcalls work on the local replica — icp-cli proxies requests through the local HTTP gateway. From 6294e16d154c1260d1941acfd229be88ff92e87d Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Mon, 16 Mar 2026 15:49:50 +0100 Subject: [PATCH 5/6] fix: address review feedback on HTTPS outcalls guide - Add 2MB response limit as prominent bullet in "How HTTPS outcalls work" - Add note that POST snippet goes inside the GET actor (self-contained) - Add both Rust and Motoko links for send_http_post example - Add HEAD method explanation in intro - Add "Limitations and pitfalls" section (public endpoints only, Host header, timeout) - Add non-replicated (flexible) outcalls future-work note - Remove is_replicated from code examples (None is the default) - Add Exchange Rate Canister (XRC) as production example in "What's next" --- docs/guides/backends/https-outcalls.md | 63 +++++++++++++++----------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/docs/guides/backends/https-outcalls.md b/docs/guides/backends/https-outcalls.md index e1bb2df9..9d64cd9d 100644 --- a/docs/guides/backends/https-outcalls.md +++ b/docs/guides/backends/https-outcalls.md @@ -8,7 +8,7 @@ icskills: [https-outcalls] Canisters can make HTTP requests to external web services using HTTPS outcalls. This lets your canister fetch offchain data, call REST APIs, or send notifications — all from onchain code. -HTTPS outcalls are available through the [IC management canister](../../reference/management-canister.md) (`aaaaa-aa`) via the `http_request` method. The `GET`, `HEAD`, and `POST` methods are supported. Only HTTPS (not plain HTTP) is supported. +HTTPS outcalls are available through the [IC management canister](../../reference/management-canister.md) (`aaaaa-aa`) via the `http_request` method. The `GET`, `HEAD`, and `POST` methods are supported. `HEAD` works identically to `GET` but returns only headers — useful for checking resource availability without downloading the body. Only HTTPS (not plain HTTP) is supported. For how the consensus mechanism works for outcalls, see [Concepts: HTTPS Outcalls](../../concepts/https-outcalls.md). @@ -18,6 +18,7 @@ Because a canister runs on a subnet of multiple replica nodes, every node indepe 1. Every HTTPS outcall **must include a transform function** — a query method exported by your canister that strips non-deterministic fields (timestamps, request IDs, dynamic headers) from the response. 2. Cycles to cover the request cost **must be attached** at call time. If you use the CDK wrappers shown below, this is handled automatically. +3. The **maximum response body is 2MB** (2,097,152 bytes). Requests exceeding this limit fail. Always set `max_response_bytes` to a tight upper bound — omitting it defaults to 2MB and charges cycles accordingly (~21.5 billion cycles on a 13-node subnet). ## GET request @@ -49,7 +50,6 @@ persistent actor { body = null; method = #get; transform = ?{ function = transform; context = Blob.fromArray([]) }; - is_replicated = null; }; // Call.httpRequest auto-computes and attaches the required cycles let response = await Call.httpRequest(request); @@ -89,7 +89,6 @@ async fn get_icp_price() -> String { function: TransformFunc::new(canister_self(), "transform".to_string()), context: vec![], }), - is_replicated: None, }; // http_request auto-attaches the required cycles match http_request(&request).await { @@ -116,34 +115,37 @@ POST requests work the same way, with two additional considerations: - **Idempotency:** Because all replicas independently send the same request, a non-idempotent endpoint (e.g., "create order") will be called once per replica — typically 13 times on a 13-node subnet. Use an idempotency key header so the server can deduplicate. - **Transform:** The POST transform often needs to strip the response body too, since some servers include per-request fields (like the caller's IP) in the response body. +Add these methods inside the actor from the GET example above: + ```motoko -public query func transformPost({ - context : Blob; - response : IC.http_request_result; -}) : async IC.http_request_result { - // Strip both headers and body — httpbin.org echoes sender IP in body - { response with headers = []; body = Blob.fromArray([]) }; -}; + // POST transform: also discard the body because httpbin.org includes the + // sender's IP in the "origin" field, which differs across replicas. + public query func transformPost({ + context : Blob; + response : IC.http_request_result; + }) : async IC.http_request_result { + { response with headers = []; body = Blob.fromArray([]) }; + }; + + public func postData(payload : Text) : async Text { + let request : IC.http_request_args = { + url = "https://httpbin.org/post"; + max_response_bytes = ?(50_000 : Nat64); + headers = [ + { name = "Content-Type"; value = "application/json" }, + { name = "Idempotency-Key"; value = "unique-request-id-12345" }, + ]; + body = ?Text.encodeUtf8(payload); + method = #post; + transform = ?{ function = transformPost; context = Blob.fromArray([]) }; + }; + let response = await Call.httpRequest(request); -public func postData(payload : Text) : async Text { - let request : IC.http_request_args = { - url = "https://httpbin.org/post"; - max_response_bytes = ?(50_000 : Nat64); - headers = [ - { name = "Content-Type"; value = "application/json" }, - { name = "Idempotency-Key"; value = "unique-request-id-12345" }, - ]; - body = ?Text.encodeUtf8(payload); - method = #post; - transform = ?{ function = transformPost; context = Blob.fromArray([]) }; - is_replicated = null; + if (response.status == 200) "POST successful" else "POST failed"; }; - let response = await Call.httpRequest(request); - if (response.status == 200) "POST successful" else "POST failed"; -}; ``` -For a complete example, see [send_http_post](https://github.com/dfinity/examples/tree/master/rust/send_http_post). +For complete working projects, see [send_http_post](https://github.com/dfinity/examples/tree/master/rust/send_http_post) (Rust) or [Motoko version](https://github.com/dfinity/examples/tree/master/motoko/send_http_post). ## Transform functions @@ -169,6 +171,14 @@ For reference, on a 13-node subnet: Unused cycles are refunded. See [Cycles Costs](../../reference/cycles-costs.md) for the full pricing table. +## Limitations and pitfalls + +- **Public endpoints only.** HTTPS outcalls can only reach public internet endpoints. Localhost (`127.0.0.1`), private IP ranges (`10.x.x.x`, `192.168.x.x`), and other non-routable addresses are blocked. +- **`Host` header may be required.** Some API endpoints require the `Host` header to be explicitly set. The IC does not automatically set it from the URL — add it to your headers if the server requires it. +- **~30-second timeout.** If the external server does not respond within the timeout, the call traps. Design for failure and handle errors gracefully. + +> **Non-replicated (flexible) outcalls** — a mode where only a single replica makes the request instead of all replicas — is under development. This page will be updated when the feature is available. + ## Testing locally ```bash @@ -184,6 +194,7 @@ HTTPS outcalls work on the local replica — icp-cli proxies requests through th ## What's next - [Concepts: HTTPS Outcalls](../../concepts/https-outcalls.md) — how consensus works for outcalls +- [Exchange Rate Canister (XRC)](https://github.com/dfinity/exchange-rate-canister) — a production service powered by HTTPS outcalls that fetches cryptocurrency and fiat exchange rates - [Chain Fusion: Ethereum](../chain-fusion/ethereum.md) — the EVM RPC canister uses HTTPS outcalls under the hood - [Cycles Costs](../../reference/cycles-costs.md) — outcall pricing details From 0e4a4e75268e850f7759370c5d6bcfd39041814c Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Mon, 16 Mar 2026 16:00:23 +0100 Subject: [PATCH 6/6] fix: correct non-replicated vs flexible outcalls distinction Non-replicated outcalls (is_replicated=false) already exist as experimental. Flexible outcalls with dedicated pricing are what's under development. --- docs/guides/backends/https-outcalls.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/backends/https-outcalls.md b/docs/guides/backends/https-outcalls.md index 9d64cd9d..33160c7a 100644 --- a/docs/guides/backends/https-outcalls.md +++ b/docs/guides/backends/https-outcalls.md @@ -177,7 +177,7 @@ Unused cycles are refunded. See [Cycles Costs](../../reference/cycles-costs.md) - **`Host` header may be required.** Some API endpoints require the `Host` header to be explicitly set. The IC does not automatically set it from the URL — add it to your headers if the server requires it. - **~30-second timeout.** If the external server does not respond within the timeout, the call traps. Design for failure and handle errors gracefully. -> **Non-replicated (flexible) outcalls** — a mode where only a single replica makes the request instead of all replicas — is under development. This page will be updated when the feature is available. +> **Non-replicated outcalls** are available as an experimental feature. Setting `is_replicated` to `false` in the request causes only a single replica to make the HTTP call. This avoids idempotency concerns but currently uses the same pricing as replicated outcalls. **Flexible outcalls** — with dedicated pricing and additional capabilities — are under development. ## Testing locally