Skip to content

Cloud Code

Cloud Code lets your app invoke JavaScript functions that run in AppAmbit, next to your app data. Instead of shipping API keys and standing up a backend, you deploy a function, give it an HTTP trigger, and call it by its slug from the app.

The SDK handles authentication, JSON encoding and decoding, request correlation, cancellation, and error mapping. Your side of the contract is one call.

Before you start

You need three things in place:

  1. The SDK initialized. Cloud Code reuses the consumer and Bearer token that the rest of the SDK already manages, so there is nothing extra to configure. See Getting Started.
  2. A function that is active. Deploy your code, then activate the built version. A draft function is not reachable.
  3. An HTTP trigger with a slug. The slug is the identifier you pass to the SDK. The function becomes reachable at /api/fn/{slug}, and the SDK builds that URL for you.
using AppAmbitMaui;

var builder = MauiApp.CreateBuilder();
builder
    .UseMauiApp<App>()
    .UseAppAmbit("<YOUR-APPKEY>");
import AppAmbit

AppAmbit.start(appKey: "<YOUR-APPKEY>")
@import AppAmbit;

[AppAmbit startWithAppKey:@"<YOUR-APPKEY>"];
import com.appambit.sdk.AppAmbit;

AppAmbit.start(getApplicationContext(), "<YOUR-APPKEY>");
import com.appambit.sdk.AppAmbit

AppAmbit.start(this, "<YOUR-APPKEY>")
import 'package:appambit_sdk_flutter/appambit_sdk_flutter.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await AppAmbitSdk.start(appKey: '<YOUR-APPKEY>');
  runApp(const MyApp());
}

CloudCode is exported from the same package, so the single import above covers it.

import * as AppAmbit from "appambit";

AppAmbit.start("<YOUR_APPKEY>");

CloudCode is exported from the same appambit package, so there is no second install and no separate import path.

using AppAmbit;

AppAmbitSdk.Start("<YOUR-APPKEY>");
using AppAmbitAvalonia;

AppAmbitSdk.Start("<YOUR-APPKEY>");

Calling Cloud Code before the SDK is started fails immediately with a notInitialized error instead of hitting the network.


A complete round trip

Start with the smallest function that does something. It reads a name out of the request body and returns a greeting.

index.js
export async function handler(ctx) {
  const { name = "world" } = ctx.req.body ?? {};
  return { greeting: `Hello, ${name}!` };
}

Deploy it, activate the version, and add an HTTP trigger with the slug greet. Whatever the handler returns is sent back as JSON with a 200 status:

response body
{ "greeting": "Hello, Ada!" }

Now call it. The slug is the first argument, the body is a dictionary or map that the SDK serializes to JSON:

using AppAmbitMaui;
using AppAmbit.Enums;
using AppAmbit.Models.CloudCode;

try
{
    var response = await CloudCode.Call("greet", body: new { name = "Ada" });

    Console.WriteLine($"HTTP {response.StatusCode}");
    Console.WriteLine(response.Data);

    if (response.Data is Dictionary<string, object?> payload)
    {
        Console.WriteLine(payload.GetValueOrDefault("greeting"));   // Hello, Ada!
    }
}
catch (CloudCodeError error)
{
    Console.WriteLine($"Cloud Code failed: {error.Message}");
}

Call returns a Task you await. Failures arrive as a thrown CloudCodeError, and cancellation is a CancellationToken you pass in.

await resumes on the context you called from, so a call started from a page or a view model continues on the UI thread and can update the view directly. Never .Result or .Wait() a Cloud Code task there: it deadlocks against the continuation waiting for that same thread.

CloudCode.call("greet", body: ["name": "Ada"]) { response, error in
    if let error {
        print("Cloud Code failed: \(error.localizedDescription)")
        return
    }
    guard let response else { return }

    print("HTTP \(response.statusCode)")
    print(response.data)

    if let payload = response.data as? [String: Any] {
        print(payload["greeting"] as? String ?? "")  // Hello, Ada!
    }
}
[CloudCode call:@"greet"
         method:CloudCodeHttpMethodPost
          query:nil
           body:@{ @"name": @"Ada" }
        headers:nil
     completion:^(CloudCodeResponse * _Nullable response, NSError * _Nullable error) {
    if (error != nil) {
        NSLog(@"Cloud Code failed: %@", error.localizedDescription);
        return;
    }

    NSLog(@"HTTP %ld", (long)response.statusCode);
    NSLog(@"%@", response.data);

    if ([response.data isKindOfClass:NSDictionary.class]) {
        NSDictionary *payload = (NSDictionary *)response.data;
        NSLog(@"%@", payload[@"greeting"]);          // Hello, Ada!
    }
}];

call takes a completion closure and returns a CloudCodeCancellationToken. Every completion is dispatched to the main queue, so you can update views directly.

The closure is @Sendable, so under the Swift 6 language mode the compiler asks you to state the isolation before you touch main actor state. MainActor.assumeIsolated covers that without an extra hop; on Swift 5 mode, or below iOS 13, use DispatchQueue.main.async.

import com.appambit.sdk.CloudCode;
import java.util.Collections;
import java.util.Map;

CloudCode.call("greet", Collections.singletonMap("name", "Ada"))
    .then(response -> {
        Log.d("CloudCode", "HTTP " + response.getStatusCode());
        Log.d("CloudCode", String.valueOf(response.getData()));

        if (response.getData() instanceof Map) {
            Map<?, ?> payload = (Map<?, ?>) response.getData();
            Log.d("CloudCode", String.valueOf(payload.get("greeting")));  // Hello, Ada!
        }
    })
    .onError(error -> Log.e("CloudCode", "Cloud Code failed", error));
import com.appambit.sdk.CloudCode

CloudCode.call("greet", mapOf("name" to "Ada"))
    .then { response ->
        println("HTTP ${response.statusCode}")
        println(response.data)

        val payload = response.data as? Map<*, *>
        println(payload?.get("greeting"))        // Hello, Ada!
    }
    .onError { error ->
        println("Cloud Code failed: ${error.message}")
    }

call returns a CloudCodeRequest you chain then and onError onto, and which doubles as the cancellation handle. Callbacks are posted through a Handler on the main looper, so you can update views directly.

getBlocking() exists for tests and for worker threads you control. It throws IllegalStateException on the main thread and on SDK owned executors, so application code should always use then and onError.

import 'package:appambit_sdk_flutter/appambit_sdk_flutter.dart';

final request = CloudCode.call('greet', body: {'name': 'Ada'});

try {
  final response = await request.future;

  print('HTTP ${response.statusCode}');
  print(response.data);

  final payload = response.data;
  if (payload is Map<String, dynamic>) {
    print(payload['greeting']);        // Hello, Ada!
  }
} on CloudCodeError catch (error) {
  print('Cloud Code failed: ${error.message}');
}

call returns a CloudCodeRequest, not a future. Await its future property for the result, and keep the request itself as the cancellation handle. Everything runs on the Dart main isolate, so you can call setState straight from the await.

Unlike the callback based SDKs, a cancelled Flutter request completes its future with a CloudCodeError whose code is cancelled rather than going silent. See Cancellation.

import { CloudCode, CloudCodeError } from 'appambit';

try {
  const response = await CloudCode.call('greet', { body: { name: 'Ada' } });

  console.log(`HTTP ${response.statusCode}`);
  console.log(response.data);

  if (response.data && typeof response.data === 'object') {
    const payload = response.data as { greeting?: string };
    console.log(payload.greeting);       // Hello, Ada!
  }
} catch (error) {
  if (error instanceof CloudCodeError) {
    console.log(`Cloud Code failed: ${error.message}`);
  }
}

call returns a CloudCodeRequest, which is a thenable: await it directly for the result, or keep it in a variable as the cancellation handle. There is no .future or .then() chain to unwrap.

Everything settles on the JS thread, so the code after the await is a legal place to call setState.

Unlike the native SDKs, a cancelled request does not go quiet — it rejects with a Cancelled error. See Cancellation.

using AppAmbit;
using AppAmbit.Enums;
using AppAmbit.Models.CloudCode;

try
{
    var response = await CloudCode.Call("greet", body: new { name = "Ada" });

    Console.WriteLine($"HTTP {response.StatusCode}");
    Console.WriteLine(response.Data);

    if (response.Data is Dictionary<string, object?> payload)
    {
        Console.WriteLine(payload.GetValueOrDefault("greeting"));   // Hello, Ada!
    }
}
catch (CloudCodeError error)
{
    Console.WriteLine($"Cloud Code failed: {error.Message}");
}

Call returns a Task you await. Failures arrive as a thrown CloudCodeError, and cancellation is a CancellationToken you pass in.

await resumes on the context you called from, so a call started from a page or a view model continues on the UI thread and can update the view directly. Never .Result or .Wait() a Cloud Code task there: it deadlocks against the continuation waiting for that same thread.

using AppAmbitAvalonia;
using AppAmbit.Enums;
using AppAmbit.Models.CloudCode;

try
{
    var response = await CloudCode.Call("greet", body: new { name = "Ada" });

    Console.WriteLine($"HTTP {response.StatusCode}");
    Console.WriteLine(response.Data);

    if (response.Data is Dictionary<string, object?> payload)
    {
        Console.WriteLine(payload.GetValueOrDefault("greeting"));   // Hello, Ada!
    }
}
catch (CloudCodeError error)
{
    Console.WriteLine($"Cloud Code failed: {error.Message}");
}

Call returns a Task you await. Failures arrive as a thrown CloudCodeError, and cancellation is a CancellationToken you pass in.

await resumes on the context you called from, so a call started from a page or a view model continues on the UI thread and can update the view directly. Never .Result or .Wait() a Cloud Code task there: it deadlocks against the continuation waiting for that same thread.

That is the whole model. The map, dictionary, or object you pass as the body becomes ctx.req.body in the function, and whatever the function returns comes back as the response data.

The call signature

// AppAmbitMaui.CloudCode

// Dynamic form
public static Task<CloudCodeResponse> Call(
    string function,
    CloudCodeHttpMethod method = CloudCodeHttpMethod.Post,
    IReadOnlyDictionary<string, string>? query = null,
    object? body = null,
    IReadOnlyDictionary<string, string>? headers = null,
    CancellationToken cancellationToken = default);

// Typed form
public static Task<CloudCodeResult<T>> Call<T>(
    string function,
    CloudCodeHttpMethod method = CloudCodeHttpMethod.Post,
    IReadOnlyDictionary<string, string>? query = null,
    object? body = null,
    IReadOnlyDictionary<string, string>? headers = null,
    CancellationToken cancellationToken = default);

Every parameter after the slug has a default, so CloudCode.Call("greet") is a POST with no query, body, or headers. Pass the rest by name. Call returns the dynamic response and Call<T> the typed one; the type argument is the only difference between them.

CloudCodeHttpMethod comes from AppAmbit.Enums, and CloudCodeResponse, CloudCodeResult<T>, CloudCodeError, and CloudCodeErrorCode from AppAmbit.Models.CloudCode, whichever package you installed.

@discardableResult
public static func call(
    _ function: String,
    method: CloudCodeHttpMethod = .post,
    query: [String: String]? = nil,
    body: [String: Any]? = nil,
    headers: [String: String]? = nil,
    completion: @escaping @Sendable (CloudCodeResponse?, Error?) -> Void
) -> CloudCodeCancellationToken
+ (CloudCodeCancellationToken *)call:(NSString *)function
                              method:(CloudCodeHttpMethod)method
                               query:(nullable NSDictionary<NSString *, NSString *> *)query
                                body:(nullable NSDictionary<NSString *, id> *)body
                             headers:(nullable NSDictionary<NSString *, NSString *> *)headers
                          completion:(void (^)(CloudCodeResponse * _Nullable,
                                               NSError * _Nullable))completion;

The return value is discardable, so you only capture the token when you intend to cancel.

// Shorthands, both POST
static CloudCodeRequest<CloudCodeResponse> call(String function);
static CloudCodeRequest<CloudCodeResponse> call(String function,
                                                @Nullable Map<String, Object> body);

// Full form
static CloudCodeRequest<CloudCodeResponse> call(String function,
                                                CloudCodeHttpMethod method,
                                                @Nullable Map<String, String> query,
                                                @Nullable Map<String, Object> body,
                                                @Nullable Map<String, String> headers);

// Typed form
static <T> CloudCodeRequest<CloudCodeResult<T>> call(String function,
                                                     CloudCodeHttpMethod method,
                                                     @Nullable Map<String, String> query,
                                                     @Nullable Map<String, Object> body,
                                                     @Nullable Map<String, String> headers,
                                                     Class<T> responseType);
// Shorthands, both POST
CloudCode.call(function: String): CloudCodeRequest<CloudCodeResponse>
CloudCode.call(function: String, body: Map<String, Any>?): CloudCodeRequest<CloudCodeResponse>

// Full form
CloudCode.call(
    function: String,
    method: CloudCodeHttpMethod,
    query: Map<String, String>?,
    body: Map<String, Any>?,
    headers: Map<String, String>?
): CloudCodeRequest<CloudCodeResponse>

// Typed form
CloudCode.call(
    function: String,
    method: CloudCodeHttpMethod,
    query: Map<String, String>?,
    body: Map<String, Any>?,
    headers: Map<String, String>?,
    responseType: Class<T>
): CloudCodeRequest<CloudCodeResult<T>>

Hold the returned CloudCodeRequest<T> at least long enough to attach then and onError.

// Dynamic form, POST unless you say otherwise
static CloudCodeRequest<CloudCodeResponse> call(
  String function, {
  CloudCodeHttpMethod method = CloudCodeHttpMethod.post,
  Map<String, String>? query,
  Map<String, dynamic>? body,
  Map<String, String>? headers,
  Duration? timeout,
});

// Typed form
static CloudCodeRequest<CloudCodeResult<T>> callTyped<T>(
  String function, {
  CloudCodeHttpMethod method = CloudCodeHttpMethod.post,
  Map<String, String>? query,
  Map<String, dynamic>? body,
  Map<String, String>? headers,
  Duration? timeout,
  required T Function(Object? value) fromJson,
});

Every parameter past the slug is named and optional, except fromJson on the typed form. method defaults to CloudCodeHttpMethod.post; the enum also carries get, put, patch, and delete.

Hold the returned CloudCodeRequest<T> if you intend to cancel it. If you do not, awaiting request.future inline is fine — the SDK marks the future as observed internally, so a request you fire and forget cannot surface as an uncaught zone error.

timeout has no counterpart on the other SDKs. It puts a Dart-side deadline in front of the fixed 60 second client timeout described under Timeouts:

final request = CloudCode.call(
  'slow-report',
  method: CloudCodeHttpMethod.get,
  timeout: const Duration(seconds: 10),
);

When it elapses the future completes with a timedOut CloudCodeError and the native request is cancelled, so nothing keeps running in the background. It can only shorten the wait, never extend it past 60 seconds.

// Dynamic form
CloudCode.call(
  fn: string,
  options?: CloudCodeCallOptions
): CloudCodeRequest<CloudCodeResponse>;

// Typed form
CloudCode.callTyped<T>(
  fn: string,
  options: CloudCodeCallOptions & { fromJson: (value: unknown) => T }
): CloudCodeRequest<CloudCodeResult<T>>;

type CloudCodeCallOptions = {
  method?: CloudCodeHttpMethod;
  query?: Record<string, string>;
  body?: Record<string, unknown> | null;
  headers?: Record<string, string>;
  timeout?: number;
};

Everything past the slug lives in one options object, and every field of it is optional, so CloudCode.call('greet') is a POST with no query, body, or headers. method is a string union rather than an enum, so you pass 'GET' directly with no constant to import.

CloudCode, CloudCodeError, CloudCodeErrorCode, and CloudCodeRequest are value exports of appambit. CloudCodeHttpMethod, CloudCodeCallOptions, CloudCodeResponse, and CloudCodeResult are type-only exports, so import them with import type.

Hold the returned CloudCodeRequest<T> if you intend to cancel it. If you do not, awaiting it inline is fine.

timeout has no counterpart on the native SDKs. It arms a JS-side deadline in front of the fixed 60 second client timeout described under Timeouts:

const request = CloudCode.call('slow-report', {
  method: 'GET',
  timeout: 10_000,
});

When it elapses the promise rejects with a TimedOut error and the native request is cancelled, so nothing keeps running in the background. It can only shorten the wait, never extend it past 60 seconds. Omit it, the default, for no JS-side deadline at all.

// AppAmbit.CloudCode

// Dynamic form
public static Task<CloudCodeResponse> Call(
    string function,
    CloudCodeHttpMethod method = CloudCodeHttpMethod.Post,
    IReadOnlyDictionary<string, string>? query = null,
    object? body = null,
    IReadOnlyDictionary<string, string>? headers = null,
    CancellationToken cancellationToken = default);

// Typed form
public static Task<CloudCodeResult<T>> Call<T>(
    string function,
    CloudCodeHttpMethod method = CloudCodeHttpMethod.Post,
    IReadOnlyDictionary<string, string>? query = null,
    object? body = null,
    IReadOnlyDictionary<string, string>? headers = null,
    CancellationToken cancellationToken = default);

Every parameter after the slug has a default, so CloudCode.Call("greet") is a POST with no query, body, or headers. Pass the rest by name. Call returns the dynamic response and Call<T> the typed one; the type argument is the only difference between them.

CloudCodeHttpMethod comes from AppAmbit.Enums, and CloudCodeResponse, CloudCodeResult<T>, CloudCodeError, and CloudCodeErrorCode from AppAmbit.Models.CloudCode, whichever package you installed.

// AppAmbitAvalonia.CloudCode

// Dynamic form
public static Task<CloudCodeResponse> Call(
    string function,
    CloudCodeHttpMethod method = CloudCodeHttpMethod.Post,
    IReadOnlyDictionary<string, string>? query = null,
    object? body = null,
    IReadOnlyDictionary<string, string>? headers = null,
    CancellationToken cancellationToken = default);

// Typed form
public static Task<CloudCodeResult<T>> Call<T>(
    string function,
    CloudCodeHttpMethod method = CloudCodeHttpMethod.Post,
    IReadOnlyDictionary<string, string>? query = null,
    object? body = null,
    IReadOnlyDictionary<string, string>? headers = null,
    CancellationToken cancellationToken = default);

