Some things your page calls itself. Most things only the shell can call, and the page asks it to. Knowing which is which before you plan the work is the difference between a week and a month.
ft.x in page JavaScript. No shell involvement, nothing to declare, nothing to relay.
ft.getLocationft.chooseLocationft.chooseImageft.callNativeAPIft.requestft.setStorage / ft.getStorageft.callNativeAPI(name, data, callback) reaches Shoppingate's own functions — payment, order history, support tickets. The callback carries the result, so this is a round trip rather than a one-way post.
// Host functions are reachable straight from page JavaScript, so a wrapped checkout can
// take payment as it stands. This path goes directly to the host; SGConf.js is not involved.
ft.callNativeAPI("orderPayment", {
orderInfo: {
items: [{ sku: "demo-1", name: "Premium dates", qty: 1,
unit_price: "98.00", total_amount: "98.00" }],
subtotal: "98.00",
delivery_fee: "12.00",
tax_amount: "16.50",
total_amount: "126.50", // subtotal + delivery_fee + tax_amount must balance
currency: "SAR",
vendor_order_ref: "order-1042",
order_type: "delivery",
},
// Stable per order. The same key on a retry is what stops a double charge.
idempotencyKey: "order-1042",
miniappId: "<your mini-app id>",
}, function (res) {
console.log("payment result", res);
});Two things that will reject a call before it does anything: the amounts inside orderInfo must balance — subtotal + delivery_fee + tax_amount equals total_amount — and miniappId must be the real id, not a placeholder.
SGConf.js entry is needed for any of this. That table exists for native pages calling ft.<name>(…); the callNativeAPI path does not read it.All nine host functions, with their request fields, responses and the web-view call for each, are on host functions — including the result field that decides whether the work actually happened, and where miniappId comes from in a page.
Everything below is a mini-app runtime API, so the shell calls it and relays the answer to your page. ft.getUserInfo is the one you will reach for first.
ft.getUserInfoft.startRecordft.saveImageToPhotosAlbumft.openBluetoothAdapterft.addPhoneContactft.addPhoneCalendarThe channel is two independent sends, and you pair them yourself:
ft.miniProgram.sendWebviewEvent({…})→the <web-view>'s bindeventft.sendWebviewEvent({…}, pageInstance)→ft.miniProgram.onWebviewEvent(cb)A correlation id is what makes them a pair. Without one, a second request landing before the first reply is indistinguishable from the first one answering — and the two promises resolve with each other's results.
// The page asks the shell to do what the page cannot.
//
// These are two INDEPENDENT one-way pipes:
// page -> shell : ft.miniProgram.sendWebviewEvent({...}) → the <web-view>'s bindevent
// shell -> page : ft.sendWebviewEvent({...}, pageInstance) → onWebviewEvent below
//
// The `id` is what turns them into request/response. Without it, a second request landing
// before the first reply cannot be told apart from the first one answering.
var pending = {};
var seq = 0;
ft.miniProgram.onWebviewEvent(function (msg) {
var body = (msg && msg.detail) || msg || {};
var entry = pending[body.id];
if (!entry) return; // not ours, or already settled
delete pending[body.id];
clearTimeout(entry.timer);
body.ok ? entry.resolve(body.payload) : entry.reject(body.err);
});
function askShell(type, params, timeoutMs) {
return new Promise(function (resolve, reject) {
var id = "r" + ++seq;
pending[id] = {
resolve: resolve,
reject: reject,
// A permission prompt can sit on screen for a long time before the customer
// answers. A short ceiling here reports a failure for a request that is still
// perfectly alive.
timer: setTimeout(function () {
delete pending[id];
reject({ errMsg: type + ": the shell did not answer in time" });
}, timeoutMs || 120000),
};
ft.miniProgram.sendWebviewEvent({ id: id, type: type, params: params || {} });
});
}
askShell("getUserInfo").then(function (profile) {
console.log(profile.first_name, profile.mobile);
});// index.js — the shell side of the same conversation.
Page({
data: { src: "" },
onLoad() {
this.setData({ src: getApp().globalData.siteUrl });
},
onPageEvent(e) {
const msg = (e && e.detail) || {};
// The runtime has been seen nesting the payload. Accept either shape rather than guess.
const body = msg.data && typeof msg.data === "object" ? msg.data : msg;
const { id, type, params } = body || {};
if (!id || !type) return; // not a relay request; ignore quietly
let answered = false;
const reply = (ok, payload) => {
// A handler that answers twice resolves one request and leaves another waiting.
if (answered) return;
answered = true;
// `payload`, not `data`: the runtime may itself wrap a message in { data }, and a
// protocol field of the same name makes the envelope and the contents impossible to
// tell apart. That exact collision silently broke this relay once.
ft.sendWebviewEvent({ id, ok, payload: ok ? payload : undefined,
err: ok ? undefined : payload }, this);
};
const handler = HANDLERS[type];
if (!handler) return reply(false, { errMsg: "no relay handler for '" + type + "'" });
// .call(HANDLERS, …) and not handler(…): a handler reaching a sibling through `this`
// gets undefined when invoked detached, and the TypeError surfaces inside an async
// callback long after this frame returned — so nothing replies and the page just times
// out, which looks like a dead bridge rather than a thrown error.
try {
handler.call(HANDLERS, params || {}, reply);
} catch (err) {
reply(false, { errMsg: String((err && err.message) || err) });
}
},
});Three details in there are scar tissue, and each one cost a debugging session:
payload, not data. The runtime may itself wrap a message in { data }, so a protocol field of the same name makes the envelope and the contents impossible to tell apart. Replies get read as envelopes and silently discarded.A few runtime controls are driven by a real tap on a mini-app control rather than by a call — anything built on open-type. Those live on a native page in your shell, and the page navigates to it.
In practice that means one screen: the permission settings screen. The pattern is in permissions.