﻿# HTTPS outcalls

> For the complete documentation index, see [llms.txt](/llms.txt)

[Canisters](../../concepts/canisters.md) 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](../../references/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).

## 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](../../concepts/cycles.md) to cover the request cost **must be attached** at call time. In Rust, `ic_cdk::management_canister::http_request` auto-calculates and attaches cycles. In Motoko, cycles must be attached explicitly with `await (with cycles = ...)`.
- 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.

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](#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:

### Motoko

```motoko
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";
  };
};
```

### Rust

```rust
#[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:

#### Motoko

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

#### Rust

```rust
// 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-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.

### Motoko

```motoko
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";
  };
};
```

### Rust

```rust
#[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_bytes` byte: 10,400 cycles

See [Cycles costs](../../references/cycles-costs.md#https-outcalls) 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.

## 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](../../concepts/https-outcalls.md): how consensus works for outcalls
- [Management canister reference](../../references/management-canister.md#http_request): full `http_request` parameter reference including all fields
- [Exchange Rate Canister (XRC)](https://github.com/dfinity/exchange-rate-canister): a production service powered by HTTPS outcalls that fetches digital asset and fiat exchange rates
- [Chain Fusion: Ethereum](../chain-fusion/ethereum.md): the EVM RPC canister uses HTTPS outcalls under the hood
- [Cycles costs](../../references/cycles-costs.md#https-outcalls): outcall pricing details