Every parameter after the slug has a default, so CloudCode.Call("greet") is a POST with no query, body, or headers. Pass the rest by name. Call returns the dynamic response and Call<T> the typed one; the type argument is the only difference between them.

CloudCodeHttpMethod comes from AppAmbit.Enums, and CloudCodeResponse, CloudCodeResult<T>, CloudCodeError, and CloudCodeErrorCode from AppAmbit.Models.CloudCode, whichever package you installed.

Parameter Arrives in the function as Notes
function the route itself The HTTP trigger slug, not a path. It must not be empty, must not contain /, and must not contain whitespace or control characters. It is percent encoded before the URL is built.
method ctx.req.method GET, POST, PUT, PATCH, or DELETE. Defaults to POST on every platform when you use a shorthand or omit the argument.
query ctx.req.query Appended as the query string. Values are percent encoded, so "hello world" and a+b survive intact. Every SDK sorts the keys, so the same map always produces the same URL.
body ctx.req.body Serialized as a JSON object. It must be a valid JSON object, which is validated before any request is sent.
headers ctx.req.headers Extra request headers. Reserved names are rejected, see Custom headers.

Why a slug and not a URL

The SDK owns the base URL, the /fn/ prefix, the Authorization header, and the app key header. Passing a slug rather than a URL keeps those concerns inside the SDK, which means an app can never accidentally point a Cloud Code call at the wrong host or leak a token to one.


Reading the response

There are two ways to read a result. Pick based on whether you control the shape of the payload.

Dynamic responses

The response object carries the decoded JSON, plus the metadata of the exchange, and maps whatever your handler returned onto that platform's native types.

Property Type
the decoded JSON value Data: object?
HTTP status, always 2xx here StatusCode: int
correlation id RequestId: string?
response headers Headers: IReadOnlyDictionary<string, string>
the body exactly as it arrived RawBody: string?

RawBody is the undecoded response text. It is handy when a payload does not match what you expected, and it is the only place a non-JSON success body survives.

The function returns Data
{ ok: true } Dictionary<string, object?>
[1, "two", true] List<object?>
"hello" string
7 long
7.5 double
true bool
null, an empty body, or { status: 204 } null

Two details to know before you pattern match. Whole numbers always arrive as long, never int, so is long count matches and is int count silently does not. And timestamps stay strings: the SDK turns off Newtonsoft's automatic date detection, so an ISO 8601 value reaches you as a string you parse yourself, rather than as a DateTime that guessed at a time zone. A typed model with a DateTime property still parses normally.

Property Type
the decoded JSON value data: Any
HTTP status, always 2xx here statusCode: Int
correlation id requestId: String?
response headers headers: [String: String]
The function returns data
{ ok: true } [String: Any]
[1, "two", true] [Any]
"hello" String
7 Int
7.5 Double
true Bool
null, an empty body, or { status: 204 } NSNull

The data property is never nil. An absent value is NSNull, so check for it with data is NSNull rather than an optional binding.

Property Type
the decoded JSON value getData(): Object
HTTP status, always 2xx here getStatusCode(): int
correlation id getRequestId(): String
response headers getHeaders(): Map<String, String>
The function returns getData()
{ ok: true } Map<String, Object>
[1, "two", true] List<Object>
"hello" String
7 Integer
7.5 Double
true Boolean
null, an empty body, or { status: 204 } null
Property Type
the decoded JSON value data: Object?
HTTP status, always 2xx here statusCode: int?
correlation id requestId: String?
response headers headers: Map<String, String>
The function returns data
{ ok: true } Map<String, dynamic>
[1, "two", true] List<Object?>
"hello" String
7 int
7.5 double
true bool
null, an empty body, or { status: 204 } null

Nested objects are normalized to Map<String, dynamic> all the way down, so a Map you pull out of data is safe to index by string without re-casting its keys.

Property Type
the decoded JSON value data: unknown
HTTP status, always 2xx here statusCode: number
correlation id requestId: string, null when absent
response headers headers: Record<string, string>
The function returns data
{ ok: true } a plain object
[1, "two", true] unknown[]
"hello" string
7 number
7.5 number
true boolean
null, an empty body, or { status: 204 } null

Whole numbers and decimals both arrive as a plain number: the bridge has no integer type, so there is no int versus double distinction to trip over the way there is on the native SDKs.

data is typed unknown rather than any, on purpose — the compiler makes you narrow it with typeof or a type guard before reading, which is the check that would otherwise be skipped.

Property Type
the decoded JSON value Data: object?
HTTP status, always 2xx here StatusCode: int
correlation id RequestId: string?
response headers Headers: IReadOnlyDictionary<string, string>
the body exactly as it arrived RawBody: string?

RawBody is the undecoded response text. It is handy when a payload does not match what you expected, and it is the only place a non-JSON success body survives.

The function returns Data
{ ok: true } Dictionary<string, object?>
[1, "two", true] List<object?>
"hello" string
7 long
7.5 double
true bool
null, an empty body, or { status: 204 } null

Two details to know before you pattern match. Whole numbers always arrive as long, never int, so is long count matches and is int count silently does not. And timestamps stay strings: the SDK turns off Newtonsoft's automatic date detection, so an ISO 8601 value reaches you as a string you parse yourself, rather than as a DateTime that guessed at a time zone. A typed model with a DateTime property still parses normally.

Property Type
the decoded JSON value Data: object?
HTTP status, always 2xx here StatusCode: int
correlation id RequestId: string?
response headers Headers: IReadOnlyDictionary<string, string>
the body exactly as it arrived RawBody: string?

RawBody is the undecoded response text. It is handy when a payload does not match what you expected, and it is the only place a non-JSON success body survives.

The function returns Data
{ ok: true } Dictionary<string, object?>
[1, "two", true] List<object?>
"hello" string
7 long
7.5 double
true bool
null, an empty body, or { status: 204 } null

Two details to know before you pattern match. Whole numbers always arrive as long, never int, so is long count matches and is int count silently does not. And timestamps stay strings: the SDK turns off Newtonsoft's automatic date detection, so an ISO 8601 value reaches you as a string you parse yourself, rather than as a DateTime that guessed at a time zone. A typed model with a DateTime property still parses normally.

The correlation id is read from the X-Request-Id response header, falling back to a request_id field in the body.

An explicit JSON null, an empty successful body, and a 204 are indistinguishable once decoded. If your app needs to tell "the function returned nothing" apart from "the function returned null", read the status code as well, or have the function return a wrapper object such as { value: null }.

Here is a function that reads rows from your app database and returns them as a list. It scopes the query to the caller, which is the pattern you want for anything user owned.

index.js
export async function handler(ctx) {
  const res = await ctx.appambit.db(
    "SELECT id, title, completed FROM tasks WHERE consumer_id = ? ORDER BY id DESC",
    [ctx.consumer.id]
  );

  // Rows come back as positional arrays, in the order of `columns`.
  const tasks = res.results[0].rows.map(([id, title, completed]) => ({
    id,
    title,
    completed: completed === 1,
  }));

  return { tasks };
}

With the slug list-tasks and an HTTP trigger, it answers:

response body
{
  "tasks": [
    { "id": 2, "title": "Buy coffee", "completed": false },
    { "id": 1, "title": "Ship the release", "completed": true }
  ]
}

Reading that from the app:

try
{
    var response = await CloudCode.Call("list-tasks", CloudCodeHttpMethod.Get);

    if (response.Data is null)
    {
        Console.WriteLine("No content");
        return;
    }

    if (response.Data is not Dictionary<string, object?> payload
        || payload.GetValueOrDefault("tasks") is not List<object?> tasks)
    {
        Console.WriteLine("Unexpected payload shape");
        return;
    }

    foreach (var item in tasks)
    {
        if (item is not Dictionary<string, object?> task) continue;

        var title = task.GetValueOrDefault("title") as string ?? "untitled";
        var done = task.GetValueOrDefault("completed") as bool? ?? false;
        var mark = done ? "[x]" : "[ ]";
        Console.WriteLine($"{mark} {title}");
    }
}
catch (CloudCodeError error)
{
    Console.WriteLine($"Failed: {error.Message}");
}
CloudCode.call("list-tasks", method: .get) { response, error in
    guard let response, error == nil else { return }

    if response.data is NSNull {
        print("No content")
        return
    }

    guard let payload = response.data as? [String: Any],
          let tasks = payload["tasks"] as? [[String: Any]] else {
        print("Unexpected payload shape")
        return
    }

    for task in tasks {
        let title = task["title"] as? String ?? "untitled"
        let done = task["completed"] as? Bool ?? false
        print("\(done ? "[x]" : "[ ]") \(title)")
    }
}
[CloudCode call:@"list-tasks"
         method:CloudCodeHttpMethodGet
          query:nil
           body:nil
        headers:nil
     completion:^(CloudCodeResponse * _Nullable response, NSError * _Nullable error) {
    if (error != nil || response == nil) { return; }

    if ([response.data isKindOfClass:NSNull.class]) {
        NSLog(@"No content");
        return;
    }

    if (![response.data isKindOfClass:NSDictionary.class]) {
        NSLog(@"Unexpected payload shape");
        return;
    }

    NSDictionary *payload = (NSDictionary *)response.data;
    for (NSDictionary *task in payload[@"tasks"]) {
        NSString *title = task[@"title"] ?: @"untitled";
        BOOL done = [task[@"completed"] boolValue];
        NSLog(@"%@ %@", done ? @"[x]" : @"[ ]", title);
    }
}];
CloudCode.call("list-tasks", CloudCodeHttpMethod.GET, null, null, null)
    .then(response -> {
        if (response.getData() == null) {
            Log.d("CloudCode", "No content");
            return;
        }
        if (!(response.getData() instanceof Map)) {
            Log.d("CloudCode", "Unexpected payload shape");
            return;
        }

        Map<?, ?> payload = (Map<?, ?>) response.getData();
        List<?> tasks = (List<?>) payload.get("tasks");
        if (tasks == null) return;

        for (Object item : tasks) {
            Map<?, ?> task = (Map<?, ?>) item;
            Object title = task.get("title");
            boolean done = Boolean.TRUE.equals(task.get("completed"));
            Log.d("CloudCode", (done ? "[x] " : "[ ] ") + title);
        }
    })
    .onError(error -> Log.e("CloudCode", "Failed", error));
CloudCode.call("list-tasks", CloudCodeHttpMethod.GET, null, null, null)
    .then { response ->
        val payload = response.data as? Map<*, *>
        if (payload == null) {
            println(if (response.data == null) "No content" else "Unexpected payload shape")
            return@then
        }

        val tasks = payload["tasks"] as? List<*> ?: return@then
        for (item in tasks) {
            val task = item as? Map<*, *> ?: continue
            val title = task["title"] ?: "untitled"
            val done = task["completed"] == true
            println("${if (done) "[x]" else "[ ]"} $title")
        }
    }
    .onError { error -> println("Failed: ${error.message}") }
final request = CloudCode.call('list-tasks', method: CloudCodeHttpMethod.get);

try {
  final response = await request.future;

  final payload = response.data;
  if (payload == null) {
    print('No content');
    return;
  }
  if (payload is! Map<String, dynamic>) {
    print('Unexpected payload shape');
    return;
  }

  final tasks = payload['tasks'];
  if (tasks is! List) return;

  for (final item in tasks) {
    if (item is! Map<String, dynamic>) continue;
    final title = item['title'] ?? 'untitled';
    final done = item['completed'] == true;
    print('${done ? '[x]' : '[ ]'} $title');
  }
} on CloudCodeError catch (error) {
  print('Failed: ${error.message}');
}

data is typed Object?, so narrow it with is before reading. A null here means an empty success, not a failure.

type Task = { id?: number; title?: string; completed?: boolean };

function isTaskList(value: unknown): value is { tasks: Task[] } {
  return (
    typeof value === 'object' &&
    value !== null &&
    Array.isArray((value as { tasks?: unknown }).tasks)
  );
}

try {
  const response = await CloudCode.call('list-tasks', { method: 'GET' });

  if (response.data === null) {
    console.log('No content');
    return;
  }
  if (!isTaskList(response.data)) {
    console.log('Unexpected payload shape');
    return;
  }

  for (const task of response.data.tasks) {
    const mark = task.completed ? '[x]' : '[ ]';
    console.log(`${mark} ${task.title ?? 'untitled'}`);
  }
} catch (error) {
  if (error instanceof CloudCodeError) {
    console.log(`Failed: ${error.message}`);
  }
}

A null here is an empty success, not a failure.

try
{
    var response = await CloudCode.Call("list-tasks", CloudCodeHttpMethod.Get);

    if (response.Data is null)
    {
        Console.WriteLine("No content");
        return;
    }

    if (response.Data is not Dictionary<string, object?> payload
        || payload.GetValueOrDefault("tasks") is not List<object?> tasks)
    {
        Console.WriteLine("Unexpected payload shape");
        return;
    }

    foreach (var item in tasks)
    {
        if (item is not Dictionary<string, object?> task) continue;

        var title = task.GetValueOrDefault("title") as string ?? "untitled";
        var done = task.GetValueOrDefault("completed") as bool? ?? false;
        var mark = done ? "[x]" : "[ ]";
        Console.WriteLine($"{mark} {title}");
    }
}
catch (CloudCodeError error)
{
    Console.WriteLine($"Failed: {error.Message}");
}
try
{
    var response = await CloudCode.Call("list-tasks", CloudCodeHttpMethod.Get);

    if (response.Data is null)
    {
        Console.WriteLine("No content");
        return;
    }

    if (response.Data is not Dictionary<string, object?> payload
        || payload.GetValueOrDefault("tasks") is not List<object?> tasks)
    {
        Console.WriteLine("Unexpected payload shape");
        return;
    }

    foreach (var item in tasks)
    {
        if (item is not Dictionary<string, object?> task) continue;

        var title = task.GetValueOrDefault("title") as string ?? "untitled";
        var done = task.GetValueOrDefault("completed") as bool? ?? false;
        var mark = done ? "[x]" : "[ ]";
        Console.WriteLine($"{mark} {title}");
    }
}
catch (CloudCodeError error)
{
    Console.WriteLine($"Failed: {error.Message}");
}

Normalize types in the function, not in the app

SQLite has no boolean type, so completed comes out of the database as 0 or 1. The function above converts it with completed === 1 before returning. Doing that conversion once in the function is much cheaper than teaching every client language to accept a number where they expect a boolean, and it is exactly the kind of mismatch that produces a decoding error on the typed path.

Typed responses

When the payload has a stable shape, decode it straight into a model.

This function combines two sources, the app database and the CMS, into one payload, so the app makes a single round trip instead of two:

index.js
export async function handler(ctx) {
  const counted = await ctx.appambit.db(
    "SELECT count(*) FROM tasks WHERE consumer_id = ?",
    [ctx.consumer.id]
  );

  const published = await ctx.appambit.cms.list("posts", { limit: 3 });

  return {
    task_count: counted.results[0].rows[0][0],
    posts: published.data.map((entry) => ({ id: entry.id, title: entry.title })),
  };
}

Deployed under the slug dashboard-summary, it answers:

response body
{
  "task_count": 7,
  "posts": [
    { "id": "550e8400-e29b-41d4-a716-446655440000", "title": "10 Laravel Tips" }
  ]
}

The model mirrors that payload one to one:

using Newtonsoft.Json;

public sealed class DashboardSummary
{
    [JsonProperty("task_count")]
    public int? TaskCount { get; set; }

    [JsonProperty("posts")]
    public List<Post>? Posts { get; set; }
}

public sealed class Post
{
    [JsonProperty("id")]
    public string? Id { get; set; }

    [JsonProperty("title")]
    public string? Title { get; set; }
}

try
{
    var result = await CloudCode.Call<DashboardSummary>(
        "dashboard-summary",
        CloudCodeHttpMethod.Get);

    var requestId = result.RequestId ?? "none";
    Console.WriteLine($"HTTP {result.StatusCode}, request {requestId}");
    Console.WriteLine($"Tasks: {result.Data?.TaskCount ?? 0}");

    foreach (var post in result.Data?.Posts ?? new List<Post>())
    {
        Console.WriteLine(post.Title);
    }
}
catch (CloudCodeError error)
{
    Console.WriteLine($"Cloud Code failed: {error.Message}");
}

Both Call and Call<T> throw CloudCodeError, so one catch covers the typed and the dynamic path alike. Data is null on an empty success.

struct DashboardSummary: Decodable {
    let taskCount: Int?
    let posts: [Post]?

    enum CodingKeys: String, CodingKey {
        case taskCount = "task_count"
        case posts
    }
}

struct Post: Decodable {
    let id: String
    let title: String
}

CloudCode.call(
    "dashboard-summary",
    method: .get,
    as: DashboardSummary.self
) { result, error in
    if let error {
        print("Cloud Code failed: \(error.localizedDescription)")
        return
    }
    guard let result else { return }

    print("HTTP \(result.statusCode), request \(result.requestId ?? "none")")
    print("Tasks: \(result.data?.taskCount ?? 0)")

    for post in result.data?.posts ?? [] {
        print(post.title)
    }
}
// Typed decoding is not available from Objective-C, because Swift
// generics do not bridge. Use the dynamic API and map it yourself.
[CloudCode call:@"dashboard-summary"
         method:CloudCodeHttpMethodGet
          query:nil
           body:nil
        headers:nil
     completion:^(CloudCodeResponse * _Nullable response, NSError * _Nullable error) {
    if (error != nil || ![response.data isKindOfClass:NSDictionary.class]) { return; }

    NSDictionary *payload = (NSDictionary *)response.data;
    NSLog(@"Tasks: %@", payload[@"task_count"] ?: @0);

    for (NSDictionary *post in payload[@"posts"]) {
        NSLog(@"%@", post[@"title"]);
    }
}];

The typed callback narrows the error to CloudCodeError instead of a bare Error, so you can pattern match on the failure without casting. result.data is nil on an empty success.

import com.appambit.sdk.utils.JsonKey;
import java.util.List;

public class DashboardSummary {
    @JsonKey("task_count")
    public Integer taskCount;

    @JsonKey("posts")
    public List<Post> posts;

    public static class Post {
        @JsonKey("id")
        public String id;

        @JsonKey("title")
        public String title;
    }
}

