Skip to content

The ctx object

ctx is the second argument to your handler. It’s the one addition Turbyn makes to HubSpot’s signature, and it’s entirely optional.

exports.main = async (event, ctx) => {
// ctx.hubspot: pre-authenticated CRM client
// ctx.secrets: decrypted secret values
// ctx.store: key-value store, atomic
// ctx.log: structured logging
// ctx.env: portal and run metadata
// ctx.retry: retry helper for flaky calls
};

A CRM client already authenticated as the portal that triggered the run. No token handling, no client construction.

const contact = await ctx.hubspot.contacts.get(event.object.objectId);
await ctx.hubspot.companies.update(companyId, {
industry: "Software",
numberofemployees: "250",
});
const results = await ctx.hubspot.contacts.search({
filterGroups: [
{
filters: [{ propertyName: "email", operator: "EQ", value: "j@example.com" }],
},
],
});

Rate limits are handled for you. The client backs off and retries on a 429 rather than surfacing it. Because your run isn’t racing a 20-second timeout, that backoff is affordable.

Values from the secrets vault, decrypted at invocation and injected into the run.

const stripe = new Stripe(ctx.secrets.STRIPE_API_KEY);

Secrets are encrypted at rest, never returned to the browser after being saved, and excluded from logs. They exist for the lifetime of the run and nothing else.

Referencing a secret that doesn’t exist gives undefined rather than throwing. Check before use if it’s required.

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.

Structured 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, so pasted code that uses it still works. ctx.log is preferable because the second argument stays structured and filterable rather than being stringified.

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
ctx.env.executionId; // this run; useful as an idempotency key
ctx.env.isDryRun; // true during an editor test run

isDryRun is useful for guarding an external side effect the interceptor can’t know about:

if (!ctx.env.isDryRun) {
await sendSlackMessage(summary);
}

Turbyn intercepts HubSpot writes during a dry run, but it can’t intercept a call to someone else’s API. Guard those yourself.

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 },
);

Only worth using where a retry is genuinely safe. Retrying a non-idempotent write can duplicate it. Combine with ctx.env.executionId as an idempotency key when the destination supports one.

Identical surface, Python conventions:

async def main(event, ctx):
contact = await ctx.hubspot.contacts.get(event["object"]["objectId"])
await ctx.store.increment("processed-count")
ctx.log.info("processed", {"id": contact["id"]})
return {"outputFields": {"output1": "ok"}}