Statement Catalog
Each element of the statements array is a single statement. This document catalogs the fields, behavior, and result of all 25 statement types. Every value slot follows the rules of Value Expressions (reference, literal, JsonLogic, locale map), with two exceptions: the pattern of Regex and the key of Cache (see Regex and Cache).
Statement summary
| Category | type | One-line summary |
|---|---|---|
| Resource writes | ResourceCreate | Create Content/Media (optionally publish) |
ResourceUpdate | Full replacement of Content/Media fields (any field/locale not provided is deleted) | |
ResourcePatch | Partial merge of Content/Media fields (only the specified fields/locales; a literal null deletes) | |
ResourceDelete | Delete (Draft/Archived only; if Published, unpublish first) | |
ResourcePublish / ResourceUnpublish | Publish / unpublish | |
ResourceArchive / ResourceUnarchive | Archive / unarchive | |
| Resource reads | ResourceRead | Read a single item by id |
ResourceFind | First matching single item by filter (null if none) | |
ResourceForEach | Iterate internally over the resources matching a filter, running onEach per item | |
ResourceCount | Count only the number of items matching a filter (the items are not read) | |
| External | Http | External HTTP call ({ status, body }) |
EmailSend | Send one email through a registered EmailAccount | |
| Variables | SetVar | Declare / update a script-scoped variable |
| Caching | Cache | Read, write, or remove a short-lived cache private to that Script |
| Value parsing | ParseJson | Parse JSON text into a value (object, array, scalar) and bind it |
| Signing and text | Signature | Verify that a received signature code matches the one built with a secret key (Boolean) |
Hash | Compute an unkeyed digest (string) | |
Regex | Apply a regular expression. Whether it matched (Boolean) or the captured groups (array) | |
| Control flow | If | Conditional branch |
Loop | Iterate (foreach / while / counted) | |
Parallel | Run branches concurrently | |
Return | Return a result and terminate early | |
Try | Exception handling (catch/finally) |
A Content statement that does not name its target by id must state the Content Type it works on. On
ResourceFind,ResourceForEach, andResourceCount,contentTypeis required whenresourceis"Content". There is no Content query that spans a whole Space.ResourceCreatestates the Content Type to create in as well. Media carries no scope because there is one set per Space, and the statements that name their target by id (ResourceRead,ResourceUpdate,ResourcePatch,ResourceDelete, and the publish and archive statements) havetarget, so they need no scope.
Cyclic calls are capped at 3. Turning on
propagateEvents(off by default) on the resource-write statements above (ResourceCreate,ResourceUpdate,ResourcePublish, etc.) raises change events, and those events can run a Script again through a Webhook. Such a chain (Script → event → Webhook → Script → …) continues at most 3 times; beyond that it is dropped automatically to prevent infinite loops.
Common fields
{ "type": "<StatementType>", "name": "<optional, unique within script>", /* ...type-specific fields... */ }type: The discriminator. One of the values in the table above (required).name: Optional. When set, the result is bound into the context at/<name>, so later statements can reference it as{ /<name>/... }. Omit it if you don't use the result.- Binding-name rules:
nameis a key laid directly onto the context root, so it is validated on save. It may use only ASCII letters (a-z, A-Z), digits,_, and-(it has to work as a JSON Pointer key, so any other character, or an empty name, is rejected), must not equal a reserved root (payload,rawPayload,headers,vars,error,now), and must be unique within a single Script. On a format violation, a reserved word, or a duplicate, the save is rejected.
Entity reference shape
Entity references such as contentType and target are unified into a single shape: { "sys": { "id": <value expression> } }. Only sys.id is needed; the target type is inferred from resource (sys.type and sys.targetType are omitted).
contentType.sys.idis usually a literal (e.g."ct_post").target.sys.idis usually a{ /ptr }value expression (resolved at runtime; e.g.{ /payload/sys/id }).
resource
Resource-family statements specify the target kind with resource: "Content" | "ContentType" | "Media" | "ServiceUser".
Content Type is accepted by ResourceCount only. Writing it in any other statement makes the save fail. Creating or changing a Content Type itself is CMA's job, not a Script's.
A ServiceUser (a member who signed up for the product) is read-only. Only the three read statements (ResourceRead, ResourceFind, ResourceForEach) accept this value; writing it in a write statement makes the save fail (see Errors). The rules are covered in Reading the member directory.
Resource writes
Every write statement has propagateEvents (default false). Setting it to true makes that write raise a change event, which runs downstream work such as a Webhook. The default does not raise one (a quiet system write).
ResourceCreate
Creates a Content or Media. Content and Media share the fields model, and values are locale maps.
| Field | Applies to | Description |
|---|---|---|
resource | Common | "Content" or "Media" (required) |
contentType | Content | The Content Type to create ({ sys: { id } }). Required for Content |
fields | Common | Field map { "<field>": { "<locale>": value } }. Each populated field requires the default locale bucket. Content keys follow the Content Type definition; Media keys are fixed (title, description, file) |
locale | Common | (Convenience) When provided, each value in fields is auto-wrapped as { <locale>: value } |
publish | Common | Publish after the write (exposed on CDA/ACDA). Default true |
Mediafile: Thefields.file.{locale}value is an ingest instruction{ "source": <value expression>, "encoding": "url"|"base64" }(both required). On a write that includes a file, the engine performs the ingest (downloading it forurl, or decoding it forbase64, then uploading and processing it). This ingest declares no time, so it comes out of the 30-second base budget (Time budget), and it does not count toward the external-call limit. You can also create a fileless Media. Ifpublish:truebut there is no file or processing is incomplete, the publish step errors; ifpublish:false, it staysDraft.- Result (
namebinding): The created resource.{ /<name>/sys/id },{ /<name>/fields/<field>/<locale> }.
// Content
{ "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_post" } },
"fields": { "title": { "en-US": "{ /payload/fields/title }" } }, "publish": true, "name": "post" }
// Media. file is an ingest instruction
{ "type": "ResourceCreate", "resource": "Media",
"fields": {
"title": { "en-US": "{ /payload/fields/prompt }" },
"file": { "en-US": { "source": "{ /gen/body/data/0/url }", "encoding": "url" } }
}, "name": "img" }ResourceUpdate
Fully replaces the fields of the target Content or Media (PUT). Whatever you pass in fields becomes the new set of fields, and any field and locale not present here is removed. To change only part of it, use ResourcePatch.
| Field | Description |
|---|---|
resource | "Content" or "Media" |
target | The target ({ sys: { id } }, required). The id is usually { /ptr } |
fields | The complete set of fields to write. Values are locale maps. Because this is a full replacement, any field and locale not present here is removed. For Media, file is an ingest instruction (see ResourceCreate above); listed files are always re-ingested, and files for locales not provided are deleted |
locale | (Convenience) Auto-wraps fields |
version | (Optional) A value expression (Int). Optimistic locking. When provided, the update runs only if it matches the target's current sys.version; on a mismatch it aborts with a version-conflict error (catchable with Try). If omitted, there is no check (last-write-wins) |
publish | Republish after the update. Default true |
Using Update to change only a Media's metadata leaves out file, so every file is deleted (because it is a full replacement). For a partial change, always use ResourcePatch.
{ "type": "ResourceUpdate", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
"fields": { "title": { "en-US": "Hello", "ko-KR": "안녕" }, "status": { "en-US": "published" } } }ResourcePatch
Partially merges the fields of the target Content or Media (PATCH). It overwrites only the fields (and the locales within them) you pass in fields, and leaves any field and locale you don't mention untouched. The value shape, locale, version, and publish are the same as ResourceUpdate.
| Field | Description |
|---|---|
resource | "Content" or "Media" |
target | The target ({ sys: { id } }, required). The id is usually { /ptr } |
fields | The fields to overwrite. Values are locale maps. Updates only the specified fields and locale buckets (the rest are kept). If a value is a literal null, that (field, locale) is deleted. For Media, file is an ingest instruction (see ResourceCreate above) |
locale | (Convenience) Auto-wraps fields |
version | (Optional) Same as ResourceUpdate (optimistic locking) |
publish | Republish after the update. Default true |
- Delete a specific locale or file: Give a literal
nullas the value. For example:"title": { "fr-FR": null }(deletes the fr-FR title),"file": { "en-US": null }(deletes the en-US file). A value expression that evaluates to null at runtime is not a deletion but an error (only a literal null deletes). - Giving an ingest instruction to a Media
filereplaces that locale's file. If you don't provide a file, it is kept.
// +1 to viewCount(en-US) only. title, other locales, and everything else are kept as-is
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
"fields": { "viewCount": { "en-US": { "$+": [ "{ /payload/fields/viewCount }", 1 ] } } } }ResourceDelete
Deletes the target. Only the Draft and Archived statuses can be deleted. If the target is Published or Changed the delete is rejected, so you must ResourceUnpublish first. For a Media, the delete is rejected while the file is being processed. It does not auto-unpublish (same as CMA/ACMA).
| Field | Description |
|---|---|
resource | "Content" or "Media" |
target | The target ({ sys: { id } }, required) |
{ "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } }ResourcePublish, ResourceUnpublish, ResourceArchive, ResourceUnarchive
Independently control the target's publish and archive state. All four share the same fields. The status precondition for each operation is the same as CMA/ACMA. ResourcePublish is not allowed from Archived and requires the file processing to be complete. ResourceUnpublish is allowed only from Published or Changed, ResourceArchive only from Draft, and ResourceUnarchive only from Archived.
| Field | Description |
|---|---|
resource | "Content" or "Media" |
target | The target ({ sys: { id } }, required) |
version | (Optional) A value expression (Int). Optimistic locking. When provided, the operation runs only if it matches the current sys.version |
{ "type": "ResourcePublish", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } }
{ "type": "ResourceUnpublish", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } }
{ "type": "ResourceArchive", "resource": "Media", "target": { "sys": { "id": "{ /m/sys/id }" } } }Resource reads
ResourceRead and ResourceFind read a resource and bind it as a value, and ResourceCount only counts. None of the three change state (no propagateEvents). ResourceForEach is also a read for the query itself, but if you put resource-write statements in onEach, that write runs per item and changes state.
All four statements (ResourceRead, ResourceFind, ResourceForEach, ResourceCount) use from (default Current) to choose which stored version to read. Current is the latest draft that the content studio sees (the value CMA/ACMA reads); Published is the published snapshot (the value CDA/ACDA delivers, as of the last publish). A ServiceUser is never published, so it accepts only Current (see Reading the member directory).
In addition, ResourceFind, ResourceForEach, and ResourceCount turn Advanced Search on and off via advanced (default true). If you do not state it, it is on. It is Content-only, so it is ignored for Media and ServiceUser reads. When on, where can use the regex, near, and within operators and full-text search on text (on a full-text-enabled LongText field, eq also matches items that contain the value, by partial and fuzzy matching), and order can sort by fields.*. When off, those three operators are rejected, eq on text is exact match, and prefix and the comparison and list operators are unaffected by Advanced Search. A newly created or edited item takes a short moment (about 1 second) to be reflected in Advanced Search, so it may be missed by the immediately following Advanced Search query. Because it is on by default, this delay applies to every query unless you set advanced to false. If you must read a just-written item right away, use ResourceRead by id (the primary store, with no reflection delay) or query by the sys.id the write returned.
createdBy: ":self" in a where means "only what the current caller created". It cannot be used in a Script that allows anonymous calls (anonymousCallEnabled): there :self resolves to the author rather than the caller, which would silently open the author's own resources, so such a definition is rejected on save (see Anonymous calls).
In where and order, write a content field as fields.<field> (the bare name alone is not recognized). For fields.<field>, the Space default locale is automatically applied, so you do not attach a locale yourself. The fields.status and fields.slug in the examples below are themselves default-locale lookups. Only when you want to query a specific (non-default) locale do you state it explicitly as fields.<field>.<locale> (e.g. fields.title.ko-KR). sys.* (such as sys.createdAt) and createdBy (:self) are written as-is, without fields.. The full rules are in Locales in where and order in Value Expressions.
Reading the member directory (ServiceUser)
ResourceRead, ResourceFind, and ResourceForEach accept "ServiceUser" in resource and read that Space's member directory (ResourceCount does not; see ResourceCount below). Use it to confirm who owns an order, or to find a member by email address and pass their sys.id to the next statement. The rules below are common to all three.
- Reads only.
ResourceCreate,ResourceUpdate,ResourcePatch,ResourceDelete, and the publish and archive statements do not accept"ServiceUser", and such a definition is rejected at save time. This is not something you can open up by adding permissions. There is no path at all for a Script to change a member, which is why it is rejected as a mis-written statement rather than as a permission error. - The author needs member-directory permission for the save to pass. It is not checked through a permission map the way Content and Media are. Instead it looks for
SETTING_SERVICE_LOGIN(orSETTING_ALL) in the author's SpaceRolesettings, because the member directory is a resource governed by Space settings on every other path as well. Without it, the save is rejected (see Security model). fromaccepts onlyCurrent. A member is not a published resource, so passingPublishedmakes execution fail.contentTypeandadvancedare ignored. The member directory is not divided by Content Type (there is one set per Space), and Advanced Search is Content-only.- In
where,sys.emailaccepts only the exact-match operators (eq,ne,in,nin). A member's address is stored encrypted, so ordering comparisons andprefixare meaningless there. Passing any other operator makes execution fail rather than quietly returning zero results. - The result is the ServiceUser resource itself. Reference it as
{ /<name>/sys/id },{ /<name>/nickname }, and so on; its structure is covered in the ServiceUser reference. To email a member you found, do not extract the address; pass theirsys.idtotoServiceUseronEmailSend(the engine resolves the address immediately before sending, so the member's address never enters the Script variable space).
// Find one member by email address. null if there is none
{ "type": "ResourceFind", "resource": "ServiceUser",
"where": { "sys.email": { "eq": "{ /payload/fields/email }" } }, "name": "member" }ResourceRead
Reads a single item by id (get-by-id). The result binds the entire resource to the name.
| Field | Description |
|---|---|
resource | "Content", "Media", or "ServiceUser" |
target | The target ({ sys: { id } }). The id is a value expression |
from | (Optional) Current (default, the latest draft) or Published (the published snapshot). ServiceUser accepts Current only |
- Result: the resource itself is bound to the statement's
name, so you reference it directly as{ /<name>/sys/id }and{ /<name>/fields/<field>/<locale> }(with the"name": "order"of the example below, that is{ /order/sys/id }). It is not a list, so no array index is involved. - If the target does not exist, it errors. You can wrap it in
Tryto handle that.
{ "type": "ResourceRead", "resource": "Content",
"target": { "sys": { "id": "{ /payload/fields/orderId }" } }, "name": "order" }ResourceFind
Reads the first matching single item by filter. If there is none, it is null. Use it to find one record by a unique business key (slug, email, sku).
| Field | Description |
|---|---|
resource | "Content", "Media", or "ServiceUser" |
contentType | The Content Type to search within ({ sys: { id } }). Required for Content. Ignored for Media and ServiceUser |
where | The filter ({ "<field>": { "<op>": <value> } }). Operators are those in the operator list (regex/near/within require advanced). createdBy: ":self" supported. For ServiceUser, sys.email accepts only eq, ne, in, and nin (see Reading the member directory) |
order | The sort that determines the "first" one when multiple match (e.g. "-sys.createdAt") |
from | (Optional) Current (default, the latest draft) or Published (the published snapshot). ServiceUser accepts Current only |
advanced | (Optional) Run via Advanced Search. Content-only (Media and ServiceUser ignored). Default true. See the Resource reads note above |
- Result: Binds the first matching resource to the statement's
name. Reference it directly as{ /<name>/fields/<field>/<locale> }. Since it isnullwhen there is none, branch on existence with{ "==": [ "{ /<name> }", null ] }(the typical find-then-upsert pattern).
{ "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
"where": { "fields.slug": { "eq": "{ /payload/fields/slug }" } }, "name": "found" }ResourceForEach
Iterates internally over the resources matching a filter and runs onEach for each item. It is a statement for performing an action on each item rather than building a collection to use as a value. Use it for repetitive work such as bulk-publishing drafts, bulk-editing Content that matches a condition, or sending/syncing each item externally. To read just one, use ResourceRead (by id) or ResourceFind (by filter).
| Field | Description |
|---|---|
resource | "Content", "Media", or "ServiceUser" (required) |
contentType | The Content Type to iterate within ({ sys: { id } }). Required for Content. Ignored for Media and ServiceUser |
where | The filter ({ "<field>": { "<op>": <value> } }). The meaning is the same as ResourceFind's where (including the ServiceUser sys.email restriction). Operators are those in the operator list (regex/near/within require advanced). createdBy: ":self" supported |
order | The sort (e.g. "sys.createdAt,sys.id"). Without it, the platform default order |
from | Current (default, the latest draft) or Published (the published snapshot). ServiceUser accepts Current only |
advanced | Iterate via Advanced Search. Content-only (Media and ServiceUser ignored). Default true. See the Resource reads note above |
limit | (Optional, 1 or more) The cap on the total number processed (not a page size). Without it, iterates up to the platform ceiling (10,000 items) |
name | (Optional) The name to bind the current item to. It is re-bound on every iteration and referenced inside onEach as { /<name> } (same lifetime as Loop's name; after the iteration ends, the last item stays bound). Omit it if you do not reference the item |
onEach | The array of child statements to run for each item (required) |
- It does not bind a collection (it is
foreach, notmap). There is no{ items, next }and no cursor. Rather than returning the iteration result as a value, it runsonEachper item. If you need a list, gather it yourself withSetVar. If all you need is a count, useResourceCount. - It is not an infinite iteration even without
limit. Without one, it iterates up to the platform ceiling (10,000 items), and if it hits that ceiling with matches remaining, it fails (so as not to report success while leaving untouched items). Conversely, reaching a declaredlimitis an intended stop and terminates normally. Alimitabove the ceiling is rejected on save. - There is no cursor. Completing the run is a success; being cut off partway (wall-clock or quota exceeded, or an unhandled failure in
onEach) is a failure, and the error points to which item failed and why. Resumption is expressed by the author with their own data (leavewhereas "unprocessed" and mark completion at the end ofonEach, so a re-run continues from what remains). - It enters the time budget as a multiplication. The time this statement declares is the time
onEachdeclares multiplied by the number of items processed (limit, or 10,000 when absent) (Time budget). Because it is a composite statement that owns children, it itself does not count toward the external-call leaf budget; the external-call statements insideonEachare what count against the budget. onEachcan hold external calls (Http,EmailSend) or Media file ingestion like any other statement (the same asLoop'sbody). Processing a resource-query result once per item is the reason this statement exists.
// find all draft posts and publish each
{ "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_post" } },
"where": { "fields.status": { "eq": "draft" } }, "order": "sys.createdAt,sys.id",
"from": "Current", "advanced": false, "name": "post",
"onEach": [
{ "type": "ResourcePublish", "resource": "Content", "target": { "sys": { "id": "{ /post/sys/id }" } } }
] }ResourceCount
Counts only how many items match a filter. It does not read the items, so use it when you need a number rather than a list. This is where you check the remaining stock, decide whether the same value already exists, or test whether a limit has been exceeded.
| Field | Description |
|---|---|
resource | "Content" or "ContentType" (required). Media and ServiceUser cannot be counted, and writing them makes the save fail |
contentType | The Content Type to count within ({ sys: { id } }). Required for Content. Ignored when counting Content Type (there is one set per Space) |
where | The filter. The meaning is the same as ResourceFind's where. Every matching item is counted |
from | (Optional) Current (default, the latest draft) or Published (the published snapshot) |
advanced | (Optional) Run via Advanced Search. Content-only (ignored when counting Content Type). Default true. See the Resource reads note above |
name | (Optional) The name to bind the count to |
- Result: Binds the matched count to the statement's
name. Reference it as{ /<name> }and use it in comparisons and branches. - It does not return the items. If you need them, use
ResourceFind(the first matching single item) orResourceForEach(run per item). - Do not iterate with
ResourceForEachjust to get a count. Iteration takes the time budget multiplied by the number of items (Time budget), and it fails if it hits the platform ceiling with matches remaining. When you only need the count, this statement does it in one go. - There is no
orderand nolimit. Counting needs no ordering, and everything that matches is counted.
// count how many comments are on this post
{ "type": "ResourceCount", "resource": "Content", "contentType": { "sys": { "id": "ct_comment" } },
"where": { "fields.postId": { "eq": "{ /payload/sys/id }" } }, "name": "commentCount" }External
Http
Calls an external HTTP endpoint. As an external call it counts toward the per-plan external-call limit, and it enters the time budget as timeoutMs (30 seconds when absent) × (1 + retry).
| Field | Description |
|---|---|
method | "GET", "POST", "PUT", "PATCH", "DELETE" |
url | The target URL (a value expression; { /ptr } can be interpolated) |
headers | [{ "key", "value", "secret"? }]. value is a value expression. A secret:true header is treated as CMA (administrator) only: it is not exposed to end users and is decrypted only immediately before the request is sent. Putting Content-Type here makes body serialize into that format (below) |
body | The request body (a value expression or JSON). The Content-Type header decides which format it goes out in |
timeoutMs | The timeout for this call (ms) |
retry | The number of retries when the response status is 400 or higher. Default 0; the ceiling is 2 |
ignoreStatusCode | Whether to treat this call as a failure when the final status (after retries) is 400 or higher. When false (default) it is treated as a failure and becomes a Try/catch target. When true it is not treated as a failure and { status, body } is bound as-is (the caller branches on status itself) |
responseType | What to receive the response body as. "Json" (default) parses it into an object or array; "Text" receives it as a string |
- Result:
{ status, body }, reachable through the statement'snameas{ /<name>/status }and{ /<name>/body/... }. The shape ofbodyis set byresponseType. responseTypeapplies to a success response only. The body of a response whose status is 400 or higher is bound for diagnostics regardless of what you declared (the parsed value when it is JSON, a string otherwise).- When it is
"Json"and the body is not JSON, this call fails (aTry/catchtarget). For an API that does not return JSON, receive the response as"Text", then parse it withParseJsonwhen you need it as a value. "Text"is decoded with the charset of the responseContent-Type, and treated as UTF-8 when there is no charset. When the body is empty,bodyisnullin both cases.- Response size limit: The response body is at most 10MiB. If it exceeds that, this call fails with an exception and can be handled like any other runtime failure with
Try/catch(this is a size-based failure, so it is not suppressed byignoreStatusCode).
{ "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
"headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
"body": { "prompt": "{ /payload/fields/prompt }" }, "timeoutMs": 15000, "retry": 1,
"responseType": "Json", "name": "resp" }What format the body goes out in
The Content-Type you put in headers decides the serialization format of body. The comparison ignores letter case and parameters such as ;charset=…, and looks only at the leading part. When the header is absent or its value is empty, the request goes out as application/json. This header is attached only when there is a body, so when there is no body the header you wrote goes out as it is. When the same key is given more than once, only the first value is used and they are merged into one.
A body that cannot be carried in the declared format is corrected to a format that can carry it before it is sent. The header never states something different from the actual body.
These are the combinations that go out exactly as declared.
Declared Content-Type | body shape | Body that goes out |
|---|---|---|
application/json | Anything | JSON |
application/x-www-form-urlencoded | An object or array | order[id]=A-2481&order[amount]=34000 |
text/plain | A scalar | The value as it is |
Anything else (text/xml and such) | Anything | JSON |
These are the combinations that get corrected because they cannot be carried in the declared format.
Declared Content-Type | body shape | Content-Type that actually goes out | Body that goes out |
|---|---|---|---|
application/x-www-form-urlencoded | A scalar | text/plain;charset=UTF-8 | The value as it is |
text/plain | An object or array | application/json | JSON |
These two rows state how the request goes out when the pair does not match, and they are not a way to obtain the format you intended. When body is assembled from a value expression, it can turn out to be a scalar depending on the payload at run time, and the correction then happens without an error. If the receiving side objects to the format, fix either the shape of body or the Content-Type to match your intent.
form-urlencoded expands an object into bracket keys and an array into indexes.
body | Expanded keys and values |
|---|---|
{ "order": { "id": "A-2481", "amount": 34000 } } | order[id]=A-2481&order[amount]=34000 |
{ "tags": ["outerwear", "winter"] } | tags[0]=outerwear&tags[1]=winter |
{ "items": [{ "sku": "TUMBLER-500" }] } | items[0][sku]=TUMBLER-500 |
{ "memo": null } | memo= |
Keys and values go out percent-encoded in UTF-8. The table above is decoded so that the key structure is visible. Even when a value contains & or +, it is not mistaken for a pair separator or a space and is delivered as it is.
Expanding nesting into bracket keys is a widely used convention, not a specification of the format itself. Check whether the receiving side restores order[id] into a nested object, and if it does not, build body with flat keys.
{ "type": "Http", "method": "POST", "url": "https://api.example.com/oauth/token",
"headers": [ { "key": "Content-Type", "value": "application/x-www-form-urlencoded" } ],
"body": { "grant_type": "client_credentials", "client_id": "{ /vars/clientId }" },
"name": "token" }EmailSend
Sends one email through a registered EmailAccount. The fields it takes are only those that map straight to SMTP/MIME. There is no template id, scheduled send, or provider-specific extension (if you need those, call that mail service's API directly with Http). The sender (the from address) is not set here; it comes from the EmailAccount that account points to.
| Field | Description |
|---|---|
account | The reference to the EmailAccount to send through ({ sys: { id } }, required). Usually a literal id. If given as a value expression, it is resolved at send time and so cannot be checked on save |
to | The recipient address (a value expression). Use exactly one of to and toServiceUser |
toServiceUser | Specifies the recipient as a ServiceUser reference ({ sys: { id } }; its sys.id can be a value expression). The engine resolves the address just before sending, so the member's address never enters the Script variable space |
cc | The array of CC addresses (a value expression) |
bcc | The array of BCC addresses (a value expression) |
subject | The subject (a value expression, required) |
body | The body (a value expression, required). Always sent as text/html, so write markup rather than plain text (line breaks become whitespace, and < is interpreted as a tag). Interpolated value-expression results are HTML-escaped |
replyTo | (Optional) The Reply-To header (a value expression). It may differ from the sender (for example, send as no-reply but route replies to a support address) |
timeoutMs | (Optional, 1 or more) The timeout for this send (ms). Without it, the platform default; a value above the ceiling is rejected on save |
- The recipient total is at most 50. It counts
to(1),cc, andbccall together (the SMTP envelope has no cc/bcc distinction and all go out as recipients, so they are counted together). Exceeding it is rejected on save and at execution. To send to many people, useResourceForEach+EmailSendto send one message per item. - It binds no result. Success only means "the provider accepted the mail," so there is no value to return and it does not take a
name. It also does not retry (email is non-idempotent, so retrying after an ambiguous failure would produce a duplicate send; that is why it does not followHttp'sretry). A failure is thrown and handled by aTry'scatch. - It is an external call. It counts toward the per-plan external-call limit, and it enters the time budget as
timeoutMs(10 seconds when absent) counted once (it does not retry, so there is no multiplying by a count as withHttp). It can be used inside aResourceForEach'sonEach(the standard form for multi-recipient sends).
{ "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
"to": "{ /order/fields/email/en-US }",
"subject": "Order received (order no. { /order/sys/id })",
"body": "<p>Your order has been received. We will let you know again once shipping begins.</p>",
"replyTo": "support@my-shop.example" }Variables
SetVar
Declares or updates a script-scoped mutable variable. Reference it as { /vars/<var> } (JsonLogic has no variable declaration, so this is provided as a statement).
| Field | Description |
|---|---|
var | The variable name. Referenced as { /vars/<var> } |
value | A value expression. It can reference itself to accumulate |
{ "type": "SetVar", "var": "total", "value": 0 }
{ "type": "SetVar", "var": "total", "value": { "$+": [ "{ /vars/total }", "{ /row/qty }" ] } } // accumulate
{ "type": "SetVar", "var": "ids", "value": { "$merge": [ "{ /vars/ids }", [ "{ /row/sys/id }" ] ] } } // collect into arrayCaching
Cache
Reads from and writes to a short-lived cache private to that Script. It is the place to hold a value that is too costly to fetch again every time, such as the result of an external call, for a few seconds and reuse it on the next call. It is not an external call, so it does not count toward the number of external calls per definition, and it declares no time in the time budget.
| Field | Description |
|---|---|
action | One of "Set" (write), "Get" (read), "Delete" (remove) (required) |
key | The cache key (required). It is a literal, not a value expression (see below). At most 128 characters, and a longer one is rejected on save |
value | The value to store (Set only) |
ttl | How long the cache stays alive (Set only, in seconds). Between 1 and 30; when omitted, 5 |
defaultValue | The value Get binds when there is no cached data (Get only). When omitted, null |
name | The name that holds the result. Required for Get (there is no reason to read a value that has nowhere to go). Set binds the value it stored and Delete binds whether a removal took place; both are optional |
- Write only the fields that belong to the action. A
ttlon aGet, or adefaultValueon aSet, is rejected on save. - Absent and expired are not distinguished. Both bind
defaultValue. The same goes for a value stored asnull. - The store is scoped to that one Script. Another Script in the same Space cannot see its data even under the same cache key. Updating or deleting that Script removes all of that Script's data.
keyis a literal. Letting a cache key that came from the request pick the data would let the caller decide what is read, and a Script that holds one value per member would hand one member's value to another. That is why a{ /pointer }insidekeyis neither turned into a value nor taken literally: the save itself is rejected.- It cannot go inside an iteration. A
Cacheinside aLooporResourceForEachblock is rejected on save. That is because the count limit below cannot limit anything inside an iteration. One piece of data is written per lap, so the number of statements in the definition and the number of cache keys actually used come apart. - Up to 5 per definition (nested included, summed regardless of action). Beyond that, the save is rejected.
- The value you store is at most 10,240 bytes (10KiB). Beyond that, the statement fails (status 422). It behaves like any other runtime failure, so
Try/catchcan handle it locally.
// Reuse the exchange rates for 30 seconds.
{ "type": "Cache", "action": "Get", "name": "cached", "key": "rates" }
// If a value is being held, return it as-is with no external call
{ "type": "If", "condition": { "!!": [ "{ /cached }" ] },
"then": [ { "type": "Return", "value": "{ /cached }" } ] }
{ "type": "Http", "name": "fetched", "method": "GET", "url": "https://api.example.com/rates" }
{ "type": "Cache", "action": "Set", "key": "rates", "value": "{ /fetched/body }", "ttl": 30 }
{ "type": "Return", "value": "{ /fetched/body }" }
// Discard the held value before it expires
{ "type": "Cache", "action": "Delete", "key": "rates" }Value parsing
ParseJson
Parses JSON text into the value it denotes and binds it to a name. Use it for a body received from Http with responseType: "Text", a JSON string that arrived in the payload, or JSON kept as a string in a field. It is not an external call, so it does not count toward the external-call limit, and it declares no time in the time budget.
| Field | Description |
|---|---|
name | The name that holds the parsed value (required). It is optional on other statements but required here. The statement does nothing beyond binding its result, so one without a name has no effect at all |
value | The JSON text to parse (value expression, required). Point at a value from an earlier step, as in { /resp/body }, or write the JSON text out literally (a { inside the literal is not read as a { pointer } template) |
- Result: the parsed value itself. An object stays an object, an array stays an array, and a single value such as
42or"a"is parsed too. Address the inside with{ /<name>/... }afterwards. - A value that is already parsed is bound unchanged. When
valueresolves to something that is not a string, there is no text to parse, so that value is bound as it is. - A
{ /pointer }inside the parsed text is not resolved again. Even when a string received from outside holds an expression such as{ /payload/... }, it is not substituted and stays text. nullcovers two different cases. When the text to parse is the single wordnull, that is normal and the result isnull. But when the placevaluepoints at is empty, so there is no value at all, there is nothing to parse and the statement fails.- Failure: when
valueresolves to no value or to whitespace only, or when the text is not JSON. Handle it withTry/catchlike any other runtime failure; the error message carries the text it tried to parse. - It counts as one statement against the per-definition statement count, but has nothing to do with the external-call limit or the
SetVarcap.
// 1) An API that does not return JSON: receive as Text, then parse
{ "type": "Http", "method": "GET", "url": "https://api.partner.example/v1/quote",
"responseType": "Text", "name": "resp" },
{ "type": "ParseJson", "name": "quote", "value": "{ /resp/body }" },
// 2) Parse a JSON string that arrived in the payload
{ "type": "ParseJson", "name": "spec", "value": "{ /payload/fields/specJson }" }Signature verification and text processing
These statements verify the signature a payment provider sent on a webhook, and take apart the string that signature arrived packed inside. All three are computation rather than an external call, so they do not count toward the external-call limit and declare no time in the time budget; and because they declare no data slot, the $ prefix rules do not apply to them. A complete example combining all three is in Verifying a webhook signature in the cookbook.
All three cap the length of the value they handle. The cap is on what the expression resolves to, not on the length of the expression (the sixteen characters of { /rawPayload } point at tens of kilobytes), and exceeding it fails execution, which Try can handle. The numbers are collected in Value length limits.
Signature
Checks whether the signature code received is the one built with secret, and binds the answer as a Boolean value. Signatures that payment providers (PG, MoR) send on webhooks are verified with this statement.
| Field | Description |
|---|---|
name | The name to bind the verification result to (required). { /<name> } is true or false. A verification whose result is never used is the same as not verifying, so it cannot be omitted |
algorithm | The hash the code is built on (required). SHA1, SHA256, SHA384, SHA512 |
secret | The secret key shared with the other side (value expression, required) |
secretEncoding | How secret is written. Utf8 (default, a text key), Hex, Base64. A key issued as hex or base64 but left as text is a different key: it produces a plausible-looking code that never matches |
value | The message the code is computed over (value expression, required). It must be character-for-character what the other side signed, so it is usually { /rawPayload }, or that prefixed with a timestamp the provider packed into its header |
expected | The code the caller sent (value expression, required). For example { /headers/x-signature } |
- Result: a
Boolean. Use{ /<name> }directly as anIfcondition afterwards. - Take
valuefrom/rawPayload, not from the parsed/payload. Turning a parsed payload back into a string normalizes whitespace, number notation, and escaping, so it does not return to the bytes the other side signed (see Context roots). - There is no field for the output notation.
algorithmfixes the byte length of the code, and for a given length the hex and base64 renderings have different string lengths, so the engine recovers the bytes without being told which one the other side sent. Hex letter case, and base64 versus base64url (padding included), are non-distinctions for the same reason. - What fails and what returns
falseis decided by who supplies the value.- If
expectedis absent or the code does not match, the result isfalse, not a failure. Reporting a missing header separately from a wrong code would tell the sender which of the two was wrong. - If
valueis empty, it is computed as the empty message. An empty body is signed too. - If
secretis absent or is not the notationsecretEncodingdeclares, it fails. Of the three, this is the only input that is the author's own. Neithersecretnorvalueappears in the failure message.
- If
- The cap on
valueis 65,536 characters (on the resolved value). It is sized for the webhook body sizes real providers send. - The comparison decides equality in constant time. How many leading bytes matched does not leak through the response time.
secretis not stored encrypted. Unlikesecret: trueon anHttpheader (stored encrypted, decrypted immediately before sending), it stays in the definition exactly as written, so the value is visible to any role that can read that Script. Members (ServiceUser) cannot read a Script definition (reading and authoring are CMA-only).
// A provider that signs the whole body
{ "type": "Signature", "name": "verified", "algorithm": "SHA256",
"secret": "whsec_9f2c1b7ae4", "value": "{ /rawPayload }",
"expected": "{ /headers/x-webhook-signature }" }
// A provider that issues its key as base64
{ "type": "Signature", "name": "verified", "algorithm": "SHA256",
"secret": "aGVsbG8td2VlZ2xvbw==", "secretEncoding": "Base64",
"value": "{ /rawPayload }", "expected": "{ /headers/webhook-signature }" }Hash
Digests value and binds it as a string in the notation encoding specifies. Use it to reproduce a signature scheme that concatenates a few fields with a secret key and computes SHA256, rather than using HMAC.
| Field | Description |
|---|---|
name | The name to bind the digest to (required) |
algorithm | MD5, SHA1, SHA256, SHA384, SHA512 (required). MD5 is for reproducing an older scheme that asks for it, not a value to choose for a new signature |
value | The message to digest (value expression, required) |
encoding | The result notation. Hex (default), HexUpper, Base64, Base64Url |
- There is no
secretfield. Schemes put the key in front, behind, or in the middle, so writing it directly insidevalueexpresses every position. - Result: a string. To compare it with the code the other side sent, write
{ "==": [ "{ /<name> }", "{ /headers/... }" ] }. UnlikeSignature's constant-time comparison, this is an ordinary equality comparison. - If
valueresolves to nothing or to whitespace only, it fails (it is the author's own expression). - The cap on
valueis 128 characters. It is the place for a few concatenated fields, so it is far narrower thanSignature's. If you must compute over an entire webhook body, useSignature.
// SHA256(order number + amount + merchantKey) as uppercase hex
{ "type": "Hash", "name": "expectedSign", "algorithm": "SHA256", "encoding": "HexUpper",
"value": "{ /payload/orderId }{ /payload/amount }9f2c1b7ae4" }Regex
Applies pattern to value and binds what mode asks for. Value expressions have no way to cut a string up (there is only cat for joining and in for containment), so use this statement when several values arrive packed into one header, as in t=…,v1=….
| Field | Description |
|---|---|
name | The name to bind the result to (required). With Capture, an element is addressed as { /<name>/1 } |
mode | "Match" binds whether it matched as a Boolean; "Capture" binds the first match as an array (required) |
pattern | The regular expression (required). It is a literal, not a value expression (see below). Flags go inline in the pattern, as in (?i). At most 128 characters, and a longer one is rejected on save |
value | The text to apply the pattern to (value expression, required). If the resolved value exceeds 10,240 characters (10KiB), execution fails |
- Result:
Matchis aBoolean;Captureis an array ornull. In the array, index0is the whole match and1onward are the capture groups, and a group that did not participate isnull(not an empty string, which would be something that did match). If the pattern does not occur,Captureisnullrather than an empty array. - Both modes ask whether the pattern occurs anywhere. If the whole text must equal the pattern, anchor it with
^…$. The question is kept the same in both modes so that aMatchcheck and theCapturethat follows it cannot disagree. patternis one of the two fields in this engine that are not value expressions (the other is thekeyofCache). Running a pattern that came from the request would let the caller choose the expression being run, and regular-expression backtracking turns that into a denial of service. That is also why a{ /pointer }inside a pattern is not substituted; it becomes part of the pattern literally.- A pattern is compiled once per run, across the whole definition. It is not recompiled per iteration even inside a
LooporResourceForEach, and an unusable pattern fails before the first statement has done anything (Trycan handle it).
// Take apart "t=1492774577,v1=<64 hex chars>" into { /sig/1 } = timestamp, { /sig/2 } = code
{ "type": "Regex", "name": "sig", "mode": "Capture",
"pattern": "^t=(\\d+),v1=([0-9a-f]{64})$", "value": "{ /headers/x-provider-signature }" }
// Check the format only
{ "type": "Regex", "name": "isOrderId", "mode": "Match",
"pattern": "^ORD-\\d{8}-\\d{4}$", "value": "{ /payload/orderId }" }Control flow
If
A conditional branch. condition is JsonLogic, and truthy/falsy follows the Truthiness rules.
| Field | Description |
|---|---|
condition | JsonLogic (evaluated as a boolean) |
then | The Statement array to run when true |
else | (Optional) The Statement array to run when false |
{ "type": "If",
"condition": { "<": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] },
"then": [ { "type": "Return", "value": { "ok": false, "reason": "insufficient credit" } } ],
"else": [ /* ... */ ] }Loop
Iteration. Pick one mode: over (foreach), while (condition), or for (counted). In every mode the engine enforces an iteration ceiling (to prevent infinite loops). You declare the ceiling with maxIterations, and when you do not state it, the platform ceiling applies. External calls (Http, EmailSend) and Media file ingestion can also go inside body, and an external-call statement actually runs on each iteration at execution time. The maximum number of external calls per definition still applies.
It enters the time budget as a multiplication. The time this statement declares is the time body declares multiplied by maxIterations (10,000 when absent) (Time budget). When body makes no external call, its declared time is 0, so the 30-second base budget is the effective limit.
| Field | Description |
|---|---|
over | foreach: a value expression that resolves to an array |
while | condition: JsonLogic (repeats while true) |
for | counted: { "from", "to", "step"? }. From from to to inclusive; step defaults to 1 |
maxIterations | The maximum iteration count (optional). When you do not state it, the platform ceiling of 10,000 applies, and a larger value is rejected on save |
name | (Optional) The name to bind the current item (foreach) or the index (while/for) to ({ /<name> }) |
body | The Statement array for the loop body |
// foreach
{ "type": "Loop", "over": "{ /payload/fields/items }", "name": "item", "maxIterations": 100,
"body": [ { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_item" } },
"fields": { "name": { "en-US": "{ /item/name }" } } } ] }
// while
{ "type": "Loop", "while": "{ /vars/hasMore }", "maxIterations": 1000, "body": [ /* ... */ ] }
// counted (1..10 step 2)
{ "type": "Loop", "for": { "from": 1, "to": 10, "step": 2 }, "name": "i", "maxIterations": 100, "body": [ /* ... */ ] }Parallel
Runs branches concurrently and continues after they join. References between branches are not allowed (if there is a dependency, place them sequentially).
| Field | Description |
|---|---|
branches | Statement[][]. Each element is one branch (an array of statements) |
{ "type": "Parallel", "branches": [
[ { "type": "Http", "method": "GET", "url": "https://api.a.com/x", "name": "a" } ],
[ { "type": "Http", "method": "GET", "url": "https://api.b.com/y", "name": "b" } ]
] }Return
This is the return from ordinary programming. It returns the Script's result to the caller and terminates normally at that point.
| Field | Description |
|---|---|
value | (Optional) The value expression to return |
isError | Default false. When true, value comes back as the response's error (otherwise as return) |
statusCode | The response status code. Default 200 |
- If
Returnis never reached, there is no return value. To return a result, specifyvalueexplicitly. - Because it is a normal termination, not an exception or throw, it is not a
catchtarget (even insideTryit terminates the entire Script, butfinallystill runs). - A guard is also expressed with this statement. Putting a
Returnin anIf'sthenreturns a value when the condition is violated and does not run the statements that follow. This is one of several uses ofReturn.
{ "type": "Return", "value": { "orderId": "{ /order/sys/id }", "status": "paid" }, "statusCode": 201 }
{ "type": "Return", "value": { "reason": "payment failed" }, "isError": true, "statusCode": 402 }Try
Exception handling.
| Field | Description |
|---|---|
body | The Statement array to attempt |
catch | (Optional) Runs when body fails. Exposes { message } at /error (which statement failed is not carried) |
finally | (Optional) Always runs regardless of success or failure |
- If
catchhandles it, the Script is not aborted. Only a failure with nocatchaborts the Script (including a compensation attempt). - What counts as a "failure", and the limits of compensation, are covered in Execution semantics, constraints, and security.
{ "type": "Try",
"body": [ { "type": "Http", "method": "POST", "url": "https://primary.api/gen", "name": "resp" },
{ "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
"fields": { "text": { "en-US": "{ /resp/body/text }" } } } ],
"catch": [ { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
"fields": { "text": { "en-US": "Generation failed" }, "error": { "en-US": "{ /error/message }" } } } ],
"finally": [ /* always runs */ ] }Related documents
- Value Expressions: The value rules that all of the fields above follow.
- Execution semantics, constraints, and security: Execution order, errors, static constraints, and security.
- Cookbook: Complete examples that combine these statements.
- Script overview: The top-level structure and how much time one execution gets.