CloudCode.call("dashboard-summary", CloudCodeHttpMethod.GET, null, null, null,
               DashboardSummary.class)
    .then(result -> {
        Log.d("CloudCode", "HTTP " + result.getStatusCode()
                + ", request " + result.getRequestId());

        DashboardSummary summary = result.getData();
        if (summary == null) return;

        Log.d("CloudCode", "Tasks: " + summary.taskCount);
        if (summary.posts != null) {
            for (DashboardSummary.Post post : summary.posts) {
                Log.d("CloudCode", post.title);
            }
        }
    })
    .onError(error -> Log.e("CloudCode", "Cloud Code failed", error));
import com.appambit.sdk.utils.JsonKey

class DashboardSummary {
    @field:JsonKey("task_count")
    var taskCount: Int? = null

    @field:JsonKey("posts")
    var posts: List<Post>? = null
}

class Post {
    @field:JsonKey("id")
    var id: String = ""

    @field:JsonKey("title")
    var title: String = ""
}

CloudCode.call(
    "dashboard-summary",
    CloudCodeHttpMethod.GET, null, null, null,
    DashboardSummary::class.java
)
    .then { result ->
        println("HTTP ${result.statusCode}, request ${result.requestId}")

        val summary = result.data ?: return@then
        println("Tasks: ${summary.taskCount}")
        summary.posts?.forEach { println(it.title) }
    }
    .onError { error -> println("Cloud Code failed: ${error.message}") }

getData() returns null on an empty success, so null check it before you read the model.

class Post {
  final String? id;
  final String? title;

  const Post({this.id, this.title});

  factory Post.fromJson(Map<String, dynamic> json) => Post(
        id: json['id'] as String?,
        title: json['title'] as String?,
      );
}

class DashboardSummary {
  final int? taskCount;
  final List<Post> posts;

  const DashboardSummary({this.taskCount, this.posts = const []});

  // Note the Object? parameter: fromJson receives the raw decoded value.
  factory DashboardSummary.fromJson(Object? value) {
    final map = value is Map<String, dynamic> ? value : const <String, dynamic>{};
    final posts = map['posts'];

    return DashboardSummary(
      taskCount: map['task_count'] as int?,
      posts: posts is List
          ? posts.whereType<Map<String, dynamic>>().map(Post.fromJson).toList()
          : const [],
    );
  }
}

final request = CloudCode.callTyped<DashboardSummary>(
  'dashboard-summary',
  method: CloudCodeHttpMethod.get,
  fromJson: DashboardSummary.fromJson,
);

try {
  final result = await request.future;

  print('HTTP ${result.statusCode}, request ${result.requestId}');

  final summary = result.data;
  if (summary == null) return;

  print('Tasks: ${summary.taskCount}');
  for (final post in summary.posts) {
    print(post.title);
  }
} on CloudCodeError catch (error) {
  print('Cloud Code failed: ${error.message}');
}

result.data is null on an empty success — fromJson is skipped entirely rather than being handed a null — so null check it before you read the model.

type Post = { id?: string; title?: string };

type DashboardSummary = {
  taskCount?: number;
  posts: Post[];
};

// Note the `unknown` parameter: fromJson receives the raw decoded value.
function summaryFromJson(value: unknown): DashboardSummary {
  const map = (typeof value === 'object' && value !== null
    ? value
    : {}) as Record<string, unknown>;

  return {
    taskCount: typeof map.task_count === 'number' ? map.task_count : undefined,
    posts: Array.isArray(map.posts) ? (map.posts as Post[]) : [],
  };
}

try {
  const result = await CloudCode.callTyped<DashboardSummary>(
    'dashboard-summary',
    { method: 'GET', fromJson: summaryFromJson }
  );

  console.log(`HTTP ${result.statusCode}, request ${result.requestId ?? 'none'}`);

  const summary = result.data;
  if (summary === null) return;

  console.log(`Tasks: ${summary.taskCount ?? 0}`);
  for (const post of summary.posts) {
    console.log(post.title);
  }
} catch (error) {
  if (error instanceof CloudCodeError) {
    console.log(`Cloud Code failed: ${error.message}`);
  }
}

result.data is null on an empty success — fromJson is skipped entirely rather than being handed a null — so check it before you read the model.

using Newtonsoft.Json;

public sealed class DashboardSummary
{
    [JsonProperty("task_count")]
    public int? TaskCount { get; set; }

    [JsonProperty("posts")]
    public List<Post>? Posts { get; set; }
}

public sealed class Post
{
    [JsonProperty("id")]
    public string? Id { get; set; }

    [JsonProperty("title")]
    public string? Title { get; set; }
}

try
{
    var result = await CloudCode.Call<DashboardSummary>(
        "dashboard-summary",
        CloudCodeHttpMethod.Get);

    var requestId = result.RequestId ?? "none";
    Console.WriteLine($"HTTP {result.StatusCode}, request {requestId}");
    Console.WriteLine($"Tasks: {result.Data?.TaskCount ?? 0}");

    foreach (var post in result.Data?.Posts ?? new List<Post>())
    {
        Console.WriteLine(post.Title);
    }
}
catch (CloudCodeError error)
{
    Console.WriteLine($"Cloud Code failed: {error.Message}");
}

Both Call and Call<T> throw CloudCodeError, so one catch covers the typed and the dynamic path alike. Data is null on an empty success.

using Newtonsoft.Json;

public sealed class DashboardSummary
{
    [JsonProperty("task_count")]
    public int? TaskCount { get; set; }

    [JsonProperty("posts")]
    public List<Post>? Posts { get; set; }
}

public sealed class Post
{
    [JsonProperty("id")]
    public string? Id { get; set; }

    [JsonProperty("title")]
    public string? Title { get; set; }
}

try
{
    var result = await CloudCode.Call<DashboardSummary>(
        "dashboard-summary",
        CloudCodeHttpMethod.Get);

    var requestId = result.RequestId ?? "none";
    Console.WriteLine($"HTTP {result.StatusCode}, request {requestId}");
    Console.WriteLine($"Tasks: {result.Data?.TaskCount ?? 0}");

    foreach (var post in result.Data?.Posts ?? new List<Post>())
    {
        Console.WriteLine(post.Title);
    }
}
catch (CloudCodeError error)
{
    Console.WriteLine($"Cloud Code failed: {error.Message}");
}

Both Call and Call<T> throw CloudCodeError, so one catch covers the typed and the dynamic path alike. Data is null on an empty success.

The typed result mirrors the dynamic response, with one difference: the data is empty when the function returned 204 or an empty body. The status code, request id, and headers are still populated in that case, so an empty success is never mistaken for a failure.

Make every field optional while a function is young

Cloud Code functions change faster than app releases. Optional fields mean a function that stops sending a key degrades into a missing value instead of a decoding error that breaks the screen for users on older builds. Note that taskCount and posts are optional in every model above for exactly this reason.


Typed models

How a payload becomes a model differs by platform. Pick your tab; the rest of the page assumes the convention shown here.

Typed responses decode with Newtonsoft.Json, which the SDK already depends on, so there is nothing to add to your project. A model needs public settable properties and, wherever the JSON name is not the C# name, a [JsonProperty] attribute:

using Newtonsoft.Json;

public sealed class Greeting
{
    [JsonProperty("greeting")]
    public string? Text { get; set; }
}

Property matching is case insensitive, so a Greeting property would bind to greeting on its own. What it will not do is bridge a naming convention: TaskCount does not match task_count. Annotating every field, as the models on this page do, keeps the mapping explicit and survives a rename on either side.

Declare each property as nullable (int?, string?, List<Post>?) rather than giving it a default value. A missing key then reads as null, which your code can check, instead of a zero or an empty string that looks like data the function actually sent.

This is the same convention the CMS guide uses for its content models, so a project that already reads CMS entries has nothing new to learn here.

Decodable does the work, and there is nothing to register or annotate. Give the model a CodingKeys enum wherever the JSON name is not the Swift name:

struct DashboardSummary: Decodable {
    let taskCount: Int?
    let posts: [Post]?

    enum CodingKeys: String, CodingKey {
        case taskCount = "task_count"
        case posts
    }
}

Declare each property optional rather than required. A missing key then decodes as nil, which your code can check, instead of throwing and failing the whole response.

Typed decoding is not available from Objective-C, because Swift generics do not bridge. Use the dynamic API and map the dictionary yourself.

Android maps JSON onto your model with reflection, which puts two requirements on the model that Swift's Decodable does not:

  1. A public no-argument constructor. A Java class with no explicit constructor already has one. A Kotlin class needs every property to have a default value.
  2. Fields that survive minification. Annotate them with @JsonKey.

@JsonKey does double duty. It maps a JSON name that differs from the field name, and the keep rules shipped in the SDK's consumer-rules.pro preserve any field carrying it, along with the model's public no-arg constructor. That means an annotated model works under R8 with no rules of your own.

public class Greeting {
    @JsonKey("greeting")
    public String greeting = "";
}
class Greeting {
    // Note `@field:`, so the annotation lands on the backing field
    // rather than the property or the constructor parameter.
    @field:JsonKey("greeting")
    var greeting: String = ""
}

If you would rather not annotate, keep the models yourself in the app's proguard-rules.pro:

-keep class com.example.app.cloudcode.** {
    <fields>;
    public <init>();
}

An unannotated model fails only in release builds

Without @JsonKey or a keep rule, R8 renames the fields, no JSON key matches any of them, and decoding fails with No response fields matched. Debug builds are not minified, so this passes every test on your machine and breaks in production. The SDK raises a decoding error rather than handing back a silently empty model, which is the failure you want, but it is still a failure worth avoiding by annotating from the start.

The same requirement applies to every reflection mapped model in the SDK: Cloud Code and CMS models use @JsonKey, and Database models use @DbColumn. Dynamic responses read as maps, and request bodies passed as maps, are not affected because no reflection is involved.

Flutter does no reflection, so there is nothing to annotate and nothing for a minifier to rename. You supply the mapping yourself as a fromJson function, which is also where a differing JSON name is handled:

class Greeting {
  final String greeting;

  const Greeting({this.greeting = ''});

  factory Greeting.fromJson(Object? value) {
    final map = value is Map<String, dynamic> ? value : const <String, dynamic>{};
    return Greeting(greeting: map['greeting'] as String? ?? '');
  }
}

fromJson takes Object?, not Map<String, dynamic>

This differs from CMS, where fromJson receives a map. A Cloud Code function may legitimately return a list, a string, or a number at the top level, so the typed call hands you the raw decoded value and lets you decide. Narrow it with is before indexing, as above — a bare value as Map<String, dynamic> throws on any other shape.

Anything fromJson throws is caught and re-raised as a CloudCodeError with code decoding, so a contract mismatch surfaces as a Cloud Code failure rather than as a raw TypeError from your widget.

React Native does no reflection, so there is nothing to annotate and nothing for a minifier to rename. You supply the mapping yourself as a fromJson function, which is also where a JSON name that differs from your field name is handled:

type Greeting = { greeting: string };

function greetingFromJson(value: unknown): Greeting {
  const map = (typeof value === 'object' && value !== null
    ? value
    : {}) as Record<string, unknown>;

  return { greeting: typeof map.greeting === 'string' ? map.greeting : '' };
}

A type annotation checks nothing at runtime

CloudCode.callTyped<Greeting>(...) does not validate anything on its own — TypeScript types are erased at build time, and fromJson is the only place the payload is really inspected. A fromJson that just casts (value as Greeting) compiles, then silently hands your screen undefined fields when the shape changed, or throws outright when the function returned null. Check each field, as above.

fromJson takes unknown, not an object, because a Cloud Code function may legitimately return a list, a string, or a number at the top level. Narrow the value before indexing it.

Anything fromJson throws is caught and re-raised as a CloudCodeError with code Decoding, so a contract mismatch surfaces as a Cloud Code failure rather than as a raw TypeError inside your component.

Typed responses decode with Newtonsoft.Json, which the SDK already depends on, so there is nothing to add to your project. A model needs public settable properties and, wherever the JSON name is not the C# name, a [JsonProperty] attribute:

using Newtonsoft.Json;

public sealed class Greeting
{
    [JsonProperty("greeting")]
    public string? Text { get; set; }
}

Property matching is case insensitive, so a Greeting property would bind to greeting on its own. What it will not do is bridge a naming convention: TaskCount does not match task_count. Annotating every field, as the models on this page do, keeps the mapping explicit and survives a rename on either side.

Declare each property as nullable (int?, string?, List<Post>?) rather than giving it a default value. A missing key then reads as null, which your code can check, instead of a zero or an empty string that looks like data the function actually sent.

This is the same convention the CMS guide uses for its content models, so a project that already reads CMS entries has nothing new to learn here.

Typed responses decode with Newtonsoft.Json, which the SDK already depends on, so there is nothing to add to your project. A model needs public settable properties and, wherever the JSON name is not the C# name, a [JsonProperty] attribute:

using Newtonsoft.Json;

public sealed class Greeting
{
    [JsonProperty("greeting")]
    public string? Text { get; set; }
}

Property matching is case insensitive, so a Greeting property would bind to greeting on its own. What it will not do is bridge a naming convention: TaskCount does not match task_count. Annotating every field, as the models on this page do, keeps the mapping explicit and survives a rename on either side.

Declare each property as nullable (int?, string?, List<Post>?) rather than giving it a default value. A missing key then reads as null, which your code can check, instead of a zero or an empty string that looks like data the function actually sent.

This is the same convention the CMS guide uses for its content models, so a project that already reads CMS entries has nothing new to learn here.


HTTP methods and bodies

All five methods are available, and every SDK matches normal HTTP semantics:

  • GET requests never send a body. If you pass one it is dropped, and no Content-Type header is set. Put the inputs in the query instead.
  • POST, PUT, PATCH, and DELETE send the body as application/json. DELETE with a body is preserved, which is useful for confirmation payloads and idempotency keys.

Inputs in the query string

A search endpoint is the natural fit for GET. Query values arrive in ctx.req.query already decoded, so "hello world" reaches the function with its space intact. Deploy this one under the slug search-products:

index.js
export async function handler(ctx) {
  const { q = "", limit = "20" } = ctx.req.query ?? {};

  const res = await ctx.appambit.db(
    "SELECT sku, name FROM products WHERE name LIKE ? LIMIT ?",
    [`%${q}%`, Number(limit)]
  );

  return {
    query: q,
    products: res.results[0].rows.map(([sku, name]) => ({ sku, name })),
  };
}
var query = new Dictionary<string, string>
{
    ["q"] = "hello world",
    ["limit"] = "20"
};

var response = await CloudCode.Call("search-products", CloudCodeHttpMethod.Get, query);

if (response.Data is Dictionary<string, object?> payload
    && payload.GetValueOrDefault("products") is List<object?> products)
{
    Console.WriteLine($"{products.Count} result(s)");
}
CloudCode.call(
    "search-products",
    method: .get,
    query: ["q": "hello world", "limit": "20"]
) { response, error in
    guard let payload = response?.data as? [String: Any],
          let products = payload["products"] as? [[String: Any]] else { return }

    print("\(products.count) result(s)")
}
[CloudCode call:@"search-products"
         method:CloudCodeHttpMethodGet
          query:@{ @"q": @"hello world", @"limit": @"20" }
           body:nil
        headers:nil
     completion:^(CloudCodeResponse * _Nullable response, NSError * _Nullable error) {
    if (![response.data isKindOfClass:NSDictionary.class]) { return; }

    NSDictionary *payload = (NSDictionary *)response.data;
    NSLog(@"%lu result(s)", (unsigned long)[payload[@"products"] count]);
}];
Map<String, String> query = new LinkedHashMap<>();
query.put("q", "hello world");
query.put("limit", "20");

CloudCode.call("search-products", CloudCodeHttpMethod.GET, query, null, null)
    .then(response -> {
        if (!(response.getData() instanceof Map)) return;

        Map<?, ?> payload = (Map<?, ?>) response.getData();
        List<?> products = (List<?>) payload.get("products");
        Log.d("CloudCode", (products == null ? 0 : products.size()) + " result(s)");
    })
    .onError(error -> Log.e("CloudCode", "Failed", error));
CloudCode.call(
    "search-products",
    CloudCodeHttpMethod.GET,
    mapOf("q" to "hello world", "limit" to "20"),
    null,
    null
)
    .then { response ->
        val payload = response.data as? Map<*, *> ?: return@then
        val products = payload["products"] as? List<*>
        println("${products?.size ?: 0} result(s)")
    }
    .onError { error -> println("Failed: ${error.message}") }
final request = CloudCode.call(
  'search-products',
  method: CloudCodeHttpMethod.get,
  query: {'q': 'hello world', 'limit': '20'},
);

try {
  final response = await request.future;

  final payload = response.data;
  if (payload is! Map<String, dynamic>) return;

  final products = payload['products'];
  print('${products is List ? products.length : 0} result(s)');
} on CloudCodeError catch (error) {
  print('Failed: ${error.message}');
}

query is typed Map<String, String>, so convert numbers and booleans to strings yourself. The SDK handles the percent encoding.

const response = await CloudCode.call('search-products', {
  method: 'GET',
  query: { q: 'hello world', limit: '20' },
});

if (response.data && typeof response.data === 'object') {
  const { products } = response.data as { products?: unknown[] };
  console.log(`${products?.length ?? 0} result(s)`);
}

query is typed Record<string, string>, so convert numbers and booleans to strings yourself. A non-string value is rejected as InvalidQuery before the request leaves the device, with the offending key on error.query. The SDK handles the percent encoding.

var query = new Dictionary<string, string>
{
    ["q"] = "hello world",
    ["limit"] = "20"
};

var response = await CloudCode.Call("search-products", CloudCodeHttpMethod.Get, query);

if (response.Data is Dictionary<string, object?> payload
    && payload.GetValueOrDefault("products") is List<object?> products)
{
    Console.WriteLine($"{products.Count} result(s)");
}
var query = new Dictionary<string, string>
{
    ["q"] = "hello world",
    ["limit"] = "20"
};

var response = await CloudCode.Call("search-products", CloudCodeHttpMethod.Get, query);

if (response.Data is Dictionary<string, object?> payload
    && payload.GetValueOrDefault("products") is List<object?> products)
{
    Console.WriteLine($"{products.Count} result(s)");
}

Inputs in the body

Writes go in the body. This function, deployed as create-order, validates its input, deduplicates on an idempotency key, and returns a 201 with the new id:

