Skip to content
This page View Markdown Open in ChatGPT Open in Claude

Custom APIs

A custom API is a TypeScript function behind a path and a method. Save it in the admin panel (API Info → Custom API) or in your editor through the local client, and it answers at /api/custom-api/<user-path>/<path> right away, in a sandbox, with the whole of API Maker available through the global object g.

URL /api/custom-api/<user-path>/<path of the settings>
Method the requestMethod of the settings : GET, POST, PUT or DELETE
Body, query, params, headers, files g.req.body, g.req.query, g.req.params, g.req.headers, g.req.body.files
Answer what main returns, in data of the envelope, or a file, or any content type
Runs in the sandbox of the account ; the native process with runOnNativeProcess: true
Access apiAccessType : TOKEN_ACCESS (default), IS_PUBLIC, NO_ACCESS (only from other code)
From code g.sys.system.callExternalApi over HTTP, or g.sys.test.runCustomApi in a test

Hello world

Every custom API has two files : the basic info (its settings) and the code.

Basic info
import * as T from 'types';

let customApi: T.ICustomApiSettingsTypes = {
    name: 'Hello World',
    path: '/hello-world',
    requestMethod: T.ERequestMethod.GET,
    apiAccessType: T.EAPIAccessType.TOKEN_ACCESS,
    errorList: [],
};
module.exports = customApi;
Code
1
2
3
4
5
6
7
import * as T from 'types';
import * as db from 'db-interfaces';

async function main(g: T.IAMGlobal) {
    return { hello: g.req.query.name || 'world' };
}
module.exports = main;
curl "$AM/api/custom-api/admin/hello-world?name=Bob" -H "x-am-authorization: $TOKEN"
{ "success": true, "statusCode": 200, "data": { "hello": "Bob" } }
  • The path and the method must match exactly : /hello-world with GET. Variables go in the query string or the body, not in the path.
  • path + requestMethod is unique in the account ; name is unique too and names the folder in Git.

The settings

Key Meaning
name, path, requestMethod Identity and URL.
apiAccessType TOKEN_ACCESS : the API user token, plus the person token when authProviders names one. IS_PUBLIC : no token. NO_ACCESS : not reachable over HTTP, only from other code and the testing page.
authProviders Names of the auth providers whose token the person must send. Absent : the providers of the default secret. [] : the API user token only.
enableCaching Cache the answer in Redis, per URL, body and headers that matter, until resetCacheOnModificationOf says otherwise or the reset custom API cache system API runs.
resetCacheOnModificationOf What resets the cache : 'DB:instance:database:table' when that table is written through API Maker, 'CA:name' when that custom API is called, 'TP:bundle:version' when a third party API of that version writes.
acceptOnlyEncryptedData Refuse plain bodies : the body must be an encrypted payload.
reqBodySchema, reqQueryParametersSchema A schema for the body and for the query params : converted and validated before the code runs, every error at once with 400.
errorList The messages your API throws, listed so they can be translated with i18n and shown in the docs of the API.
runOnNativeProcess Run in the API Maker process instead of the sandbox : native modules and the packages of API Maker, no isolation. Use with care.
customApiTimeoutInSeconds How long the code may run, in seconds (10 by default). Sent as x-am-sandbox-timeout.
separateSandboxSettings A sandbox of its own for this API, with its own packages and memory.
fileUpload enable, allowFileUploadFields, and per field minFileSizeBytes, maxFileSizeBytes, allowedExtensionsArr.
swaggerDocs What the Swagger document of the API users shows for this API.

See Custom API settings for every key with its example.

Validate the request with a schema

Basic info
let customApi: T.ICustomApiSettingsTypes = {
    name: 'Create Order', path: '/orders', requestMethod: T.ERequestMethod.POST, errorList: [],
    reqBodySchema: {
        customer_id: { __type: T.EType.number, validations: { required: true } },
        items: [ { product_id: { __type: T.EType.string, validations: { required: true } }, qty: { __type: T.EType.number, validations: { min: 1 } } } ],
    },
    reqQueryParametersSchema: {
        dryRun: { __type: T.EType.boolean, conversions: { defaults: { defaultValue: false } } },
    },
};
  • The body and the query params reach your code converted (types, trims, defaults) and checked (required, min, enum…). A wrong request never reaches the code.
  • The is valid data for custom API system API runs the same check without calling the API.

Reach your data and the rest of API Maker

import * as T from 'types';
import * as db from 'db-interfaces';
import * as Pricing from 'utils/Pricing';

