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 (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.
How HTTPS outcalls work
By default, every replica node in the subnet independently makes the same HTTP request: called replicated mode. All nodes must agree on the response before execution continues. Two constraints apply regardless of mode:
- Cycles to cover the request cost must be attached at call time. In Rust,
ic_cdk::management_canister::http_requestauto-calculates and attaches cycles. In Motoko, cycles must be attached explicitly withawait (with cycles = ...). - The maximum response body is 2MB (2,097,152 bytes). Requests exceeding this limit fail. Always set
max_response_bytesto a tight upper bound: omitting it defaults to 2MB and charges cycles accordingly.
In replicated mode, a transform function is strongly recommended: without one, responses across nodes will likely differ and consensus will fail. In non-replicated mode (is_replicated = false), a transform is unnecessary because only one node makes the request. See Replicated vs non-replicated mode below.
Replicated vs non-replicated mode
HTTPS outcalls have two modes, controlled by the is_replicated field:
| Replicated (default) | Non-replicated (is_replicated = false) | |
|---|---|---|
| Who sends the request | All N nodes on the subnet | One node |
| Consensus on response | Yes | No |
| Transform needed | Strongly recommended | No |
| Risk | API rate limits (N simultaneous requests) | Response could be tampered with |
Rate limit risk in replicated mode: On a 13-node subnet, 13 identical requests hit the external API within milliseconds. Many APIs enforce per-second or per-IP rate limits that this will trigger. If the API you’re calling has rate limits, prefer is_replicated = false.
Use replicated mode when you need a strong integrity guarantee that the response was not tampered with by a single node: for example, fetching price data used in financial logic.
Use non-replicated mode when calling APIs with rate limits, when the endpoint is idempotent and you trust the result, or for POST requests where duplicate submission is undesirable.
The tradeoff with non-replicated mode: the single node that makes the request could theoretically observe and modify the response before returning it to the canister.
GET request
A minimal example that sends a GET request to an echo service. The response body is deterministic, so this uses replicated mode for strong integrity guarantees:
public func send_http_get_request() : async Text { let request : IC.http_request_args = { url = "https://postman-echo.com/get?greeting=hello-from-icp"; // Always set max_response_bytes to a tight bound. The cycle cost scales // with this value, not the actual response size. If omitted, the system // assumes 2MB. Unused cycles are refunded, but you still pay for the // declared maximum. max_response_bytes = ?(3_000 : Nat64); headers = [{ name = "User-Agent"; value = "ic-canister" }]; body = null; method = #get; transform = ?{ function = transform; context = Blob.fromArray([]) }; // Replicated mode: all subnet nodes make the request independently, // providing strong integrity guarantees via consensus. is_replicated = ?true; };
// Cycles must be explicitly attached to management canister calls. // The amount is based on request size and max_response_bytes. let response = await (with cycles = 230_949_972_000) IC.http_request(request);
// postman-echo.com echoes back the request metadata as JSON, letting you // verify the query params and headers were sent correctly. switch (Text.decodeUtf8(response.body)) { case (?text) text; case null "Response is not valid UTF-8"; };};#[ic_cdk::update]async fn send_http_get_request() -> String { let request = HttpRequestArgs { url: "https://postman-echo.com/get?greeting=hello-from-icp".to_string(), method: HttpMethod::GET, // Always set max_response_bytes to a tight bound. The cycle cost scales // with this value, not the actual response size. If omitted, the system // assumes 2MB. Unused cycles are refunded, but you still pay for the // declared maximum. max_response_bytes: Some(3_000), 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![], }), // Replicated mode: all subnet nodes make the request independently, // providing strong integrity guarantees via consensus. is_replicated: Some(true), };
// http_request auto-calculates and attaches the required cycles match http_request(&request).await { // postman-echo.com echoes back the request metadata as JSON, letting you // verify the query params and headers were sent correctly. Ok(response) => String::from_utf8(response.body).unwrap_or_default(), Err(err) => format!("Outcall failed: {err}"), }}Because these examples use replicated mode, they include a transform function to strip non-deterministic HTTP response headers before consensus:
// Strip HTTP response headers (date, cookies, tracking IDs) that vary across replicas.// In replicated mode, all replicas must see an identical response for consensus to// succeed — the transform ensures this by discarding non-deterministic fields.public query func transform({ context : Blob; response : IC.http_request_result;}) : async IC.http_request_result { { response with headers = [] };};// Strip HTTP response headers (date, cookies, tracking IDs) that vary across replicas.// In replicated mode, all replicas must see an identical response for consensus to// succeed — the transform ensures this by discarding non-deterministic fields.#[ic_cdk::query(hidden = true)]fn transform(raw: TransformArgs) -> HttpRequestResult { HttpRequestResult { headers: vec![], ..raw.response }}POST request
POST requests work the same way, with two additional considerations:
- Idempotency: In replicated mode, all replicas independently send the same request: typically 13 times on a 13-node subnet. Add an
Idempotency-Keyheader so the server can deduplicate. Alternatively, use non-replicated mode (is_replicated = false) where only one replica sends the request. - Non-replicated mode: For POST requests where you don’t need consensus on the response, non-replicated mode avoids duplicate requests entirely.
public func send_http_post_request() : async Text { let body = Text.encodeUtf8("This is a POST request from an ICP canister.");
let request : IC.http_request_args = { url = "https://postman-echo.com/post"; // Always set max_response_bytes to a tight bound. The cycle cost scales // with this value, not the actual response size. If omitted, the system // assumes 2MB. Unused cycles are refunded, but you still pay for the // declared maximum. max_response_bytes = ?(3_000 : Nat64); headers = [ { name = "Content-Type"; value = "text/plain" }, ]; body = ?body; method = #post; transform = ?{ function = transform; context = Blob.fromArray([]) }; // Non-replicated: only one replica sends the request. For replicated // mode (true), add an Idempotency-Key header so the server can // deduplicate the requests sent by each replica independently. is_replicated = ?false; };
// Cycles must be explicitly attached to management canister calls. // The amount is based on request size and max_response_bytes. let response = await (with cycles = 230_949_972_000) IC.http_request(request);
// postman-echo.com echoes back the request data as JSON, letting you // verify the POST body and headers were sent correctly. switch (Text.decodeUtf8(response.body)) { case (?text) text; case null "Response is not valid UTF-8"; };};#[ic_cdk::update]async fn send_http_post_request() -> String { let body = "This is a POST request from an ICP canister.";
let request = HttpRequestArgs { url: "https://postman-echo.com/post".to_string(), method: HttpMethod::POST, // Always set max_response_bytes to a tight bound. The cycle cost scales // with this value, not the actual response size. If omitted, the system // assumes 2MB. Unused cycles are refunded, but you still pay for the // declared maximum. max_response_bytes: Some(3_000), headers: vec![HttpHeader { name: "Content-Type".to_string(), value: "text/plain".to_string(), }], body: Some(body.as_bytes().to_vec()), transform: Some(TransformContext { function: TransformFunc::new(canister_self(), "transform".to_string()), context: vec![], }), // Non-replicated: only one replica sends the request. For replicated // mode (true), add an Idempotency-Key header so the server can // deduplicate the requests sent by each replica independently. is_replicated: Some(false), };
// http_request auto-calculates and attaches the required cycles match http_request(&request).await { // postman-echo.com echoes back the request data as JSON, letting you // verify the POST body and headers were sent correctly. Ok(response) => String::from_utf8(response.body).unwrap_or_default(), Err(err) => format!("Outcall failed: {err}"), }}Transform functions
In replicated mode, a transform function is strongly recommended (without one, responses across nodes will likely differ and consensus will fail. In non-replicated mode it is unnecessary. The transform runs on each replica before consensus and must be a query method. At minimum, strip all HTTP response headers) they contain non-deterministic fields like Date, Set-Cookie, and tracking IDs:
- In Motoko:
{ response with headers = [] } - In Rust:
HttpRequestResult { headers: vec![], ..raw.response }
If the response body also contains dynamic fields (timestamps, per-request IDs, the caller’s IP), 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. Unused cycles are refunded, but you still pay for the declared maximum.
In Rust, ic_cdk::management_canister::http_request computes and attaches the exact cost automatically using the ic0.cost_http_request system API. In Motoko, cycles must be attached explicitly with await (with cycles = ...).
For reference, on a 13-node subnet:
- Base cost: ~49 million cycles
- Per request byte: 5,200 cycles
- Per
max_response_bytesbyte: 10,400 cycles
See Cycles Costs 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. Hostheader may be required. Some API endpoints require theHostheader 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.
Testing locally
Use the “Full example in ICP Ninja” links above to deploy and test directly in the browser. To test locally with icp-cli, clone the example and run icp network start -d && icp deploy.
Note: The local replica runs a single node, so all responses reach consensus automatically: even without a transform function. 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.
Next steps
- Concepts: HTTPS Outcalls: how consensus works for outcalls
- Management canister reference: full
http_requestparameter reference including all fields - Exchange Rate Canister (XRC): a production service powered by HTTPS outcalls that fetches digital asset and fiat exchange rates
- Chain Fusion: Ethereum: the EVM RPC canister uses HTTPS outcalls under the hood
- Cycles Costs: outcall pricing details