index.js
export async function handler(ctx) {
  const { sku, quantity, idempotency_key: key } = ctx.req.body ?? {};

  if (!sku || !Number.isInteger(quantity) || quantity < 1) {
    return { status: 400, body: { error: "invalid_order" } };
  }

  // The same key never creates two orders, even if the request is replayed.
  const existing = await ctx.appambit.db(
    "SELECT id FROM orders WHERE consumer_id = ? AND idempotency_key = ?",
    [ctx.consumer.id, key ?? ""]
  );

  if (existing.results[0].rows.length > 0) {
    return { body: { id: existing.results[0].rows[0][0], duplicate: true } };
  }

  await ctx.appambit.db(
    "INSERT INTO orders (consumer_id, sku, quantity, idempotency_key) VALUES (?, ?, ?, ?)",
    [ctx.consumer.id, sku, quantity, key ?? ""]
  );

  const created = await ctx.appambit.db(
    "SELECT id FROM orders WHERE consumer_id = ? ORDER BY id DESC LIMIT 1",
    [ctx.consumer.id]
  );

  return { status: 201, body: { id: created.results[0].rows[0][0], duplicate: false } };
}
var response = await CloudCode.Call(
    "create-order",
    CloudCodeHttpMethod.Post,
    body: new
    {
        sku = "COFFEE-01",
        quantity = 2,
        idempotency_key = Guid.NewGuid().ToString()
    });

// 201 on a fresh order, 200 when the key was already used.
Console.WriteLine($"HTTP {response.StatusCode}");

An anonymous object, a Dictionary<string, object>, or one of your own classes all work, because the SDK hands the body to Newtonsoft. What matters is the shape it serializes to: it must be a JSON object, so a string, a number, or a list is rejected with InvalidBody before any request is sent. A DateTime property is allowed, but it serializes to a string, so the function receives text rather than a date.

The body is serialized inside Call, before the returned task is awaited, so mutating your own dictionary afterwards cannot change what gets sent.

CloudCode.call(
    "create-order",
    method: .post,
    body: [
        "sku": "COFFEE-01",
        "quantity": 2,
        "idempotency_key": UUID().uuidString
    ]
) { response, error in
    guard let response, error == nil else { return }

    // 201 on a fresh order, 200 when the key was already used.
    print("HTTP \(response.statusCode)")
}
[CloudCode call:@"create-order"
         method:CloudCodeHttpMethodPost
          query:nil
           body:@{ @"sku": @"COFFEE-01",
                   @"quantity": @2,
                   @"idempotency_key": NSUUID.UUID.UUIDString }
        headers:nil
     completion:^(CloudCodeResponse * _Nullable response, NSError * _Nullable error) {
    if (error != nil) { return; }

    // 201 on a fresh order, 200 when the key was already used.
    NSLog(@"HTTP %ld", (long)response.statusCode);
}];

Body values must be JSON serializable. A Date, a URL, or a custom class produces an invalidBody error before the request leaves the device, so convert to strings and numbers first.

Map<String, Object> body = new LinkedHashMap<>();
body.put("sku", "COFFEE-01");
body.put("quantity", 2);
body.put("idempotency_key", UUID.randomUUID().toString());

CloudCode.call("create-order", CloudCodeHttpMethod.POST, null, body, null)
    // 201 on a fresh order, 200 when the key was already used.
    .then(response -> Log.d("CloudCode", "HTTP " + response.getStatusCode()))
    .onError(error -> Log.e("CloudCode", "Failed", error));
CloudCode.call(
    "create-order",
    CloudCodeHttpMethod.POST,
    null,
    mapOf(
        "sku" to "COFFEE-01",
        "quantity" to 2,
        "idempotency_key" to UUID.randomUUID().toString()
    ),
    null
)
    // 201 on a fresh order, 200 when the key was already used.
    .then { response -> println("HTTP ${response.statusCode}") }
    .onError { error -> println("Failed: ${error.message}") }

Body values must be JSON serializable. Anything that is not a string, number, boolean, map, iterable, or array produces an INVALID_BODY error before the request leaves the device, so convert dates to ISO 8601 strings and objects to maps first.

The map you pass is deep copied before the request runs, so mutating your own map afterwards cannot change what gets sent. That makes it safe to reuse a builder map across calls.

final request = CloudCode.call(
  'create-order',
  method: CloudCodeHttpMethod.post,
  body: {
    'sku': 'COFFEE-01',
    'quantity': 2,
    'idempotency_key': uuid,
  },
);

try {
  // 201 on a fresh order, 200 when the key was already used.
  final response = await request.future;
  print('HTTP ${response.statusCode}');
} on CloudCodeError catch (error) {
  print('Failed: ${error.message}');
}

Body values must be JSON serializable. String, num, bool, null, List, and Map with non-empty String keys are accepted; anything else produces an invalidBody error before the request leaves the device, so convert DateTime to an ISO 8601 string and model objects to maps first.

The map is deep copied synchronously inside call, so mutating your own map afterwards cannot change what gets sent — safe to reuse a builder map across calls.

const response = await CloudCode.call('create-order', {
  method: 'POST',
  body: {
    sku: 'COFFEE-01',
    quantity: 2,
    idempotency_key: idempotencyKey,
  },
});

// 201 on a fresh order, 200 when the key was already used.
console.log(`HTTP ${response.statusCode}`);

The body must be a JSON object: an array or a bare value is rejected with InvalidBody. Inside it, strings, numbers, booleans, null, arrays, and plain objects are accepted. Date, Map, Set, RegExp, ArrayBuffer, functions, NaN, Infinity, a circular reference, and nesting deeper than 32 levels are each rejected before the request leaves the device, so convert dates to ISO 8601 strings and class instances to plain objects first.

undefined follows JSON.stringify semantics rather than becoming null: an object property set to undefined is omitted from the payload, while an explicit null is sent. That distinction is what lets a PATCH handler read a missing key as "leave this alone" and a null as "clear this". An undefined array element still becomes null, since JSON arrays cannot have holes.

Omitting body, or passing an empty object, sends no body and no Content-Type at all — the two are indistinguishable to the function.

The body is deep copied synchronously inside call, so mutating your own object afterwards cannot change what gets sent. That makes it safe to reuse a builder object across calls.

var response = await CloudCode.Call(
    "create-order",
    CloudCodeHttpMethod.Post,
    body: new
    {
        sku = "COFFEE-01",
        quantity = 2,
        idempotency_key = Guid.NewGuid().ToString()
    });

// 201 on a fresh order, 200 when the key was already used.
Console.WriteLine($"HTTP {response.StatusCode}");

An anonymous object, a Dictionary<string, object>, or one of your own classes all work, because the SDK hands the body to Newtonsoft. What matters is the shape it serializes to: it must be a JSON object, so a string, a number, or a list is rejected with InvalidBody before any request is sent. A DateTime property is allowed, but it serializes to a string, so the function receives text rather than a date.

The body is serialized inside Call, before the returned task is awaited, so mutating your own dictionary afterwards cannot change what gets sent.

var response = await CloudCode.Call(
    "create-order",
    CloudCodeHttpMethod.Post,
    body: new
    {
        sku = "COFFEE-01",
        quantity = 2,
        idempotency_key = Guid.NewGuid().ToString()
    });

// 201 on a fresh order, 200 when the key was already used.
Console.WriteLine($"HTTP {response.StatusCode}");

An anonymous object, a Dictionary<string, object>, or one of your own classes all work, because the SDK hands the body to Newtonsoft. What matters is the shape it serializes to: it must be a JSON object, so a string, a number, or a list is rejected with InvalidBody before any request is sent. A DateTime property is allowed, but it serializes to a string, so the function receives text rather than a date.

The body is serialized inside Call, before the returned task is awaited, so mutating your own dictionary afterwards cannot change what gets sent.


Custom headers

Headers are for your own metadata: tracing ids, client hints, feature flags. They reach the function in ctx.req.headers.

This function echoes back what it received, which makes echo-request a useful thing to keep deployed while you are wiring up a new client:

index.js
export async function handler(ctx) {
  // Header casing is not guaranteed, so look names up case insensitively.
  const header = (name) =>
    Object.entries(ctx.req.headers ?? {})
      .find(([key]) => key.toLowerCase() === name)?.[1] ?? null;

  ctx.log("inspecting request", { method: ctx.req.method });

  return {
    method: ctx.req.method,
    query: ctx.req.query ?? {},
    trace_id: header("x-trace-id"),
    screen: header("x-client-screen"),
    consumer_id: ctx.consumer?.id ?? null,
  };
}
var response = await CloudCode.Call(
    "echo-request",
    CloudCodeHttpMethod.Get,
    headers: new Dictionary<string, string>
    {
        ["X-Trace-Id"] = traceId,
        ["X-Client-Screen"] = "checkout"
    });

Console.WriteLine(response.Data);
// { method = GET, trace_id = ..., screen = checkout, ... }

A null value, and a control character anywhere in the name or value, are rejected too.

CloudCode.call(
    "echo-request",
    method: .get,
    headers: [
        "X-Trace-Id": traceId,
        "X-Client-Screen": "checkout"
    ]
) { response, error in
    print(response?.data ?? error ?? "no result")
    // ["method": "GET", "trace_id": "...", "screen": "checkout", ...]
}
[CloudCode call:@"echo-request"
         method:CloudCodeHttpMethodGet
          query:nil
           body:nil
        headers:@{ @"X-Trace-Id": traceId,
                   @"X-Client-Screen": @"checkout" }
     completion:^(CloudCodeResponse * _Nullable response, NSError * _Nullable error) {
    NSLog(@"%@", response.data ?: error);
    // { method = GET; "trace_id" = "..."; screen = checkout; ... }
}];
Map<String, String> headers = new LinkedHashMap<>();
headers.put("X-Trace-Id", traceId);
headers.put("X-Client-Screen", "checkout");

CloudCode.call("echo-request", CloudCodeHttpMethod.GET, null, null, headers)
    .then(response -> Log.d("CloudCode", String.valueOf(response.getData())))
    // {method=GET, trace_id=..., screen=checkout, ...}
    .onError(error -> Log.e("CloudCode", "Failed", error));
CloudCode.call(
    "echo-request",
    CloudCodeHttpMethod.GET,
    null,
    null,
    mapOf("X-Trace-Id" to traceId, "X-Client-Screen" to "checkout")
)
    .then { response -> println(response.data) }
    // {method=GET, trace_id=..., screen=checkout, ...}
    .onError { error -> println("Failed: ${error.message}") }

A null value, and a carriage return or newline in the name or value, are rejected too.

final request = CloudCode.call(
  'echo-request',
  method: CloudCodeHttpMethod.get,
  headers: {
    'X-Trace-Id': traceId,
    'X-Client-Screen': 'checkout',
  },
);

try {
  final response = await request.future;
  print(response.data);
  // {method: GET, trace_id: ..., screen: checkout, ...}
} on CloudCodeError catch (error) {
  print('Failed: ${error.message}');
}

An empty name, and a control character anywhere in the name or value, are rejected as invalidHeader. The offending name is reported on error.header.

const response = await CloudCode.call('echo-request', {
  method: 'GET',
  headers: {
    'X-Trace-Id': traceId,
    'X-Client-Screen': 'checkout',
  },
});

console.log(response.data);
// { method: 'GET', trace_id: '...', screen: 'checkout', ... }

A non-string header value is rejected locally as InvalidHeader; a reserved name is rejected by the native layer under the same code. Either way the offending name is reported on error.header.

var response = await CloudCode.Call(
    "echo-request",
    CloudCodeHttpMethod.Get,
    headers: new Dictionary<string, string>
    {
        ["X-Trace-Id"] = traceId,
        ["X-Client-Screen"] = "checkout"
    });

Console.WriteLine(response.Data);
// { method = GET, trace_id = ..., screen = checkout, ... }

A null value, and a control character anywhere in the name or value, are rejected too.

var response = await CloudCode.Call(
    "echo-request",
    CloudCodeHttpMethod.Get,
    headers: new Dictionary<string, string>
    {
        ["X-Trace-Id"] = traceId,
        ["X-Client-Screen"] = "checkout"
    });

Console.WriteLine(response.Data);
// { method = GET, trace_id = ..., screen = checkout, ... }

A null value, and a control character anywhere in the name or value, are rejected too.

These names are reserved and rejected with an invalidHeader error on every platform, because the SDK sets them and overriding them would either break authentication or corrupt the request:

authorization, cookie, host, content-length, content-type, accept, x-app-key, x-request-id

The check is case insensitive. A header with an empty name, or a value containing a carriage return or newline, is rejected for the same reason.

Tracing headers such as X-Trace-Id, traceparent, and your own X- names are accepted.

The caller's token never reaches your function

AppAmbit strips authorization and cookie before the function runs, so ctx.req.headers will not contain them. Use ctx.consumer.id to identify the caller, as the function above does.


Cancellation

Cancelling stops the result from reaching a screen that has already gone away.

Cancellation is client side everywhere. The function keeps running on the server and any side effect it performs still happens. Do not rely on cancellation to undo a write.

public sealed class CheckoutViewModel : IDisposable
{
    private CancellationTokenSource? _pendingCall;

    public async Task LoadSummaryAsync()
    {
        _pendingCall?.Cancel();
        _pendingCall?.Dispose();

        var cancellation = new CancellationTokenSource();
        _pendingCall = cancellation;

        try
        {
            var result = await CloudCode.Call<DashboardSummary>(
                "dashboard-summary",
                CloudCodeHttpMethod.Get,
                cancellationToken: cancellation.Token);
            // ...
        }
        catch (OperationCanceledException)
        {
            // The screen moved on. Nothing to show, and nothing to report.
        }
        catch (CloudCodeError error)
        {
            // ...
        }
    }

    public void Dispose()
    {
        _pendingCall?.Cancel();
        _pendingCall?.Dispose();
    }
}

Cancel the CancellationTokenSource whose token you passed to Call.

There is no silent path here, because the awaited task has to complete somehow: it throws OperationCanceledException. That is deliberate, and it is not a CloudCodeError — the call did not fail, you stopped caring about it. Catch it separately and do nothing, as above; an uncaught one propagates out of your async method like any other exception.

A CancellationTokenSource cannot be reset, so cancel it, dispose it, and create a new one for the next call.

final class CheckoutViewModel: ObservableObject {
    private var pendingCall: CloudCodeCancellationToken?

    func loadSummary() {
        pendingCall?.cancel()
        pendingCall = CloudCode.call(
            "dashboard-summary",
            method: .get,
            as: DashboardSummary.self
        ) { [weak self] result, error in
            self?.pendingCall = nil
            // ...
        }
    }

    deinit {
        pendingCall?.cancel()
    }
}
@property (nonatomic, strong, nullable) CloudCodeCancellationToken *pendingCall;

- (void)loadSummary {
    [self.pendingCall cancel];

    __weak typeof(self) weakSelf = self;
    self.pendingCall = [CloudCode call:@"dashboard-summary"
                                method:CloudCodeHttpMethodGet
                                 query:nil
                                  body:nil
                               headers:nil
                            completion:^(CloudCodeResponse * _Nullable response,
                                         NSError * _Nullable error) {
        weakSelf.pendingCall = nil;
        // ...
    }];
}

- (void)dealloc {
    [_pendingCall cancel];
}

Cancel the CloudCodeCancellationToken the call returned. The completion is then suppressed: nothing reports, and no error is delivered.

public class CheckoutViewModel extends ViewModel {
    @Nullable private CloudCodeRequest<CloudCodeResult<DashboardSummary>> pendingCall;

    void loadSummary() {
        if (pendingCall != null) pendingCall.cancel();

        pendingCall = CloudCode.call(
                "dashboard-summary", CloudCodeHttpMethod.GET, null, null, null,
                DashboardSummary.class)
            .then(result -> {
                pendingCall = null;
                // ...
            })
            .onError(error -> {
                pendingCall = null;
                // ...
            });
    }

    @Override
    protected void onCleared() {
        if (pendingCall != null) pendingCall.cancel();
        super.onCleared();
    }
}
class CheckoutViewModel : ViewModel() {
    private var pendingCall: CloudCodeRequest<CloudCodeResult<DashboardSummary>>? = null

    fun loadSummary() {
        pendingCall?.cancel()

        pendingCall = CloudCode.call(
            "dashboard-summary",
            CloudCodeHttpMethod.GET, null, null, null,
            DashboardSummary::class.java
        )
            .then { result ->
                pendingCall = null
                // ...
            }
            .onError { error ->
                pendingCall = null
                // ...
            }
    }

    override fun onCleared() {
        pendingCall?.cancel()
        super.onCleared()
    }
}

Cancel the CloudCodeRequest itself, which stops both then and onError and marks the request done.

cancel() is terminal. isDone becomes true and neither callback fires again, so attaching a then afterwards will not run it. Cancel on teardown rather than as a way to pause a request you intend to resume.

class _SummaryScreenState extends State<SummaryScreen> {
  CloudCodeRequest<CloudCodeResult<DashboardSummary>>? _pendingCall;

  Future<void> _loadSummary() async {
    await _pendingCall?.cancel();

    final request = CloudCode.callTyped<DashboardSummary>(
      'dashboard-summary',
      method: CloudCodeHttpMethod.get,
      fromJson: DashboardSummary.fromJson,
    );
    _pendingCall = request;

    try {
      final result = await request.future;
      if (!mounted) return;
      setState(() { /* ... */ });
    } on CloudCodeError catch (error) {
      // A newer load replaced this one, or the screen went away.
      if (error.code == CloudCodeErrorCode.cancelled) return;
      if (!mounted) return;
      setState(() { /* ... */ });
    } finally {
      if (identical(_pendingCall, request)) _pendingCall = null;
    }
  }

  @override
  void dispose() {
    _pendingCall?.cancel();
    super.dispose();
  }
}

Cancellation is not silent on Flutter

On iOS and Android, cancelling suppresses the callbacks and nothing else happens. On Flutter, cancel() completes the future with a CloudCodeError whose code is cancelled, so an await that was in flight throws. Branch on CloudCodeErrorCode.cancelled and return quietly, as above, or a routine refresh will surface as an error to the user.

cancel() is terminal and idempotent: it completes the future once, notifies the native side, and further calls do nothing. It returns a Future<void> you can await if you want the native cancellation to have landed before starting the replacement call.

A request you never await is safe to cancel — the SDK observes the future internally, so the cancelled error cannot escape as an unhandled async error and get auto-reported as a crash.

