HTTPS outcalls
Canisters can make HTTP requests to external web services using HTTPS outcalls. This lets your canister call REST APIs or send notifications: all from canister code.
HTTPS outcalls are available through the IC management canister (aaaaa-aa): the http_request method, and flexible_http_request for the flexible mode described below. GET, HEAD, and POST are supported in every mode; PUT, DELETE, and PATCH only in some (see Outcall modes). 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. Both languages provide a wrapper that computes the amount and attaches it: the
HttpRequestbuilder in Rust (theic-cdk-management-canistercrate, from 0.2.0), andCall.httpRequestfrom theicpackage in Motoko. Prefer these over a hand-picked figure: attached cycles are held for the duration of the call, so an arbitrary margin caps how many outcalls the canister can have in flight. - The maximum response size is 2MB (2,000,000 bytes). This covers the response’s header names and values plus the body, so size a cap against both: a response can carry 1 to 2 KB of headers before any body. Requests exceeding this limit fail. Under legacy pricing (version 1), always set
max_response_bytesto a tight upper bound: omitting it defaults to 2MB and charges cycles accordingly. In flexible mode the same 2MB cap applies to each node’s response, and the responses delivered together (at leastmin_responsesmany) must additionally fit a 2 MiB total (2,097,152 bytes).
In replicated mode, a transform function is strongly recommended: without one, responses across nodes will likely differ and consensus will fail. In non-replicated (is_replicated = false) and flexible mode it is optional, because the nodes’ individual responses are returned rather than reconciled. See Outcall modes below.
Outcall modes
HTTPS outcalls have three modes. Replicated and non-replicated are selected by the is_replicated field of http_request; flexible is a separate management canister method, flexible_http_request.
| Replicated (default) | Non-replicated (is_replicated = false) | Flexible (flexible_http_request) | |
|---|---|---|---|
| Who sends the request | All N nodes on the subnet | One node | A committee of total_requests nodes |
| What the canister gets | One agreed response | The one node’s response | Between min_responses and max_responses individual responses |
| Methods | GET, HEAD, POST | plus PUT, DELETE, PATCH | plus PUT, DELETE, PATCH when total_requests, min_responses, and max_responses are equal |
| Consensus on response | Yes | No | No: consensus is on which responses to deliver |
| Transform needed | Strongly recommended | Optional | Optional |
| Pricing | Version 1 (deprecated) or 2 | Version 1 (deprecated) or 2 | Always version 2 |
| Risk | API rate limits (N simultaneous requests) | Response could be tampered with | Reconciling the responses is your canister’s job |
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.
Use flexible mode when the data changes too fast for replicas to ever agree, so replicated mode would fail consensus, and you would rather compare several independent answers than trust one. The tradeoff is between cost and trust: a committee of three costs less than one of 13 and is correspondingly easier for a single node to skew. Your canister must handle any count between min_responses and max_responses, including responses that disagree with each other. See flexible_http_request in the Interface Specification for the argument and result types.
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.HttpRequestArgs = { 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 = ([] : [Nat8]).toBlob() }; // Replicated mode: all subnet nodes make the request independently, // providing strong integrity guarantees via consensus. is_replicated = ?true; };
// Cycles must be attached to management canister calls. Call.httpRequest // computes the exact amount from the request size and max_response_bytes // and attaches it. Prefer this over a hand-picked figure: attached cycles // are held for the duration of the call, so an arbitrary margin caps how // many outcalls the canister can have in flight. let response = await Call.httpRequest(request);
// postman-echo.com echoes back the request metadata as JSON, letting you // verify the query params and headers were sent correctly. switch (response.body.decodeUtf8()) { case (?text) text; case null "Response is not valid UTF-8"; };};#[ic_cdk::update]async fn send_http_get_request() -> String { // Replicated mode is the builder's default: all subnet nodes make the request // independently, providing strong integrity guarantees via consensus. let request = HttpRequest::new("https://postman-echo.com/get?greeting=hello-from-icp") .with_method(HttpMethod::GET) // max_response_bytes bounds the response. A larger one fails the call. // Under pay-as-you-go pricing it no longer sets the price, because only // the bytes that actually arrive are charged. .with_max_response_bytes(3_000) .with_header("User-Agent", "ic-canister") .with_transform(transform_context_from_query("transform".to_string(), vec![])) // Unset expectations are reserved at their worst case: a 60 second round // trip, and a transform running to the full query instruction limit. // Declaring what this call expects holds far fewer cycles while it runs. // The charge is unchanged, because only the resources actually used are // billed. Leave headroom, since the reservation doubles as each node's // budget for the call. .with_expected_roundtrip_time_ms(10_000) .with_expected_transform_instructions(1_000_000);
// send() computes the cycles the call may need and attaches them. match request.send().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}"), }}The two with_expected_* calls size the cycles reservation, not the price. They are Rust only: the Motoko wrapper prices with version 1, which has no equivalent. Cycle costs below explains what they do.
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 = _context : Blob; response : IC.HttpRequestResult;}) : async IC.HttpRequestResult { { 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 = "This is a POST request from an ICP canister.".encodeUtf8();
let request : IC.HttpRequestArgs = { 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 = ([] : [Nat8]).toBlob() }; // 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 attached to management canister calls. Call.httpRequest // computes the exact amount from the request size and max_response_bytes // and attaches it. Prefer this over a hand-picked figure: attached cycles // are held for the duration of the call, so an arbitrary margin caps how // many outcalls the canister can have in flight. let response = await Call.httpRequest(request);
// postman-echo.com echoes back the request data as JSON, letting you // verify the POST body and headers were sent correctly. switch (response.body.decodeUtf8()) { 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 = HttpRequest::new("https://postman-echo.com/post") .with_method(HttpMethod::POST) // max_response_bytes bounds the response. A larger one fails the call. // Under pay-as-you-go pricing it no longer sets the price, because only // the bytes that actually arrive are charged. .with_max_response_bytes(3_000) .with_header("Content-Type", "text/plain") .with_body(body.as_bytes().to_vec()) .with_transform(transform_context_from_query("transform".to_string(), vec![])) // Unset expectations are reserved at their worst case: a 60 second round // trip, and a transform running to the full query instruction limit. // Declaring what this call expects holds far fewer cycles while it runs. // The charge is unchanged, because only the resources actually used are // billed. Leave headroom, since the reservation doubles as each node's // budget for the call. .with_expected_roundtrip_time_ms(10_000) .with_expected_transform_instructions(1_000_000) // Non-replicated: only one node sends the request. In replicated mode // (the builder's default) add an Idempotency-Key header, so the server can // deduplicate the request that each node sends independently. .non_replicated();
// send() computes the cycles the call may need and attaches them. match request.send().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}"), }}Flexible request
Flexible outcalls go through the flexible_http_request management canister method, which hands the canister each node’s own response instead of one the subnet agreed on.
#[ic_cdk::update]async fn fetch_server_time() -> Result<TimeReport, String> { // A committee of five, rather than every node on the subnet. Fewer requests // cost less and are gentler on the server's rate limits. The tradeoff is // that a smaller committee is easier for a single node to skew. let total_requests = subnet_self_node_count().min(5); // Return as soon as a strict majority of the committee has answered. let min_responses = total_requests / 2 + 1;
// No transform function. A flexible outcall hands back each node's own // response instead of one the subnet agreed on, so there is nothing to make // identical across nodes. Leaving it off also means no cycles are reserved // for running it. let result = FlexibleHttpRequest::new("https://postman-echo.com/time/now") .with_max_response_bytes(1_000) .with_replication(ReplicationCounts { total_requests, min_responses, max_responses: total_requests, }) .with_expected_roundtrip_time_ms(10_000) .send() .await .map_err(|err| format!("Outcall failed: {err}"))?;
match result { // Any count between min_responses and max_responses is a normal success, // not a degraded one. FlexibleHttpRequestResult::Ok(responses) => Ok(reconcile(&responses)), // global_error says why the replication could not be met: a timeout, // out_of_cycles, responses_too_large, or too_many_rejects. node_details // reports what the individual nodes did. FlexibleHttpRequestResult::Err(err) => Err(format!( "Fewer than {min_responses} responses: {:?}, {}", err.global_error, err.message )), }}The committee is five nodes, or the whole subnet if it is smaller: total_requests cannot exceed the number of nodes, which subnet_self_node_count() reports. The example sets no transform: each node’s own response is what the canister receives, so there is nothing to make identical across nodes. The Motoko ic package has no equivalent for flexible outcalls yet, so this is Rust only.
Handle any count between min_responses and max_responses: fewer than max_responses is a normal success, not a degraded one. The responses do not say which node produced them and their order is not specified, so treat them as an unordered multiset. On failure, err.global_error distinguishes a timeout from out_of_cycles, responses_too_large, and too_many_rejects, and err.node_details reports what the individual nodes did.
Reconciling the responses is what the canister has to do with them. This example tallies the distinct bodies and reports the most common first, checking each response’s status on its own, because no node had to agree with any other:
// Reconciling the responses is the canister's job. They do not say which node// produced them and their order is not specified, so treat them as an unordered// multiset. Each one carries its own status, because no node had to agree with// any other. This tallies the distinct bodies and puts the most common first.fn reconcile(responses: &[HttpRequestResult]) -> TimeReport { let mut tally: Vec<Tally> = Vec::new(); let mut counted = 0;
for response in responses.iter().filter(|r| r.status == Nat::from(200u32)) { counted += 1; let value = String::from_utf8_lossy(&response.body).to_string(); match tally.iter_mut().find(|entry| entry.value == value) { Some(entry) => entry.count += 1, None => tally.push(Tally { value, count: 1 }), } }
tally.sort_by(|a, b| b.count.cmp(&a.count)); TimeReport { responses: counted, tally, }}The delivered responses must fit 2 MiB between them. The limit applies to the combined encoded size of every response returned after the transform, and not to each response on its own. When they do not all fit, fewer are returned, down to min_responses. The call fails only when even the smallest min_responses responses exceed the limit together.
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 and flexible mode it is optional; each node runs it on its own response. The transform runs on each replica before consensus and must be a query method. At minimum, strip all HTTP response headers, which carry 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.
max_response_bytes is enforced twice: once on the raw response as it arrives from the server, and again on the transform’s own output. Stripping headers in the transform therefore cannot rescue a response that already exceeded the cap, because the first check runs before the transform does. It only keeps the transform’s own output within the cap. Size max_response_bytes for the headers and body as they arrive from the server.
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 outcalls are not free, and there are two pricing versions, chosen per call by the pricing_version field of http_request. Flexible outcalls have no such field and are always priced with version 2.
Version 1 is deprecated
Version 1 is still the default, so a call that does not set pricing_version gets it. Version 2 is to become the default, after which version 1 will be removed.
| Version 1 (default, deprecated) | Version 2 (pay-as-you-go) | |
|---|---|---|
| Charged for | max_response_bytes, whether you use it or not | the bytes that arrive, the round-trip time, the transform instructions, the size of the delivered response |
Role of max_response_bytes | sets both the limit and the price | sets the limit and, through the worst-case usage computed from it, the size of the reservation; not the price |
| Amount to attach | the exact charge, computable from the request | a cycles budget, based on expected resource consumption |
| Cost system API | ic0.cost_http_request | ic0.cost_http_request_v2 |
| Attaching too little | rejected up front | rejected up front if it misses the base fee; otherwise runs with tighter per-node limits and may fail partway |
| Flexible outcalls | not available | required |
Which version a call gets depends on the wrapper you call through, and both compute the amount to attach for you. In Rust, the HttpRequest builder always selects version 2 and prices the call with ic0.cost_http_request_v2; with_expected_* narrows the reservation from the worst case to what the call is expected to consume. In Motoko, Call.httpRequest from the ic package attaches the version 1 cost using ic0.cost_http_request. The Rust examples above are therefore priced with version 2, and the Motoko ones with version 1. Do not set pricing_version = 2 on a request you pass to Call.httpRequest: the wrapper would still attach the version 1 amount, which is far below what version 2 reserves, so the call would run within a budget too small to finish. Attaching a hand-picked amount instead is counterproductive: the cycles are held for the duration of the call, so a margin caps how many outcalls the canister can have in flight.
Version 1
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 20.85 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.
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
Version 2
Setting pricing_version = 2 prices the resources the call actually consumes: the bytes that arrive, the time the request takes, the instructions the transform runs, and the size of the response that is delivered. A conservative max_response_bytes therefore costs nothing extra. It still bounds the response, and because the worst-case usage that bounds the reservation is computed from it, a larger value holds more cycles while the call runs, but it no longer sets the price.
Compute the recommended cycles amount to attach with ic0.cost_http_request_v2, passing the resources you expect the call to use. Passing the maximum of each instead gives you an amount the call cannot exhaust, but reserves far more for the duration of the call, since the maxima include a 60-second round trip and the full query instruction limit for the transform.
In Rust, each with_expected_* on the builder replaces one of those maxima. The GET example above declares a 10-second round trip and 1 million transform instructions. On a 13-node subnet that takes the reservation from about 5.33 billion cycles down to about 136 million. What the call is charged does not change, because only the resources it consumes are billed.
The four expectations are not four separate limits. They size a single per-node budget, and the response size cap and response timeout the nodes run under are derived from whatever is left of it. Expecting less therefore also narrows what the call is allowed to do.
The round trip and the transform instructions are the safe two to narrow, and they carry most of the saving. Their maxima are 60 seconds and the full query instruction limit, both far above what a normal call uses. Declaring a modest figure for either does not cap the call at that figure: a node derives its time and instruction limits from its whole remaining budget, most of which the byte reservations make up, so it can usually go well beyond the declared figure before running short. The two byte expectations are the ones to leave at their defaults: max_response_bytes for the raw response, and max_response_bytes plus 1 KiB for the transformed one. The extra kilobyte covers the Candid encoding of a response delivered without a transform, which is the largest content a node can deliver; a transformed response itself is capped at max_response_bytes. Declaring less is a bet on the server. The download cap each node applies follows its remaining budget rather than the byte expectation, so a larger response may still be downloaded, and the nodes may then be unable to fund delivering it. The call then fails at delivery, after the request has already been made. Without a transform there is no instruction expectation to narrow: the builder reserves zero for it.
See Cycles costs for the full pricing formulas for both versions.
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.- Two timeouts. If the external server does not respond within 30 seconds, or the subnet does not produce a response within 60 seconds, the call is rejected. It does not trap, so handle the error case rather than relying on a trap.
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.
Flexible outcalls do run locally, but every response comes from that same single node, so a local run exercises the call and your reconciliation code without ever producing the disagreement reconciliation exists for.
Next steps
- Concepts: HTTPS Outcalls: how consensus works for outcalls
- Management canister reference: full
http_requestparameter reference including all fields flexible_http_requestreference: arguments, results, and errors of the flexible mode- 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