Cookbook (Worked Examples)

Each scenario is shown as a complete ScriptDefinition. For the syntax they build on, see the Statement Catalog and Value Expressions; for execution and constraints, see Execution Semantics, Constraints, and Security. In every example the write fields value is a locale map ({ "<locale>": value }), and the example locale is standardized on en-US. Every example runs inline on the path that handles the calling request and returns its result as the response body of that call. In an example that makes an external call (Http, EmailSend), that statement's timeoutMs is added to the execution time budget (× (1 + retry) for Http), and if that statement sits inside an iteration (Loop, ResourceForEach), it is multiplied by the iteration ceiling (Time budget).

Table of Contents

Basic CRUD

1. Create and publish Content

{ "method": "Post",
  "statements": [
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_post" } },
      "fields": { "title": { "en-US": "{ /payload/fields/title }" }, "body": { "en-US": "{ /payload/fields/body }" } },
      "publish": true, "name": "post" },
    { "type": "Return", "value": { "id": "{ /post/sys/id }" }, "statusCode": 201 } ] }

2. Update with a computed value (view count +1)

{ "method": "Post",
  "statements": [
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
      "fields": { "viewCount": { "en-US": { "$+": [ "{ /payload/fields/viewCount }", 1 ] } } } } ] }

3. Collect and return my order list

{ "method": "Get",
  "statements": [
    { "type": "SetVar", "var": "orders", "value": [] },
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "createdBy": ":self" }, "order": "-sys.createdAt", "from": "Current", "advanced": false,
      "limit": 20, "name": "order",
      "onEach": [
        { "type": "SetVar", "var": "orders", "value": { "$merge": [ "{ /vars/orders }", [ "{ /order }" ] ] } } ] },
    { "type": "Return", "value": { "orders": "{ /vars/orders }" } } ] }

With createdBy: ":self" it iterates over "only mine" and gathers the items with SetVar to return them. ResourceForEach does not bind the iteration result as a collection, so to return a list you gather it yourself like this. An iteration enters the time budget as the time onEach declares multiplied by the number of items processed. Here onEach makes no external call, so the declared time is 0 and the 30-second base budget is the effective limit; put an external call in onEach and that multiplication goes straight into the budget, and once it reaches the 180-second ceiling execution stops there (Time budget). When all you need is to read a list and return it, it is better to call the CDA/CMA list API directly from the frontend instead of a Script traversal.

4. Read one, guard, then approve

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "target": { "sys": { "id": "{ /payload/fields/orderId }" } }, "name": "order" },
    { "type": "If", "condition": { "!=": [ "{ /order/fields/status/en-US }", "pending" ] },
      "then": [ { "type": "Return", "value": { "reason": "not pending" }, "isError": true, "statusCode": 409 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
      "fields": { "status": { "en-US": "approved" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

Binding a single item to a name with ResourceRead lets you reference it directly as { /order/fields/... }. If it does not exist, the read errors out (you can wrap it in Try).

Lookup and upsert

5. slug upsert (find-then-upsert)

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
      "where": { "fields.slug": { "eq": "{ /payload/fields/slug }" } }, "name": "found" },
    { "type": "If", "condition": { "!!": "{ /found/sys/id }" },
      "then": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /found/sys/id }" } },
          "fields": { "body": { "en-US": "{ /payload/fields/body }" } } },
        { "type": "Return", "value": { "id": "{ /found/sys/id }", "op": "updated" } } ],
      "else": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
          "fields": { "slug": { "en-US": "{ /payload/fields/slug }" }, "body": { "en-US": "{ /payload/fields/body }" } }, "name": "created" },
        { "type": "Return", "value": { "id": "{ /created/sys/id }", "op": "created" }, "statusCode": 201 } ] } ] }

ResourceFind binds the first match directly (or null if there is none), and { "!!": "{ /found/sys/id }" } branches on whether it exists.

6. Dynamic field key and dynamic locale patch

{ "method": "Patch",
  "statements": [
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
      "fields": { "{ /payload/fields/fieldKey }": { "{ /payload/fields/locale }": "{ /payload/fields/value }" } } } ] }

Both the field key and the locale bucket key are { /ptr } references. Use this when you want to place a translation into a specific locale bucket.

