A custom API is your own function behind a path and a method. Write it in any of the languages of API Maker. Save it in the admin panel (APIs & Logic → Custom API), or in your editor through the local client. It answers at /api/custom-api/<user-path>/<path> at once. Your code reaches your databases and the rest of API Maker through the global object g.
In short
Two files : the basic info (path, method, access, cache…) in TypeScript, and the code in your language.
main gets g. What it returns is data in the answer.
The code runs in a sandbox of your account, or on the server with runOnNativeProcess: true.
New to your own code ?
Start with Your own code : one custom API, step by step, on the real screens.
URL
/api/custom-api/<user-path>/<path of the settings>
Method
the requestMethod of the settings : GET, POST, PUT or DELETE
Every custom API has two files : the basic info (its settings) and the code. The code is written in any language ; the basic info is always TypeScript.
Pick the language above the editor, and the editor offers its template. The list of the custom APIs shows the language of each with its logo. The hooks of a custom API are written in the language of its code.
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.
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.
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 on the machine of the server instead of in the sandbox : the packages of the Sandbox Settings, the packages of the folder API Maker runs in and the programs of the machine, no isolation. Use with care.
customApiTimeoutInSeconds
How long the code and the hooks of the API may run, in seconds, wherever they run (the sandbox, runOnNativeProcess). Empty : sandboxReqTimeout of the configuration (13 seconds). A request which sends x-am-sandbox-timeout is given that time instead.
separateSandboxSettings
A sandbox of its own for this API, with its own runtime, version, packages and count : separate sandbox.
fileUpload
enable, allowFileUploadFields, and per field minFileSizeBytes, maxFileSizeBytes, allowedExtensionsArr.
swaggerDocs
What the Swagger document of the API users shows for this API.
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.
import*asTfrom'types';import*asdbfrom'db-interfaces';asyncfunctionmain(g:T.IAMGlobal){constperson=g.req.auth.authAMDB;// the signed-in personconstorders=awaitg.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'}],});consttotal=orders.reduce((sum,order)=>sum+order.amount,0);g.logger.log(`${orders.length} orders`);// reaches the log table and the caller's logsawaitg.sys.cache.setKey(`orders:${person.customer_id}`,JSON.stringify(orders),60);return{orders,total};}module.exports=main;
importjsonfromamimportDeepItem,Gasyncdefmain(g:G):person=g.req.auth.authAMDB# the signed-in personorders=awaitg.sys.db.query('mongodb','shop','orders',find={'customer_id':person.customer_id,'status':'PAID'},sort='-created_at',limit=10,deep=[DeepItem('items')],)total=sum(order.amountor0fororderinorders)g.logger.log(f'{len(orders)} orders')# reaches the log table and the caller's logsawaitg.sys.cache.set_key(f'orders:{person.customer_id}',json.dumps(orders),60)return{'orders':orders,'total':total}
<?phpfunctionmain(AmG$g){$person=$g->req->auth->authAMDB;// the signed-in person$orders=$g->sys->db->query('mongodb','shop','orders',find:['customer_id'=>$person->customer_id,'status'=>'PAID'],sort:'-created_at',limit:10,deep:[newDeepItem('items')],);$total=array_sum(array_map(fn($order)=>$order->amount??0,$orders));$g->logger->log(count($orders).' orders');// reaches the log table and the caller's logs$g->sys->cache->setKey("orders:{$person->customer_id}",json_encode($orders),60);return['orders'=>$orders,'total'=>$total];}
filestaticclassApi{publicstaticasyncTask<object?>Main(Gg){varperson=g.Req.Auth.GetObject("authAMDB");// the signed-in personvarorders=awaitg.Sys.Db.Query(newQuery("mongodb","shop","orders"){Find=newJsonObject{["customer_id"]=person["customer_id"]?.DeepClone(),["status"]="PAID"},Sort="-created_at",Limit=10,Deep=new[]{newDeepItem("items")},});vartotal=orders.Sum(order=>order.GetDouble("amount"));g.Logger.Log($"{orders.Count} orders");// reaches the log table and the caller's logsawaitg.Sys.Cache.SetKey($"orders:{person["customer_id"]}",JsonSerializer.Serialize(orders),60);returnnew{orders,total};}}
packagecodeimport("encoding/json""fmt""apimaker/am")funcMain(g*am.G)(any,error){person:=g.Req.Auth().Map("authAMDB")// the signed-in personorders,err:=g.Sys.Db.Query(am.Query{Instance:"mongodb",Database:"shop",Collection:"orders",Find:am.M{"customer_id":person.Get("customer_id"),"status":"PAID"},Sort:"-created_at",Limit:10,Deep:[]am.DeepItem{am.DeepItem{SKey:"items"}},})iferr!=nil{returnnil,err}total:=0.0for_,order:=rangeorders{total+=order.Float("amount")}g.Logger.Log(fmt.Sprintf("%d orders",len(orders)))// reaches the log table and the caller's logskept,err:=json.Marshal(orders)iferr!=nil{returnnil,err}if_,err:=g.Sys.Cache.SetKey(fmt.Sprintf("orders:%v",person.Get("customer_id")),string(kept),60);err!=nil{returnnil,err}returnam.M{"orders":orders,"total":total},nil}
useam::{DeepItem,Errors,G,Query,Value,json};pubfnmain(g:&mutG)->Result<Value,Errors>{letperson=g.req.auth()["authAMDB"].clone();// the signed-in personletorders=g.sys.db.query(Query::new("mongodb","shop","orders").find(json!({"customer_id":person["customer_id"],"status":"PAID"})).sort("-created_at").limit(10).deep(vec![DeepItem::new("items")]),)?;lettotal:f64=orders.iter().map(|order|order["amount"].as_f64().unwrap_or(0.0)).sum();g.logger.log(format!("{} orders",orders.len()));// reaches the log table and the caller's logsg.sys.cache.set_key(&format!("orders:{}",person["customer_id"]),&json!(orders).to_string(),Some(60))?;Ok(json!({"orders":orders,"total":total}))}
importam.DeepItem;importam.G;importam.Json;importam.JsonObject;importam.Query;importjava.util.List;importjava.util.Map;classApi{staticObjectmain(Gg){JsonObjectperson=g.req.getAuth().getObject("authAMDB");// the signed-in personList<JsonObject>orders=g.sys.db.query(newQuery("mongodb","shop","orders").find(Map.of("customer_id",person.get("customer_id"),"status","PAID")).sort("-created_at").limit(10).deep(List.of(newDeepItem("items"))));doubletotal=orders.stream().mapToDouble(order->order.getDouble("amount")).sum();g.logger.log(orders.size()+" orders");// reaches the log table and the caller's logsg.sys.cache.setKey("orders:"+person.get("customer_id"),Json.stringify(orders),60);returnMap.of("orders",orders,"total",total);}}
importam.DeepItemimportam.Gimportam.Jsonimportam.Queryfunmain(g:G):Any?{valperson=g.req.auth.getObject("authAMDB")// the signed-in personvalorders=g.sys.db.query(Query("mongodb","shop","orders").find(mapOf("customer_id"toperson["customer_id"],"status"to"PAID")).sort("-created_at").limit(10).deep(listOf(DeepItem("items"))),)valtotal=orders.sumOf{it.getDouble("amount")}g.logger.log("${orders.size} orders")// reaches the log table and the caller's logsg.sys.cache.setKey("orders:${person["customer_id"]}",Json.stringify(orders),60)returnmapOf("orders"toorders,"total"tototal)}
In TypeScript await every g.sys call (a coroutine of Python and a task of C# too) ; the other languages wait for the answer of each call. The whole envelope instead of an error comes with true as the second argument in TypeScript, and through full in the other languages (g.sys.db.full.query).
In TypeScript, import * as db from 'db-interfaces' gives the interfaces generated from your schemas, db.<instance>.<database>.I<Table> ; the editors of the other languages offer the same columns on the rows a call answers.
Code shared by many APIs (a price, a rule) goes into a utility class.
import*asTfrom'types';asyncfunctionmain(g:T.IAMGlobal){if(!g.req.body?.customer_id){throw[{code:T.EStatusCode.BAD_REQUEST,message:'Please provide customer_id.'}];// 400 with the message in errors}if(g.req.body.customer_id<0){g.res.statusCode=T.EStatusCode.BAD_REQUEST;// 400 with your datareturn{field:'customer_id',message:'A customer_id is positive.'};}return{ok:true};}module.exports=main;
fromamimportErrors,Gasyncdefmain(g:G):ifnotg.req.body.customer_id:raiseErrors('Please provide customer_id.',code=400)# 400 with the message in errorsifg.req.body.customer_id<0:g.res.status_code=400# 400 with your datareturn{'field':'customer_id','message':'A customer_id is positive.'}return{'ok':True}
<?phpfunctionmain(AmG$g){if(empty($g->req->body->customer_id)){thrownewAmErrors('Please provide customer_id.',400);// 400 with the message in errors}if($g->req->body->customer_id<0){$g->res->statusCode=400;// 400 with your datareturn['field'=>'customer_id','message'=>'A customer_id is positive.'];}return['ok'=>true];}
filestaticclassApi{publicstaticasyncTask<object?>Main(Gg){if(g.Req.Body["customer_id"]isnull)thrownewAmErrors("Please provide customer_id.",400);// 400 with the message in errorsif(g.Req.Body.GetDouble("customer_id")<0){g.Res.StatusCode=400;// 400 with your datareturnnew{field="customer_id",message="A customer_id is positive."};}returnnew{ok=true};}}
packagecodeimport"apimaker/am"funcMain(g*am.G)(any,error){body:=g.Req.Body()ifbody.Get("customer_id")==nil{returnnil,am.Fail(400,"Please provide customer_id.")// 400 with the message in errors}ifbody.Float("customer_id")<0{g.Res.SetStatusCode(400)// 400 with your datareturnam.M{"field":"customer_id","message":"A customer_id is positive."},nil}returnam.M{"ok":true},nil}
useam::{Errors,G,Value,json};pubfnmain(g:&mutG)->Result<Value,Errors>{letSome(customer_id)=g.req.body()["customer_id"].as_f64()else{returnErr(am::fail(400,"Please provide customer_id."));// 400 with the message in errors};ifcustomer_id<0.0{g.res.set_status_code(400);// 400 with your datareturnOk(json!({"field":"customer_id","message":"A customer_id is positive."}));}Ok(json!({"ok":true}))}
importam.AmErrors;importam.G;importjava.util.Map;classApi{staticObjectmain(Gg){if(!g.req.getBody().has("customer_id"))thrownewAmErrors("Please provide customer_id.",400);// 400 with the message in errorsif(g.req.getBody().getDouble("customer_id")<0){g.res.setStatusCode(400);// 400 with your datareturnMap.of("field","customer_id","message","A customer_id is positive.");}returnMap.of("ok",true);}}
importam.AmErrorsimportam.Gfunmain(g:G):Any?{if(!g.req.body.has("customer_id"))throwAmErrors("Please provide customer_id.",400)// 400 with the message in errorsif(g.req.body.getDouble("customer_id")<0){g.res.statusCode=400// 400 with your datareturnmapOf("field"to"customer_id","message"to"A customer_id is positive.")}returnmapOf("ok"totrue)}
An error your code raises answers success: false, with its message in errors : throw [{ code, message }] in TypeScript, raise Errors(message, code=400) in Python, AmErrors in PHP, C#, Java and Kotlin, am.Fail(400, message) in Go, am::fail(400, message) in Rust.
The code of the first error is the status of the answer when it is 400, 401, 403, 404 or 500. Any other code (422…) answers 500, and the error keeps its code. For another status, set g.res.statusCode.
Any other exception answers 500 with its message. A message listed in errorList can be translated with i18n.
g.res.statusCode sets the status of a normal answer ; g.res.warnings adds warnings.
Upload. Send multipart/form-data with the files in the field files (or files1, files2… up to files50, to keep groups of files apart). They are on disk when the code runs, in g.req.body.files :
importam.Gfunmain(g:G):Any?{// each : originalname, mimetype, size, path, filenamereturng.req.body.getObjects("files").map{mapOf("name"toit.getString("originalname"),"bytes"toit.getLong("size"))}}
path is where the code finds the file wherever it runs : /opt/am/work/uploads/<filename> in the sandbox, the uploads folder of the server with runOnNativeProcess. uploads/<filename> from the folder the code works in is the same file.
A form may be as long as uploadLimit (512 MiB by default) : its files go to disk while they arrive, never into memory. Its text fields are in g.req.body, together no longer than bodyLimit.
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). A request refused before the code runs (no such API, a caller who may not call it) leaves no file.
Download. Return an object with the __am__ keys of IDownloadResponse : the file (or a zip of several) is streamed to the caller.
import*asTfrom'types';asyncfunctionmain(g:T.IAMGlobal){// a file written earlier in the uploads folder of the sandboxreturn<T.IDownloadResponse>{__am__downloadFilePath:'reports/2026-09.pdf',__am__downloadFolderFileName:'September report.pdf',__am__cleanupFileOrFolderPaths:'reports/2026-09.pdf',};}module.exports=main;
fromamimportGasyncdefmain(g:G):# a file written earlier in the uploads folder of the sandboxreturn{'__am__downloadFilePath':'reports/2026-09.pdf','__am__downloadFolderFileName':'September report.pdf','__am__cleanupFileOrFolderPaths':'reports/2026-09.pdf',}
<?phpfunctionmain(AmG$g){// a file written earlier in the uploads folder of the sandboxreturn['__am__downloadFilePath'=>'reports/2026-09.pdf','__am__downloadFolderFileName'=>'September report.pdf','__am__cleanupFileOrFolderPaths'=>'reports/2026-09.pdf',];}
filestaticclassApi{publicstaticasyncTask<object?>Main(Gg){// a file written earlier in the uploads folder of the sandboxreturnnewJsonObject{["__am__downloadFilePath"]="reports/2026-09.pdf",["__am__downloadFolderFileName"]="September report.pdf",["__am__cleanupFileOrFolderPaths"]="reports/2026-09.pdf",};}}
packagecodeimport"apimaker/am"funcMain(g*am.G)(any,error){// a file written earlier in the uploads folder of the sandboxreturnam.M{"__am__downloadFilePath":"reports/2026-09.pdf","__am__downloadFolderFileName":"September report.pdf","__am__cleanupFileOrFolderPaths":"reports/2026-09.pdf",},nil}
useam::{Errors,G,Value,json};pubfnmain(g:&mutG)->Result<Value,Errors>{// a file written earlier in the uploads folder of the sandboxOk(json!({"__am__downloadFilePath":"reports/2026-09.pdf","__am__downloadFolderFileName":"September report.pdf","__am__cleanupFileOrFolderPaths":"reports/2026-09.pdf",}))}
importam.G;importjava.util.Map;classApi{staticObjectmain(Gg){// a file written earlier in the uploads folder of the sandboxreturnMap.of("__am__downloadFilePath","reports/2026-09.pdf","__am__downloadFolderFileName","September report.pdf","__am__cleanupFileOrFolderPaths","reports/2026-09.pdf");}}
importam.Gfunmain(g:G):Any?{// a file written earlier in the uploads folder of the sandboxreturnmapOf("__am__downloadFilePath"to"reports/2026-09.pdf","__am__downloadFolderFileName"to"September report.pdf","__am__cleanupFileOrFolderPaths"to"reports/2026-09.pdf",)}
__am__downloadFileOrFolderPaths with several paths, or { fsSource, archiveDestination } objects, answers a zip.
import*asTfrom'types';asyncfunctionmain(g:T.IAMGlobal){g.res.contentType='image/gif'asT.EContentType;// not in EContentType : the return is base64return'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOwA=';}module.exports=main;
fromamimportGasyncdefmain(g:G):g.res.content_type='image/gif'# another type : what is returned is base64return'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOwA='
<?phpfunctionmain(AmG$g){$g->res->contentType='image/gif';// another type : what is returned is base64return'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOwA=';}
filestaticclassApi{publicstaticasyncTask<object?>Main(Gg){g.Res.ContentType="image/gif";// another type : what is returned is base64return"R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOwA=";}}
packagecodeimport"apimaker/am"funcMain(g*am.G)(any,error){g.Res.SetContentType("image/gif")// another type : what is returned is base64return"R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOwA=",nil}
useam::{Errors,G,Value,json};pubfnmain(g:&mutG)->Result<Value,Errors>{g.res.set_content_type("image/gif");// another type : what is returned is base64Ok(json!("R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOwA="))}
importam.G;classApi{staticObjectmain(Gg){g.res.setContentType("image/gif");// another type : what is returned is base64return"R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOwA=";}}
importam.Gfunmain(g:G):Any?{g.res.contentType="image/gif"// another type : what is returned is base64return"R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOwA="}
g.res.headers adds response headers. See Content types.
Hooks : pre and post hooks on the custom API itself, see Pre hooks.
Events and WebSockets : an event can trigger automatically after a successful call of 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.
Git : src/custom_apis/<name>/ holds the item (<name>.yaml), the basic info (<name>.config.ts), the code (<name>.ts, or .py, .go… in another language), its hooks and the docs. Every file of a custom API, and of every other kind of item, with what goes in it : Items as files.
Groups : an API user calls a custom API only when a group grants it, see API group permission.
runOnNativeProcess: true runs the function on the machine of the server instead of in Docker, in one runner of its runtime shared by every account with the same packages : no sandbox start, the packages of the Sandbox Settings of its language (installed on the machine the first time, in a folder of their own), the packages installed in the folder API Maker runs in, and the programs of the machine, such as a browser for Playwright. Code of a group of separate sandboxes runs on the runtime of its group, with the packages of its group. It runs on the version of its language you installed for it in Sandbox settings → Native languages (every server installs it from the publisher of the language, for your account), or on the runtime of the server (node, python3, php…) in a version API Maker runs, which API Maker Launchpad can install too. The code is not isolated from the server, so keep it for the few APIs which need it.
That runner loads API Maker itself, as a sandbox does (g.sys is answered where the code runs) : g.sys.db.getAll(…), a key of the cache, a secret are function calls in the process of the runner, which reads the databases and Redis itself, for the account of each request. Only the answer of the API goes back to the server. This holds in every language ; a release has the libraries it needs, built for its machine. Where none is found for the machine of the server (a server built by hand on a Mac : AM_NATIVE_CORE in the configuration), the server answers those calls itself, as it says in its log.
A new account comes with custom APIs under /default/… which show the features at work. Open them on the Custom API page, or read each one in your language on Custom API examples :