# Cookbook (Worked Examples)

Each scenario is shown as a complete `ScriptDefinition`. For the syntax they build on, see the [Statement Catalog](/api/reference/script/statements.md) and [Value Expressions](/api/reference/script/value-expressions.md); for execution and constraints, see [Execution Semantics, Constraints, and Security](/api/reference/script/execution-and-limits.md). 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](/api/reference/script/execution-and-limits.md#time-budget)).

## Table of Contents {#toc}

- **Basic CRUD**: [1. Create and publish Content](#create-and-publish) · [2. Update with a computed value](#computed-update) · [3. Collect and return my order list](#read-only-list) · [4. Read one, guard, then approve](#read-guard-approve)
- **Lookup and upsert**: [5. slug upsert](#slug-upsert) · [6. Dynamic field key and locale patch](#dynamic-key-locale)
- **External API**: [7. Charge credit up front (CAS), LLM call, refund](#credit-guard-llm) · [8. Image URL to Media](#image-url-media) · [9. base64 image to Media](#image-base64-media) · [10. Conditional handling after moderation](#moderation-conditional) · [11. try/catch fallback](#try-catch-fallback) · [12. AI summary and tags](#llm-json-string)
- **Parallel**: [13. Merge after parallel calls](#parallel-merge) · [14. Signup review](#signup-review-parallel)
- **Loops and aggregation**: [15. Create N from an array input](#loop-over-array) · [16. Counted-loop seed](#counted-loop-seed) · [17. Cascade delete](#cascade-delete) · [18. Loop accumulation: sum](#loop-accumulate-sum) · [19. Bulk-process every matching item](#paginate-all) · [20. Batch-collect ids](#batch-collect-merge)
- **Saga and concurrency**: [21. Payment saga](#payment-saga) · [22. Optimistic-lock CAS](#optimistic-cas)
- **Email**: [23. Notification email to each order's buyer](#foreach-email-notify)
- **Signature verification**: [24. Verifying a webhook signature](#verify-webhook-signature) · [25. Verifying an unkeyed hash signature](#hash-signature)
- **Member lookup**: [26. Find a member by email, then coupon and notification email](#find-service-user-notify)

## Basic CRUD {#basic-crud}

### 1. Create and publish Content {#create-and-publish}

```jsonc
{ "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) {#computed-update}

```jsonc
{ "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 {#read-only-list}

```jsonc
{ "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](/api/reference/script/execution-and-limits.md#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 {#read-guard-approve}

```jsonc
{ "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 {#find-and-upsert}

### 5. slug upsert (find-then-upsert) {#slug-upsert}

```jsonc
{ "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 {#dynamic-key-locale}

```jsonc
{ "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 {#external-api}

### 7. Credit guard, charge up front (CAS), LLM call, refund on failure (flagship example) {#credit-guard-llm}

```jsonc
{ "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](/api/reference/script/execution-and-limits.md#no-transaction).

### 8. Turn an image (URL) into Media and attach it to Content {#image-url-media}

```jsonc
{ "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](/api/reference/script/execution-and-limits.md#static-constraints)).

### 9. base64 image to Media {#image-base64-media}

```jsonc
{ "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 {#moderation-conditional}

```jsonc
{ "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 {#try-catch-fallback}

```jsonc
{ "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 {#llm-json-string}

```jsonc
{ "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](/api/reference/script/statements.md#http) and [ParseJson](/api/reference/script/statements.md#parse-json)).

## Parallel {#parallel-calls}

### 13. Merge two parallel external calls into Content {#parallel-merge}

```jsonc
{ "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 {#signup-review-parallel}

```jsonc
{ "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](/pricing/pricing.md)), so check that you are within that limit.

## Loops and aggregation {#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](/api/reference/script/execution-and-limits.md#time-budget), [Loop](/api/reference/script/statements.md#loop)).

### 15. N Content items from an array input (Loop over) {#loop-over-array}

```jsonc
{ "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 {#counted-loop-seed}

```jsonc
{ "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) {#cascade-delete}

```jsonc
{ "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 {#loop-accumulate-sum}

```jsonc
{ "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 {#paginate-all}

```jsonc
{ "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) {#batch-collect-merge}

```jsonc
{ "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 {#saga-and-concurrency}

### 21. Payment saga (Try/catch/finally) {#payment-saga}

```jsonc
{ "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](/api/reference/script/execution-and-limits.md#no-transaction)).

### 22. Optimistic-lock CAS {#optimistic-cas}

```jsonc
{ "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 {#email}

### 23. Notification email to each order's buyer (ForEach + EmailSend) {#foreach-email-notify}

```jsonc
{ "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 {#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) {#verify-webhook-signature}

```jsonc
{ "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](/api/reference/script/execution-and-limits.md#secret-headers)).

**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](/api/reference/cma/space-role.md#script-permission), and the token in [Space Access Token](/api/reference/cma/space-access-token.md). 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](/api/reference/script/endpoints.md#anonymous-call).

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 {#hash-signature}

```jsonc
{ "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`](/api/reference/script/statements.md#signature).

## Member lookup {#service-user-reads}

### 26. Find a member by email, then coupon and notification email {#find-service-user-notify}

```jsonc
{ "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](/api/reference/script/statements.md#service-user-reads)).

`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.

## Related documents {#related-documents}

- [Statement Catalog](/api/reference/script/statements.md): the fields and results of the statements used in the examples.
- [Value Expressions](/api/reference/script/value-expressions.md): references, JsonLogic, and locale-map rules.
- [Execution Semantics, Constraints, and Security](/api/reference/script/execution-and-limits.md): execution order, compensation, optimistic locking, and constraints.
- [Script Overview](/api/reference/script.md): the top-level structure and the time allowed for one run.