External API

7. Credit guard, charge up front (CAS), LLM call, refund on failure (flagship example)

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_wallet" } },
      "where": { "createdBy": ":self" }, "name": "wallet" },
    { "type": "If", "condition": { "<": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "insufficient credit" }, "isError": true, "statusCode": 402 } ] },
    { "type": "Try",
      "body": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /wallet/sys/id }" } },
          "version": "{ /wallet/sys/version }",
          "fields": { "balance": { "en-US": { "$-": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] } } },
          "name": "charged" } ],
      "catch": [ { "type": "Return", "value": { "ok": false, "reason": "version conflict, retry" }, "isError": true, "statusCode": 409 } ] },
    { "type": "Try",
      "body": [
        { "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, "name": "resp" },
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "{ /resp/body/choices/0/message/content }" } }, "name": "out" },
        { "type": "Return", "value": { "ok": true, "id": "{ /out/sys/id }", "remaining": "{ /charged/fields/balance/en-US }" } } ],
      "catch": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /wallet/sys/id }" } },
          "fields": { "balance": { "en-US": { "$+": [ "{ /charged/fields/balance/en-US }", "{ /payload/fields/cost }" ] } } } },
        { "type": "Return", "value": { "ok": false, "reason": "generation failed, refunded" }, "isError": true, "statusCode": 502 } ] } ] }

First a guard checks whether the balance is sufficient, then it deducts before the external call. The deduction takes an optimistic lock (CAS) on the wallet's sys.version. If another execution changed the wallet between reading the balance and deducting, it aborts on a version mismatch and catch returns 409. Because no external call has been made, concurrent requests are not double-charged. Only after the deduction is committed does it call the LLM, and if that call fails, catch adds the deducted amount (cost) back to issue a refund (compensation), then returns 502. The order is to commit the charge before the irreversible external call and to compensate only on failure. The secret key goes in a secret:true header. For the limits of compensation, see No transactions and compensation in Execution Semantics.

8. Turn an image (URL) into Media and attach it to Content

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.img.com/gen",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "title": { "en-US": "{ /payload/fields/prompt }" },
                  "file":  { "en-US": { "source": "{ /gen/body/data/0/url }", "encoding": "url" } } }, "name": "img" },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_artwork" } },
      "fields": { "prompt": { "en-US": "{ /payload/fields/prompt }" }, "image": { "en-US": "{ /img/sys/id }" } } } ] }

Create the Media with a name, and ResourceCreate (Content) places { /img/sys/id } into the reference field. Media uses the same fields model as Content. file is the ingest directive { source, encoding }. A file ingest declares no time, so it comes out of the 30-second base budget, and it does not count toward the per-definition external-call limit either (Static constraints).

9. base64 image to Media

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.img.com/gen",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "file": { "en-US": { "source": "{ /gen/body/data/0/b64_json }", "encoding": "base64" } } } } ] }

10. Conditional publish or delete after moderation

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.mod.com/check",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "body": { "text": "{ /payload/fields/body }" }, "name": "mod" },
    { "type": "If", "condition": { "==": [ "{ /mod/body/flagged }", true ] },
      "then": [ { "type": "ResourceDelete",  "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ],
      "else": [ { "type": "ResourcePublish", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ] } ] }

11. try/catch: fallback on external failure

{ "method": "Post",
  "statements": [
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://primary.api/gen",
          "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
          "body": { "prompt": "{ /payload/fields/prompt }" }, "timeoutMs": 8000, "name": "resp" },
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "{ /resp/body/text }" }, "source": { "en-US": "primary" } } } ],
      "catch": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "Generation failed" }, "error": { "en-US": "{ /error/message }" }, "source": { "en-US": "fallback" } } } ] } ] }

12. Fill in an AI summary and tags for a post

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
      "headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/body }", "response_format": { "type": "json_object" } },
      "timeoutMs": 15000, "name": "resp" },
 
    { "type": "Try",
      "body": [
        { "type": "ParseJson", "name": "ai", "value": "{ /resp/body/choices/0/message/content }" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
          "fields": { "summary": { "en-US": "{ /ai/summary }" },
                      "tags":    { "en-US": "{ /ai/tags }" } } },
        { "type": "Return", "value": { "ok": true, "tags": "{ /ai/tags }" } } ],
      "catch": [
        { "type": "Return", "value": { "ok": false, "reason": "model did not return JSON" }, "isError": true, "statusCode": 502 } ] } ] }

