Imported from caffeinelabs/skills (
skills/extension-http-endpoints/SKILL.md) via skills.sh. Install upstream withnpx skills add caffeinelabs/skills --skill extension-http-endpoints. Copyright stays with the author.
HTTP Endpoints (inbound)
Inbound HTTP for Caffeine AI Motoko backends via mo:caffeineai-http-endpoints.
When to use which skill
| Need | Skill |
|---|---|
External client (curl, webhook, bot) hits the canister over HTTPS |
this skill |
| Canister calls an external HTTPS API | extension-http-outcalls |
| Typed OpenAI / Google / X / Stripe client | the matching domain skill (those wrap outbound calls) |
Do not implement inbound handlers with the management-canister http_request / Call.httpRequest outcall API — that is the opposite direction.
How inbound HTTP works
- Client hits the backend Motoko canister over HTTPS (not
*.caffeine.xyz, custom domain, or frontend/asset canister). - The HTTP Gateway calls
http_request(query). - Mutating routes return
Http.upgrade()so the gateway re-issues ashttp_request_update(update).
URLs
- Webhooks / POST:
https://<backend-canister-id>.icp0.io/...(or.ic0.app) - Uncertified GET (health / plaintext):
https://<backend-canister-id>.raw.icp0.io/...— non-raw gateways expect certified query bodies (out of scope)
Backend
Use the prefabricated modules (do not modify them):
module {
public type Request = { method : Text; url : Text; headers : [HeaderField]; body : Blob; certificate_version : ?Nat16 };
public type Response = { status_code : Nat16; headers : [HeaderField]; body : Blob; streaming_strategy : ?StreamingStrategy; upgrade : ?Bool };
public func path(url : Text) : Text;
public func header(req : Request, name : Text) : ?Text; // case-insensitive
public func text(status : Nat16, body : Text) : Response;
public func upgrade() : Response; // query path asks gateway to call http_request_update
public func notFound() : Response;
public func methodNotAllowed() : Response;
public func bodyText(req : Request) : ?Text; // null if body is not UTF-8; HMAC must use req.body
};
mixin (
onQuery : Http.Request -> Http.Response,
onUpdate : Http.Request -> async Http.Response,
) {
public query func http_request(req : Http.Request) : async Http.Response;
public func http_request_update(req : Http.Request) : async Http.Response;
};
Add mops add caffeineai-http-endpoints. Wire handlers on the existing actor in main.mo — keep MixinAuthorization, do not replace the actor or redeclare http_request*.
onQuery must stay query-safe: no awaits, no state writes. Mutating work belongs in onUpdate after Http.upgrade() from the query path.
import Http "mo:caffeineai-http-endpoints/Http";
import MixinHttpEndpoints "mo:caffeineai-http-endpoints/MixinHttpEndpoints";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
actor {
let accessControlState : AccessControl.AccessControlState;
include MixinAuthorization(accessControlState, null);
func onHttpQuery(req : Http.Request) : Http.Response {
let path = Http.path(req.url);
if (req.method == "POST" and path == "/webhook") {
return Http.upgrade();
};
if (req.method == "GET" and (path == "/" or path == "/health")) {
return Http.text(200, "ok");
};
Http.notFound();
};
func onHttpUpdate(req : Http.Request) : async Http.Response {
let path = Http.path(req.url);
if (req.method == "POST" and path == "/webhook") {
// Signature checks: Http.header(req, "stripe-signature"), HMAC over req.body
ignore Http.bodyText(req);
return Http.text(200, "received");
};
Http.notFound();
};
include MixinHttpEndpoints(onHttpQuery, onHttpUpdate);
};
The migration chain head:
import AccessControl "mo:caffeineai-authorization/access-control";
module {
type NewActor = {
accessControlState : AccessControl.AccessControlState;
};
public func migration(_old : {}) : NewActor {
{ accessControlState = AccessControl.initState() };
};
};
Rules of thumb
- Mutations only in
onUpdate. Query returnsHttp.upgrade()for POST/PUT/PATCH/DELETE routes that write. - Backend canister id only for webhook URLs — project/frontend origins never reach Motoko
http_request. - Uncertified GET →
.raw.icp0.io. Do not invent response certificates. - Prefer Candid + React for app UI; use inbound HTTP only for external HTTP clients.
- Auth: no IC caller principal from webhook senders — verify signatures/secrets yourself (
Http.header, rawreq.body). - Large JSON: keep payloads small/schema-simple, or store the blob and process elsewhere.
How to curl
Uncertified GET on a non-raw host is a hard gateway failure (503 / backend_response_verification / Certification values not found), not a 404 from your handler. POSTs that return Http.upgrade() skip that check.
# Reads — must be raw
curl https://<backend-canister-id>.raw.icp0.io/health
# Writes — non-raw is fine (update path)
curl -X POST https://<backend-canister-id>.icp0.io/webhook \
-H 'content-type: application/json' \
-H 'x-webhook-secret: …' \
-d '{"event":"…"}'
?canisterId=<id> on https://icp0.io/... is an alternate host form. Prefer the canister subdomain. An IP/localhost GET with only ?canisterId= can return an empty 204 instead of your body — treat that as the wrong URL shape, not an empty handler.
Related
extension-http-outcalls— canister → internet (module helpers, no inbound mixin)extension-stripe— checkout via outcalls; inbound Stripe webhooks still use this skill- icskills#337 — upstream skill-gap discussion
Local PocketIC (limitations)
PocketIC is useful for proving the upgrade dance, not as a drop-in curl replica.
- The
pocket-icbinary is a control server. It does not listen forcurluntil a client (@dfinity/pic) creates an instance (include an NNS subnet) and callsmakeLive(), which returns a port. - There is no
mopsone-liner that prints a curl URL. The gateway dies with that Node/pic process. - Once live, the same rules apply:
http://<canister-id>.raw.localhost:<port>/health— uncertified GEThttp://127.0.0.1:<port>/notes?canisterId=<id>— POST / upgrade (worked in a live gateway test)http://<canister-id>.localhost:<port>/health(no.raw) —503certification failure
- Do not document PocketIC as the way users hit production webhooks. Production URLs are the
*.icp0.io/*.raw.icp0.ioforms above.
