# V4 sync job reference

> Source: https://truto.one/docs/guides/sync-jobs/v4-reference/

A version 4 sync job is a graph of named nodes: requests fetch data, transforms reshape it, and destination nodes send it to a webhook or a datastore. This page lists every node type and field the V4 runtime accepts.

:::skip-reading{cli="truto sync-jobs create --stdin < job.json" skill="truto-operator"}
:::

## The job

| Field | Required | What it does |
|---|---|---|
| `default_runtime_version` | Yes | `4`. Always send it: a job created without it runs on version 2. |
| `label` | No | A name for the job. Defaults to `Untitled`. |
| `integration_name` | No | The integration the job is for, or `""` for a job any integration can use. |
| `resources` | Yes | The nodes, as an array. A run of a job with no nodes is refused with `sync_job_no_resources`. |
| `args_schema` | No | The arguments a run takes. See [Args](#args). |
| `args_validation` | No | A JSONata expression over `args`. Return `null` to accept the run, or `{ "message": "..." }` to refuse it with `400` and that message. |
| `mutex_key` | No | A placeholder template, such as `{{args.integrated_account_id}}`. While a run with the same resolved key is still active, a new run is refused with `400` and `truto_error_code: sync_job_run_not_completed`. |
| `state_key` | No | A placeholder template that names where `get_state` and `update_state` keep their values. Runs that resolve to the same key share state. A state node in a job without a `state_key` fails the run. |
| `delay_between_requests_ms` | No | A pause between requests. |

## Args

A V4 run gets everything it needs from `args`: which account to read, and where to send the output. The run itself has no `integrated_account_id`, `webhook_id` or `datastore_id`. Truto ignores those top-level fields on a V4 run.

Each key of `args_schema` describes one argument with `type`, an optional `format` and an optional `required`. Truto reads `args_schema` only for the ID formats in the table below. For those, a missing `required` argument refuses the run with `400`, and so does a value that doesn't match its format:

| `format` | The value must be |
|---|---|
| `integrated_account_id` | An integrated account in the job's environment |
| `webhook_id` | A webhook in the job's environment |
| `super_query` | A SuperQuery region: `apac` or `wnam` |
| `datastore_id` | A [datastore](/docs/guides/datastores/overview) in the job's environment |

For any other argument, `type`, `format` and `required` are not checked: a run without `start_date` below still starts, and `{{args.start_date}}` stays in the output as literal text. To refuse such a run, check the argument in `args_validation`, as the example does for `start_date`.

If every account named by an `integrated_account_id` argument needs reauthentication, the run fails as soon as it starts. See [Sync Jobs overview](/docs/guides/sync-jobs/overview#sync-job-runs).

```json
"args_schema": {
  "integrated_account_id": { "type": "string", "format": "integrated_account_id", "required": true },
  "webhook_id": { "type": "string", "format": "webhook_id" },
  "start_date": { "type": "string", "format": "date", "required": true }
},
"args_validation": "$exists(args.start_date) ? null : { \"message\": \"start_date is required\" }"
```

## Nodes

Every node has a `name` and a `type`. Names must be unique: `depends_on` and `resources_to_persist` refer to nodes by name. Every node also takes:

- `depends_on`: the name of **one** parent node. A node without one starts the run.
- `run_if`: a JSONata expression. The node runs only when it's truthy.
- `debug`: `true` logs a transform's input and output, which helps when an expression misbehaves.

### `request`

Calls an API for an integrated account.

| Field | What it does |
|---|---|
| `resource` | `<unified model>/<resource>`, such as `ticketing/tickets`, for a Unified API call. A name without a `/`, such as `tickets`, is a Proxy API resource. |
| `method` | `list`, `get`, or another method of the resource. |
| `integrated_account_id` | Required. Usually `{{args.integrated_account_id}}`. |
| `id` | The record ID for methods that take one. |
| `query`, `body` | An object with `{{placeholders}}`, or a JSONata string that returns the object. |
| `loop_on` | A dotted path, such as `resources.ticketing.tickets`. The node runs once per item, and each run sees that path set to one item. |
| `recurse` | `{ "if": ..., "config": { "id": ..., "query": ... } }`: call the same resource again for each record where `if` holds, such as child pages. |
| `paginate_if` | A JSONata expression. Truto fetches the next page only while it's truthy. It sees `payload` and `pagination` (`previous_total_records_size`, `current_page_records_size`, `total_records_size`, `next_cursor`). |
| `delay_after_ms` | A pause after each call. The longest of this and the job's and run's delays wins. |

Later nodes read a request's records under `resources`, with each `/` in `resource` as a nested key: `ticketing/tickets` is `resources.ticketing.tickets`. In JSONata, quote a key with a hyphen in backticks, such as ``resources.`user-directory`.users``.

### `transform`

Runs `config.expression`, a JSONata expression over the parent's output, and passes the result on as `payload.records`. An empty result passes nothing. Set `use_args: true` when the parent isn't a request, such as a transform that builds records from `args`.

### `spool`

Waits for every page and iteration of its parent, then passes all of it on at once. Put one before a transform that needs the whole set, such as one that combines pages into a single document.

### `add_context`

Runs `config.expression` and merges the object it returns into the context of later nodes. Use it to expose a value, such as a watermark, as `{{last_synced_at}}` or `last_synced_at` in JSONata.

### `get_state`, `update_state`, `delete_state`

Read and write values kept under the job's `state_key`, so one run can pick up where the last one stopped.

| Node | `config` |
|---|---|
| `get_state` | `key`, and `default`, which is stored and used the first time the key has no value. Later nodes read it at `resources.<node name>.value`. |
| `update_state` | `key`, and `value` (a placeholder template) or `value_expression` (JSONata). |
| `delete_state` | `key`. |

You can inspect and reset a job's state on its **Run State** tab in the dashboard.

### `event`

`config.name` is `start` or `complete`. Nodes that depend on a `start` event run when the run starts. Nodes that depend on a `complete` event run after everything else, which is where an `update_state` that moves the watermark belongs.

### `destination`

Sends the output of the nodes named in `resources_to_persist` somewhere. `config.id` names the target, usually from `args`. A destination's `run_if` also sees `payload`, the records about to be sent, so `$count(payload.records) > 0` skips empty batches.

| `destination_type` | What it does |
|---|---|
| `webhook` | Sends each batch to the webhook in `config.id` as a `sync_job_run:record` event. The run's `sync_job_run:started`, `sync_job_run:completed` and `sync_job_run:failed` events go to the same webhooks. A job with no webhook destination sends those to every webhook in the environment that subscribes to them. |
| `datastore` | Calls `method` on the datastore in `config.id`, with `config.config` as the method's input. `loop_on` calls it once per item at that path. See [the methods each type takes](/docs/guides/datastores/overview#3-write-sync-job-output-to-it). |

## Placeholders and context

`{{...}}` placeholders and JSONata expressions read the same context:

- `args`: the run's arguments.
- `resources`: output of earlier requests and `get_state` nodes.
- `payload`: in a destination, the batch being sent, so `{{payload.records.0.file_name}}` is the first record's `file_name`.
- `sync_job_run`: the run, such as `sync_job_run.started_at.toISOString()` or `sync_job_run.status`.
- Anything an `add_context` node added.

Add a type suffix when a value must not become text: `{{resources.knowledge-base.page-content.has_children:bool}}` is a boolean, and `{{payload.records.0.parquet:any}}` keeps the value as it is, such as binary Parquet built with `$jsonToParquet`. Expressions can use the `$` functions of [`@truto/truto-jsonata`](https://www.npmjs.com/package/@truto/truto-jsonata).

## An incremental job

This job reads tickets created since the last successful run and sends them to a webhook. The first run starts from `args.start_date`.

```json
{
  "label": "Fetch tickets with incremental sync",
  "default_runtime_version": 4,
  "integration_name": "",
  "state_key": "{{args.integrated_account_id}}",
  "mutex_key": "{{args.integrated_account_id}}",
  "args_schema": {
    "start_date": { "type": "string", "format": "date", "required": true },
    "integrated_account_id": { "type": "string", "format": "integrated_account_id", "required": true },
    "webhook_id": { "type": "string", "format": "webhook_id", "required": true }
  },
  "args_validation": "$exists(args.start_date) ? null : { \"message\": \"start_date is required\" }",
  "resources": [
    {
      "type": "get_state",
      "name": "get-last-synced-at",
      "config": { "key": "last_synced_at", "default": "{{args.start_date}}" }
    },
    {
      "type": "add_context",
      "name": "add-last-synced-at",
      "depends_on": "get-last-synced-at",
      "config": { "expression": "{ 'last_synced_at': resources.`get-last-synced-at`.value }" }
    },
    {
      "type": "request",
      "name": "list-tickets",
      "resource": "ticketing/tickets",
      "method": "list",
      "depends_on": "get-last-synced-at",
      "query": { "created_at": { "gt": "{{last_synced_at}}" } },
      "integrated_account_id": "{{args.integrated_account_id}}"
    },
    {
      "name": "webhook",
      "type": "destination",
      "destination_type": "webhook",
      "config": { "id": "{{args.webhook_id}}" },
      "resources_to_persist": ["list-tickets"]
    },
    { "name": "on-complete", "type": "event", "config": { "name": "complete" } },
    {
      "type": "update_state",
      "name": "update-last-synced-at",
      "depends_on": "on-complete",
      "config": {
        "key": "last_synced_at",
        "value_expression": "sync_job_run.status = 'completed' ? sync_job_run.started_at.toISOString()"
      }
    }
  ]
}
```

Which filters a Unified API `list` accepts, such as `created_at` with `gt`, depends on the integration's mapping. Check the resource's query parameters in the [Unified API reference](/docs/api-reference/overview/querying) before you rely on one.

Create the job, then start a run with its args:

:::code-tabs

```bash label="cURL"
curl -X POST "https://api.truto.one/sync-job" \
  -H "Authorization: Bearer <API token>" \
  -H "Content-Type: application/json" \
  -d @job.json

curl -X POST "https://api.truto.one/sync-job-run" \
  -H "Authorization: Bearer <API token>" \
  -H "Content-Type: application/json" \
  -d '{
    "sync_job_id": "<sync_job_id>",
    "args": {
      "integrated_account_id": "<integrated_account_id>",
      "webhook_id": "<webhook_id>",
      "start_date": "2026-01-01"
    }
  }'
```

```bash label="CLI"
truto sync-jobs create --stdin < job.json
truto sync-job-runs create -b '{
  "sync_job_id": "<sync_job_id>",
  "args": {
    "integrated_account_id": "<integrated_account_id>",
    "webhook_id": "<webhook_id>",
    "start_date": "2026-01-01"
  }
}'
```

:::

## How V4 differs from versions 1 to 3

| | Versions 1 to 3 | Version 4 |
|---|---|---|
| Node `name` and `type` | Optional on requests. A node without `type` is a request. | Required on every node. |
| Where output goes | The run picks one destination: `webhook_id`, `datastore_id`, `super_query` or `daemon_group_key` (a [daemon](/docs/guides/daemon/overview) group). | `destination` nodes in the job, with targets from `args`. A job can write to several. |
| What gets sent | Requests marked `persist: true`, and transforms marked `persist`. | The nodes listed in each destination's `resources_to_persist`. |
| Integrated account | The run's `integrated_account_id`. | Each request's `integrated_account_id`, usually from `args`. One run can read several accounts. |
| Datastores | MongoDB Data API, one collection per resource (versions 2 and 3). | Any datastore type with methods, called by name. |
| Incremental state | Not available as nodes. | `get_state`, `update_state`, `delete_state` and `event` nodes. |
| Response format | `response_format` on a request: `raw`, `unified` or `normalized`. | Not a node field. |

A job runs on the version in its `default_runtime_version`.

## Next

For the dashboard screens that create, run and inspect these jobs, see [Using the Sync Jobs UI](/docs/guides/sync-jobs/using-the-ui). To start a V4 run each time an account connects, use a [workflow](/docs/guides/workflows/overview).