Once a post is created, the model fills in its summary and tags (wire it to Content.Create as a Webhook's linked action). The response here comes in two layers. Http's responseType is Json by default, so the API's response envelope is already an object, but the answer the model produced sits inside it at choices/0/message/content as a string. That is why you unwrap one more layer with ParseJson before you can pull values out as { /ai/summary } and { /ai/tags }. The tags go into an Array field (elements of ShortText) as the array they are.

Even with a structured-output contract (response_format), something that is not JSON arrives when the response is cut off by a length limit or the model refuses the request. So the parse is wrapped in Try, which turns a parse failure into a 502. The failure message carries the text it tried to parse, so you can see what came back. For an API whose envelope is not JSON in the first place, give Http a responseType: "Text" and pass { /resp/body } straight in (see Http and ParseJson).

Parallel

13. Merge two parallel external calls into Content

{ "method": "Post",
  "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" } ] ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_merged" } },
      "fields": { "left": { "en-US": "{ /a/body/value }" }, "right": { "en-US": "{ /b/body/value }" } } } ] }

14. Signup review: parallel scores, then an and-based decision

{ "method": "Post",
  "statements": [
    { "type": "Parallel", "branches": [
      [ { "type": "Http", "method": "POST", "url": "https://api.fraud.com/score",
          "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
          "body": { "email": "{ /payload/fields/email }" }, "name": "fraud" } ],
      [ { "type": "Http", "method": "GET", "url": "https://api.credit.com/v1/{ /payload/fields/userId }/score",
          "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ], "name": "credit" } ] ] },
    { "type": "If",
      "condition": { "and": [ { "<": [ "{ /fraud/body/risk }", 0.5 ] }, { ">=": [ "{ /credit/body/score }", 700 ] } ] },
      "then": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_account" } },
          "fields": { "email": { "en-US": "{ /payload/fields/email }" }, "status": { "en-US": "approved" } } },
        { "type": "Return", "value": { "decision": "approved" }, "statusCode": 201 } ],
      "else": [ { "type": "Return", "value": { "decision": "manual-review" }, "statusCode": 202 } ] } ] }

You can also insert { /ptr } into the URL path. Branch results are referenced after the join. There are two external calls here. How many external calls one definition may hold is a per-plan limit (see Pricing), so check that you are within that limit.

Loops and aggregation

A Loop and a ResourceForEach enter the time budget as the time their body (onEach) declares multiplied by the iteration ceiling. The examples in this section make no external call in the body, so the declared time is 0 and the 30-second base budget is their effective limit. That is also where an iteration actually gets caught (Time budget, Loop).

15. N Content items from an array input (Loop over)

{ "method": "Post",
  "statements": [
    { "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 }" }, "qty": { "en-US": "{ /item/qty }" } } } ] } ] }

16. Counted loop (for): seed slots

{ "method": "Post",
  "statements": [
    { "type": "Loop", "for": { "from": 1, "to": 5 }, "name": "i", "maxIterations": 100,
      "body": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_slot" } },
          "fields": { "index": { "en-US": "{ /i }" }, "status": { "en-US": "open" } } } ] } ] }

for covers from to to, inclusive (integer literals, step defaults to 1). name binds the current counter to { /i }.

17. Cascade delete (ForEach, Delete)

{ "method": "Delete",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_comment" } },
      "where": { "fields.postId": { "eq": "{ /payload/sys/id }" } }, "from": "Current", "advanced": false, "name": "comment",
      "onEach": [ { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /comment/sys/id }" } } } ] },
    { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ] }

ResourceForEach internally pages through the matches and deletes each item, so without manual pagination it deletes all matching comments (up to the platform ceiling) and then deletes the post itself. Because onEach declares no time, the execution time budget for this definition is 30 seconds.

18. Loop accumulation: SetVar sum

{ "method": "Post",
  "statements": [
    { "type": "SetVar", "var": "total", "value": 0 },
    { "type": "Loop", "over": "{ /payload/fields/items }", "name": "row", "maxIterations": 100,
      "body": [ { "type": "SetVar", "var": "total", "value": { "$+": [ "{ /vars/total }", "{ /row/qty }" ] } } ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_summary" } },
      "fields": { "totalQty": { "en-US": "{ /vars/total }" } } } ] }

19. Bulk-process every matching item

{ "method": "Post",
  "statements": [
    { "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 }" } } } ] } ] }

