For AI agents: Documentation index at /llms.txt

Skip to content

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 HttpRequest builder in Rust (the ic-cdk-management-canister crate, from 0.2.0), and Call.httpRequest from the ic package 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_bytes to 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 least min_responses many) 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 requestAll N nodes on the subnetOne nodeA committee of total_requests nodes
What the canister getsOne agreed responseThe one node’s responseBetween min_responses and max_responses individual responses
MethodsGET, HEAD, POSTplus PUT, DELETE, PATCHplus PUT, DELETE, PATCH when total_requests, min_responses, and max_responses are equal
Consensus on responseYesNoNo: consensus is on which responses to deliver
Transform neededStrongly recommendedOptionalOptional
PricingVersion 1 (deprecated) or 2Version 1 (deprecated) or 2Always version 2
RiskAPI rate limits (N simultaneous requests)Response could be tampered withReconciling 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";
};
};

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 = [] };
};

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-Key header 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";
};
};

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 formax_response_bytes, whether you use it or notthe bytes that arrive, the round-trip time, the transform instructions, the size of the delivered response
Role of max_response_bytessets both the limit and the pricesets the limit and, through the worst-case usage computed from it, the size of the reservation; not the price
Amount to attachthe exact charge, computable from the requesta cycles budget, based on expected resource consumption
Cost system APIic0.cost_http_requestic0.cost_http_request_v2
Attaching too littlerejected up frontrejected up front if it misses the base fee; otherwise runs with tighter per-node limits and may fail partway
Flexible outcallsnot availablerequired

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_bytes byte: 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.
  • 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.
  • 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