import { useCallback, useEffect, useRef, useState } from 'react';
import {
  CloudCode,
  CloudCodeError,
  CloudCodeErrorCode,
  type CloudCodeRequest,
  type CloudCodeResult,
} from 'appambit';

function useDashboardSummary() {
  const pendingCall =
    useRef<CloudCodeRequest<CloudCodeResult<DashboardSummary>> | null>(null);
  const [summary, setSummary] = useState<DashboardSummary | null>(null);

  const load = useCallback(async () => {
    await pendingCall.current?.cancel();

    const request = CloudCode.callTyped<DashboardSummary>('dashboard-summary', {
      method: 'GET',
      fromJson: summaryFromJson,
    });
    pendingCall.current = request;

    try {
      const result = await request;
      setSummary(result.data);
    } catch (error) {
      if (!(error instanceof CloudCodeError)) throw error;
      // A newer load replaced this one, or the screen went away.
      if (error.code === CloudCodeErrorCode.Cancelled) return;
      // ... surface the failure
    } finally {
      if (pendingCall.current === request) pendingCall.current = null;
    }
  }, []);

  // Cancel whatever is still in flight when the screen unmounts.
  useEffect(() => () => { pendingCall.current?.cancel(); }, []);

  return { summary, load };
}

Cancellation is not silent on React Native

On iOS and Android, cancelling suppresses the callbacks and nothing else happens. Here, cancel() rejects the pending promise with a CloudCodeError whose code is Cancelled, so an await that was in flight throws. Branch on that code and return quietly, as above, or a routine refresh will surface as an error to the user.

cancel() is terminal and idempotent: it rejects the promise once, notifies the native side, and further calls do nothing. It returns a Promise<void> you can await when you want the native cancellation to have landed before the replacement call starts, as load does above.

Keep the handle in a useRef, not in state. A re-render must not lose it, and putting it in state would schedule a render for something the UI never shows.

The bridge also cancels every pending Cloud Code request when it is torn down, so a JS reload during development never leaves a native request running against a promise that no longer exists.

public sealed class CheckoutViewModel : IDisposable
{
    private CancellationTokenSource? _pendingCall;

    public async Task LoadSummaryAsync()
    {
        _pendingCall?.Cancel();
        _pendingCall?.Dispose();

        var cancellation = new CancellationTokenSource();
        _pendingCall = cancellation;

        try
        {
            var result = await CloudCode.Call<DashboardSummary>(
                "dashboard-summary",
                CloudCodeHttpMethod.Get,
                cancellationToken: cancellation.Token);
            // ...
        }
        catch (OperationCanceledException)
        {
            // The screen moved on. Nothing to show, and nothing to report.
        }
        catch (CloudCodeError error)
        {
            // ...
        }
    }

    public void Dispose()
    {
        _pendingCall?.Cancel();
        _pendingCall?.Dispose();
    }
}

Cancel the CancellationTokenSource whose token you passed to Call.

There is no silent path here, because the awaited task has to complete somehow: it throws OperationCanceledException. That is deliberate, and it is not a CloudCodeError — the call did not fail, you stopped caring about it. Catch it separately and do nothing, as above; an uncaught one propagates out of your async method like any other exception.

A CancellationTokenSource cannot be reset, so cancel it, dispose it, and create a new one for the next call.

public sealed class CheckoutViewModel : IDisposable
{
    private CancellationTokenSource? _pendingCall;

    public async Task LoadSummaryAsync()
    {
        _pendingCall?.Cancel();
        _pendingCall?.Dispose();

        var cancellation = new CancellationTokenSource();
        _pendingCall = cancellation;

        try
        {
            var result = await CloudCode.Call<DashboardSummary>(
                "dashboard-summary",
                CloudCodeHttpMethod.Get,
                cancellationToken: cancellation.Token);
            // ...
        }
        catch (OperationCanceledException)
        {
            // The screen moved on. Nothing to show, and nothing to report.
        }
        catch (CloudCodeError error)
        {
            // ...
        }
    }

    public void Dispose()
    {
        _pendingCall?.Cancel();
        _pendingCall?.Dispose();
    }
}

Cancel the CancellationTokenSource whose token you passed to Call.

There is no silent path here, because the awaited task has to complete somehow: it throws OperationCanceledException. That is deliberate, and it is not a CloudCodeError — the call did not fail, you stopped caring about it. Catch it separately and do nothing, as above; an uncaught one propagates out of your async method like any other exception.

A CancellationTokenSource cannot be reset, so cancel it, dispose it, and create a new one for the next call.


Timeouts

There are two independent timeouts, and they are easy to confuse:

Timeout Where it lives Value
Client timeout The SDK Fixed at 60 seconds on every platform, including any token refresh
Function timeout The function configuration Whatever you set, see Configuration and Limits

The function timeout is almost always the shorter of the two. When a function runs past its own budget the platform answers 504, and the SDK surfaces that as an http error, not as a timeout. A timeout error means the full 60 seconds elapsed without a usable response, which usually points at connectivity rather than at the function.


Authentication

Cloud Code uses the same consumer Bearer token as the rest of the SDK, so there is no separate login step. Inside the function, that caller is ctx.consumer.id.

If the token is missing or expired, the SDK refreshes it and retries the call once, for every HTTP method including mutating ones. If the retry also returns 401, that status is handed to your callback as an http error rather than being retried again. Concurrent Cloud Code calls that all hit a 401 share a single token renewal instead of stampeding the token endpoint.

ctx.consumer identifies, it does not authorize

Every function runs with your app's full scope, so a consumer id in the context does not by itself limit what the function can touch. If a user should only see their own rows, put WHERE consumer_id = ? in the query, the way the examples above do.


Error handling

Every SDK reports the same set of failures; only the shape and the naming differ.

There is no error argument to inspect: await CloudCode.Call(...) either produces a response or throws. Wrap the call in try / catch (CloudCodeError error) and switch on error.Code.

Meaning CloudCodeErrorCode
Called before the SDK was started NotInitialized
Slug is empty, has /, whitespace, or control characters InvalidFunction
The body is not valid JSON InvalidBody
Reserved header, empty name, null value, or a control character in the name or value InvalidHeader
The device is offline or the connection dropped NetworkUnavailable
The 60 second client timeout elapsed TimedOut
The resolved URL could not be built InvalidUrl
Any other network failure Transport
A successful body was not JSON, or decoding failed Decoding
The function replied with a status outside 2xx Http
No HTTP method was passed InvalidMethod
A query entry has a null or empty key, a null value, or a control character in the key or value InvalidQuery
No response type was passed to the typed call InvalidResponseType

Cancellation is not in the list because it arrives as OperationCanceledException, not as a CloudCodeError. error.Message is always populated and safe to log, while StatusCode, Body, and RawBody are null for every code other than Http.

InvalidMethod and InvalidResponseType are almost unreachable from ordinary code, since the method is an enum with a default and the response type is a generic argument. InvalidQuery is the one worth remembering, because a control character in a key or value is easy to pick up from user input.

Failures arrive as CloudCodeError in Swift, and as NSError in the domain com.appambit.cloud-code in Objective-C. The NSError code is the number below.

Meaning Case, NSError code
Called before the SDK was started notInitialized, 1
Slug is empty, has /, whitespace, or control characters invalidFunction, 2
The body is not valid JSON invalidBody, 3
Reserved header, empty name, or CR or LF in the value invalidHeader, 4
The device is offline or the connection dropped networkUnavailable, 5
The 60 second client timeout elapsed timedOut, 6
The resolved URL could not be built invalidURL, 7
Any other network failure transport, 8
A successful body was not JSON, or decoding failed decoding, 9
The function replied with a status outside 2xx http, 10

Cancelling delivers nothing at all, so there is no case for it. Every case sets NSLocalizedDescriptionKey, so localizedDescription is always safe to log, and only the http case populates the extra userInfo keys.

Failures arrive in onError as a CloudCodeError, which extends Exception and carries a Code enum.

Meaning Code
Called before the SDK was started NOT_INITIALIZED
Slug is empty, has /, whitespace, or control characters INVALID_FUNCTION
The body is not valid JSON INVALID_BODY
Reserved header, empty name, null value, or CR or LF in the name or value INVALID_HEADER
The device is offline or the connection dropped NETWORK_UNAVAILABLE
The 60 second client timeout elapsed TIMED_OUT
The resolved URL could not be built INVALID_URL
Any other network failure TRANSPORT
A successful body was not JSON, or decoding failed DECODING
The function replied with a status outside 2xx HTTP
No HTTP method was passed INVALID_METHOD
A query entry has a null or empty key, or a null value INVALID_QUERY
No response type was passed to the typed call INVALID_RESPONSE_TYPE
The request was cancelled CANCELLED

CANCELLED never reaches an onError callback, since cancelling suppresses callbacks; you will only see it from getBlocking(). getStatusCode() returns -1 and the body accessors return null for every case other than HTTP.

Failures complete the future with a CloudCodeError, which implements Exception and carries a CloudCodeErrorCode. Catch it with on CloudCodeError catch (error) and switch on error.code.

Meaning code
Called before the SDK was started notInitialized
Slug is empty, has /, whitespace, or control characters invalidFunction
The body is not valid JSON invalidBody
Reserved header, empty name, or CR or LF in the value invalidHeader
The device is offline or the connection dropped networkUnavailable
The client timeout elapsed timedOut
The resolved URL could not be built invalidUrl
Any other network failure transport
A successful body was not JSON, or fromJson threw decoding
The function replied with a status outside 2xx http
No HTTP method reached the bridge invalidMethod
A query entry has a null or empty key, or a null value invalidQuery
No response type reached the typed call invalidResponseType
The request was cancelled cancelled
The platform channel call itself was rejected badArgs
Anything the SDK could not classify unknown

Unlike the native SDKs, cancelled does reach your catch — see Cancellation. statusCode and the body accessors are null for every case other than http.

badArgs has no counterpart on the native SDKs: it means the Dart side reached the native bridge with a malformed platform-channel payload. Seeing one points at a version mismatch between appambit_sdk_flutter and the native SDK it resolved, not at your call.

Failures reject the promise with a CloudCodeError, which extends Error and carries a CloudCodeErrorCode. Catch it, narrow with error instanceof CloudCodeError, and switch on error.code.

Meaning code
Called before the SDK was started NotInitialized
Slug is empty, has /, whitespace, or control characters InvalidFunction
The body is not a JSON object, or holds an unsupported value InvalidBody
Reserved header, empty name, or a non-string value InvalidHeader
The device is offline or the connection dropped NetworkUnavailable
A client deadline elapsed TimedOut
The resolved URL could not be built InvalidUrl
Any other network failure Transport
A successful body was not JSON, or fromJson threw Decoding
The function replied with a status outside 2xx Http
The method string did not reach the bridge as a known verb InvalidMethod
A query entry has a non-string value InvalidQuery
No response type reached the typed call InvalidResponseType
The request was cancelled Cancelled
Anything the SDK could not classify Unknown

CloudCodeErrorCode is a string enum whose values are the wire codes, so CloudCodeErrorCode.NotInitialized is 'NOT_INITIALIZED' — compare against the enum member in code, and expect the screaming-snake form in a raw log line.

Unlike the native SDKs, Cancelled does reach your catch — see Cancellation. statusCode, body, and rawBody are undefined for every case other than Http, and requestId is null unless the platform supplied one.

Always narrow with instanceof CloudCodeError before reading code, and rethrow anything else. A plain TypeError from your own try block would otherwise be swallowed and reported to the user as a network problem.

There is no error argument to inspect: await CloudCode.Call(...) either produces a response or throws. Wrap the call in try / catch (CloudCodeError error) and switch on error.Code.

Meaning CloudCodeErrorCode
Called before the SDK was started NotInitialized
Slug is empty, has /, whitespace, or control characters InvalidFunction
The body is not valid JSON InvalidBody
Reserved header, empty name, null value, or a control character in the name or value InvalidHeader
The device is offline or the connection dropped NetworkUnavailable
The 60 second client timeout elapsed TimedOut
The resolved URL could not be built InvalidUrl
Any other network failure Transport
A successful body was not JSON, or decoding failed Decoding
The function replied with a status outside 2xx Http
No HTTP method was passed InvalidMethod
A query entry has a null or empty key, a null value, or a control character in the key or value InvalidQuery
No response type was passed to the typed call InvalidResponseType

Cancellation is not in the list because it arrives as OperationCanceledException, not as a CloudCodeError. error.Message is always populated and safe to log, while StatusCode, Body, and RawBody are null for every code other than Http.

InvalidMethod and InvalidResponseType are almost unreachable from ordinary code, since the method is an enum with a default and the response type is a generic argument. InvalidQuery is the one worth remembering, because a control character in a key or value is easy to pick up from user input.

There is no error argument to inspect: await CloudCode.Call(...) either produces a response or throws. Wrap the call in try / catch (CloudCodeError error) and switch on error.Code.

Meaning CloudCodeErrorCode
Called before the SDK was started NotInitialized
Slug is empty, has /, whitespace, or control characters InvalidFunction
The body is not valid JSON InvalidBody
Reserved header, empty name, null value, or a control character in the name or value InvalidHeader
The device is offline or the connection dropped NetworkUnavailable
The 60 second client timeout elapsed TimedOut
The resolved URL could not be built InvalidUrl
Any other network failure Transport
A successful body was not JSON, or decoding failed Decoding
The function replied with a status outside 2xx Http
No HTTP method was passed InvalidMethod
A query entry has a null or empty key, a null value, or a control character in the key or value InvalidQuery
No response type was passed to the typed call InvalidResponseType

Cancellation is not in the list because it arrives as OperationCanceledException, not as a CloudCodeError. error.Message is always populated and safe to log, while StatusCode, Body, and RawBody are null for every code other than Http.

InvalidMethod and InvalidResponseType are almost unreachable from ordinary code, since the method is an enum with a default and the response type is a generic argument. InvalidQuery is the one worth remembering, because a control character in a key or value is easy to pick up from user input.

The first four rows are validated locally and fail before a request is sent, which makes them cheap to catch in development.

The http case

Any status outside 2xx becomes an http error, including the ones your own function chooses to return. This function, deployed as validate-quantity, rejects bad input with a 422 and a message the app can show:

index.js
export async function handler(ctx) {
  const { quantity } = ctx.req.body ?? {};

  if (!Number.isInteger(quantity) || quantity < 1) {
    return {
      status: 422,
      body: {
        error: "invalid_quantity",
        message: "Quantity must be a whole number of 1 or more.",
      },
    };
  }

  return { ok: true, quantity };
}

The error carries everything you need to handle that: the status code, the parsed body when it is a JSON object or array, the raw body string when it is anything else such as an HTML error page or a bare scalar, and the request id.

try
{
    await CloudCode.Call("validate-quantity", body: new { quantity = 0 });
}
catch (CloudCodeError error)
{
    switch (error.Code)
    {
        case CloudCodeErrorCode.Http:
            var requestId = error.RequestId ?? "none";
            Console.WriteLine($"HTTP {error.StatusCode}, request {requestId}");

            // The 422 body above arrives parsed, as a Dictionary.
            if (error.Body is Dictionary<string, object?> fields)
            {
                var message = fields.GetValueOrDefault("message");
                Console.WriteLine($"Function said: {message}");
                // Function said: Quantity must be a whole number of 1 or more.
            }
            else if (error.RawBody is not null)
            {
                Console.WriteLine($"Unparsed body: {error.RawBody}");
            }
            break;

        case CloudCodeErrorCode.NetworkUnavailable:
            Console.WriteLine("Offline, retry later");
            break;

        case CloudCodeErrorCode.TimedOut:
            Console.WriteLine("Took too long");
            break;

        case CloudCodeErrorCode.Decoding:
            Console.WriteLine($"Contract mismatch: {error.Message}");
            break;

        default:
            Console.WriteLine(error.Message);
            break;
    }
}

StatusCode is an int? that is null outside the Http case, so check Code before you read it rather than comparing against a sentinel.

CloudCode.call("validate-quantity", body: ["quantity": 0]) { response, error in
    guard let error = error as? CloudCodeError else { return }

    switch error {
    case .http(let statusCode, let body, let rawBody, let requestId):
        print("HTTP \(statusCode), request \(requestId ?? "none")")

        // The 422 body above arrives parsed, as a JSONValue.
        if case .object(let fields)? = body,
           case .string(let message)? = fields["message"] {
            print("Function said: \(message)")
            // Function said: Quantity must be a whole number of 1 or more.
        } else if let rawBody {
            print("Unparsed body: \(rawBody)")
        }

    case .networkUnavailable:
        print("Offline, retry later")

    case .timedOut:
        print("Took too long")

    case .decoding(let message):
        print("Contract mismatch: \(message)")

    default:
        print(error.localizedDescription)
    }
}
[CloudCode call:@"validate-quantity"
         method:CloudCodeHttpMethodPost
          query:nil
           body:@{ @"quantity": @0 }
        headers:nil
     completion:^(CloudCodeResponse * _Nullable response, NSError * _Nullable error) {
    if (error == nil) { return; }

    if ([error.domain isEqualToString:@"com.appambit.cloud-code"] && error.code == 10) {
        NSNumber *status = error.userInfo[CloudCodeErrorKeys.statusCode];
        NSString *requestId = error.userInfo[CloudCodeErrorKeys.requestId];
        id body = error.userInfo[CloudCodeErrorKeys.body];
        NSString *rawBody = error.userInfo[CloudCodeErrorKeys.rawBody];

        NSLog(@"HTTP %@, request %@", status, requestId ?: @"none");
        if ([body isKindOfClass:NSDictionary.class]) {
            NSLog(@"Function said: %@", body[@"message"]);
            // Function said: Quantity must be a whole number of 1 or more.
        } else if (rawBody != nil) {
            NSLog(@"Unparsed body: %@", rawBody);
        }
        return;
    }

    NSLog(@"Cloud Code failed: %@", error.localizedDescription);
}];

Only the http case populates the extra userInfo keys, so read them behind a code check as above.