ResourceForEach internally pages through the matches, so there is no need for a cursor loop (Loop while + SetVar accumulation). It finds all drafts that match the condition and publishes each. If there are so many that completing the run is hard, set a per-run cap with limit and leave where as an "unprocessed" condition so a re-run continues from what remains.

20. Batch-collect ids from an email list (merge)

{ "method": "Post",
  "statements": [
    { "type": "SetVar", "var": "ids",     "value": [] },
    { "type": "SetVar", "var": "missing", "value": [] },
    { "type": "Loop", "over": "{ /payload/fields/emails }", "name": "email", "maxIterations": 100,
      "body": [
        { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_account" } },
          "where": { "fields.email": { "eq": "{ /email }" } }, "name": "acc" },
        { "type": "If", "condition": { "!!": "{ /acc/sys/id }" },
          "then": [ { "type": "SetVar", "var": "ids",     "value": { "$merge": [ "{ /vars/ids }",     [ "{ /acc/sys/id }" ] ] } } ],
          "else": [ { "type": "SetVar", "var": "missing", "value": { "$merge": [ "{ /vars/missing }", [ "{ /email }" ] ] } } ] } ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_campaign" } },
      "fields": { "recipients": { "en-US": "{ /vars/ids }" }, "unresolved": { "en-US": "{ /vars/missing }" } } } ] }

A read (ResourceFind) is not an external call, so it is allowed inside a Loop body. Presence and absence are each accumulated with merge.

Saga and concurrency

21. Payment saga (Try/catch/finally)

{ "method": "Post",
  "statements": [
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "fields": { "sku": { "en-US": "{ /payload/fields/sku }" }, "status": { "en-US": "reserved" } },
      "publish": false, "name": "order" },
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://api.pay.com/charge",
          "headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
          "body": { "amount": "{ /payload/fields/amount }", "ref": "{ /order/sys/id }" }, "timeoutMs": 10000, "name": "pay" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "status": { "en-US": "paid" }, "txId": { "en-US": "{ /pay/body/transactionId }" } }, "publish": true },
        { "type": "Return", "value": { "orderId": "{ /order/sys/id }", "status": "paid" }, "statusCode": 201 } ],
      "catch": [
        { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } } },
        { "type": "Return", "value": { "reason": "payment failed", "detail": "{ /error/message }" }, "isError": true, "statusCode": 402 } ],
      "finally": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_paylog" } },
          "fields": { "orderRef": { "en-US": "{ /order/sys/id }" }, "amount": { "en-US": "{ /payload/fields/amount }" } } } ] } ] }

After reserving (draft): on payment success it confirms, publishes, and returns 201; on failure catch deletes the reservation (compensation) and returns 402; finally always logs. Delete-based compensation produces a new sys.id, so it falls within the limitation where references break (see No transactions and compensation in Execution Semantics).

22. Optimistic-lock CAS

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_stock" } },
      "where": { "fields.sku": { "eq": "{ /payload/fields/sku }" } }, "name": "stock" },
    { "type": "If", "condition": { "<": [ "{ /stock/fields/qty/en-US }", "{ /payload/fields/amount }" ] },
      "then": [ { "type": "Return", "value": { "reason": "out of stock" }, "isError": true, "statusCode": 409 } ] },
    { "type": "Try",
      "body": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /stock/sys/id }" } },
          "version": "{ /stock/sys/version }",
          "fields": { "qty": { "en-US": { "$-": [ "{ /stock/fields/qty/en-US }", "{ /payload/fields/amount }" ] } } } },
        { "type": "Return", "value": { "ok": true } } ],
      "catch": [
        { "type": "Return", "value": { "reason": "version conflict, retry" }, "isError": true, "statusCode": 409 } ] } ] }

