powered by
Automation

How workflows work

Updated Sep 30, 2026
Workflows run your backend logic inside Anythink. A trigger starts a job, the job runs a chain of steps on the workflow worker, and every step's output is available to the steps after it.

Workflows are Anythink's automation engine. Use them for the logic that would otherwise need a backend service: reacting to new records, running nightly jobs, exposing an endpoint that does several things at once, calling other APIs, and sending email or push notifications.

This page explains the model and what happens at runtime. To build one, start with Build your first workflow.

The moving parts

Part What it is
Workflow A named automation with one or more triggers and a set of steps. Names are unique within a project.
Trigger What starts the workflow: a data event, a schedule, a button, or an API call. A workflow can have several.
Step One action — read data, create a record, call an API, run a script, send a notification. Each has a key.
Job One run of a workflow. It records the trigger payload, every step's log and output, and the final status.

Steps are connected, not ordered. Each step has an on success link and an optional on failure link to another step. One step is marked as the start step. A job begins there and follows the links until a step has nowhere left to go.

What happens when a workflow runs

  1. A trigger fires. A record is created or updated, a schedule comes due, someone presses a workflow button, or your code calls the workflow's API route.
  2. Anythink creates a job with status Pending and a payload of { id, entity_name, data }, then puts it on the workflow queue. For event triggers with a filter, the filter is checked first; if it doesn't match, no job is created.
  3. The workflow worker picks the job up, checks your plan's workflow quota, and sets the job to Running.
  4. It runs the start step. Templates such as {{ $anythink.trigger.data.email }} in the step's parameters are resolved just before it runs.
  5. The step's output is saved and becomes available to later steps as $anythink.steps.<key>.
  6. The worker follows a link. Success follows on success; failure follows on failure.
  7. The job finishes. It ends Success when the last step it ran succeeded, or when a failure was handled by an on failure path that completed. It ends Failed when a step fails and has no on failure link.

Every job and step result is kept in the workflow's Job History, so you can see exactly what each step received and returned. See Monitoring and testing.

Triggers

Trigger Starts a job when Typical use
Event A record is created or updated, a user registers or is invited, a payment or subscription changes, or a push notification action is tapped Welcome emails, follow-up tasks, syncing data
Timed A cron schedule comes due (UTC, checked every minute) Nightly imports, weekly reports, reminders
Manual Someone runs it from a record in the dashboard, or your code calls the trigger endpoint Admin actions, one-off fixes
API endpoint A POST reaches /org/{orgId}/workflows/api/{route} Custom endpoints for your app

Event triggers can carry a filter so a workflow only runs when it matters — for example, only when an order's status changes to paid. Full details: Triggers and scheduling.

Steps

Step What it does
Read data Reads records from an entity, with filters, sorting and a limit
Create data Creates one record, or one per item in a list
Update data Updates records by id or by filter
Upsert data Matches records on key fields, then updates, ignores or creates
Delete data Deletes every record matching a filter
Condition Branches the workflow on one or more comparisons
Run a script Runs JavaScript against the trigger data and earlier step outputs
Call an API Sends an HTTP request and keeps the JSON response
Send an email Sends one of your project's email templates
Send a push notification Sends to a user, a list of users, a record's owners, or everyone — once or per row
File handler Downloads a file into Anythink and attaches it to a record, links an existing file, or detaches one
Integration Calls a connected service: Claude, OpenAI, Grok, Slack, GitHub, Google Calendar, LinkedIn or X

Every parameter and output is listed in Step types.

Passing data between steps

Parameters can contain templates. The most common ones:

text
{{ $anythink.trigger.id }}                  the record id that fired an event trigger
{{ $anythink.trigger.data.email }}          a field from the trigger data
{{ $anythink.steps.find_customer.data[0].name }}   a field from an earlier step's output
{{ $anythink.secrets.stripe_key }}          a project secret
{{ $anythink.now }}                         the current time (UTC, ISO 8601)

When a template is the whole value of a JSON field, the value keeps its type, so a number stays a number and an object stays an object. The full rules are in Template syntax.

Who a workflow acts as

Workflow steps run as the Anythink workflow worker, a trusted service, not as the person who triggered the job. That has two consequences you need to design for:

  • Reads ignore row-level security. A Read data step sees every record in the entity. Filter explicitly to the user or group you're acting for — for example user_id eq {{ $anythink.trigger.data.user_id }}.
  • Records created by a workflow have no owner. On entities with row-level security, grant access in the step itself with its row-level security options (auto_set_rls, group grants, or an _rls payload). See Step types.

Anyone who can edit a workflow can therefore read and send any data in the project. Keep workflow permissions (anythink_workflows:create, :update) for people you'd trust with that access.

Limits and trade-offs

Limit Value
Step executions per job 250. A job that loops past this fails with "exceeded maximum step limit".
Retries None. A failed step fails the job unless it has an on failure path. Re-run by triggering again.
Schedules Five-field cron, UTC only, one-minute resolution. Missed runs aren't caught up.
Scripts 30 seconds, 64 MB and 100,000 statements per run. No network access.
File downloads 50 MB and 30 seconds per file.
Runs per month Set by your plan's workflow quota. Jobs over quota fail with a quota message and aren't billed.
API endpoint responses 204 No Content. The job runs in the background; the response doesn't include its result.
Job order Not guaranteed. Jobs run concurrently.
Sub-workflows Not supported. Build shared logic into each workflow, or trigger another workflow's API route with Call an API.

A few behaviours are worth knowing before you build:

  • Workflows can trigger workflows. Create and update steps write through the normal API, so they fire event triggers — including on the entity the workflow itself is watching. Use a trigger filter to stop a workflow re-triggering itself.
  • A false Condition is a failure. If a Condition evaluates to false and has no on failure link, the job ends Failed. Link the false branch to a step, even a no-op, when "nothing to do" is a normal outcome.
  • A step that runs twice keeps its first output. If your links loop back to a step, later references to that step see the first run's output.
  • Events are delivered once. If a job fails, nothing retries it automatically; Job History keeps its payload so you can trigger it again.