CloudCode.call("validate-quantity", Collections.singletonMap("quantity", 0))
    .onError(throwable -> {
        if (!(throwable instanceof CloudCodeError)) return;
        CloudCodeError error = (CloudCodeError) throwable;

        switch (error.getCode()) {
            case HTTP:
                Log.d("CloudCode", "HTTP " + error.getStatusCode()
                        + ", request " + error.getRequestId());

                // The 422 body above arrives parsed, as a Map.
                if (error.getBody() instanceof Map) {
                    Map<?, ?> fields = (Map<?, ?>) error.getBody();
                    Log.d("CloudCode", "Function said: " + fields.get("message"));
                    // Function said: Quantity must be a whole number of 1 or more.
                } else if (error.getRawBody() != null) {
                    Log.d("CloudCode", "Unparsed body: " + error.getRawBody());
                }
                break;

            case NETWORK_UNAVAILABLE:
                Log.d("CloudCode", "Offline, retry later");
                break;

            case TIMED_OUT:
                Log.d("CloudCode", "Took too long");
                break;

            case DECODING:
                Log.d("CloudCode", "Contract mismatch: " + error.getMessage());
                break;

            default:
                Log.d("CloudCode", String.valueOf(error.getMessage()));
        }
    });
CloudCode.call("validate-quantity", mapOf("quantity" to 0))
    .onError { throwable ->
        val error = throwable as? CloudCodeError ?: return@onError

        when (error.code) {
            CloudCodeError.Code.HTTP -> {
                println("HTTP ${error.statusCode}, request ${error.requestId}")

                // The 422 body above arrives parsed, as a Map.
                val fields = error.body as? Map<*, *>
                if (fields != null) {
                    println("Function said: ${fields["message"]}")
                    // Function said: Quantity must be a whole number of 1 or more.
                } else if (error.rawBody != null) {
                    println("Unparsed body: ${error.rawBody}")
                }
            }

            CloudCodeError.Code.NETWORK_UNAVAILABLE -> println("Offline, retry later")
            CloudCodeError.Code.TIMED_OUT -> println("Took too long")
            CloudCodeError.Code.DECODING -> println("Contract mismatch: ${error.message}")
            else -> println(error.message)
        }
    }

getStatusCode() returns -1 outside the HTTP case, so branch on getCode() before you read it.

final request = CloudCode.call('validate-quantity', body: {'quantity': 0});

try {
  await request.future;
} on CloudCodeError catch (error) {
  switch (error.code) {
    case CloudCodeErrorCode.http:
      print('HTTP ${error.statusCode}, request ${error.requestId}');

      // The 422 body above arrives parsed, as a Map.
      final fields = error.body;
      if (fields is Map<String, dynamic>) {
        print('Function said: ${fields['message']}');
        // Function said: Quantity must be a whole number of 1 or more.
      } else if (error.rawBody != null) {
        print('Unparsed body: ${error.rawBody}');
      }

    case CloudCodeErrorCode.networkUnavailable:
      print('Offline, retry later');

    case CloudCodeErrorCode.timedOut:
      print('Took too long');

    case CloudCodeErrorCode.decoding:
      print('Contract mismatch: ${error.message}');

    case CloudCodeErrorCode.cancelled:
      break;  // A newer call replaced this one.

    default:
      print(error.message);
  }
}

statusCode is null outside the http case, so branch on code before you read it. error.toString() already folds the code, status, and request id into one line, which is usually what you want in a log.

try {
  await CloudCode.call('validate-quantity', { body: { quantity: 0 } });
} catch (error) {
  if (!(error instanceof CloudCodeError)) throw error;

  switch (error.code) {
    case CloudCodeErrorCode.Http:
      console.log(`HTTP ${error.statusCode}, request ${error.requestId ?? 'none'}`);

      // The 422 body above arrives parsed, as an object.
      if (error.body && typeof error.body === 'object') {
        const fields = error.body as { message?: string };
        console.log(`Function said: ${fields.message}`);
        // Function said: Quantity must be a whole number of 1 or more.
      } else if (error.rawBody) {
        console.log(`Unparsed body: ${error.rawBody}`);
      }
      break;

    case CloudCodeErrorCode.NetworkUnavailable:
      console.log('Offline, retry later');
      break;

    case CloudCodeErrorCode.TimedOut:
      console.log('Took too long');
      break;

    case CloudCodeErrorCode.Decoding:
      console.log(`Contract mismatch: ${error.message}`);
      break;

    case CloudCodeErrorCode.Cancelled:
      break;  // A newer call replaced this one.

    default:
      console.log(error.message);
  }
}

statusCode is undefined outside the Http case, so branch on code before you read it. error.body is the parsed JSON when the function returned an object or an array, and error.rawBody holds the text for anything else, such as an HTML error page from a proxy.

try
{
    await CloudCode.Call("validate-quantity", body: new { quantity = 0 });
}
catch (CloudCodeError error)
{
    switch (error.Code)
    {
        case CloudCodeErrorCode.Http:
            var requestId = error.RequestId ?? "none";
            Console.WriteLine($"HTTP {error.StatusCode}, request {requestId}");

            // The 422 body above arrives parsed, as a Dictionary.
            if (error.Body is Dictionary<string, object?> fields)
            {
                var message = fields.GetValueOrDefault("message");
                Console.WriteLine($"Function said: {message}");
                // Function said: Quantity must be a whole number of 1 or more.
            }
            else if (error.RawBody is not null)
            {
                Console.WriteLine($"Unparsed body: {error.RawBody}");
            }
            break;

        case CloudCodeErrorCode.NetworkUnavailable:
            Console.WriteLine("Offline, retry later");
            break;

        case CloudCodeErrorCode.TimedOut:
            Console.WriteLine("Took too long");
            break;

        case CloudCodeErrorCode.Decoding:
            Console.WriteLine($"Contract mismatch: {error.Message}");
            break;

        default:
            Console.WriteLine(error.Message);
            break;
    }
}

StatusCode is an int? that is null outside the Http case, so check Code before you read it rather than comparing against a sentinel.

try
{
    await CloudCode.Call("validate-quantity", body: new { quantity = 0 });
}
catch (CloudCodeError error)
{
    switch (error.Code)
    {
        case CloudCodeErrorCode.Http:
            var requestId = error.RequestId ?? "none";
            Console.WriteLine($"HTTP {error.StatusCode}, request {requestId}");

            // The 422 body above arrives parsed, as a Dictionary.
            if (error.Body is Dictionary<string, object?> fields)
            {
                var message = fields.GetValueOrDefault("message");
                Console.WriteLine($"Function said: {message}");
                // Function said: Quantity must be a whole number of 1 or more.
            }
            else if (error.RawBody is not null)
            {
                Console.WriteLine($"Unparsed body: {error.RawBody}");
            }
            break;

        case CloudCodeErrorCode.NetworkUnavailable:
            Console.WriteLine("Offline, retry later");
            break;

        case CloudCodeErrorCode.TimedOut:
            Console.WriteLine("Took too long");
            break;

        case CloudCodeErrorCode.Decoding:
            Console.WriteLine($"Contract mismatch: {error.Message}");
            break;

        default:
            Console.WriteLine(error.Message);
            break;
    }
}

StatusCode is an int? that is null outside the Http case, so check Code before you read it rather than comparing against a sentinel.

Return a code the app can branch on, and a message it can show

error: "invalid_quantity" is for your code, message is for the user. Returning both means the app never has to parse prose to decide what happened, and every platform gets the same wording for free.

Do not put the raw body in front of users

The raw body is populated when the response was not JSON at all, so it can contain gateway HTML or a crash trace from a function that threw. Log it, and show the user something you wrote yourself.


Request ids

Every response carries a request id when the platform supplies one, taken from the X-Request-Id header and falling back to a request_id field in the body. The header wins when both are present.

Log it next to your own errors. When a call misbehaves, the request id is what ties the client failure to the invocation record, its output, and everything the function wrote with ctx.log.

try
{
    var response = await CloudCode.Call("create-order", CloudCodeHttpMethod.Post, body: payload);
    var requestId = response.RequestId ?? "none";
    Console.WriteLine($"Created, request {requestId}");
}
catch (CloudCodeError error)
{
    await Crashes.LogError("Order creation failed", new Dictionary<string, string>
    {
        ["function"] = "create-order",
        ["request_id"] = error.RequestId ?? "none"
    });
}
CloudCode.call("create-order", body: payload) { response, error in
    if let error = error as? CloudCodeError {
        let requestId: String?
        if case .http(_, _, _, let id) = error { requestId = id } else { requestId = nil }

        Crashes.logError(
            message: "Order creation failed",
            properties: [
                "function": "create-order",
                "request_id": requestId ?? "none"
            ],
            exception: nil
        )
        return
    }
    print("Created, request \(response?.requestId ?? "none")")
}
[CloudCode call:@"create-order"
         method:CloudCodeHttpMethodPost
          query:nil
           body:payload
        headers:nil
     completion:^(CloudCodeResponse * _Nullable response, NSError * _Nullable error) {
    if (error != nil) {
        NSString *requestId = error.userInfo[CloudCodeErrorKeys.requestId] ?: @"none";
        NSMutableDictionary *props = [NSMutableDictionary dictionary];
        props[@"function"] = @"create-order";
        props[@"request_id"] = requestId;

        [Crashes logErrorWithMessage:@"Order creation failed"
                          properties:props
                            classFqn:nil
                           exception:nil
                            fileName:nil
                          lineNumber:0
                           createdAt:nil
                          completion:nil];
        return;
    }
    NSLog(@"Created, request %@", response.requestId ?: @"none");
}];
CloudCode.call("create-order", CloudCodeHttpMethod.POST, null, payload, null)
    .then(response -> Log.d("CloudCode",
            "Created, request " + response.getRequestId()))
    .onError(throwable -> {
        String requestId = throwable instanceof CloudCodeError
                ? ((CloudCodeError) throwable).getRequestId()
                : null;

        Map<String, String> props = new LinkedHashMap<>();
        props.put("function", "create-order");
        props.put("request_id", requestId == null ? "none" : requestId);

        Crashes.logError("Order creation failed", props);
    });
CloudCode.call("create-order", CloudCodeHttpMethod.POST, null, payload, null)
    .then { response -> println("Created, request ${response.requestId}") }
    .onError { throwable ->
        val requestId = (throwable as? CloudCodeError)?.requestId ?: "none"

        Crashes.logError(
            "Order creation failed",
            mapOf("function" to "create-order", "request_id" to requestId)
        )
    }
try {
  final response = await CloudCode.call(
    'create-order',
    method: CloudCodeHttpMethod.post,
    body: payload,
  ).future;

  print('Created, request ${response.requestId ?? 'none'}');
} on CloudCodeError catch (error) {
  await AppAmbitSdk.logError(
    message: 'Order creation failed',
    properties: {
      'function': 'create-order',
      'request_id': error.requestId ?? 'none',
    },
  );
}

Note

CloudCodeResponse.requestId is the server's request id, taken from X-Request-Id. It is unrelated to the internal correlation id the Dart side generates to address a cancel across the platform channel, which is never returned to you.

import { CloudCode, CloudCodeError, logError } from 'appambit';

try {
  const response = await CloudCode.call('create-order', {
    method: 'POST',
    body: payload,
  });

  console.log(`Created, request ${response.requestId ?? 'none'}`);
} catch (error) {
  if (!(error instanceof CloudCodeError)) throw error;

  await logError({
    message: 'Order creation failed',
    properties: {
      function: 'create-order',
      request_id: error.requestId ?? 'none',
    },
  });
}

Note

CloudCodeResponse.requestId is the server's request id, taken from X-Request-Id. It is unrelated to the request.requestId the JS side generates to address a cancel across the bridge, which never leaves the device.

try
{
    var response = await CloudCode.Call("create-order", CloudCodeHttpMethod.Post, body: payload);
    var requestId = response.RequestId ?? "none";
    Console.WriteLine($"Created, request {requestId}");
}
catch (CloudCodeError error)
{
    await Crashes.LogError("Order creation failed", new Dictionary<string, string>
    {
        ["function"] = "create-order",
        ["request_id"] = error.RequestId ?? "none"
    });
}
try
{
    var response = await CloudCode.Call("create-order", CloudCodeHttpMethod.Post, body: payload);
    var requestId = response.RequestId ?? "none";
    Console.WriteLine($"Created, request {requestId}");
}
catch (CloudCodeError error)
{
    await Crashes.LogError("Order creation failed", new Dictionary<string, string>
    {
        ["function"] = "create-order",
        ["request_id"] = error.RequestId ?? "none"
    });
}

Best practices

Treat the slug as an API contract. Renaming a slug breaks every shipped build that calls it, on every platform at once. Add a new function instead, migrate, then retire the old slug once the old versions are gone.

Keep secrets in the function. Third party API keys belong in ctx.secrets, never in the app bundle or the APK. That is the main reason to route a call through Cloud Code rather than calling the third party directly.

Send an idempotency key on writes. Mobile networks retry, users tap twice, and the SDK itself replays a request once after a 401. A POST that creates something should carry a key the function can deduplicate on, as create-order does above.

Do not call Cloud Code in a loop. If a screen needs three pieces of data, write one function that returns all three, the way dashboard-summary merges the database and the CMS. Each call is a full authenticated round trip, and the function reaches your data far faster than the device can.

Never block waiting for a result. Every SDK delivers on the UI thread, so blocking that thread deadlocks the call it is waiting for. Use the callback or the await.

Validate on the server. Everything the app sends is user controlled. The function is the trust boundary, so check ownership and permissions there with WHERE consumer_id = ?, even when the UI already did.

Shape the payload for the client. Convert SQLite integers to booleans, format dates as ISO 8601 strings, and drop the columns nobody reads. Every conversion you skip in the function gets written again in each platform's app code.

Annotate your typed models. Every platform needs the JSON name spelled out somewhere, and on some of them an unannotated model fails only in release builds. See Typed models for the convention your SDK uses.

Check for offline before you show an error. A network error deserves a retry affordance, not an error dialog. Cloud Code calls are live requests and are not queued by the offline batching pipeline that analytics and logs use.

Cancel on teardown. Hold the cancellation handle in the view model and cancel it when the view model goes away, especially for calls started as a screen appears. See Cancellation for the handle your SDK gives you.


Complete example

A task list, both halves. Two functions, then the app code that drives them.

list-tasks / index.js
export async function handler(ctx) {
  const res = await ctx.appambit.db(
    "SELECT id, title, completed FROM tasks WHERE consumer_id = ? ORDER BY id DESC",
    [ctx.consumer.id]
  );

  return {
    tasks: res.results[0].rows.map(([id, title, completed]) => ({
      id,
      title,
      completed: completed === 1,
    })),
  };
}
create-task / index.js
export async function handler(ctx) {
  const { title, idempotency_key: key } = ctx.req.body ?? {};

  if (typeof title !== "string" || title.trim() === "") {
    return { status: 400, body: { error: "title_required", message: "Give the task a title." } };
  }

  const existing = await ctx.appambit.db(
    "SELECT id FROM tasks WHERE consumer_id = ? AND idempotency_key = ?",
    [ctx.consumer.id, key ?? ""]
  );

  if (existing.results[0].rows.length > 0) {
    return { body: { id: existing.results[0].rows[0][0], duplicate: true } };
  }

  await ctx.appambit.db(
    "INSERT INTO tasks (consumer_id, title, completed, idempotency_key) VALUES (?, ?, 0, ?)",
    [ctx.consumer.id, title.trim(), key ?? ""]
  );

  ctx.log(`created task for consumer ${ctx.consumer.id}`);
  return { status: 201, body: { duplicate: false } };
}

The app side, with cancellation, typed decoding, and errors mapped to messages a user can read:

using AppAmbitMaui;
using AppAmbit.Enums;
using AppAmbit.Models.CloudCode;
using Newtonsoft.Json;
using System.Collections.ObjectModel;

public sealed class TaskItem
{
    [JsonProperty("id")]        public int Id { get; set; }
    [JsonProperty("title")]     public string? Title { get; set; }
    [JsonProperty("completed")] public bool? Completed { get; set; }
}

public sealed class TaskList
{
    [JsonProperty("tasks")]
    public List<TaskItem>? Tasks { get; set; }
}

public sealed class TaskViewModel : IDisposable
{
    public ObservableCollection<TaskItem> Tasks { get; } = new();
    public string? ErrorMessage { get; private set; }
    public bool IsLoading { get; private set; }

    private CancellationTokenSource? _pendingCall;

    public async Task ReloadAsync()
    {
        _pendingCall?.Cancel();
        _pendingCall?.Dispose();

        var cancellation = new CancellationTokenSource();
        _pendingCall = cancellation;
        IsLoading = true;

        try
        {
            var result = await CloudCode.Call<TaskList>(
                "list-tasks",
                CloudCodeHttpMethod.Get,
                cancellationToken: cancellation.Token);

            ErrorMessage = null;
            Tasks.Clear();
            foreach (var task in result.Data?.Tasks ?? new List<TaskItem>())
                Tasks.Add(task);
        }
        catch (OperationCanceledException)
        {
            // A newer reload replaced this one, or the screen went away.
        }
        catch (CloudCodeError error)
        {
            ErrorMessage = MessageFor(error);
        }
        finally
        {
            IsLoading = false;
        }
    }

    public async Task CreateTaskAsync(string title)
    {
        var trimmed = title?.Trim() ?? string.Empty;
        if (trimmed.Length == 0) return;

        try
        {
            await CloudCode.Call(
                "create-task",
                CloudCodeHttpMethod.Post,
                body: new
                {
                    title = trimmed,
                    idempotency_key = Guid.NewGuid().ToString()
                });

            await ReloadAsync();
        }
        catch (CloudCodeError error)
        {
            ErrorMessage = MessageFor(error);
        }
    }

    private static string MessageFor(CloudCodeError error) => error.Code switch
    {
        CloudCodeErrorCode.NetworkUnavailable =>
            "You are offline. Check your connection and try again.",

        CloudCodeErrorCode.TimedOut =>
            "The request took too long. Please try again.",

        CloudCodeErrorCode.Http => HttpMessage(error),

        _ => "Something went wrong. Please try again."
    };

    private static string HttpMessage(CloudCodeError error)
    {
        // Prefer the message the function wrote for us.
        if (error.Body is Dictionary<string, object?> fields
            && fields.GetValueOrDefault("message") is string message)
        {
            return message;
        }

        var requestId = error.RequestId ?? "none";
        return $"Something went wrong ({error.StatusCode}, ref {requestId}).";
    }

    public void Dispose()
    {
        _pendingCall?.Cancel();
        _pendingCall?.Dispose();
    }
}