Read the stock to secure a fresh sys.version, then deduct with that version (version). If another execution changed the value between the read and the write, it aborts on a version mismatch and catch returns 409. The out-of-stock guard sits outside Try (a normal early return).

Email

23. Notification email to each order's buyer (ForEach + EmailSend)

{ "method": "Post",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "fields.notified": { "ne": true } }, "order": "sys.createdAt,sys.id",
      "from": "Current", "advanced": false, "name": "order",
      "onEach": [
        { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
          "toServiceUser": { "sys": { "id": "{ /order/fields/buyer/en-US/sys/id }" } },
          "subject": "Your order has shipped",
          "body": "<p>Your order has shipped.</p>" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "notified": { "en-US": true } } } ] } ] }

It iterates over orders not yet notified (where fields.notified is not true), sends mail to each order's buyer, and immediately marks notified. Because EmailSend has one recipient per message, multi-recipient sends are done like this with ResourceForEach, one per item (onEach can hold external calls). Giving toServiceUser means the member's address never enters the Script variable space and is resolved just before sending. Since where is set to "unprocessed" and completion is marked at the end of onEach, a re-run continues from the remaining orders even if it was cut off partway (if the mark fails right after the side effect succeeds, that item may be duplicated on the next run; at-least-once).

Signature verification

A payment provider (PG, MoR) attaches a signature to the body when it sends a webhook. Before doing anything, the receiving side has to confirm that the signature can be reproduced with the secret key it holds. The two examples below are the two schemes that actually differ in the field: one builds a code with a secret key (keyed), the other computes a digest over fields concatenated with a secret key.

24. Verifying a webhook signature (unpacking a header, replay window)

