A custom API is a function behind a path and a method, written 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, 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
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 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 raised answers success: false with its message in errors, and its code as the status : 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. Any other exception answers 500 with its message. A message listed in errorList is translated by 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 files41 to keep groups 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"))}}
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.
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.
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 : no sandbox start, the packages installed in the folder API Maker runs in, and the programs of the machine, such as a browser for Playwright. The runtime must be installed on the server (node, python3, php…), in a version API Maker runs : API Maker Launchpad installs it for you, on your computer and on your servers, or finds the one they have. 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 : 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.