The view model is awaited from the UI thread, so the continuation lands there and mutating Tasks updates the bound view directly.

The catch (OperationCanceledException) arm comes first and does nothing on purpose: a reload that a newer reload replaced is not a failure, and treating it as one would flash an error every time the user refreshes twice. Disposing the view model cancels whatever is still in flight.

import AppAmbit
import SwiftUI

struct TaskItem: Decodable, Identifiable {
    let id: Int
    let title: String
    let completed: Bool?
}

struct TaskList: Decodable {
    let tasks: [TaskItem]?
}

@MainActor
final class TaskViewModel: ObservableObject {
    @Published private(set) var tasks: [TaskItem] = []
    @Published private(set) var errorMessage: String?
    @Published private(set) var isLoading = false

    private var pendingCall: CloudCodeCancellationToken?

    func reload() {
        pendingCall?.cancel()
        isLoading = true

        pendingCall = CloudCode.call(
            "list-tasks",
            method: .get,
            as: TaskList.self
        ) { [weak self] result, error in
            // Safe: the SDK guarantees main queue delivery.
            MainActor.assumeIsolated {
                guard let self else { return }
                self.isLoading = false
                self.pendingCall = nil

                if let error {
                    self.errorMessage = self.message(for: error)
                    return
                }
                self.errorMessage = nil
                self.tasks = result?.data?.tasks ?? []
            }
        }
    }

    func createTask(title: String) {
        let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }

        CloudCode.call(
            "create-task",
            method: .post,
            body: [
                "title": trimmed,
                "idempotency_key": UUID().uuidString
            ]
        ) { [weak self] response, error in
            MainActor.assumeIsolated {
                guard let self else { return }

                if let error = error as? CloudCodeError {
                    self.errorMessage = self.message(for: error)
                    return
                }
                self.reload()
            }
        }
    }

    private func message(for error: CloudCodeError) -> String {
        switch error {
        case .networkUnavailable:
            return "You are offline. Check your connection and try again."

        case .timedOut:
            return "The request took too long. Please try again."

        case .http(let statusCode, let body, _, let requestId):
            // Prefer the message the function wrote for us.
            if case .object(let fields)? = body,
               case .string(let message)? = fields["message"] {
                return message
            }
            return "Something went wrong (\(statusCode), ref \(requestId ?? "none"))."

        default:
            return "Something went wrong. Please try again."
        }
    }

    deinit {
        pendingCall?.cancel()
    }
}
@import AppAmbit;

@interface TaskListController ()
@property (nonatomic, strong, nullable) CloudCodeCancellationToken *pendingCall;
@property (nonatomic, copy) NSArray<NSDictionary *> *tasks;
@end

@implementation TaskListController

- (void)reload {
    [self.pendingCall cancel];
    [self.spinner startAnimating];

    __weak typeof(self) weakSelf = self;
    self.pendingCall = [CloudCode call:@"list-tasks"
                                method:CloudCodeHttpMethodGet
                                 query:nil
                                  body:nil
                               headers:nil
                            completion:^(CloudCodeResponse * _Nullable response,
                                         NSError * _Nullable error) {
        __strong typeof(weakSelf) self = weakSelf;
        if (self == nil) { return; }

        [self.spinner stopAnimating];
        self.pendingCall = nil;

        if (error != nil) {
            [self showMessage:[self messageForError:error]];
            return;
        }
        if (![response.data isKindOfClass:NSDictionary.class]) {
            self.tasks = @[];
            [self.tableView reloadData];
            return;
        }

        NSDictionary *payload = (NSDictionary *)response.data;
        self.tasks = payload[@"tasks"] ?: @[];
        [self.tableView reloadData];
    }];
}

- (void)createTaskWithTitle:(NSString *)title {
    NSString *trimmed = [title stringByTrimmingCharactersInSet:
                         NSCharacterSet.whitespaceAndNewlineCharacterSet];
    if (trimmed.length == 0) { return; }

    __weak typeof(self) weakSelf = self;
    [CloudCode call:@"create-task"
             method:CloudCodeHttpMethodPost
              query:nil
               body:@{ @"title": trimmed,
                       @"idempotency_key": NSUUID.UUID.UUIDString }
            headers:nil
         completion:^(CloudCodeResponse * _Nullable response, NSError * _Nullable error) {
        __strong typeof(weakSelf) self = weakSelf;
        if (self == nil) { return; }

        if (error != nil) {
            [self showMessage:[self messageForError:error]];
            return;
        }
        [self reload];
    }];
}

- (NSString *)messageForError:(NSError *)error {
    if (![error.domain isEqualToString:@"com.appambit.cloud-code"]) {
        return @"Something went wrong. Please try again.";
    }

    switch (error.code) {
        case 5: return @"You are offline. Check your connection and try again.";
        case 6: return @"The request took too long. Please try again.";
        case 10: {
            // Prefer the message the function wrote for us.
            id body = error.userInfo[CloudCodeErrorKeys.body];
            if ([body isKindOfClass:NSDictionary.class] && body[@"message"] != nil) {
                return body[@"message"];
            }
            NSNumber *status = error.userInfo[CloudCodeErrorKeys.statusCode];
            NSString *requestId = error.userInfo[CloudCodeErrorKeys.requestId] ?: @"none";
            return [NSString stringWithFormat:@"Something went wrong (%@, ref %@).",
                    status, requestId];
        }
        default: return @"Something went wrong. Please try again.";
    }
}

- (void)dealloc {
    [_pendingCall cancel];
}

@end

MainActor.assumeIsolated states what is already true, that the callback is running on the main queue, so the Swift 6 compiler lets the view model mutate its published properties without an extra hop. It requires iOS 13 or newer; on iOS 12, or in Swift 5 language mode, replace it with DispatchQueue.main.async.

import com.appambit.sdk.CloudCode;
import com.appambit.sdk.enums.CloudCodeHttpMethod;
import com.appambit.sdk.models.cloudcode.CloudCodeError;
import com.appambit.sdk.models.cloudcode.CloudCodeRequest;
import com.appambit.sdk.models.cloudcode.CloudCodeResult;
import com.appambit.sdk.utils.JsonKey;

public class TaskItem {
    @JsonKey("id")        public int id;
    @JsonKey("title")     public String title = "";
    @JsonKey("completed") public boolean completed;
}

public class TaskList {
    @JsonKey("tasks")
    public List<TaskItem> tasks;
}

public class TaskViewModel extends ViewModel {
    private final MutableLiveData<List<TaskItem>> tasks = new MutableLiveData<>();
    private final MutableLiveData<String> errorMessage = new MutableLiveData<>();
    private final MutableLiveData<Boolean> loading = new MutableLiveData<>(false);

    @Nullable private CloudCodeRequest<CloudCodeResult<TaskList>> pendingCall;

    public void reload() {
        if (pendingCall != null) pendingCall.cancel();
        loading.setValue(true);

        pendingCall = CloudCode.call(
                "list-tasks", CloudCodeHttpMethod.GET, null, null, null, TaskList.class)
            .then(result -> {
                loading.setValue(false);
                pendingCall = null;
                errorMessage.setValue(null);

                TaskList list = result.getData();
                tasks.setValue(list == null || list.tasks == null
                        ? Collections.emptyList()
                        : list.tasks);
            })
            .onError(error -> {
                loading.setValue(false);
                pendingCall = null;
                errorMessage.setValue(messageFor(error));
            });
    }

    public void createTask(String title) {
        String trimmed = title == null ? "" : title.trim();
        if (trimmed.isEmpty()) return;

        Map<String, Object> body = new LinkedHashMap<>();
        body.put("title", trimmed);
        body.put("idempotency_key", UUID.randomUUID().toString());

        CloudCode.call("create-task", CloudCodeHttpMethod.POST, null, body, null)
            .then(response -> reload())
            .onError(error -> errorMessage.setValue(messageFor(error)));
    }

    private String messageFor(Throwable throwable) {
        if (!(throwable instanceof CloudCodeError)) {
            return "Something went wrong. Please try again.";
        }
        CloudCodeError error = (CloudCodeError) throwable;

        switch (error.getCode()) {
            case NETWORK_UNAVAILABLE:
                return "You are offline. Check your connection and try again.";
            case TIMED_OUT:
                return "The request took too long. Please try again.";
            case HTTP:
                // Prefer the message the function wrote for us.
                if (error.getBody() instanceof Map) {
                    Object message = ((Map<?, ?>) error.getBody()).get("message");
                    if (message != null) return String.valueOf(message);
                }
                return "Something went wrong (" + error.getStatusCode()
                        + ", ref " + error.getRequestId() + ").";
            default:
                return "Something went wrong. Please try again.";
        }
    }

    @Override
    protected void onCleared() {
        if (pendingCall != null) pendingCall.cancel();
        super.onCleared();
    }
}
import com.appambit.sdk.CloudCode
import com.appambit.sdk.enums.CloudCodeHttpMethod
import com.appambit.sdk.models.cloudcode.CloudCodeError
import com.appambit.sdk.models.cloudcode.CloudCodeRequest
import com.appambit.sdk.models.cloudcode.CloudCodeResult
import com.appambit.sdk.utils.JsonKey

class TaskItem {
    @field:JsonKey("id")        var id: Int = 0
    @field:JsonKey("title")     var title: String = ""
    @field:JsonKey("completed") var completed: Boolean = false
}

class TaskList {
    @field:JsonKey("tasks")
    var tasks: List<TaskItem>? = null
}

class TaskViewModel : ViewModel() {
    private val _tasks = MutableLiveData<List<TaskItem>>(emptyList())
    val tasks: LiveData<List<TaskItem>> = _tasks

    private val _errorMessage = MutableLiveData<String?>(null)
    val errorMessage: LiveData<String?> = _errorMessage

    private val _loading = MutableLiveData(false)
    val loading: LiveData<Boolean> = _loading

    private var pendingCall: CloudCodeRequest<CloudCodeResult<TaskList>>? = null

    fun reload() {
        pendingCall?.cancel()
        _loading.value = true

        pendingCall = CloudCode.call(
            "list-tasks", CloudCodeHttpMethod.GET, null, null, null, TaskList::class.java
        )
            .then { result ->
                _loading.value = false
                pendingCall = null
                _errorMessage.value = null
                _tasks.value = result.data?.tasks ?: emptyList()
            }
            .onError { error ->
                _loading.value = false
                pendingCall = null
                _errorMessage.value = messageFor(error)
            }
    }

    fun createTask(title: String) {
        val trimmed = title.trim()
        if (trimmed.isEmpty()) return

        CloudCode.call(
            "create-task",
            CloudCodeHttpMethod.POST,
            null,
            mapOf(
                "title" to trimmed,
                "idempotency_key" to UUID.randomUUID().toString()
            ),
            null
        )
            .then { reload() }
            .onError { error -> _errorMessage.value = messageFor(error) }
    }

    private fun messageFor(throwable: Throwable): String {
        val error = throwable as? CloudCodeError
            ?: return "Something went wrong. Please try again."

        return when (error.code) {
            CloudCodeError.Code.NETWORK_UNAVAILABLE ->
                "You are offline. Check your connection and try again."

            CloudCodeError.Code.TIMED_OUT ->
                "The request took too long. Please try again."

            CloudCodeError.Code.HTTP -> {
                // Prefer the message the function wrote for us.
                val message = (error.body as? Map<*, *>)?.get("message")
                message?.toString()
                    ?: "Something went wrong (${error.statusCode}, ref ${error.requestId})."
            }

            else -> "Something went wrong. Please try again."
        }
    }

    override fun onCleared() {
        pendingCall?.cancel()
        super.onCleared()
    }
}

The callbacks are posted to the main looper, which is why setValue is safe here rather than postValue. Cancelling in onCleared covers configuration changes and back navigation.

import 'package:appambit_sdk_flutter/appambit_sdk_flutter.dart';
import 'package:flutter/material.dart';

class TaskItem {
  final int? id;
  final String? title;
  final bool completed;

  const TaskItem({this.id, this.title, this.completed = false});

  factory TaskItem.fromJson(Map<String, dynamic> json) => TaskItem(
        id: json['id'] as int?,
        title: json['title'] as String?,
        completed: json['completed'] == true,
      );
}

class TaskList {
  final List<TaskItem> tasks;

  const TaskList({this.tasks = const []});

  factory TaskList.fromJson(Object? value) {
    final map = value is Map<String, dynamic> ? value : const <String, dynamic>{};
    final tasks = map['tasks'];

    return TaskList(
      tasks: tasks is List
          ? tasks.whereType<Map<String, dynamic>>().map(TaskItem.fromJson).toList()
          : const [],
    );
  }
}

class TaskScreen extends StatefulWidget {
  const TaskScreen({super.key});

  @override
  State<TaskScreen> createState() => _TaskScreenState();
}

class _TaskScreenState extends State<TaskScreen> {
  List<TaskItem> _tasks = const [];
  String? _errorMessage;
  bool _isLoading = false;

  CloudCodeRequest<CloudCodeResult<TaskList>>? _pendingCall;

  @override
  void initState() {
    super.initState();
    _reload();
  }

  Future<void> _reload() async {
    await _pendingCall?.cancel();

    final request = CloudCode.callTyped<TaskList>(
      'list-tasks',
      method: CloudCodeHttpMethod.get,
      fromJson: TaskList.fromJson,
    );
    _pendingCall = request;
    setState(() => _isLoading = true);

    try {
      final result = await request.future;
      if (!mounted) return;

      setState(() {
        _errorMessage = null;
        _tasks = result.data?.tasks ?? const [];
      });
    } on CloudCodeError catch (error) {
      // A newer reload replaced this one, or the screen went away.
      if (error.code == CloudCodeErrorCode.cancelled) return;
      if (!mounted) return;

      setState(() => _errorMessage = _messageFor(error));
    } finally {
      if (identical(_pendingCall, request)) _pendingCall = null;
      if (mounted) setState(() => _isLoading = false);
    }
  }

  Future<void> _createTask(String title) async {
    final trimmed = title.trim();
    if (trimmed.isEmpty) return;

    try {
      await CloudCode.call(
        'create-task',
        method: CloudCodeHttpMethod.post,
        body: {'title': trimmed, 'idempotency_key': _newIdempotencyKey()},
      ).future;

      await _reload();
    } on CloudCodeError catch (error) {
      if (!mounted) return;
      setState(() => _errorMessage = _messageFor(error));
    }
  }

  static String _messageFor(CloudCodeError error) {
    switch (error.code) {
      case CloudCodeErrorCode.networkUnavailable:
        return 'You are offline. Check your connection and try again.';
      case CloudCodeErrorCode.timedOut:
        return 'The request took too long. Please try again.';
      case CloudCodeErrorCode.http:
        return _httpMessage(error);
      default:
        return 'Something went wrong. Please try again.';
    }
  }

  static String _httpMessage(CloudCodeError error) {
    // Prefer the message the function wrote for us.
    final fields = error.body;
    if (fields is Map<String, dynamic> && fields['message'] is String) {
      return fields['message'] as String;
    }

    return 'Something went wrong '
        '(${error.statusCode}, ref ${error.requestId ?? 'none'}).';
  }

  @override
  void dispose() {
    _pendingCall?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // ... render _tasks, _isLoading, and _errorMessage
    return const SizedBox.shrink();
  }
}

Everything runs on the Dart main isolate, so the code after each await lands where setState is legal and no marshalling is needed.

The cancelled arm returns before touching state, on purpose: a reload that a newer reload replaced is not a failure, and treating it as one would flash an error every time the user refreshes twice. dispose cancels whatever is still in flight, and the mounted guards cover the window between a response arriving and the widget being torn down.

import { useCallback, useEffect, useRef, useState } from 'react';
import {
  CloudCode,
  CloudCodeError,
  CloudCodeErrorCode,
  type CloudCodeRequest,
  type CloudCodeResult,
} from 'appambit';

type TaskItem = { id?: number; title?: string; completed: boolean };
type TaskList = { tasks: TaskItem[] };

function taskFromJson(value: unknown): TaskItem {
  const map = (typeof value === 'object' && value !== null
    ? value
    : {}) as Record<string, unknown>;

  return {
    id: typeof map.id === 'number' ? map.id : undefined,
    title: typeof map.title === 'string' ? map.title : undefined,
    completed: map.completed === true,
  };
}

function taskListFromJson(value: unknown): TaskList {
  const map = (typeof value === 'object' && value !== null
    ? value
    : {}) as Record<string, unknown>;

  return {
    tasks: Array.isArray(map.tasks) ? map.tasks.map(taskFromJson) : [],
  };
}

function httpMessage(error: CloudCodeError): string {
  // Prefer the message the function wrote for us.
  if (error.body && typeof error.body === 'object') {
    const fields = error.body as { message?: unknown };
    if (typeof fields.message === 'string') return fields.message;
  }

  return `Something went wrong (${error.statusCode}, ref ${error.requestId ?? 'none'}).`;
}

function messageFor(error: CloudCodeError): string {
  switch (error.code) {
    case CloudCodeErrorCode.NetworkUnavailable:
      return 'You are offline. Check your connection and try again.';
    case CloudCodeErrorCode.TimedOut:
      return 'The request took too long. Please try again.';
    case CloudCodeErrorCode.Http:
      return httpMessage(error);
    default:
      return 'Something went wrong. Please try again.';
  }
}

export function useTasks() {
  const [tasks, setTasks] = useState<TaskItem[]>([]);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const pendingCall =
    useRef<CloudCodeRequest<CloudCodeResult<TaskList>> | null>(null);
  const isMounted = useRef(true);

  const reload = useCallback(async () => {
    await pendingCall.current?.cancel();

    const request = CloudCode.callTyped<TaskList>('list-tasks', {
      method: 'GET',
      fromJson: taskListFromJson,
    });
    pendingCall.current = request;
    setIsLoading(true);

    try {
      const result = await request;
      if (!isMounted.current) return;

      setErrorMessage(null);
      setTasks(result.data?.tasks ?? []);
    } catch (error) {
      if (!(error instanceof CloudCodeError)) throw error;
      // A newer reload replaced this one, or the screen went away.
      if (error.code === CloudCodeErrorCode.Cancelled) return;
      if (!isMounted.current) return;

      setErrorMessage(messageFor(error));
    } finally {
      if (pendingCall.current === request) pendingCall.current = null;
      if (isMounted.current) setIsLoading(false);
    }
  }, []);

  const createTask = useCallback(
    async (title: string) => {
      const trimmed = title.trim();
      if (trimmed === '') return;

      try {
        await CloudCode.call('create-task', {
          method: 'POST',
          body: { title: trimmed, idempotency_key: newIdempotencyKey() },
        });

        await reload();
      } catch (error) {
        if (!(error instanceof CloudCodeError)) throw error;
        if (!isMounted.current) return;

        setErrorMessage(messageFor(error));
      }
    },
    [reload]
  );

  useEffect(() => {
    isMounted.current = true;
    reload();

    return () => {
      isMounted.current = false;
      pendingCall.current?.cancel();
    };
  }, [reload]);

  return { tasks, isLoading, errorMessage, reload, createTask };
}