{ "method": "Post",
  "statements": [
    { "type": "Regex", "name": "sig", "mode": "Capture",
      "pattern": "^t=(\\d+),v1=([0-9a-f]{64})$", "value": "{ /headers/x-provider-signature }" },
    { "type": "If", "condition": { "==": [ "{ /sig }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "malformed signature header" }, "isError": true, "statusCode": 400 } ] },
 
    { "type": "Signature", "name": "verified", "algorithm": "SHA256",
      "secret": "whsec_9f2c1b7ae4",
      "value": "{ /sig/1 }.{ /rawPayload }", "expected": "{ /sig/2 }" },
    { "type": "If", "condition": { "!": "{ /verified }" },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "signature mismatch" }, "isError": true, "statusCode": 401 } ] },
 
    { "type": "If", "condition": { ">=": [ { "-": [ "{ /now/seconds }", "{ /sig/1 }" ] }, 300 ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "timestamp outside the replay window" }, "isError": true, "statusCode": 401 } ] },
 
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "fields.orderId": { "eq": "{ /payload/data/orderId }" } }, "name": "order" },
    { "type": "If", "condition": { "==": [ "{ /order }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "unknown order" }, "isError": true, "statusCode": 404 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
      "fields": { "status": { "en-US": "paid" }, "paidAt": { "en-US": "{ /now/iso }" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

The provider packs the timestamp and the code into one header (t=1492774577,v1=<64 hex chars>), so the message to be signed cannot be assembled until the header has been taken apart. That fixes the order.

  1. Regex in Capture mode takes the header apart into { /sig/1 } (the timestamp) and { /sig/2 } (the code). Index 0 is the whole match, and 1 onward are the capture groups. If the format does not match, { /sig } is null and the run returns 400 right there.
  2. Signature takes "<timestamp>.<raw body>" as its message, builds the code, and compares it with { /sig/2 }. The key is taking the message from the raw body before parsing ({ /rawPayload }). Turning the parsed /payload back into a string normalizes whitespace and number notation, so it does not return to the bytes the other side signed. Two pointers placed side by side in a string concatenate, so no operator is involved.
  3. If { /verified } is false, the answer is 401. A wrong signature and a missing header are one and the same false (the sender is not told which of the two was wrong).
  4. Even when the signature matches, a stale request is refused. { /now/seconds } is the time this run started, so it checks whether the gap from the timestamp carried in the signature exceeds the replay window (300 seconds here). The timestamp arrived as a string from the header, but the arithmetic coerces it to a number.
  5. Only after all of that does it find the order and change its status.

There is no external call, so nothing declares any time and the run finishes within the 30-second base budget, and the provider gets its response on the spot. The secret used for verification is not stored encrypted, unlike secret: true on an Http header, so keep the set of roles that can read this Script narrow (see Secret headers in the security model).

There are two ways to let the provider call this endpoint, and which one applies depends on whether that provider can send a custom header.

  • If it can, issue a token that carries only the Execute permission for that Script, have the provider put it in the Authorization header, and let it call /execute. Narrowing a role to one specific Script is covered in the script permission on SpaceRole, and the token in Space Access Token. This is the default choice.
  • If it cannot (a provider that can only register a callback URL and has no setting for attaching headers), turn on anonymousCallEnabled for that Script and register the /execute/anonymous address as the callback. The run is then under the author's identity, so the updatedBy of the order this Script changes is the author as well, and since there is no authentication, the signature verification above becomes the only authentication on this endpoint. The conditions and save-time rules are covered in Anonymous calls.

The definition above already satisfies the conditions for an anonymous Script as written. It uses no createdBy: ":self" filter, and a request that fails the signature or the replay window is cut short with Return before anything is touched.

25. Verifying an unkeyed hash signature

{ "method": "Post",
  "statements": [
    { "type": "Hash", "name": "expectedSign", "algorithm": "SHA256", "encoding": "HexUpper",
      "value": "{ /payload/orderId }{ /payload/amount }9f2c1b7ae4" },
    { "type": "If", "condition": { "!=": [ "{ /expectedSign }", "{ /payload/signature }" ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "signature mismatch" }, "isError": true, "statusCode": 401 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/orderRef }" } },
      "fields": { "status": { "en-US": "paid" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

This is the scheme that, instead of HMAC, concatenates a fixed set of fields with a secret key and computes SHA256. Hash has no secret field; you write the secret key (9f2c1b7ae4) directly inside value, in the position that scheme puts it. Since schemes place the key in front, behind, or in the middle, this is what expresses every position.

Match encoding to the other side's notation (Hex, HexUpper, Base64, Base64Url). Unlike Signature, the result is a string, so you have to compare it yourself, and that comparison is an ordinary equality comparison. The cap on value is 128 characters, so for a scheme that computes over an entire body use Signature.

Member lookup

26. Find a member by email, then coupon and notification email

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "ServiceUser",
      "where": { "sys.email": { "eq": "{ /payload/fields/email }" } }, "name": "member" },
    { "type": "If", "condition": { "==": [ "{ /member }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "member not found" }, "isError": true, "statusCode": 404 } ] },
 
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_coupon" } },
      "fields": { "code": { "en-US": "WELCOME-{ /member/sys/id }" },
                  "owner": { "en-US": "{ /member/sys/id }" } }, "publish": false, "name": "coupon" },
 
    { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
      "toServiceUser": { "sys": { "id": "{ /member/sys/id }" } },
      "subject": "A coupon has been issued to you",
      "body": "<p>{ /member/nickname }, we have given you the coupon { /coupon/fields/code/en-US }.</p>" },
    { "type": "Return", "value": { "ok": true, "memberId": "{ /member/sys/id }" } } ] }

It finds a member by a single email address and uses their sys.id as both the coupon's owner and the mail recipient. The rules for reading the member directory are these.

  • sys.email is stored encrypted, so it accepts only the exact-match operators (eq, ne, in, nin). Passing another operator such as prefix makes execution fail rather than quietly returning zero results.
  • When there is no match, ResourceFind binds null, so you branch on existence in the same shape as when looking up a Content.
  • A member's fields are not locale maps, unlike Content and Media. Reference them directly, as in { /member/nickname }.
  • When sending mail, do not extract the address; pass the sys.id to toServiceUser. The engine resolves the address immediately before sending, so the member's address never enters the Script variable space.
  • To save this definition, the author's SpaceRole settings must contain SETTING_SERVICE_LOGIN. A statement that creates, updates, or deletes a member cannot be saved under any role (see Reading the member directory).

EmailSend adds its timeoutMs (10 seconds when not declared) to the execution time budget, and it counts as one against the per-definition external-call limit.