Run Custom Code
Run JavaScript or Python inside a workflow: read the data of earlier steps, return a result, call HTTP APIs with fetch, let your connected integrations authenticate those calls, and use the built-in cryptography modules.
Run Custom Code
Applies to: Run Custom Code 1.0.0, 1.1.0, 1.2.0
The Run Custom Code action runs JavaScript or Python inside your workflow. Use it for logic that formulas cannot express: parsing complex JSON, multi-step transformations, or calls to an API that has no dedicated action yet. The code can call HTTP APIs, and from version 1.2.0 the integrations you attach to the step authenticate those calls for you.
How the step works
- Data to use in the code: name the values your code needs and map them to outputs of earlier steps. The code reads them from
inputData. - Integrations the code can call (1.2.0 and later): attach the connected integrations the code may talk to. Each one gets an alias, a short name the code can use.
- Language and Code: write the body of a function. Whatever you
returnbecomes the output of the step, available to later steps asbody.

Reading input data
Every entry of Data to use in the code is a key of inputData. A value that is exactly one variable keeps its type: a number stays a number, a record stays an object, a list stays an array.
const email = inputData.email;
const items = inputData.items;
return { domain: email.split('@')[1], count: items.length };email = inputData['email']
items = inputData['items']
return {'domain': email.split('@')[1], 'count': len(items)}Returning a result
Return any JSON-compatible value: an object, a list, a string, a number or a boolean. Later steps reference it through the variable menu as body, or a field of it. A thrown error or a raised exception fails the step, and the message appears in the run details and in Test your step.
Calling APIs with fetch
Applies to: 1.2.0 and later
Both languages offer fetch(url, options). It sends an HTTP request and returns a response object. In JavaScript you await it; in Python it is a plain call.
const res = await fetch('https://api.example.com/v1/contacts', {
method: 'POST',
headers: { 'X-Request-Source': 'softr' },
body: { email: inputData.email, name: inputData.name },
});
if (!res.ok) {
throw new Error(`Contact API answered ${res.status}: ${await res.text()}`);
}
return await res.json();res = fetch('https://api.example.com/v1/contacts', {
'method': 'POST',
'headers': {'X-Request-Source': 'softr'},
'body': {'email': inputData['email'], 'name': inputData['name']},
})
if not res.ok:
raise RuntimeError(f'Contact API answered {res.status}: {res.text()}')
return res.json()Options
| Option | Meaning |
|---|---|
method | GET by default; POST, PUT, PATCH, DELETE and the others work as expected. |
headers | An object of header names and values. |
body | A string is sent as it is, as text/plain unless you set Content-Type. An object or a list is sent as JSON with Content-Type: application/json. In JavaScript, URLSearchParams is sent form-encoded. |
integration | The alias of an attached integration that should authenticate this call. Needed only when two attached integrations share a host. |
Response
| Member | Meaning |
|---|---|
status, ok | The HTTP status code, and whether it is in the 200 range. |
headers | The response headers with lower-case names. JavaScript: res.headers.get('content-type'). Python: res.headers['content-type']. |
text(), json() | The body as text or parsed JSON. In JavaScript both are awaited. |
arrayBuffer() (JavaScript), bytes() (Python) | The raw body, for binary answers. |
Letting an integration authenticate the call
Attach the integrations your code talks to under Integrations the code can call. When the code calls a URL on the API host of an attached integration, the request carries that integration’s credentials automatically. The code never sees a token, and nothing you paste into the code is needed.
// "salesforce" is attached to the step; the call to its API host is authenticated for you.
const res = await fetch(`${inputData.instanceUrl}/services/data/v60.0/sobjects/Account`, {
method: 'POST',
body: { Name: inputData.companyName },
});
return { created: res.ok, account: await res.json() };# Two attached integrations share the host api.hubapi.com; the alias picks the one to use.
res = fetch('https://api.hubapi.com/crm/v3/objects/contacts', {
'method': 'POST',
'body': {'properties': {'email': inputData['email']}},
'integration': 'sales_hubspot',
})
return res.json()- The credentials are only added when the URL points at the provider’s own API host. A call to any other host goes out without them, whatever alias you pass.
- You can attach every integration the Call API action can authenticate with. Database connections, Softr Databases, Softr Apps, Telegram and Trello cannot be attached, because their credentials have no HTTP header form.
- An alias is a short name of letters, digits and underscores, unique within the step. It defaults to the integration type, such as
salesforceorrest_api. - A REST API integration has no provider host, so it never authenticates a call by host alone. Name its alias in the call, and its headers go to whatever host that call names.
Calls without an integration
fetch also reaches any public host without an attached integration. The request is sent exactly as you wrote it, including any headers you set. Private networks, localhost and cloud metadata addresses are blocked.
Calling HTTP APIs with http and https
Applies to: 1.0.0, 1.1.0
Removed in: 1.2.0. Use
fetchinstead.
JavaScript can call HTTP APIs with the Node.js http and https modules. Return the promise of the result; the step waits for it.
return new Promise((resolve, reject) => {
const url = 'https://api.example.com/v1/contacts?email=' + encodeURIComponent(inputData.email);
const req = https.request(url, { headers: { Authorization: 'Bearer ' + inputData.apiToken } }, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve(JSON.parse(data)));
});
req.on('error', reject);
req.end();
});Python code has no network access in these versions. In JavaScript, localhost and the cloud metadata addresses are blocked.
Cryptography
Both languages come with a cryptography module. The code can hash values, compute and check HMAC signatures, and decrypt or verify data that a partner encrypted or signed with RSA.
JavaScript: crypto and Buffer
Applies to: 1.0.0, 1.1.0, 1.2.0
crypto is the Node.js crypto module: createHash, createHmac, privateDecrypt, publicEncrypt, sign, verify, randomUUID and the rest of it. Buffer converts between text, base64, hex and raw bytes.
const decrypted = crypto.privateDecrypt(
{ key: inputData.privateKeyPem, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(inputData.encryptedToken, 'base64'),
);
const signature = crypto.createHmac('sha256', inputData.signingSecret).update(inputData.payload).digest('hex');
return { token: decrypted.toString('utf8'), signature };Python: rsa
Applies to: 1.0.0, 1.1.0, 1.2.0
import rsa gives you the API of the rsa package: rsa.PrivateKey.load_pkcs1 and rsa.PublicKey.load_pkcs1 read a PEM or DER key in PKCS#1 or PKCS#8 form, rsa.decrypt and rsa.verify accept the ciphertext or signature as base64 text or as bytes, and rsa.encrypt, rsa.sign, rsa.DecryptionError and rsa.VerificationError work as in the package. Padding is PKCS#1 v1.5.
import rsa
key = rsa.PrivateKey.load_pkcs1(inputData['privateKeyPem'])
token = rsa.decrypt(inputData['encryptedToken'], key).decode()
return {'token': token}Runtimes and limits
| JavaScript | Python | |
|---|---|---|
| Runtime | Node.js (1.0.0, 1.1.0); Node.js 24 (1.2.0 and later) | Python 3.11 (1.0.0, 1.1.0); Python 3.12 (1.2.0 and later) |
| Available | crypto (the Node.js crypto module: hashes, HMAC, RSA, AES, signatures, random values), Buffer, timers, promises, JSON, Math, Date, regular expressions. http and https (1.0.0, 1.1.0). fetch, URL, URLSearchParams, TextEncoder, TextDecoder, structuredClone and top-level await (1.2.0 and later) | rsa (RSA with PKCS#1 v1.5 padding, PKCS#1 or PKCS#8 keys), the standard modules json, re, math, statistics, decimal, fractions, datetime, random, and the common built-in functions such as len, range, enumerate, zip, sorted, min, max, sum, str, int, float, dict, list, set, tuple. fetch, class definitions, isinstance, map, filter, bytes, and the further modules zoneinfo, time, calendar, secrets, collections, itertools, functools, operator, string, textwrap, unicodedata, difflib, html, base64, hashlib, hmac, uuid, struct, copy, heapq, bisect, typing, enum, dataclasses, csv, io (StringIO, BytesIO), urllib.parse (1.2.0 and later) |
| Not available | require, import, console output, the file system, environment variables. In 1.0.0 and 1.1.0 also fetch and top-level await: return a promise instead | open, print output, os, sys, subprocess, requests, urllib.request, other third-party packages, attribute names that start with __. In 1.0.0 and 1.1.0 also class definitions, isinstance, map, filter, bytes and every module not listed above |
| Limit | 1.0.0, 1.1.0 | 1.2.0 and later |
|---|---|---|
| Run time of one step | JavaScript: a returned promise must settle within five seconds, or the step fails with Async script timed out after 5000ms. Python: three seconds; the step then fails with Execution timed out. | About two minutes; the step then fails with Execution timed out. |
| Synchronous JavaScript | Two seconds of code without a promise in between. | Two seconds of code without an await in between. |
| Python CPU time | Two of the three seconds. | Two seconds; waiting for fetch does not count. |
| Python memory | 256 MB. | 256 MB. |
fetch calls | – | Up to 20 per second per run, and up to 10 open at the same time. |
One fetch | – | The target must answer within 30 seconds, or the call fails. |
| Request body | – | Up to 5 MB per fetch; a bigger body fails before it is sent. |
| Response size | – | Up to about 6 MB per fetch response. |
When something fails
- A script error fails the step with the message of the error or exception. Test the step to see it while you write the code.
- A
fetcherror (1.2.0 and later) is raised as an exception in your code, so you can catch it. Its message names the reason: a blocked host, an unknown alias, too many calls, or a target that did not answer. - Execution timed out, Async script timed out and Memory limit exceeded name the limit the run hit. Split the work between steps, or narrow the data the step receives.
Versions
Steps created with an earlier version of Run Custom Code keep working. Opening such a step in the workflow editor upgrades it to the current version; code that used http or https must switch to fetch then. The Applies to line at the top of this page names the versions the page covers. An Applies to line under a heading names the versions that section applies to; without one, a section applies to every version of the page. A Removed in line names the version that dropped that part, and a Version history block lists what changed inside it.
| Version | What changed |
|---|---|
| 1.2.0 | fetch in both languages, and the integrations attached to the step authenticate the calls. Node.js 24 and Python 3.12. Class definitions and many more standard modules in Python, top-level await in JavaScript, about two minutes per run. http and https removed. |
| 1.1.0 | The same inputs and runtime as 1.0.0. |
| 1.0.0 | The first version. |