Next steps

Build your first workflow

Updated Sep 30, 2026
In about ten minutes, build a workflow that flags every order over £100 for review the moment it's created, and check it worked.

You'll build a workflow that watches for new orders and creates a review task for any order of £100 or more. Along the way you'll use an event trigger, a trigger filter, a Create data step and a template, then test the result and read its job.

Before you start

You need a project and two entities:

Entity Fields
orders customer_email (text), total (decimal), status (text)
order_reviews order_id (integer), note (text)

If you haven't created entities before, see Data modelling.

1. Create the workflow

  1. Go to Workflows and select New Workflow.
  2. Name it Flag large orders and select Create Workflow.

The editor opens with a Start node on the canvas.

2. Add the trigger and its filter

  1. Select Workflow Settings.
  2. Add a trigger with type Event, event EntityCreated and entity orders.
  3. Add a filter condition: field total, operator gte, value 100.
  4. Save the settings.

The filter is checked before a job is created, so orders under £100 won't start the workflow at all.

3. Add the step

  1. Select Add Step and choose Create data.
  2. Set the key to create_review and the entity to order_reviews.
  3. Set the payload:
json
{
  "order_id": "{{ $anythink.trigger.id }}",
  "note": "Order from {{ $anythink.trigger.data.customer_email }} for £{{ $anythink.trigger.data.total }} needs review"
}
  1. Connect the Start node to the step to make it the start step.
  2. Select Save Workflow, then enable the workflow.

{{ $anythink.trigger.id }} is the whole value of order_id, so it's written as a number. The note mixes text and templates, so it's written as text.

4. Check it works

Create an order that should be flagged, and one that shouldn't:

bash
curl -X POST "https://api.my.anythink.cloud/org/$ORG_ID/entities/orders/items" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"customer_email":"sam@example.com","total":149.50,"status":"new"}'

curl -X POST "https://api.my.anythink.cloud/org/$ORG_ID/entities/orders/items" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"customer_email":"ali@example.com","total":24.00,"status":"new"}'

Then check:

  1. In the workflow, select View Job History. There's one job, for the £149.50 order, with status Success.
  2. Open the job. The create_review step's data shows the review it created.
  3. Open the order_reviews entity. There's one review: "Order from sam@example.com for £149.5 needs review".

The £24 order didn't match the filter, so it left no job.

What you built

Part What it did
Event trigger Started a job whenever an order was created
Trigger filter Skipped orders under £100 before any job was created
Create data Wrote a review, using the order's id and fields from the trigger data

Take it further

  • Notify someone. Add a Send an email step after create_review to email your support inbox, or an Integration step to post to Slack. See Step types.
  • Only flag paid orders. Change the event to EntityUpdated and add a filter condition status changed_to paid. See Triggers and scheduling.
  • Summarise with AI. Add an Integration step using Claude to summarise the customer's order history into the review note. See Workflow recipes.

Next steps

Triggers and scheduling

Updated Sep 30, 2026
Start workflows from data events, schedules, dashboard buttons or your own API routes, and use trigger filters so a workflow only runs when it should.

A trigger decides when a workflow runs and what data the job starts with. A workflow can have several triggers of different types — for example, run when an order is created and every night at 02:00 to catch anything missed.

Add and edit triggers in the workflow editor under Workflow Settings.

Event triggers

Event triggers run a workflow when something happens in your project.

Event Fires when $anythink.trigger.id $anythink.trigger.data
EntityCreated A record is created through the API, in bulk or by import The new record's id The fields that were sent, not the saved row (see below)
EntityUpdated A record is updated The record's id The full record after the update
UserRegistered Someone registers in your project The user's id first_name, last_name, email, referral_code
UserInvited A user is invited The user's id first_name, last_name, email, referral_code
SubscriptionCreated, SubscriptionActivated, SubscriptionExpired An AnythinkPay subscription is created, activated or expires 0 The subscription
PaymentCreated, PaymentSucceeded, PaymentFailed An AnythinkPay payment is created, succeeds or fails 0 The payment
PaymentMethodCaptured, PaymentMethodCaptureFailed, PaymentMethodRemoved A saved payment method is captured, fails to capture or is removed 0 The payment method
PushActionTaken A user taps a push notification action button The user's id, or 0 notification_id, category, action_id, target_user_id, received_at, responded_at and any custom data

For the three entity events, choose the entity in Entity. For PushActionTaken, the entity box is the notification category; leave it empty to react to every category.

Note: On EntityCreated, trigger.data holds the fields in the create request, so it doesn't include the new id or values the server filled in. Use {{ $anythink.trigger.id }} for the id, or add a Read data step to load the saved record.

Limit: EntityDeleted appears in the event list but doesn't fire yet. Don't build on it.

Trigger filters

A filter makes an event trigger skip events you don't care about. Filters are checked before a job is created, so a filtered-out event costs nothing and leaves no job in Job History.

Filters are a tree of and, or and not groups around conditions:

json
{
  "and": [
    { "field": "status", "op": "changed_to", "value": "paid" },
    { "field": "total", "op": "gte", "value": 100 }
  ]
}
op Matches when
eq, neq The field equals (or doesn't equal) value. Numbers compare as numbers.
gt, gte, lt, lte The field compares with value as a number, then as a date, then as text. Null never matches.
in value is a list that contains the field's value
contains The field's text contains value, ignoring case
is_null, is_not_null The field is missing or null, or present and not null
changed The field's value is different from before the update
changed_to The field changed and now equals value

How filters behave:

  • field is a top-level field on the record. Dotted paths and related records aren't supported.
  • changed and changed_to only work on EntityUpdated, the only event that knows the previous values.
  • Types matter: the boolean true doesn't equal the text "true".
  • Text that looks like a date (for example 2026-09-01) is compared as a date.
  • An empty and matches everything; an empty or matches nothing.
  • An unrecognised op counts as a match, so check spelling — a typo makes the filter let every event through.

The dashboard filter builder covers and conditions. Use the workflow API to save or and not groups.

Timed triggers

Timed triggers run on a cron schedule.

Setting Value
cron_expression Five fields: minute, hour, day of month, month, day of week. No seconds field.
Time zone UTC. 0 9 * * * runs at 09:00 UTC, which is 10:00 in London during summer time.
Resolution One minute. Anythink checks for due schedules every minute.
Schedule Cron
Every 15 minutes */15 * * * *
Every day at 02:00 UTC 0 2 * * *
Weekdays at 08:30 UTC 30 8 * * 1-5
First day of the month 0 0 1 * *

Timed jobs start with an empty payload: there's no trigger.id or trigger.data. Begin with a Read data step to fetch what the job should work on.

Limit: Missed runs aren't caught up. If a timed workflow is disabled, it doesn't run while disabled; when you enable it again it runs once at the next minute, then follows its schedule.

Note: An invalid cron expression is saved but never runs. After saving, check the schedule's next run time.

Manual triggers

Manual triggers add the workflow to the Workflows menu on records of the entities you choose. Running it from a record starts a job with that record's id in {{ $anythink.trigger.id }} and the entity name in the payload; trigger.data is empty, so load the record with Read data if you need its fields.

You can also start a workflow from your own code:

http
POST /org/{orgId}/workflows/{workflowId}/trigger
Authorization: Bearer <token>
Content-Type: application/json

{
  "entity_name": "orders",
  "entity_id": 1042,
  "data": "{\"reason\":\"manual resend\"}"
}
  • data is a JSON string, not an object. It becomes trigger.data.
  • For a workflow with a manual trigger, entity_name is required and must be one of the trigger's entities.
  • The caller needs the anythink_workflows:trigger permission.
  • A successful call returns 204 No Content. The job runs in the background.

Limit: anythink workflows create <name> --trigger Manual --entity <entity> saves the trigger with no entities attached, so calls to it are then rejected with "not permitted for this workflow". Until this is fixed, create the workflow first, then set its entities in the Anythink dashboard.

API endpoint triggers

An API endpoint trigger gives the workflow its own route:

http
POST /org/{orgId}/workflows/api/sync-orders?id=1042&entity_name=orders
Authorization: Bearer <token>
Content-Type: application/json

{ "source": "shopify", "since": "2026-09-01T00:00:00Z" }
Rule Detail
Method POST only
Route One path segment, such as sync-orders. Don't include slashes. Stored in lower case and matched without regard to case.
Uniqueness Unique across the project's API endpoint triggers, including disabled ones
Authentication A user token or an API key with anythink_workflows:trigger
Payload The JSON body becomes trigger.data; the id and entity_name query parameters become trigger.id and the entity name
Response 204 No Content when the job is queued, 404 when no enabled trigger has that route

The response doesn't wait for the workflow, so an API endpoint trigger suits fire-and-forget work: imports, syncs and notifications. If your app needs the result, have the workflow write it to a record and read that record.

Workflows with several triggers

  • Events: if more than one trigger on a workflow matches the same event, the workflow runs once.
  • Schedules: each timed trigger runs independently. Two schedules due in the same minute create two jobs.
  • Manual and API calls: when a workflow has both manual and API endpoint triggers, calls may be validated against either one, so an API call can be asked for an entity_name. Keep manual and API triggers on separate workflows.
  • Steps can't tell which trigger started the job. If that matters, use separate workflows or check the payload shape in a Condition or Run a script step.

Note: anythink workflows seed doesn't apply a seed file's triggers array — the workflow is created with a single default Manual trigger regardless of what the file specifies, even though workflows export produces a triggers array in the same shape. Add extra triggers in the Anythink dashboard until this is fixed.

Enabling and disabling

  • A disabled workflow doesn't run for events or schedules, and its buttons disappear from records.
  • Each trigger also has its own Enabled switch.
  • Edits apply to jobs that haven't started yet. A job that's already running finishes with the version it started with.

Note: Calls to the trigger endpoint and to API routes check the trigger's Enabled switch, not the workflow's. To stop a workflow's API route, disable that trigger.

Next steps

Step types

Updated Sep 30, 2026
Every workflow step, its parameters, what it returns to later steps, and when it fails.

Each step does one job. Configure steps on the workflow canvas, or send the same parameters object to the workflow API. Parameter names are snake_case.

How steps return data

A step's output is saved under its key, so a step with key find_orders is read with {{ $anythink.steps.find_orders… }}. Where the output lives depends on the step:

Output shape Steps Read it with
A data list Read data, Create data, Update data, Upsert data, Run a script, Call an API $anythink.steps.<key>.data[0].field
Fields at the top level Condition, Send an email, Send a push notification, File handler, Integration $anythink.steps.<key>.field
Nothing Delete data —

When a step fails, its error details are saved at the top level of its output, and the workflow follows the step's on failure link if it has one.

Filters in data steps

Read data, Update data and Delete data select records with filter_conditions, a list of conditions that must all match:

json
[
  { "field": "status", "operator": "eq", "value": "pending" },
  { "field": "created_at", "operator": "lt", "value": "{{ $anythink.now }}" }
]
operator Meaning
eq, neq Equals, doesn't equal
contains, sw, ew Contains, starts with, ends with
gt, gte, lt, lte Greater or less than
is_null, is_not_null Empty or not empty (no value)

value can contain templates.

Warning: Operators other than these are ignored rather than rejected. For a Delete data step, a filter whose operators are all misspelled matches every record. Test delete steps against a copy of your data first.

Note: Values are added to the query as they are. Avoid values containing & in filters until this is handled.

Row-level security options

Steps run as the workflow worker, so records they create have no owner and aren't readable by users on entities with row-level security. Create data, Update data and Upsert data can grant access as part of the write:

Parameter Default What it does
auto_set_rls false Grant the users in rls_user_field read and write access
rls_user_field user Payload field holding a user id, a comma-separated list of ids, or a list
auto_set_rls_groups false Grant the groups in rls_group_field read and write access
rls_group_field group Payload field holding group ids
auto_set_rls_user_groups false Grant access to the groups the rls_user_field user belongs to
rls_user_group_mode all all of that user's groups, or selected to use rls_user_group_id
rls_user_group_id — Group id for selected mode. Templated.
rls_user_groups_readonly true Group members can read but not edit
rls_user_group_grant false Also write a group grant, so every member of the group can read the record
rls_user_group_field — Field to stamp with the user's group id, when the user belongs to exactly one group
rls_mode add Update data only: add, remove or set the grants

A payload can also carry grants directly:

json
{
  "title": "Weekly check-in",
  "user_id": 21,
  "_rls": { "mode": "add", "users": [{ "user_id": 21, "readonly": false }] }
}

If a field named for grants is missing or can't be read, the step logs a warning and writes the record without that grant.

Limit: Update data ignores the row-level security options when it updates by ids. Use filters, or an _rls payload, to change grants.


Read data

Reads records from an entity.

Parameter Required Default Notes
entity yes — Entity name. Note: entity, not entity_name.
filter_conditions no none See Filters in data steps
limit no 5000 Maximum records. Fetched 1,000 at a time.
fields no all Comma-separated field names
sort_by no — Field name
sort_direction no — asc or desc

Limit: Omitting limit currently fails validation with "Limit must be greater than or equal to 0" instead of falling back to 5000. Set limit explicitly until this is fixed.

Limit: anythink workflows step-add and step-update both silently rewrite a Read data step's entity parameter to entity_name, which then fails with "Entity is required". Neither CLI command can set a working Read data step yet — set parameters with a direct PUT /org/{orgId}/workflows/{workflowId}/steps/{stepId} call instead, until this is fixed.

Returns data: the list of records.

json
{
  "entity": "orders",
  "filter_conditions": [{ "field": "status", "operator": "eq", "value": "pending" }],
  "sort_by": "created_at",
  "sort_direction": "asc",
  "limit": 100
}

Reads ignore row-level security. Filter to the records the workflow should act on.

Create data

Creates one record, or one record per item in a list.

Parameter Required Default Notes
entity_name yes —
payload yes — A JSON object, or a list of objects. Templated.
loop_over no — A template resolving to a list, such as {{ $anythink.steps.find_orders.data }}. The payload is filled in once per item, and the item's fields are available without a prefix: {{ id }}, {{ email }}.
prevent_duplicates no false Skip an item when a record already matches all of its non-empty fields
Row-level security no See Row-level security options

Returns data: the created records, or an empty list if every item was a duplicate. Fails when loop_over doesn't resolve to a list or a create fails.

json
{
  "entity_name": "order_reviews",
  "loop_over": "{{ $anythink.steps.large_orders.data }}",
  "payload": "{ \"order_id\": \"{{ id }}\", \"note\": \"Review orders over £100\" }",
  "prevent_duplicates": true
}

Update data

Updates records by id or by filter.

Parameter Required Notes
entity_name yes
payload yes JSON object. Templated. With filters, it's filled in once per matched record, and that record's fields are available without a prefix.
ids one of ids or filters Comma-separated ids, templated. Takes priority over filters.
filter_conditions one of ids or filters
Row-level security no Includes rls_mode. Filters only.

Returns data: the payloads that were sent (not the saved records). Fails when ids resolves to no numbers, or when neither ids nor filters are set.

json
{
  "entity_name": "orders",
  "ids": "{{ $anythink.trigger.id }}",
  "payload": "{ \"status\": \"reviewed\", \"reviewed_at\": \"{{ $anythink.now }}\" }"
}

Upsert data

For each item, finds a matching record by key fields, then updates it, leaves it, or creates a new one. Use it for imports and enrichment, where the same data may arrive more than once.

Parameter Required Default Notes
entity_name yes —
payload yes — JSON object or list. Templated.
match_on yes — List of fields that identify a record, such as ["external_id"]
on_match no Ignore Ignore returns the existing record; Update writes the payload to it
update_fields no all fields On Update, only write these fields
loop_over no — As in Create data
Row-level security no Applied to created and updated records

Returns data: one record per item, in the order of the input. Fails when an item is missing a match_on field.

When more than one record matches, the oldest (lowest id) is used and a warning is logged. Items are processed one at a time; if one fails, earlier items stay written.

json
{
  "entity_name": "customers",
  "loop_over": "{{ $anythink.steps.fetch_customers.data }}",
  "payload": "{ \"external_id\": \"{{ id }}\", \"email\": \"{{ email }}\", \"name\": \"{{ name }}\" }",
  "match_on": ["external_id"],
  "on_match": "Update",
  "update_fields": ["email", "name"]
}

Delete data

Deletes every record matching the filter.

Parameter Required Notes
entity_name yes
filter_conditions yes At least one condition

Returns nothing, so later steps can't reference it. All matching ids are collected first and then deleted.

json
{
  "entity_name": "sessions",
  "filter_conditions": [{ "field": "expires_at", "operator": "lt", "value": "{{ $anythink.now }}" }]
}

Condition

Branches the workflow. When the conditions hold, the workflow follows on success; otherwise it follows on failure.

Parameter Required Default Notes
filter_conditions yes — field is a template path, such as $anythink.steps.find_orders.data[0].total; value can be templated
logical_operator yes AND AND or OR

Text compares without regard to case. gt, gte, lt and lte compare as numbers when both sides are numbers.

Returns condition_result, logical_operator and evaluation_details, which lists each comparison with its actual value — useful when a branch goes the wrong way.

json
{
  "logical_operator": "AND",
  "filter_conditions": [
    { "field": "$anythink.trigger.data.total", "operator": "gte", "value": "100" }
  ]
}

Note: A false result with no on failure link ends the job as Failed. When "nothing to do" is normal, link the false branch to a harmless step.

Limit: is_null and is_not_null don't work reliably in conditions yet, because missing values are compared as text. Compare with eq and an empty value, or test for emptiness in Run a script.

Run a script

Runs JavaScript for logic that the other steps can't express: reshaping data, calculations, building prompts, parsing responses.

Parameter Required Notes
script yes The body of a function. Use return to output data.

The script can read a $anythink object:

Property Contents
$anythink.trigger id, entity_name and data
$anythink.steps Every earlier step's output, by key
$anythink.workflow id, tenant_id, name, description, created_at, updated_at
$anythink.job id and the current step

console.log, info, warn, error and debug write to the step's log. isArray, isObject and deepClone are available as helpers.

Script returns Output
An object data is a list containing that object
A list data is that list
Nothing No output
A number, text or boolean The step fails — return an object instead
Throws an error The step fails and follows on failure
javascript
var orders = $anythink.steps.find_orders.data;
var total = orders.reduce(function (sum, o) { return sum + o.total; }, 0);
return { count: orders.length, total: total, over_budget: total > 5000 };

Scripts run on the Jint JavaScript interpreter with a 30-second timeout, 64 MB of memory and 100,000 statements. They have no network or file access, templates aren't applied inside scripts, and secrets aren't available — use Call an API or Integration for anything external.

Call an API

Sends an HTTP request.

Parameter Required Notes
url yes Templated. Template values aren't URL-encoded, so encode them yourself.
method yes GET, POST, PUT or DELETE, in capitals
headers no Object of header names to values. Values are templated.
body no Templated. Sent as application/json.
results_path no Dot path into the response to keep, such as data.items

Returns data: the parsed JSON response, or the part at results_path. Fails on a non-2xx status, with status_code and response in the output.

json
{
  "url": "https://api.example.com/v1/customers?updated_since={{ $anythink.steps.last_sync.data[0].synced_at }}",
  "method": "GET",
  "headers": { "Authorization": "Bearer {{ $anythink.secrets.example_api_key }}" },
  "results_path": "customers"
}

Note: An empty response stores no output. A response that isn't JSON, or a results_path that isn't found, succeeds with data set to {} — check the result in a Condition when it matters.

Note: Put credentials in headers, not in the URL. Request URLs are written to the step log.

Send an email

Sends one of your project's email templates. See Email templates.

Parameter Required Default Notes
to yes — Templated; must resolve to an address
from no project default Used only when it's on one of your verified sending domains
template_type yes — The template's key
payload no {} JSON object of template variables. Templated.
attachments no — Up to 10 { "file_id", "disposition", "content_id" } entries

Returns to, template_type, variables and attachment_count. Success means the email was queued for sending, not that it was delivered.

json
{
  "to": "{{ $anythink.trigger.data.email }}",
  "template_type": "welcome",
  "payload": "{ \"first_name\": \"{{ $anythink.trigger.data.first_name }}\" }"
}

Send a push notification

Sends a push notification through your configured providers and waits up to 30 seconds for the delivery result.

Parameter Required Notes
template_type no A push template that supplies any content you leave empty
title, body yes, unless a template is set Templated
image_url, click_action no Templated
category no An action-button category registered for your project
buttons no [{ "id", "label", "click_action" }]
data no Object of extra values delivered with the notification
audience.kind no user (default), users, entity or all
audience.user_id for user Defaults to the trigger data's user_id
audience.user_ids for users One notification to every user in the list
audience.owner_entity_name, audience.owner_entity_id for entity Users with access to that record. The id defaults to the trigger id.
audience.provider no fcm, web_push or apns

Send once per row. When the title, body or another content field refers to $anythink.steps.<key>.data[*], the step sends one notification per item in that step's data, with the item's fields available by name. Up to 10 send at once.

json
{
  "title": "{{ $anythink.steps.due_checkins.data[*].first_name }}, time for your check-in",
  "body": "It takes two minutes.",
  "audience": { "kind": "user", "user_id": "{{ $anythink.steps.due_checkins.data[*].user_id }}" }
}

Returns send_log_id, delivery_status, delivered, failed and unregistered; per-row sends return iterations and totals. The step fails if delivery fails — for per-row sends, only if every send fails.

File handler

Moves files in and out of records.

Parameter Required Default Notes
operation yes — FetchAndLink, LinkExisting or Detach
entity_name, record_id, field_name yes — The record and file field to change. Templated.
source_url for FetchAndLink — Public http(s) URL to download
file_name, folder_id no from the URL Templated
is_public no false
link_mode no set set replaces the field's files; add appends
overwrite_if_present no false With set, replace a file that's already there
skip_if_empty_source_url no false Succeed without doing anything when source_url is empty
existing_file_id for LinkExisting — Templated

Downloads are limited to 50 MB and 30 seconds, and private or local network addresses are refused.

Returns file_id, url, mime_type, size_bytes and skipped, which is true when an existing file was kept.

json
{
  "operation": "FetchAndLink",
  "entity_name": "products",
  "record_id": "{{ $anythink.trigger.id }}",
  "field_name": "image",
  "source_url": "{{ $anythink.trigger.data.image_url }}",
  "skip_if_empty_source_url": true
}

Integration

Calls a connected service with your project's or a user's credentials. Connect services in Settings › Integrations.

Parameter Required Default Notes
provider yes — See the table below
operation yes —
credential_source no system system (project connection), current_user, connection or entity_field
connection_id for connection — Templated
credential_field_path for entity_field — Template resolving to stored credentials
inputs no {} Operation inputs. Text values are templated.
Provider Operations
claude, openai, grok generate-text, analyse-image, analyse-document, summarise, translate; OpenAI adds generate-image
slack send-message, send-direct-message
github list-commits, list-pull-requests, list-releases, list-issues, get-file-contents, get-repo-tree, list-workflow-runs (read-only)
google-calendar create-event, list-events, get-event, update-event, delete-event
linkedin create-post
twitter create-tweet

Returns the service's result at the top level. AI text operations return text, model, input_tokens and output_tokens, read with {{ $anythink.steps.<key>.text }}.

json
{
  "provider": "claude",
  "operation": "generate-text",
  "credential_source": "system",
  "inputs": {
    "model": "claude-opus-4-8",
    "max_tokens": "2000",
    "system_prompt": "Summarise customer feedback in two sentences.",
    "prompt": "{{ $anythink.trigger.data.feedback }}"
  }
}

Limit: Claude models that start their reply with a thinking block, including Claude Opus 5, currently return empty text. Use claude-opus-4-8 until support lands. With no credentials connected, the step fails and tells you which service to connect.

Next steps

Monitoring and testing

Updated Sep 30, 2026
Test workflows before enabling them, read what every step received and returned, and track down failures.

Every run of a workflow is a job, and Anythink keeps each job's payload, status and step-by-step results. That history is how you test a workflow and how you find out why one failed.

Test a workflow

In the Anythink dashboard

  1. Open the workflow and select Test Workflow.
  2. Choose an entity and a record to use as the trigger data, or edit the JSON payload directly.
  3. Run it. The canvas highlights each step as it runs: running, succeeded or failed.
  4. Select View Job History to see the job in full.

With the CLI

bash
anythink workflows trigger 174
anythink workflows trigger 174 --payload '{"total":129.5,"status":"paid"}'

Limit: The CLI prints API error (204): Empty response even when the job was queued successfully — a 204 No Content is the documented success response, not an error. Check Job History, or anythink workflows jobs <id>, to confirm the run.

What a test run does

A test run works the same way for any trigger type, including timed workflows, which run once immediately without affecting their schedule.

Note: Test runs are real runs. Steps write data, call APIs and send email and push notifications. Test against sample records, or disable steps with side effects by routing around them while you build.

Job statuses

Status Meaning
Pending Queued, not started
Running The worker is running its steps; the job shows the current step key
Success The last step it ran succeeded, or a failure was handled by an on failure path
Failed A step failed with no on failure link, the job hit a limit, or the project's workflow quota was used up

Reading a job

Job History lists the workflow's jobs, 25 per page, newest first. Open a job to see:

  • Payload: the id, entity_name and data the job started with.
  • Error: the message from the step that failed the job.
  • Each step's log: what the step did, including resolved queries, request URLs and condition values.
  • Each step's data: the output saved for later steps, or the failure details — for example the HTTP status and response from Call an API, or every comparison a Condition evaluated.

The job view refreshes every two seconds while a job is running.

Jobs from the API

http
GET /org/{orgId}/workflows/{workflowId}/jobs?page=1&pageSize=25
GET /org/{orgId}/workflows/{workflowId}/jobs/{jobId}

Both need anythink_workflows:read. Each job includes its status, payload, error_message, current_executing_step_key and a steps list with every step's success, log, data_json and error_message.

anythink workflows jobs <id> calls the same endpoint, but its list view doesn't surface that detail yet:

text
── Jobs (1) ────────────────────────────────────────────────────────────────────

  Job #133086 Success — ?

The ? stands in for fields the CLI doesn't render (payload, error message). Use anythink workflows step-get <workflow_id> <step_id> for a step's parameters, or call the API endpoints above directly for the full job, including each step's data_json.

Limit: anythink workflows list currently crashes with Value cannot be null. (Parameter 'text') once the project has a workflow with certain malformed trigger configuration. Use anythink workflows get <id> for a single workflow, or call GET /org/{orgId}/workflows directly, until this is fixed.

Re-running a job

Failed jobs aren't retried automatically. To run one again with the same input, copy the job's payload and start the workflow with it:

http
POST /org/{orgId}/workflows/{workflowId}/trigger
Content-Type: application/json

{ "entity_name": "orders", "entity_id": 1042, "data": "{\"total\":129.5,\"status\":\"paid\"}" }

data must be the payload's data as a JSON string. See Triggers and scheduling.

Why didn't my workflow run?

Symptom Check
No job appears for an event The workflow and trigger are enabled, the event and entity are right, and the trigger filter matches. Filtered-out events leave no job.
A timed workflow never runs The cron expression is valid and written in UTC
A job failed with a quota message Your plan's monthly workflow runs are used up
A job failed at a Condition The condition was false and has no on failure link. Link the false branch.
A value shows up as {{ … }} in a record or message The template path doesn't exist: check the step key, data[0], and field names
A step can't find records you can see in the dashboard Workflow reads aren't limited by row-level security, but filters are exact. Check the filter values in the step log.
Users can't see records a workflow created Grant access with the row-level security options on the create step
An integration step fails with "No credentials found" Connect the service in Settings › Integrations
A job failed with "exceeded maximum step limit" Your links form a loop. Jobs stop after 250 step executions.
A workflow runs again and again A step writes to the entity that triggers the workflow. Add a trigger filter so the workflow's own writes don't match.

Keep logs useful and safe

  • Step logs record resolved values, including request URLs. Send credentials in headers or integration connections, never in URLs or query strings.
  • Use console.log in Run a script steps to record intermediate values while you build, and remove noisy logging once the workflow is stable.
  • Give steps descriptive keys (find_due_orders, not step_2): keys appear in templates, logs and the job's current-step field.

Permissions

To Permission
View workflows and job history anythink_workflows:read
Create workflows and add steps anythink_workflows:create
Edit, enable and disable anythink_workflows:update
Delete anythink_workflows:delete
Start a workflow by API or button anythink_workflows:trigger

Project administrators have all of these. API keys need the exact permissions for the calls they make.

Next steps

Secrets

Updated Mar 19, 2026
Secrets are encrypted values — API keys, tokens, and passwords — stored securely against your project. They are available to workflows at runtime but their values are never exposed in the dashboard, logs, or API responses.

What secrets are for

Any time a workflow needs to call an external API, it needs credentials — an API key, a bearer token, a webhook secret. Rather than pasting these values directly into your workflow configuration (where they would appear in logs and step outputs), you store them as secrets and reference them by name.

Secrets are encrypted at rest. Only the workflow engine can decrypt them at runtime, and only for the project they belong to.


Managing secrets

In the dashboard

Go to Settings → Secrets in your Anythink dashboard. From here you can:

  • See all your stored secret keys (names only — values are never shown)
  • Add a new secret
  • Rotate (overwrite) an existing secret
  • Delete a secret

Via the CLI

bash
# List all secret keys
anythink secrets list

# Create a new secret (you will be prompted to enter the value securely)
anythink secrets create STRIPE_SECRET_KEY
anythink secrets create ANTHROPIC_API_KEY

# Rotate a secret (overwrites the existing value)
anythink secrets update STRIPE_SECRET_KEY

# Delete a secret
anythink secrets delete STRIPE_SECRET_KEY --yes

Values are entered via a hidden prompt — they are never visible in your terminal history or shell output.


Using secrets in workflows

Reference a secret in any workflow step that supports template syntax using:

text
{{$anythink.secrets.YOUR_KEY_NAME}}

The most common use is in the Call an API step headers or body. For example, to call an external API that requires an API key:

Headers:

json
{
  "Authorization": "Bearer {{$anythink.secrets.MY_SERVICE_TOKEN}}",
  "x-api-key": "{{$anythink.secrets.MY_API_KEY}}"
}

Body:

json
{
  "api_key": "{{$anythink.secrets.STRIPE_SECRET_KEY}}",
  "amount": "{{ $anythink.trigger.data.amount }}"
}

At runtime, the workflow engine decrypts the secret value and substitutes it into the payload before the request is made. The decrypted value never appears in job history or step logs.


Best practices

  • One secret per credential — give each key a clear, descriptive name (STRIPE_SECRET_KEY not KEY1)
  • Rotate regularly — use secrets update to rotate keys when you cycle credentials in external services
  • Delete unused secrets — if a workflow no longer uses a secret, remove it to keep things tidy
  • Never hardcode sensitive values — if you find a token or password pasted directly into a workflow step body, move it to secrets

Email Templates

Updated Mar 19, 2026
Anythink sends transactional emails automatically as part of authentication flows — account confirmation, password recovery, user invitations, and more. Every template can be customised to match your brand and voice.

System templates

Anythink maintains a set of system email templates, one for each triggered event. You can edit the subject line and body content of any template to match your product's tone and branding.

Template When it is sent
Email Confirmation When a new user registers — they must confirm before their account activates
User Invitation When you invite a user to your project via the dashboard or CLI
Password Recovery When a user requests a password reset
Email Change When a user updates their email address
Added to Organisation When a user is added to your organisation

Editing a template

  1. Go to Settings → Email Templates in your Anythink dashboard
  2. Click the template you want to edit
  3. Update the Subject and Body fields
  4. Use the Preview button to see how the rendered email will look with real sample data
  5. Click Save

Changes take effect immediately — the next time that event is triggered, the updated template is used.


Template variables

Each template has access to a set of variables that are automatically substituted at send time. Use double curly braces to insert them:

text
{{ OrgName }}
{{ Email }}
{{ ConfirmationUrl }}
{{ PasswordResetUrl }}
{{ InviteUrl }}
{{ DashboardUrl }}

Commonly used variables:

Variable Description
{{ OrgName }} Your organisation or project name
{{ Email }} The recipient's email address
{{ ConfirmationUrl }} Link the user clicks to confirm their email
{{ PasswordResetUrl }} Link the user clicks to reset their password
{{ InviteUrl }} Link the user clicks to accept an invitation
{{ DashboardUrl }} Direct link to the dashboard
{{ Token }} The one-time token (if you need to build a custom URL)

The available variables differ slightly between templates — use the Preview panel to see exactly which variables are in scope for each template.


Branding your emails

To make emails feel like they come from your product rather than Anythink:

  • Set your organisation name in Settings → General — this populates {{ OrgName }} in all templates
  • If you have white-labelling enabled, your logo and brand colours are applied automatically to the email wrapper
  • Edit the body copy of each template to match your product's voice

Sender address

Emails are sent from Anythink's shared sending infrastructure. If you need emails to come from your own domain (e.g. noreply@yourapp.com), this is available on higher-tier plans — contact support to configure custom sending.

Workflow recipes

Updated Sep 30, 2026
Complete workflows that combine triggers, data steps, scripts, notifications and integrations — with the reasoning behind each part so you can adapt them.

Each recipe is a full workflow you can rebuild and change. They show the patterns that come up most: importing data without duplicates, messaging many users individually, adding AI to a record, and keeping team data private.

Import customers from another system every night

Goal: keep a customers entity in step with an external CRM, updating existing customers and adding new ones, without creating duplicates when the job runs twice.

Trigger: Timed, 0 2 * * * (02:00 UTC).

Key Step Why
fetch_customers Call an API Pull customers changed in the last day from the CRM
upsert_customers Upsert data Match on the CRM's id, update what changed, create what's new

fetch_customers:

json
{
  "url": "https://api.examplecrm.com/v2/customers?updated_within=24h",
  "method": "GET",
  "headers": { "Authorization": "Bearer {{ $anythink.secrets.crm_api_key }}" },
  "results_path": "customers"
}

upsert_customers:

json
{
  "entity_name": "customers",
  "loop_over": "{{ $anythink.steps.fetch_customers.data }}",
  "payload": "{ \"crm_id\": \"{{ id }}\", \"email\": \"{{ email }}\", \"name\": \"{{ full_name }}\", \"plan\": \"{{ plan }}\" }",
  "match_on": ["crm_id"],
  "on_match": "Update",
  "update_fields": ["email", "name", "plan"]
}

How it works:

  • The API key lives in a project secret and goes in a header, keeping it out of the request URL, which is written to the step log.
  • loop_over runs the payload once per customer, with each customer's fields available by name.
  • match_on: ["crm_id"] makes the import safe to re-run: a customer that already exists is updated, never duplicated.
  • update_fields stops the import overwriting fields your team edits in Anythink.

Adapt it: give crm_id a unique constraint so a manual edit can't create a second match, and add a File handler step if the CRM provides avatars — FetchAndLink with skip_if_empty_source_url: true.

Send each user a personalised reminder

Goal: every morning, remind users who haven't checked in this week, using their own name.

Trigger: Timed, 0 8 * * *.

Key Step Why
due_checkins Read data Find users whose last check-in is more than a week old
remind Send a push notification One notification per user, with their name

due_checkins reads a checkin_status entity filtered on last_checkin_at lt your cut-off date, returning user_id and first_name.

remind:

json
{
  "title": "{{ $anythink.steps.due_checkins.data[*].first_name }}, how was your week?",
  "body": "Your check-in takes two minutes.",
  "click_action": "/check-in",
  "audience": { "kind": "user", "user_id": "{{ $anythink.steps.due_checkins.data[*].user_id }}" }
}

How it works:

  • data[*] in the title and audience makes the push step send once per row, with each row's fields filled in. Up to ten send at a time.
  • When nobody is due, the push step has nothing to send and succeeds with iterations: 0, so a quiet morning isn't a failed job.
  • The step's output totals delivered, failed and unregistered devices, which you can see in Job History. It fails only if every send fails.

Summarise support tickets with Claude

Goal: when a ticket is created, add a two-sentence summary and a suggested priority.

Trigger: Event, EntityCreated on tickets.

Key Step Why
summarise Integration Ask Claude for a summary and a priority, as JSON
parse Run a script Turn Claude's reply into fields
save Update data Write them back to the ticket

summarise:

json
{
  "provider": "claude",
  "operation": "generate-text",
  "credential_source": "system",
  "inputs": {
    "model": "claude-opus-4-8",
    "max_tokens": "1000",
    "system_prompt": "You triage support tickets. Reply with only JSON: {\"summary\": \"two sentences\", \"priority\": \"low\" | \"normal\" | \"urgent\"}",
    "prompt": "Subject: {{ $anythink.trigger.data.subject }}\n\n{{ $anythink.trigger.data.body }}"
  }
}

parse:

javascript
var text = $anythink.steps.summarise.text || "";
var start = text.indexOf("{"), end = text.lastIndexOf("}");
if (start < 0) throw new Error("No JSON in reply: " + text.slice(0, 200));
var reply = JSON.parse(text.slice(start, end + 1));
return { ai_summary: reply.summary, ai_priority: reply.priority };

save:

json
{
  "entity_name": "tickets",
  "ids": "{{ $anythink.trigger.id }}",
  "payload": "{{ $anythink.steps.parse.data[0] }}"
}

How it works:

  • The Integration step returns Claude's reply at the top level, so the script reads $anythink.steps.summarise.text.
  • Parsing in a script means a malformed reply fails the job visibly instead of writing bad data.
  • The whole payload of save is one template, so the script's object is written as-is.
  • save updates by $anythink.trigger.id because trigger.data on a create doesn't include the new id.

Note: Use claude-opus-4-8 for now. Models that begin their reply with a thinking block, including Claude Opus 5, currently return empty text through the Integration step.

Share records with a user's team

Goal: when a coach logs a session note for an athlete, the athlete and everyone in their team can read it, but other teams can't.

Trigger: Event, EntityCreated on session_logs.

One Create data step writes the shared note with row-level security set from the athlete:

json
{
  "entity_name": "session_notes",
  "payload": "{ \"user_id\": \"{{ $anythink.trigger.data.athlete_id }}\", \"note\": \"{{ $anythink.trigger.data.summary }}\" }",
  "auto_set_rls": true,
  "rls_user_field": "user_id",
  "auto_set_rls_user_groups": true,
  "rls_user_group_mode": "all",
  "rls_user_groups_readonly": true,
  "rls_user_group_grant": true,
  "rls_user_group_field": "team_group_id"
}

How it works:

  • Workflow writes have no owner, so without grants nobody but project administrators could read the note.
  • auto_set_rls with rls_user_field gives the athlete read and write access.
  • auto_set_rls_user_groups looks up the athlete's groups; rls_user_group_grant shares the note with every member, read-only.
  • rls_user_group_field stamps the team's group id on the note, so you can filter and report by team.

Adapt it: leave rls_user_group_grant off when only staff should see team records; staff can still read them through group-scoped reads without every team member seeing each other's notes. See Roles and permissions.

Next steps

Build your first workflow | Anythink Docs