In this guide
ctx is the optional second argument to a Turbyn handler. It provides run capabilities; it is not HubSpot’s native JavaScript callback argument. See Writing actions for Turbyn’s input and output contract.
exports.main = async (event, ctx) => {
// ctx.secrets: decrypted secret values
// ctx.store: key-value store, atomic
// ctx.log: leveled logging
// ctx.env: portal and run metadata
// ctx.retry: retry helper for flaky calls
};
What a test run touches
Your workspace role determines the kind of test run. The editor and run result show which kind you are using; there is no toggle to switch between them.
Dry run: the default when an editor or admin presses Test.
Preview: members can write and execute code to check syntax and logic, but the run has no secrets or store access. Testing with real secrets requires an editor or admin.
Neither kind reads a record from your portal. The event is built from the sample you choose in the editor: Synthetic input (a generated event for the object type) or Custom JSON (an event you paste or write).
| Live run (a real workflow) | Dry run | Preview | |
|---|---|---|---|
| Event data | The record HubSpot enrolled, with inputs resolved by HubSpot | The synthetic input or custom JSON you supplied | The synthetic input or custom JSON you supplied |
ctx.store reads |
Real | Real, overlaid by this run’s own writes | Unavailable; access throws |
ctx.store writes |
Applied | Captured to a per-run test namespace; production untouched | Unavailable; access throws |
ctx.secrets |
Real values | Real values | Withheld; empty, and access throws |
| Outbound requests your code makes (HubSpot API included) | Real | Real; not intercepted | Real; not intercepted |
| Console output | Retained with the run | Retained with the run | Retained with the run |
A test run is not a network sandbox. Requests your code makes to HubSpot or any other service happen for real. Guard side effects with ctx.mode.
Console output is retained and readable by anyone with workspace access, regardless of the run kind.
The event
event is HubSpot’s payload. event.object identifies the enrolled record (objectId, objectType) and works for any object type: contacts, companies, deals, tickets and custom objects.
Values you put in the step’s Inputs JSON arrive already resolved on event.inputs:
const email = event.inputs.email; // JavaScript
email = event["inputs"]["email"] # Python
See Writing actions for JSON inputs and outputs.
Calling HubSpot and other APIs
There is no built-in HubSpot client. Turbyn’s app only has oauth and automation scopes and can’t read your CRM records. When your code needs HubSpot data, create a private app or service key in HubSpot with the scopes it needs, save it as a Turbyn secret, and call the API yourself with fetch or @hubspot/api-client:
exports.main = async (event, ctx) => {
const id = event.object.objectId;
const headers = {
Authorization: `Bearer ${ctx.secrets.HUBSPOT_TOKEN}`,
"Content-Type": "application/json",
};
const res = await fetch(
`https://api.hubapi.com/crm/v3/objects/companies/${id}?properties=domain,industry`,
{ headers },
);
if (!res.ok) throw new Error(`HubSpot returned ${res.status}`);
const company = await res.json();
// Test runs make real requests too. Only write when live.
if (ctx.mode === "live") {
await fetch(`https://api.hubapi.com/crm/v3/objects/companies/${id}`, {
method: "PATCH",
headers,
body: JSON.stringify({ properties: { industry: "Software" } }),
});
}
return { domain: company.properties.domain ?? "" };
};
Public outbound requests are allowed; private networks and cloud metadata endpoints are blocked. HubSpot’s rate limits apply to your key, so wrap bursty calls in ctx.retry.
ctx.secrets
Values from the secrets vault, decrypted at invocation and injected into the run.
const stripe = new Stripe(ctx.secrets.STRIPE_API_KEY);
Vault values are stored encrypted and supplied to authorized runs through ctx.secrets. The secrets-management API does not return the saved plaintext value. This is not automatic log redaction: if your code logs or returns a secret, it can appear in retained run data and the run console. Never log ctx.secrets, authorization headers or complete request objects that may contain credentials.
Referencing a secret that doesn’t exist gives undefined rather than throwing. Check before use if it’s required.
Secret values are decrypted only for runs whose role is allowed to access them. Preview runs receive no decrypted values. Accessing ctx.secrets in a preview throws an explanatory error rather than returning undefined. The run result also lists the bindings that were withheld.
ctx.store
A key-value store scoped to your portal. Operations are atomic, which matters because workflow actions run concurrently across enrolled records.
await ctx.store.set("last-sync", new Date().toISOString());
const last = await ctx.store.get("last-sync");
// Atomic. Safe when 200 records are enrolled at once.
const n = await ctx.store.increment("processed-count");
await ctx.store.delete("last-sync");
Use it for counters, round-robin assignment cursors, deduplication markers and cross-run state. It is not a database: no queries, no indexes, no scans. Keys are strings; values are JSON-serialisable.
Round-robin assignment is the canonical case:
const reps = ["alice", "bob", "carol"];
const n = await ctx.store.increment("rr-cursor");
const owner = reps[n % reps.length];
Because increment is atomic, two records enrolled simultaneously get different reps. A read-then-write would hand both the same one.
Store scope and access
ctx.store is scoped per portal, not per run or action. Runs for the same portal share a namespace, so a round-robin cursor can carry over from one run to the next. Any action in the workspace can read or overwrite keys in that portal. Treat it as shared workspace state and only store values that other actions may read. Test-run access is described below.
It is not shared across portals, and never across workspaces.
ctx.store during a dry run
On a dry run, set, delete and increment do not touch your real values. They are written to a private, per-run test namespace and reported back to the editor as store writes.
Reads include writes made earlier in the same test:
// Production has rr-cursor = 41.
const n = await ctx.store.increment("rr-cursor"); // 42, inside this test
await ctx.store.set("marker", "test");
await ctx.store.get("marker"); // "test": reads back this run's write
// Production still has rr-cursor = 41 and no "marker" key at all.
A read of a key the run hasn’t written returns the real production value. Only editors and admins can perform dry runs, and they already have access to these values. A dry run can still print production state to the retained console.
The test namespace is discarded when the run finishes. Nothing carries over to the next test.
In a preview run, ctx.store is not available at all and calling it throws.
ctx.log
Leveled logging. Output streams to the run console live, and is retained with the run.
ctx.log.info("enriching", { domain });
ctx.log.warn("no match found", { domain });
ctx.log.error("provider rejected the request", { status: res.status });
console.log is captured too. Both it and ctx.log serialize arguments into retained log lines; object arguments are not a separate structured storage field. Use the level helpers to distinguish informational messages, warnings and errors.
Caution
Log output is retained for your plan’s history period and visible to anyone with workspace access. Don’t log secrets or personal data you wouldn’t want stored. Turbyn doesn’t filter what you choose to log.
ctx.env
Read-only metadata about the run.
ctx.env.portalId; // 12345678
ctx.env.actionId; // the saved action's id
ctx.env.versionId; // which published version is executing
Run identity and authority live directly on ctx, not under ctx.env:
ctx.executionId; // stable across attempts; use as an idempotency key
ctx.attempt; // 0 the first time your code runs for this execution
ctx.mode; // "live" | "dry_run" | "preview"
ctx.mode is what you guard a side effect on:
if (ctx.mode === "live") {
await sendSlackMessage(summary);
}
Turbyn captures ctx.store writes when ctx.mode isn’t "live". This does not intercept network requests. HubSpot updates, Slack posts, charges and emails sent by your code happen in every mode. Guard these side effects with the check above.
ctx.retry
Retry with exponential backoff, for flaky third parties.
const data = await ctx.retry(
async () => {
const res = await fetch("https://api.example.com/enrich");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
{ attempts: 3, backoffMs: 1000 },
);
Retry only when repeating the operation is safe. Retrying a non-idempotent write can duplicate it. Combine with ctx.executionId as an idempotency key when the destination supports one.
Python
Identical surface, Python conventions:
import json
import urllib.request
async def main(event, ctx):
object_id = event["object"]["objectId"]
req = urllib.request.Request(
f"https://api.hubapi.com/crm/v3/objects/companies/{object_id}?properties=domain",
headers={"Authorization": f"Bearer {ctx.secrets['HUBSPOT_TOKEN']}"},
)
with urllib.request.urlopen(req) as res:
company = json.load(res)
await ctx.store.increment("processed-count")
ctx.log.info("processed", {"id": company["id"], "email": event["inputs"].get("email")})
return {"domain": company["properties"].get("domain") or ""}