Skip to main content

How to use HTTPS outcalls: POST

Advanced
Tutorial

A minimal example of how to make a POST HTTP request. The purpose of this dapp is only to show how to make HTTP requests from a canister. It sends a POST request with some JSON to a free API where you can verify the headers and body were sent correctly.

The HTTPS outcalls feature only works for sending HTTP POST requests to servers or API endpoints that support IPV6.

In order to verify that your canister sent the HTTP request you expected, this canister is sending HTTP requests to a public API service where the HTTP request can be inspected. As you can see the image below, the POST request headers and body can be inspected to make sure it is what the canister sent.

Public API to inspect POST request

Important notes on POST requests

Because HTTP outcalls go through consensus, a developer should expect any HTTP POST request from a canister to be sent many times to its destination. Even if you ignore the Web3 component, multiple identical POST requests are not a new problem in HTTPS where it is common for clients to retry requests for a variety of reasons (e.g. destination server being unavailable).

The recommended way for HTTP POST requests is to add the idempotency keys in the header so the destination server knows which POST requests from the client are the same.

Developers should be careful that the destination server understands and uses idempotency keys. A canister can be coded to send idempotency keys, but it is ultimately up to the recipient server to know what to do with them. Here is an example of an API service that uses idempotency keys.

POST example

import Blob "mo:base/Blob";
import Cycles "mo:base/ExperimentalCycles";
import Text "mo:base/Text";
import IC "ic:aaaaa-aa";

actor {

//function to transform the response
public query func transform({
context : Blob;
response : IC.http_request_result;
}) : async IC.http_request_result {
{
response with headers = []; // not intersted in the headers
};
};

//PULIC METHOD
//This method sends a POST request to a URL with a free API we can test.
public func send_http_post_request() : async Text {

//1. SETUP ARGUMENTS FOR HTTP GET request

// 1.1 Setup the URL and its query parameters
//This URL is used because it allows us to inspect the HTTP request sent from the canister
let host : Text = "putsreq.com";
let url = "https://putsreq.com/aL1QS5IbaQd4NTqN3a81"; //HTTP that accepts IPV6

// 1.2 prepare headers for the system http_request call

//idempotency keys should be unique so we create a function that generates them.
let idempotency_key : Text = generateUUID();
let request_headers = [
{ name = "Host"; value = host # ":443" },
{ name = "User-Agent"; value = "http_post_sample" },
{ name = "Content-Type"; value = "application/json" },
{ name = "Idempotency-Key"; value = idempotency_key },
];

// The request body is a Blob, so we do the following:
// 1. Write a JSON string
// 2. Convert Text into a Blob
let request_body_json : Text = "{ \"name\" : \"Grogu\", \"force_sensitive\" : \"true\" }";
let request_body = Text.encodeUtf8(request_body_json);

// 1.3 The HTTP request
let http_request : IC.http_request_args = {
url = url;
max_response_bytes = null; //optional for request
headers = request_headers;
//note: type of `body` is ?Blob so we pass it here as "?request_body" instead of "request_body"
body = ?request_body;
method = #post;
transform = ?{
function = transform;
context = Blob.fromArray([]);
};
};

//2. ADD CYCLES TO PAY FOR HTTP REQUEST

//IC management canister will make the HTTP request so it needs cycles
//See: https://internetcomputer.org/docs/current/motoko/main/cycles

//The way Cycles.add() works is that it adds those cycles to the next asynchronous call
//See:
// - https://internetcomputer.org/docs/current/references/ic-interface-spec/#ic-http_request
// - https://internetcomputer.org/docs/current/references/https-outcalls-how-it-works#pricing
// - https://internetcomputer.org/docs/current/developer-docs/gas-cost
Cycles.add<system>(230_850_258_000);

//3. MAKE HTTPS REQUEST AND WAIT FOR RESPONSE
let http_response : IC.http_request_result = await IC.http_request(http_request);

//4. DECODE THE RESPONSE

//As per the type declarations, the BODY in the HTTP response
//comes back as Blob. Type signature:

//public type http_request_result = {
// status : Nat;
// headers : [HttpHeader];
// body : Blob;
// };

//We need to decode that Blob that is the body into readable text.
//To do this, we:
// 1. Use Text.decodeUtf8() method to convert the Blob to a ?Text optional
// 2. We use a switch to explicitly call out both cases of decoding the Blob into ?Text
let decoded_text : Text = switch (Text.decodeUtf8(http_response.body)) {
case (null) { "No value returned" };
case (?y) { y };
};

//5. RETURN RESPONSE OF THE BODY
let result : Text = decoded_text # ". See more info of the request sent at: " # url # "/inspect";
result;
};

//PRIVATE HELPER FUNCTION
//Helper method that generates a Universally Unique Identifier
//this method is used for the Idempotency Key used in the request headers of the POST request.
//For the purposes of this exercise, it returns a constant, but in practice it should return unique identifiers
func generateUUID() : Text {
"UUID-123456789";
};
};

Headers in the response may not always be identical across all nodes that process the request for consensus, causing the result of the call to be "No consensus could be reached." This particular error message can be hard to debug, but one method to resolve this error is to edit the response using the transform function. The transform function is run before consensus, and can be used to remove some headers from the response.

You can see a deployed version of this canister's send_http_post_request method onchain here: https://a4gq6-oaaaa-aaaab-qaa4q-cai.raw.icp0.io/?id=fm664-jyaaa-aaaap-qbomq-cai.

Additional resources