Everything settles on the JS thread, so the code after each await lands where setState is legal and no marshalling is needed.

The Cancelled arm returns before touching state, on purpose: a reload that a newer reload replaced is not a failure, and treating it as one would flash an error every time the user refreshes twice. The useEffect cleanup cancels whatever is still in flight, and the isMounted ref covers the window between a response arriving and the component unmounting.

using AppAmbit;
using AppAmbit.Enums;
using AppAmbit.Models.CloudCode;
using Newtonsoft.Json;
using System.Collections.ObjectModel;

public sealed class TaskItem
{
    [JsonProperty("id")]        public int Id { get; set; }
    [JsonProperty("title")]     public string? Title { get; set; }
    [JsonProperty("completed")] public bool? Completed { get; set; }
}

public sealed class TaskList
{
    [JsonProperty("tasks")]
    public List<TaskItem>? Tasks { get; set; }
}

public sealed class TaskViewModel : IDisposable
{
    public ObservableCollection<TaskItem> Tasks { get; } = new();
    public string? ErrorMessage { get; private set; }
    public bool IsLoading { get; private set; }

    private CancellationTokenSource? _pendingCall;

    public async Task ReloadAsync()
    {
        _pendingCall?.Cancel();
        _pendingCall?.Dispose();

        var cancellation = new CancellationTokenSource();
        _pendingCall = cancellation;
        IsLoading = true;

        try
        {
            var result = await CloudCode.Call<TaskList>(
                "list-tasks",
                CloudCodeHttpMethod.Get,
                cancellationToken: cancellation.Token);

            ErrorMessage = null;
            Tasks.Clear();
            foreach (var task in result.Data?.Tasks ?? new List<TaskItem>())
                Tasks.Add(task);
        }
        catch (OperationCanceledException)
        {
            // A newer reload replaced this one, or the screen went away.
        }
        catch (CloudCodeError error)
        {
            ErrorMessage = MessageFor(error);
        }
        finally
        {
            IsLoading = false;
        }
    }

    public async Task CreateTaskAsync(string title)
    {
        var trimmed = title?.Trim() ?? string.Empty;
        if (trimmed.Length == 0) return;

        try
        {
            await CloudCode.Call(
                "create-task",
                CloudCodeHttpMethod.Post,
                body: new
                {
                    title = trimmed,
                    idempotency_key = Guid.NewGuid().ToString()
                });

            await ReloadAsync();
        }
        catch (CloudCodeError error)
        {
            ErrorMessage = MessageFor(error);
        }
    }

    private static string MessageFor(CloudCodeError error) => error.Code switch
    {
        CloudCodeErrorCode.NetworkUnavailable =>
            "You are offline. Check your connection and try again.",

        CloudCodeErrorCode.TimedOut =>
            "The request took too long. Please try again.",

        CloudCodeErrorCode.Http => HttpMessage(error),

        _ => "Something went wrong. Please try again."
    };

    private static string HttpMessage(CloudCodeError error)
    {
        // Prefer the message the function wrote for us.
        if (error.Body is Dictionary<string, object?> fields
            && fields.GetValueOrDefault("message") is string message)
        {
            return message;
        }

        var requestId = error.RequestId ?? "none";
        return $"Something went wrong ({error.StatusCode}, ref {requestId}).";
    }

    public void Dispose()
    {
        _pendingCall?.Cancel();
        _pendingCall?.Dispose();
    }
}

The view model is awaited from the UI thread, so the continuation lands there and mutating Tasks updates the bound view directly.

The catch (OperationCanceledException) arm comes first and does nothing on purpose: a reload that a newer reload replaced is not a failure, and treating it as one would flash an error every time the user refreshes twice. Disposing the view model cancels whatever is still in flight.

using AppAmbitAvalonia;
using AppAmbit.Enums;
using AppAmbit.Models.CloudCode;
using Newtonsoft.Json;
using System.Collections.ObjectModel;

public sealed class TaskItem
{
    [JsonProperty("id")]        public int Id { get; set; }
    [JsonProperty("title")]     public string? Title { get; set; }
    [JsonProperty("completed")] public bool? Completed { get; set; }
}

public sealed class TaskList
{
    [JsonProperty("tasks")]
    public List<TaskItem>? Tasks { get; set; }
}

public sealed class TaskViewModel : IDisposable
{
    public ObservableCollection<TaskItem> Tasks { get; } = new();
    public string? ErrorMessage { get; private set; }
    public bool IsLoading { get; private set; }

    private CancellationTokenSource? _pendingCall;

    public async Task ReloadAsync()
    {
        _pendingCall?.Cancel();
        _pendingCall?.Dispose();

        var cancellation = new CancellationTokenSource();
        _pendingCall = cancellation;
        IsLoading = true;

        try
        {
            var result = await CloudCode.Call<TaskList>(
                "list-tasks",
                CloudCodeHttpMethod.Get,
                cancellationToken: cancellation.Token);

            ErrorMessage = null;
            Tasks.Clear();
            foreach (var task in result.Data?.Tasks ?? new List<TaskItem>())
                Tasks.Add(task);
        }
        catch (OperationCanceledException)
        {
            // A newer reload replaced this one, or the screen went away.
        }
        catch (CloudCodeError error)
        {
            ErrorMessage = MessageFor(error);
        }
        finally
        {
            IsLoading = false;
        }
    }

    public async Task CreateTaskAsync(string title)
    {
        var trimmed = title?.Trim() ?? string.Empty;
        if (trimmed.Length == 0) return;

        try
        {
            await CloudCode.Call(
                "create-task",
                CloudCodeHttpMethod.Post,
                body: new
                {
                    title = trimmed,
                    idempotency_key = Guid.NewGuid().ToString()
                });

            await ReloadAsync();
        }
        catch (CloudCodeError error)
        {
            ErrorMessage = MessageFor(error);
        }
    }

    private static string MessageFor(CloudCodeError error) => error.Code switch
    {
        CloudCodeErrorCode.NetworkUnavailable =>
            "You are offline. Check your connection and try again.",

        CloudCodeErrorCode.TimedOut =>
            "The request took too long. Please try again.",

        CloudCodeErrorCode.Http => HttpMessage(error),

        _ => "Something went wrong. Please try again."
    };

    private static string HttpMessage(CloudCodeError error)
    {
        // Prefer the message the function wrote for us.
        if (error.Body is Dictionary<string, object?> fields
            && fields.GetValueOrDefault("message") is string message)
        {
            return message;
        }

        var requestId = error.RequestId ?? "none";
        return $"Something went wrong ({error.StatusCode}, ref {requestId}).";
    }

    public void Dispose()
    {
        _pendingCall?.Cancel();
        _pendingCall?.Dispose();
    }
}

The view model is awaited from the UI thread, so the continuation lands there and mutating Tasks updates the bound view directly.

The catch (OperationCanceledException) arm comes first and does nothing on purpose: a reload that a newer reload replaced is not a failure, and treating it as one would flash an error every time the user refreshes twice. Disposing the view model cancels whatever is still in flight.

Notice that every version surfaces the message the function returned with its 400, and falls back to a generic line only when there is nothing usable. That is the payoff of shaping errors in the function: one piece of backend copy serves every client.


Troubleshooting

The callback never runs, or the task never finishes

  1. The task was cancelled. A cancelled call throws OperationCanceledException, so look for a catch that swallows it, and check that you are not cancelling the CancellationTokenSource of the call you just started.
  2. The method is async void. Its exceptions never reach the caller and, depending on the host, are lost entirely. Make every method that awaits Cloud Code an async Task.
  3. You are blocking the thread the continuation needs. .Result or .Wait() on the UI thread deadlocks against the very continuation it is waiting for. await all the way up instead.
  1. The request was cancelled. Cancellation is silent by design and delivers no error. Check that the screen holding the token was not torn down, and that you are not cancelling the request you just created.
  2. The owner was released. A [weak self] closure that returns early on self == nil looks identical to a missing callback. Put a log line before the guard.
  3. You are blocking the main queue. Completions are dispatched there, so blocking it while waiting deadlocks the call.
  1. The request was cancelled. Cancellation is silent by design and delivers no error. Check that the view model holding the request was not cleared, and that you are not cancelling the request you just created.
  2. The owner was released. A view model cleared mid flight drops the callback. Put a log line before the guard.
  3. You are blocking the main looper. Callbacks are posted there, so blocking it while waiting deadlocks the call. getBlocking() throws rather than letting you do this by accident.
  1. The request was cancelled. Unlike the native SDKs this is not silent — the future completes with a cancelled CloudCodeError. If your catch returns early on that code, a genuine hang and a cancellation look identical. Log before the guard.
  2. You awaited the request instead of its future. CloudCode.call returns a CloudCodeRequest, not a Future. Await request.future.
  3. The widget was disposed. An if (!mounted) return; guard placed before your only log line hides the fact that the response did arrive.
  1. The request was cancelled. Unlike the native SDKs this is not silent: the promise rejects with a Cancelled CloudCodeError. If your catch returns early on that code, a genuine hang and a routine cancellation look identical. Log before the guard.
  2. The rejection has nowhere to land. A CloudCode.call(...) that is never awaited and carries no .catch surfaces only as an unhandled promise rejection, which is a dev-only warning. Await it inside a try, or attach a catch.
  3. The component unmounted. An if (!isMounted.current) return; guard placed before your only log line hides the fact that the response did arrive.
  1. The task was cancelled. A cancelled call throws OperationCanceledException, so look for a catch that swallows it, and check that you are not cancelling the CancellationTokenSource of the call you just started.
  2. The method is async void. Its exceptions never reach the caller and, depending on the host, are lost entirely. Make every method that awaits Cloud Code an async Task.
  3. You are blocking the thread the continuation needs. .Result or .Wait() on the UI thread deadlocks against the very continuation it is waiting for. await all the way up instead.
  1. The task was cancelled. A cancelled call throws OperationCanceledException, so look for a catch that swallows it, and check that you are not cancelling the CancellationTokenSource of the call you just started.
  2. The method is async void. Its exceptions never reach the caller and, depending on the host, are lost entirely. Make every method that awaits Cloud Code an async Task.
  3. You are blocking the thread the continuation needs. .Result or .Wait() on the UI thread deadlocks against the very continuation it is waiting for. await all the way up instead.

notInitialized, NOT_INITIALIZED, or NotInitialized

Cloud Code ran before the SDK was started. Move the start call to the earliest point of app launch, and make sure nothing calls Cloud Code from an initializer that runs before it.

Start the SDK in MauiProgram.CreateMauiApp or OnFrameworkInitializationCompleted. This is easy to hit from a view model constructed during startup, so kick the call off from the page's Appearing or Loaded event rather than from the constructor.

Call AppAmbit.start in application(_:didFinishLaunchingWithOptions:), before any view controller can reach Cloud Code.

Call AppAmbit.start in Application.onCreate or the launcher Activity.onCreate, before any fragment or view model can reach Cloud Code.

Call AppAmbitSdk.start in main(), awaited, before runApp(). This is easy to hit from a widget that calls Cloud Code in initState when start() was fired without await, since the first frame builds before the native SDK has come up.

Call start at the app entry point — module scope in index.js, or the top of your root component's module — before any screen can reach Cloud Code. Starting the SDK from a useEffect is too late for a component that calls Cloud Code during its own first render.

Start the SDK in MauiProgram.CreateMauiApp or OnFrameworkInitializationCompleted. This is easy to hit from a view model constructed during startup, so kick the call off from the page's Appearing or Loaded event rather than from the constructor.

Start the SDK in MauiProgram.CreateMauiApp or OnFrameworkInitializationCompleted. This is easy to hit from a view model constructed during startup, so kick the call off from the page's Appearing or Loaded event rather than from the constructor.

invalidFunction, INVALID_FUNCTION, or InvalidFunction

You passed a path or a URL instead of a slug. Pass "list-tasks", not "/api/fn/list-tasks" and not the full URL. Also check for a trailing space from a copy paste, since whitespace is rejected.

invalidBody, INVALID_BODY, or InvalidBody

The body serialized fine, but not to an object. A List<T>, an array, or a bare string is rejected. Wrap it, so new { items = list } rather than list, and read ctx.req.body.items in the function.

Something in the dictionary is not JSON serializable. The usual suspects are Date, URL, and model objects. Convert dates to ISO 8601 strings and objects to dictionaries before passing them.

Something in the map is not JSON serializable. The usual suspects are Date, custom classes, and non string map keys. Convert dates to ISO 8601 strings and objects to maps before passing them.

Something in the map is not JSON serializable. The usual suspects are DateTime, Uri, enums, and model objects. Convert dates to ISO 8601 strings and objects to maps before passing them. Empty map keys are rejected too. The check runs synchronously inside call, so the error arrives before any network work happens.

Either the body is not a JSON object — an array or a bare value is rejected, so wrap it as { items: list } and read ctx.req.body.items in the function — or something inside it is not JSON serializable. The usual suspects are Date, Map, Set, class instances, NaN, and a circular reference. Convert dates to ISO 8601 strings and objects to plain ones first. The check runs synchronously inside call, so the error arrives before any network work happens.

The body serialized fine, but not to an object. A List<T>, an array, or a bare string is rejected. Wrap it, so new { items = list } rather than list, and read ctx.req.body.items in the function.

The body serialized fine, but not to an object. A List<T>, an array, or a bare string is rejected. Wrap it, so new { items = list } rather than list, and read ctx.req.body.items in the function.

invalidHeader, INVALID_HEADER, or InvalidHeader

You set a reserved header. Content-Type and Authorization are the two that come up most often, and both are managed by the SDK. Remove them. If you need to identify the caller, read ctx.consumer.id in the function instead of sending your own identity header.

INVALID_METHOD or INVALID_RESPONSE_TYPE on Android

You passed null where the API needs a value. The full call overload requires a CloudCodeHttpMethod, and the typed overload requires a Class<T> that is not Void.class. Use the one or two argument shorthands if you just want a POST.

InvalidMethod or InvalidResponseType on .NET

Both are almost unreachable from ordinary code, because the method is an enum with a default and the response type is a generic argument. Seeing one means a cast produced an undefined CloudCodeHttpMethod, or Call<T> was instantiated with void.

No response fields matched on Android

The typed model decoded nothing. Either the JSON keys genuinely do not match your field names, or R8 renamed the fields in a release build. Annotate every field with @JsonKey("json_name"), or add a keep rule for the model package. See Typed models.

The typed model comes back with default values on Android

Same root cause as above, caught earlier. If only some fields are empty, only those field names disagree with the JSON. Log the dynamic response once to see the real key names, then annotate accordingly.

The typed model comes back with null properties on .NET

Newtonsoft found no JSON key matching the property, so it left it alone. Matching is case insensitive but not convention aware, so TaskCount never binds to task_count. Add [JsonProperty("task_count")], and check RawBody on the result to see exactly what arrived. This produces no error at all: a model whose names all disagree decodes silently into an empty instance.

An InvalidCastException or a failed pattern match on a number in .NET

Every whole number on the dynamic path is a long, because that is what Newtonsoft produces when it has no target type. (int)data["count"] throws and data["count"] is int quietly fails. Match on long and convert, or use the typed API where an int property parses correctly.

404 from a function you can see in the dashboard

The function exists, but the route does not. Check, in order:

  • The version is active, not just deployed and built.
  • The trigger is an HTTP trigger and it is enabled.
  • The slug in the trigger matches the string in your code exactly, including case and hyphens.
  • The app key passed to AppAmbit.start belongs to the same app as the function. A staging key cannot reach a production function.

401 after the SDK retried

The SDK already refreshed the token and retried once, so a 401 that reaches your code is not a stale token. Verify that the app key is correct for the environment, and that the device actually completed consumer registration, which it cannot do on the very first launch while offline.

500 with no useful body

Your function threw. The SDK cannot tell you why, but the invocation can: take the request id from the error and open that invocation to read its logs and the exception. Wrapping data calls in try / catch and returning { status: 400, body: { error: err.code } } turns those opaque 500s into errors your app can act on.

503 or 504

503 means the function has no active version, so a deploy was never activated or a rollback left it empty. 504 means the function ran past its own timeout, which is a function configuration matter, not an SDK one. Neither is a client bug.

A decoding error on a call that used to work

Either the function now returns something that is not JSON, such as an HTML error page from an upstream proxy, or the payload shape changed. A very common version of this: the function started returning a SQLite 0 or 1 where the model expects a boolean. Switch that call to the dynamic API temporarily and log the response data to see what actually arrived.

A timeout, but the function is fast

The 60 second client timeout covers the whole exchange, including a token refresh. Hitting it with a fast function points at the network, not at the code: a captive portal, a VPN, or a proxy that accepts the connection and never answers. Test on a different network before changing the function.

The function returns data but the response data is empty

The function returned 204, an empty body, or a literal null, and all three decode to the same empty value. Check the status code, and make sure every code path in the handler has a return, since a handler that falls off the end returns undefined and serializes to nothing.

The rows come back as arrays instead of objects

That is the database result shape, not an SDK behavior. ctx.appambit.db returns results[0].rows as positional arrays matching results[0].columns. Map them into objects in the function, as every example here does, rather than shipping index math to every client codebase.

Query values arrive mangled in the function

They do not need manual encoding. Every SDK percent encodes query values, so encoding them yourself double encodes them and a b becomes a%2520b. Pass the raw string.

The body is missing on a GET

That is intentional on every platform: GET requests never carry a body, and the SDK drops it without complaining. A call that works as a POST and returns nothing useful as a GET is usually this. Move those inputs into the query and read them from ctx.req.query, or change the method back to POST.