async function main(g: T.IAMGlobal) {
    const person = g.req.auth.authAMDB;                          // the signed-in person
    const orders = await g.sys.db.query<db.mongodb.shop.IOrders>({
        instance: 'mongodb', database: 'shop', collection: 'orders',
        find: { customer_id: person.customer_id, status: 'PAID' },
        sort: '-created_at', limit: 10,
        deep: [ { s_key: 'items' } ],
    });
    const total = Pricing.sum(orders);                           // a utility class of yours
    g.logger.log(`${orders.length} orders`);                     // reaches the log table and the caller's logs
    await g.sys.cache.setKey(`orders:${person.customer_id}`, JSON.stringify(orders), 60);
    return { orders, total };
}
module.exports = main;
  • await every g.sys call. The second argument true returns the whole envelope instead of throwing.
  • import * as db from 'db-interfaces' gives the interfaces generated from your schemas, db.<instance>.<database>.I<Table>.
  • Every method of g has a page in the code examples.

Errors and status codes

1
2
3
4
5
6
async function main(g: T.IAMGlobal) {
    if (!g.req.body?.customer_id) throw new Error('Please provide customer_id.');   // 500 with the message

    g.res.statusCode = T.EStatusCode.BAD_REQUEST;                                  // 400 with your data
    return { field: 'customer_id', message: 'Please provide customer_id.' };
}
  • A thrown error answers success: false with the message in errors ; a message listed in errorList is translated by i18n.
  • g.res.statusCode sets the status of a normal answer ; g.res.warnings adds warnings.

Files

Upload. Send multipart/form-data with the files in the field files (or files1, files2… up to files41 to keep groups apart). They are on disk when the code runs, in g.req.body.files :

1
2
3
4
async function main(g: T.IAMGlobal) {
    const files: { originalname: string; mimetype: string; size: number; path: string; filename: string }[] = g.req.body.files || [];
    return files.map(f => ({ name: f.originalname, bytes: f.size }));
}
  • fileUpload.validations.files.maxFileSizeBytes and allowedExtensionsArr in the settings refuse a wrong file before the code runs. Uploaded files are cleaned from the uploads folder after uploadedFileRemoveOlderThanThisTimeInSeconds (30 minutes by default).

Download. Return an object with the __am__ keys of IDownloadResponse : the file (or a zip of several) is streamed to the caller.

1
2
3
4
5
6
7
8
async function main(g: T.IAMGlobal) {
    // a file written earlier in the uploads folder of the sandbox
    return <T.IDownloadResponse>{
        __am__downloadFilePath: 'reports/2026-09.pdf',
        __am__downloadFolderFileName: 'September report.pdf',
        __am__cleanupFileOrFolderPaths: 'reports/2026-09.pdf',
    };
}
  • __am__downloadFileOrFolderPaths with several paths, or { fsSource, archiveDestination } objects, answers a zip.

Any content type

Set g.res.contentType and return the content : HTML, text, XML, YAML, or bytes as base64 for any other MIME type.

1
2
3
4
async function main(g: T.IAMGlobal) {
    g.res.contentType = T.EContentType.HTML;
    return '<h1>Hello</h1>';
}
A tiny gif
1
2
3
4
async function main(g: T.IAMGlobal) {
    g.res.contentType = <any>'image/gif';   // not in EContentType : the return is base64
    return 'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOwA=';
}

Around the code

  • Hooks : pre and post hooks on the custom API itself, see Pre hooks.
  • Events and WebSockets : an event can trigger automatically after the API, and WebSocket clients subscribed to the API get its answer.
  • Versions : several versions of the code, one active ; switch back in one click.
  • Docs tab : notes for the people who use the API, shown in the API testing page and the Swagger of the API users.
  • Test cases : g.sys.test.runCustomApi runs the code with mocks and measures its coverage.
  • Git : src/Custom APIs/<name>/ holds the basic info, the code and the docs.
  • Groups : an API user calls a custom API only when a group grants it, see API group permission.

Native process

runOnNativeProcess: true runs the function inside API Maker itself : no sandbox start, access to the packages of API Maker and to native modules such as a browser for Playwright. A bug there can hurt the whole server, so keep it for the few APIs which need it.

Samples in every account

A new account comes with custom APIs under /default/… which show the features at work : deep populate, find and join, caching, a WebSocket emitter, S3 upload and download, a login which returns both tokens, a captcha, data isolation with hooks. Open them on the Custom API page and read the code.