{
  "info": {
    "name": "Joryio API",
    "description": "REST API collection for the Joryio Marketing Automation Platform. Authenticate every request with `Authorization: Bearer {{token}}` — set `token` to an API key (`jry_live_...` / `jry_test_...`, created in Settings > API Keys or via the API Keys folder) or to a dashboard JWT. Full reference: https://docs.joryio.com/api/overview",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "variable": [
    {
      "key": "baseUrl",
      "value": "https://api-eu1.joryio.com/api",
      "type": "string"
    },
    {
      "key": "token",
      "value": "jry_live_your_api_key_here",
      "type": "string"
    },
    {
      "key": "organizationId",
      "value": "",
      "type": "string"
    },
    {
      "key": "workspaceId",
      "value": "",
      "type": "string"
    },
    {
      "key": "listId",
      "value": "",
      "type": "string"
    }
  ],
  "item": [
    {
      "name": "Authentication",
      "description": "Dashboard account endpoints. For API integrations prefer an API key (`jry_live_...`) as the bearer token — API keys skip MFA/Turnstile and carry scoped permissions. Interactive login may additionally require MFA (`requiresMfa` + `tempToken` response) and, when Turnstile is configured, a `turnstileToken` body field.",
      "item": [
        {
          "name": "Login",
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "if (pm.response.code === 200) {",
                  "    const response = pm.response.json();",
                  "    if (response.token) {",
                  "        pm.collectionVariables.set('token', response.token);",
                  "    }",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"admin@example.com\",\n  \"password\": \"Str0ng!Passw0rd42\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/auth/login",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "auth",
                "login"
              ]
            },
            "description": "Dashboard login. On success returns { user, organization, workspaces, token } — the JWT is in `token`. Accounts with MFA enabled instead get { requiresMfa: true, tempToken }. When Turnstile is configured a `turnstileToken` body field is required, so scripted integrations should use an API key rather than this endpoint."
          }
        },
        {
          "name": "Get Profile",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/auth/profile",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "auth",
                "profile"
              ]
            }
          }
        }
      ]
    },
    {
      "name": "Me",
      "description": "Who is calling. Works with every credential: an API key, a dashboard session, or an OAuth access token (an integration such as Zapier, or an MCP client).",
      "item": [
        {
          "name": "Who am I",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/me",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "me"
              ]
            },
            "description": "Returns the organization and workspace this credential acts in, the region and `api_base_url` to call, the principal (`type`: `user`, `api_key` or `oauth`; for OAuth also `client_kind` and `client`, e.g. `zapier`, and the person who connected it) and the permissions it carries right now. For an OAuth token the scopes are what was granted AND what the person still holds. Needs no particular permission - use it as a connection test."
          },
          "response": [
            {
              "name": "OAuth token (Zapier)",
              "originalRequest": {
                "method": "GET",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{token}}"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/me",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "me"
                  ]
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"organization\": {\n    \"id\": \"8f0c2c1e-3b0a-4d2e-9a51-0f6f4d1c2b11\",\n    \"name\": \"Acme\"\n  },\n  \"workspace\": {\n    \"id\": \"1d2e3f40-5a6b-4c7d-8e9f-a0b1c2d3e4f5\",\n    \"name\": \"Production\"\n  },\n  \"region\": \"eu1\",\n  \"api_base_url\": \"https://api-eu1.joryio.com/api\",\n  \"principal\": {\n    \"type\": \"oauth\",\n    \"client_kind\": \"integration\",\n    \"client\": \"zapier\",\n    \"email\": \"dana@acme.com\",\n    \"name\": \"Dana Levi\"\n  },\n  \"scopes\": [\n    \"events:track\",\n    \"users:read\",\n    \"users:write\"\n  ]\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Users",
      "description": "User management API. Uses `userId` (your identifier) for creating users, and can reference users by either `userId` or Joryio's internal `id`.",
      "item": [
        {
          "name": "Track (attributes, events, purchases)",
          "description": "Create a new user or update existing. Uses `userId` (your identifier) as the primary key.",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"attributes\": {\n    \"externalId\": \"user-123\",\n    \"email\": \"jane@example.com\",\n    \"phone\": \"+14155550100\",\n    \"attributes\": {\n      \"firstName\": \"Jane\",\n      \"plan\": \"premium\"\n    },\n    \"subscriptions\": [\n      {\n        \"listId\": \"{{listId}}\",\n        \"channel\": \"email\",\n        \"status\": \"subscribed\"\n      }\n    ]\n  },\n  \"events\": {\n    \"externalId\": \"user-123\",\n    \"name\": \"Product Viewed\",\n    \"properties\": {\n      \"productId\": \"SKU-456\",\n      \"price\": 49.99\n    },\n    \"timestamp\": \"2026-08-19T10:00:00.000Z\"\n  },\n  \"purchases\": {\n    \"orderId\": \"ORD-2026-001\",\n    \"externalId\": \"user-123\",\n    \"total\": 89.97,\n    \"subtotal\": 99.97,\n    \"discount\": 10.0,\n    \"currency\": \"USD\",\n    \"salesChannel\": \"pos\",\n    \"storeId\": \"tlv-dizengoff\",\n    \"items\": [\n      {\n        \"productId\": \"SKU-12345\",\n        \"name\": \"Blue T-Shirt\",\n        \"quantity\": 2,\n        \"price\": 29.99,\n        \"total\": 59.98\n      }\n    ]\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/users/track",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "track"
              ]
            },
            "description": "THE ingest door. Send who someone is (`attributes`), what they did (`events`) and what they bought (`purchases`) in ONE call. Every field takes a single object OR an array. Attributes are applied BEFORE events and purchases, so an event may reference a person created in the same request.\n\nIdentity is the same everywhere: `externalId` is YOUR identifier and the only create-or-update key; `userId` is Joryio's internal 24-hex id and is update-only; `anonymousId` is a pre-identification session.\n\nMATCHING BY EMAIL. An `attributes` element with an `email` but no `externalId` and no `userId` is matched by email (lowercased) in the workspace. Email is NOT unique: none -> a contact is created; exactly one -> it is updated; several -> exactly ONE is updated, the most recently active (latest profile change, then newest), and the others are left as they are - contacts are never merged. A list unsubscribe in the element still applies to every contact with that email; a list subscribe applies to the updated contact only. Such an element is listed in `attributes.matchedSeveral` [{ index, userId, count }] - a notice, not an error. Send `externalId` (or the internal `userId`) to update one exact contact.\n\n`purchases` takes the same shape as POST /orders and goes through the same pipeline, so the Orders page, RFM and revenue attribution all see it.\n\nNOTE: do NOT send `Order Completed` in `events` - it is emitted automatically from a purchase, and sending both double-counts revenue. The request will tell you so.\n\nFailures are reported per array BY INDEX; one bad element never fails the batch. Caps: attributes 1000, events 500, purchases 500.\n\nLIST SUBSCRIPTIONS IN THE SAME CALL. Each `attributes` element may carry `subscriptions`: [{ listId, channel? (default 'email'), status? ('subscribed' | 'unsubscribed', default 'subscribed') }], max 100 per element. They are applied after the person is created or updated. An existing opt-out is never resurrected - it is kept and reported in `attributes.subscriptions.skipped` (by element index, with a reason), as is a listId that is unknown or belongs to another workspace; re-subscribing an opted-out contact is an explicit call to POST /subscriptions/contacts/:userId/lists/:listId. The API records the subscription directly: no confirmation email is sent, even for a list that requires double opt-in on forms. `attributes.subscriptions` = { applied, skipped[] } appears only when some element sent subscriptions.\n\nStore and sales channel: `salesChannel` is where the order was placed - `online`, `pos`, `app`, `phone`, `marketplace` or `other` (any letter case; any other value is a 400). Left out, a new order is `online` and an existing one keeps its channel. `storeId` (up to 100 characters) is your own id for the store the order belongs to and `storeName` (up to 255) its display name; the Orders page Store filter and the \"Bought in store\" segment condition match `storeId`. Order events carry them as `sales_channel` and `store_id`.\n\nPHONE NUMBERS. Send `phone` in international format (+972501234567). A number without a country code (0501234567) is read in the workspace's default country for phone numbers (Settings, opt-in); Joryio never guesses one. A phone that is not a valid number is kept, not dropped: it is stored as `phoneUnverified`, the contact's existing `phone` is not changed, it is never used for messages, and the element gets a non-fatal warning in `attributes.warnings`: { \"index\": 0, \"field\": \"phone\", \"reason\": \"Phone saved as unverified and not used for messages - the number is not valid as written. Add the country code (for example +972501234567), or set a default country for phone numbers in workspace settings.\" }. A later valid phone clears `phoneUnverified`."
          }
        },
        {
          "name": "Import push devices (bulk, from another vendor)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/track",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "track"
              ]
            },
            "description": "Bulk-load push tokens exported from a previous vendor. Up to 1000 devices per request, each naming its person by externalId - the id your export actually contains, not Joryio's internal id.\n\nWHAT ACTUALLY TRANSFERS. iOS/APNs tokens are issued per (bundle id, environment) and are not vendor-specific, so they carry over. Android/FCM tokens are scoped to a Firebase SENDER: same project and they work, a new project and they were already dead. Web push subscriptions are bound to the VAPID key that created them and CANNOT transfer - those users have to be prompted again.\n\nRegistering a token never grants push consent. Same rule as file import: holding a token is not evidence of permission, suppression still applies, and granting stays a compliance action.\n\nA token already attached to someone else MOVES to the person named here - the device belongs to whoever holds it - and the previous contact's device is marked deactivatedReason='reassigned' with reassignedToUserId, so the move is visible on both sides.\n\nTokens are taken as given, not probed. A token is presumed valid until a send bounces, at which point the delivery path deactivates it.\n\n`appId` attaches each token to one of your apps. Campaign sends filter on it, so a token imported without one is excluded from every push - it is held but never used. You can omit it when the workspace has exactly one app on that platform, in which case it is resolved for you; pass it when you have several (an iOS app and an Android app each carry their own credentials). Get the ids from GET /apps.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"devices\": [\n    {\n      \"externalId\": \"user-123\",\n      \"platform\": \"ios\",\n      \"pushToken\": \"a1b2c3\\u2026\",\n      \"appId\": \"{{iosAppId}}\",\n      \"deviceInfo\": {\n        \"model\": \"iPhone15,2\",\n        \"osVersion\": \"18.2\"\n      }\n    },\n    {\n      \"externalId\": \"user-456\",\n      \"platform\": \"android\",\n      \"pushToken\": \"d4e5f6\\u2026\",\n      \"appId\": \"{{androidAppId}}\"\n    }\n  ]\n}"
            }
          },
          "response": []
        },
        {
          "name": "Identify (anonymous -> known)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/identify",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "identify"
              ]
            },
            "description": "Promote an anonymous session to a known person: upserts the known user by externalId, finds the anonymous record, MERGES them and reassigns the anonymous history.\n\nThis is not the same as sending anonymousId on /users/track. That field is accepted there and does not merge anything - tracking upserts a person, identify joins two records. Use this endpoint when you learn who an anonymous visitor is.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"anonymousId\": \"anon_abc123\",\n  \"externalId\": \"user-123\",\n  \"attributes\": {\n    \"plan\": \"premium\"\n  }\n}"
            }
          },
          "response": []
        },
        {
          "name": "Track many (array bodies)",
          "description": "Create a new user or update existing. Uses `userId` (your identifier) as the primary key.",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"attributes\": [\n    {\n      \"externalId\": \"user-123\",\n      \"email\": \"jane@example.com\",\n      \"phone\": \"+14155550100\",\n      \"attributes\": {\n        \"firstName\": \"Jane\",\n        \"plan\": \"premium\"\n      },\n      \"subscriptions\": [\n        {\n          \"listId\": \"{{listId}}\",\n          \"channel\": \"email\",\n          \"status\": \"subscribed\"\n        }\n      ]\n    },\n    {\n      \"externalId\": \"user-456\",\n      \"email\": \"sam@example.com\"\n    }\n  ],\n  \"events\": [\n    {\n      \"externalId\": \"user-123\",\n      \"name\": \"Product Viewed\",\n      \"properties\": {\n        \"productId\": \"SKU-456\",\n        \"price\": 49.99\n      },\n      \"timestamp\": \"2026-08-19T10:00:00.000Z\"\n    },\n    {\n      \"externalId\": \"user-456\",\n      \"name\": \"Checkout Started\"\n    }\n  ],\n  \"purchases\": [\n    {\n      \"orderId\": \"ORD-2026-001\",\n      \"externalId\": \"user-123\",\n      \"total\": 89.97,\n      \"subtotal\": 99.97,\n      \"discount\": 10.0,\n      \"currency\": \"USD\",\n      \"items\": [\n        {\n          \"productId\": \"SKU-12345\",\n          \"name\": \"Blue T-Shirt\",\n          \"quantity\": 2,\n          \"price\": 29.99,\n          \"total\": 59.98\n        }\n      ]\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/users/track",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "track"
              ]
            },
            "description": "The same endpoint with arrays. Each element carries its own identity, so one call can span many people. Response is a per-array summary: { attributes, events, purchases, devices } each with { processed, failed[] }, where failed itemizes by request index; `attributes` adds `warnings[]` and `subscriptions` { applied, skipped[] } when there is something to report.\n\nLIST SUBSCRIPTIONS IN THE SAME CALL. Each `attributes` element may carry `subscriptions`: [{ listId, channel? (default 'email'), status? ('subscribed' | 'unsubscribed', default 'subscribed') }], max 100 per element. They are applied after the person is created or updated. An existing opt-out is never resurrected - it is kept and reported in `attributes.subscriptions.skipped` (by element index, with a reason), as is a listId that is unknown or belongs to another workspace; re-subscribing an opted-out contact is an explicit call to POST /subscriptions/contacts/:userId/lists/:listId. The API records the subscription directly: no confirmation email is sent, even for a list that requires double opt-in on forms. `attributes.subscriptions` = { applied, skipped[] } appears only when some element sent subscriptions.\n\nMATCHING BY EMAIL. An `attributes` element with an `email` but no `externalId` and no `userId` is matched by email (lowercased) in the workspace. Email is NOT unique: none -> a contact is created; exactly one -> it is updated; several -> exactly ONE is updated, the most recently active (latest profile change, then newest), and the others are left as they are - contacts are never merged. A list unsubscribe in the element still applies to every contact with that email; a list subscribe applies to the updated contact only. Such an element is listed in `attributes.matchedSeveral` [{ index, userId, count }] - a notice, not an error. Send `externalId` (or the internal `userId`) to update one exact contact.\n\nPHONE NUMBERS. Send `phone` in international format (+972501234567). A number without a country code (0501234567) is read in the workspace's default country for phone numbers (Settings, opt-in); Joryio never guesses one. A phone that is not a valid number is kept, not dropped: it is stored as `phoneUnverified`, the contact's existing `phone` is not changed, it is never used for messages, and the element gets a non-fatal warning in `attributes.warnings`: { \"index\": 0, \"field\": \"phone\", \"reason\": \"Phone saved as unverified and not used for messages - the number is not valid as written. Add the country code (for example +972501234567), or set a default country for phone numbers in workspace settings.\" }. A later valid phone clears `phoneUnverified`."
          }
        },
        {
          "name": "Get All Users",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users?limit=50&offset=0&email=&externalId=&userId=&q=",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                },
                {
                  "key": "offset",
                  "value": "0"
                },
                {
                  "key": "sortBy",
                  "value": "updatedAt",
                  "disabled": true,
                  "description": "createdAt | updatedAt | email | name. Ordered across the whole list with a stable tiebreak; default updatedAt."
                },
                {
                  "key": "sortOrder",
                  "value": "desc",
                  "disabled": true,
                  "description": "asc | desc; default desc."
                },
                {
                  "key": "email",
                  "value": "",
                  "disabled": true,
                  "description": "Exact match, case-insensitive. For partial or prefix matching use GET /users/search?query="
                },
                {
                  "key": "externalId",
                  "value": "",
                  "disabled": true,
                  "description": "Exact match on your own identifier. GET /users/by-user-id/:userId returns that one record directly."
                },
                {
                  "key": "userId",
                  "value": "",
                  "disabled": true,
                  "description": "Exact match on Joryio's internal id."
                },
                {
                  "key": "q",
                  "value": "",
                  "disabled": true,
                  "description": "Partial match across email, externalId, name and phone. Ignored when an exact parameter is present. This is the recommended way to find a person - see the Find a person guide."
                }
              ]
            }
          }
        },
        {
          "name": "Get User by Joryio ID",
          "description": "Get user by Joryio's internal ID (the `id` field from API responses, a 24-character hex string)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c",
                  "description": "Joryio's internal user ID (24-char hex)"
                }
              ]
            }
          }
        },
        {
          "name": "Get User by Your User ID",
          "description": "Get user by your userId (the identifier you use to create/track users). This is the recommended lookup method.",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/by-user-id/:userId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "by-user-id",
                ":userId"
              ],
              "variable": [
                {
                  "key": "userId",
                  "value": "user-123",
                  "description": "Your user identifier"
                }
              ]
            }
          }
        },
        {
          "name": "Update User by Joryio ID",
          "description": "Update user by Joryio's internal ID (the `id` field from API responses). PHONE: `phone` is read like every contact write (a number without a country code in the workspace's default country for phone numbers); one that is not valid is kept as `phoneUnverified` and the current `phone` is not changed. `{\"clearPhoneUnverified\": true}` removes the unverified number and leaves `phone` as it is.",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"attributes\": {\n    \"plan\": \"enterprise\",\n    \"lastSeen\": \"2026-01-04T12:00:00Z\"\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/users/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c",
                  "description": "Joryio's internal user ID (24-char hex)"
                }
              ]
            }
          }
        },
        {
          "name": "Update User by Your User ID",
          "description": "Update user by your userId. This is the recommended update method.",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"attributes\": {\n    \"plan\": \"enterprise\",\n    \"lastSeen\": \"2026-01-04T12:00:00Z\"\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/users/by-user-id/:userId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "by-user-id",
                ":userId"
              ],
              "variable": [
                {
                  "key": "userId",
                  "value": "user-123",
                  "description": "Your user identifier"
                }
              ]
            }
          }
        },
        {
          "name": "Search Users",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/search?query=jane&limit=10",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "search"
              ],
              "query": [
                {
                  "key": "query",
                  "value": "jane"
                },
                {
                  "key": "limit",
                  "value": "10"
                }
              ]
            }
          }
        },
        {
          "name": "Delete User",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c"
                }
              ]
            }
          },
          "description": "Delete a user. The user is moved to the recycle bin (archived with a who/when/why audit) and can be restored - it is not permanently erased. ClickHouse event history is retained. Pass an optional `?reason=` to record why."
        },
        {
          "name": "List Deleted Users (Recycle Bin)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/deleted?query=&limit=50&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "deleted"
              ],
              "query": [
                {
                  "key": "query",
                  "value": "",
                  "description": "Match email/externalId (prefix), phone, or original id"
                },
                {
                  "key": "limit",
                  "value": "50"
                },
                {
                  "key": "offset",
                  "value": "0"
                }
              ]
            },
            "description": "List deleted users (the recycle bin) with the delete audit - who deleted each one, when, how, and why. Deleted users are archived, not lost."
          }
        }
      ]
    },
    {
      "name": "Imports",
      "description": "Bulk-load contacts, events or entity records from a CSV/JSON file. Three calls: presign an upload, PUT the file straight to storage, then create the job. The PUT succeeding says nothing about the job - validation runs at job creation and again per row. See docs /api/imports-api.",
      "item": [
        {
          "name": "Get upload URL",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/import/upload-url",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "import",
                "upload-url"
              ]
            },
            "description": "Returns a presigned uploadUrl and the fileKey that names it. The key is scoped to your org+workspace; pass it back verbatim when creating the job.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"filename\": \"customers-2026-08.csv\"\n}"
            }
          },
          "response": []
        },
        {
          "name": "Create import job",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/import",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "import"
              ]
            },
            "description": "Creates the job and returns immediately; the file is processed in the background.\n\nfieldMapping holds COLUMNS ONLY - import options are separate typed fields (overrideConsent, entityName, maxBackdatingDays). A column literally named '_overrideConsent' is just a column and does not enable consent override.\n\noverrideConsent must be a JSON boolean and additionally requires compliance:write; a string like \"true\" is rejected with 400 rather than read as false.\n\nsmsAlreadyConfirmed (JSON boolean, requires compliance:write like overrideConsent): only matters when the workspace's SMS double opt-in is on. Without it, each NEW SMS opt-in in the file is stored as pending and gets one \"Reply YES\" text; with it, the importer states it holds proof the SMS opt-ins were already confirmed, and they are stored subscribed with no text. With overrideConsent, opted-out SMS contacts in the file are treated like new sign-ups (pending + one confirmation text, back to unsubscribed if unanswered); without it they are left unchanged and get no text.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"format\": \"csv\",\n  \"fileKey\": \"imports/{{organizationId}}/{{workspaceId}}/customers-2026-08.csv\",\n  \"importType\": \"users\",\n  \"originalFilename\": \"customers-2026-08.csv\",\n  \"fieldMapping\": {\n    \"Email Address\": \"email\",\n    \"First Name\": \"firstName\",\n    \"Loyalty Tier\": \"attributes.loyaltyTier\"\n  },\n  \"smsAlreadyConfirmed\": false\n}"
            }
          },
          "response": []
        },
        {
          "name": "List import jobs",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/import",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "import"
              ]
            },
            "description": "Import history for the workspace, newest first, with status and the created/updated/skipped/failed counts. A workspace may only have one pending or processing import at a time."
          },
          "response": []
        },
        {
          "name": "List import sources (for segments)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/import/sources?limit=100",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "import",
                "sources"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "100",
                  "description": "Most imports to return (default 100, max 200)"
                },
                {
                  "key": "ids",
                  "value": "",
                  "description": "Optional. Comma-separated import ids (max 100): return exactly these, whatever their age - to name the imports a saved condition points at",
                  "disabled": true
                }
              ]
            },
            "description": "Finished contact (users) imports, newest first: id, fileName, status, createdAt, completedAt, rowCount, createdCount, updatedCount. With ids, exactly those imports. Needs users:read. Every contact a contact import creates or updates is marked with the import id, so a segment can target it with { \"type\": \"contact_import\", \"operator\": \"any_of\" | \"none_of\", \"value\": [importId] }. Contacts imported before this feature was released carry no mark."
          },
          "response": []
        },
        {
          "name": "Get import job",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/import/:importJobId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                "import",
                ":importJobId"
              ],
              "variable": [
                {
                  "key": "importJobId",
                  "value": "",
                  "description": "The id returned when the job was created"
                }
              ]
            },
            "description": "One import job: status, row counts, and a bounded sample of row-level errors with their source line numbers. Poll this rather than re-posting the import."
          },
          "response": []
        }
      ]
    },
    {
      "name": "Events",
      "description": "Event tracking API. Identify users by `userId` (your identifier), `anonymousId`, or a `userAlias` object.",
      "item": [
        {
          "name": "Query Events",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/events/query?eventName=Order Completed&startDate=2026-01-01&endDate=2026-01-31",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "events",
                "query"
              ],
              "query": [
                {
                  "key": "eventName",
                  "value": "Order Completed"
                },
                {
                  "key": "startDate",
                  "value": "2026-01-01"
                },
                {
                  "key": "endDate",
                  "value": "2026-01-31"
                }
              ]
            }
          }
        },
        {
          "name": "Update an Event Schema",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"description\": \"A purchase was completed\",\n  \"properties\": [\n    { \"name\": \"coupon\", \"description\": \"Promo code applied at checkout\" }\n  ]\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/events/schema/:eventName",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "events",
                "schema",
                ":eventName"
              ]
            },
            "description": "Record an event's description and per-property descriptions in the workspace's data dictionary (the property pickers and the AI assistant read them). Events stay schema-less: nothing is validated or rejected on ingestion. `:eventName` is URL-encoded (e.g. `Order%20Completed`). Requires `event_schema:write`."
          }
        },
        {
          "name": "Declare an Event Property",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"coupon\",\n  \"type\": \"string\",\n  \"description\": \"Promo code applied at checkout\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/events/schema/:eventName/properties",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "events",
                "schema",
                ":eventName",
                "properties"
              ]
            },
            "description": "Declare an event property's type - `string`, `number`, `boolean` or `date` - and optionally its description, persisted to the data dictionary. `GET /events/schema/:eventName` then reports it with `typeSource: \"declared\"` and segment filters compare it that way. An unknown type, or a standard property whose type the platform fixes (`total`, `price`...), is refused with 400. Requires `event_schema:write`."
          }
        }
      ]
    },
    {
      "name": "Apps",
      "description": "Your workspace's apps and their ids. A push campaign needs a target app, and its id is only visible here - without it, POST /campaigns for a push campaign has nothing to aim at.\n\nThese two reads accept `apps:read`. Everything else on /apps (create, update, delete, SDK-key rotation) requires `settings:write`, which is not grantable to an API key, so those stay in the dashboard.",
      "item": [
        {
          "name": "List Apps",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/apps",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps"
              ]
            },
            "description": "Every app in the workspace, with `id`, `name` and `platform` (ios / android / web). Use the id as `appId` when creating a push campaign."
          }
        },
        {
          "name": "Get App",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/apps/{{appId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                "{{appId}}"
              ]
            },
            "description": "One app by id."
          }
        }
      ]
    },
    {
      "name": "E-Commerce - Catalog",
      "description": "Product catalog management. Uses `productId` (your SKU/product ID) for identifying products.",
      "item": [
        {
          "name": "Create Product",
          "description": "Create a new product. Uses `productId` as your unique identifier. This endpoint accepts two body forms: a single product object (this request - returns the full product) or a bare JSON array of product objects for bulk upsert (max 500; see 'Create Products (array body)').",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"productId\": \"SKU-12345\",\n  \"name\": \"Classic Blue T-Shirt\",\n  \"price\": 29.99,\n  \"compareAtPrice\": 39.99,\n  \"currency\": \"USD\",\n  \"categories\": [\"Clothing\", \"T-Shirts\"],\n  \"tags\": [\"sale\", \"bestseller\"],\n  \"brand\": \"Acme Apparel\",\n  \"imageUrl\": \"https://example.com/images/blue-tshirt.jpg\",\n  \"url\": \"https://example.com/products/blue-tshirt\",\n  \"inStock\": true,\n  \"sku\": \"BTS-001-BL\",\n  \"variants\": [\n    {\n      \"id\": \"var-s\",\n      \"name\": \"Small\",\n      \"sku\": \"BTS-001-BL-S\",\n      \"price\": 29.99,\n      \"inStock\": true,\n      \"options\": { \"size\": \"S\", \"color\": \"Blue\" }\n    },\n    {\n      \"id\": \"var-m\",\n      \"name\": \"Medium\",\n      \"sku\": \"BTS-001-BL-M\",\n      \"price\": 29.99,\n      \"inStock\": true,\n      \"options\": { \"size\": \"M\", \"color\": \"Blue\" }\n    }\n  ]\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/catalog/products",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "catalog",
                "products"
              ]
            }
          }
        },
        {
          "name": "Create Products (array body)",
          "description": "Create or update multiple products at once with a bare JSON array body.",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "[\n  {\n    \"productId\": \"SKU-001\",\n    \"name\": \"Product 1\",\n    \"price\": 19.99\n  },\n  {\n    \"productId\": \"SKU-002\",\n    \"name\": \"Product 2\",\n    \"price\": 29.99,\n    \"source\": \"shopify\"\n  }\n]"
            },
            "url": {
              "raw": "{{baseUrl}}/catalog/products",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "catalog",
                "products"
              ]
            },
            "description": "Create or update MANY products in one call: POST /catalog/products with a bare JSON array body (max 500 elements; no wrapper object). Each element follows the same format as the single-object form and is upserted by productId. Every element is fully validated; invalid elements are reported in failed[] by index while valid elements still process. Response is an aggregate summary: {processed, upserted, failed: [{index, productId?, sku?, reason}]}."
          }
        },
        {
          "name": "List Products",
          "description": "List products with filters and pagination. Each product also carries fields a connected store (WooCommerce) fills and the create/update routes do not accept: gtin (GTIN/UPC/EAN/ISBN or null), attributes (name to values, e.g. {\"Material\": [\"Linen\"]}, or null), variants[].gtin, and sourceModifiedAt (when the store last changed the product).",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/catalog/products?limit=50&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "catalog",
                "products"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                },
                {
                  "key": "offset",
                  "value": "0"
                },
                {
                  "key": "categories",
                  "value": "T-Shirts",
                  "disabled": true
                },
                {
                  "key": "inStock",
                  "value": "true",
                  "disabled": true
                },
                {
                  "key": "brand",
                  "value": "Acme",
                  "disabled": true
                }
              ]
            }
          }
        },
        {
          "name": "Get Product by Joryio ID",
          "description": "Get product using Joryio's internal ID (returned in API responses)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/catalog/products/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "catalog",
                "products",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c",
                  "description": "Joryio's internal product ID (24-char hex)"
                }
              ]
            }
          }
        },
        {
          "name": "Get Product by Your Product ID",
          "description": "Get product using your productId (SKU, product ID from your e-commerce platform). This is the recommended lookup method.",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/catalog/products/by-product-id/:productId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "catalog",
                "products",
                "by-product-id",
                ":productId"
              ],
              "variable": [
                {
                  "key": "productId",
                  "value": "SKU-12345",
                  "description": "Your product identifier"
                }
              ]
            }
          }
        },
        {
          "name": "Update Product",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"price\": 24.99,\n  \"inStock\": false,\n  \"tags\": [\"clearance\"]\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/catalog/products/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "catalog",
                "products",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c"
                }
              ]
            }
          }
        },
        {
          "name": "Delete Product",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/catalog/products/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "catalog",
                "products",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c"
                }
              ]
            }
          }
        },
        {
          "name": "Get Categories",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/catalog/categories",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "catalog",
                "categories"
              ]
            }
          }
        },
        {
          "name": "Get Brands",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/catalog/brands",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "catalog",
                "brands"
              ]
            }
          }
        },
        {
          "name": "Get Catalog Stats",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/catalog/stats",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "catalog",
                "stats"
              ]
            },
            "description": "Catalogue totals plus catalogue health, counted over EVERY product (not a page): productCount, categoryCount, brandCount, outOfStockCount, lowStockCount, unclassifiedCount (missing a category or a brand) and lowStockThreshold (the units-remaining boundary lowStockCount was computed at)."
          }
        }
      ]
    },
    {
      "name": "E-Commerce - Orders",
      "description": "Order management. Uses `orderId` (your order number) and identifies customers by `userId` (your user identifier).",
      "item": [
        {
          "name": "Create Order",
          "description": "Create an order for a user (identified by your `userId`). Set `totalRefunded` (order currency, default 0) to record refunds so revenue is netted and the refunded amount shows on the Orders page: for a partial refund send the partial amount with `status: partiallyRefunded`; for a full refund set it to `total` with `status: refunded`. Send every coupon code the order used in `coupons` (each with the discount it gave, when you know it); the single `couponCode` is still accepted and becomes a one-code list. Codes appear on the order, on the Order Completed event (`coupon_codes`, `coupon_count`) and in segment filters. Payment: `paymentStatus` is `paid` or `unpaid` - a Cash-on-delivery or bank-transfer order is `processing` and `unpaid` until the money arrives, and shows Awaiting payment on the Orders page instead of Paid. Left out, a processing / shipped / delivered order counts as paid (an existing order keeps its value). Revenue does not depend on it. Orders in responses carry `paymentStatus` (null when no store said).",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"orderId\": \"ORD-2024-001\",\n  \"externalId\": \"user-123\",\n  \"total\": 89.97,\n  \"subtotal\": 99.97,\n  \"discount\": 10.0,\n  \"shipping\": 5.99,\n  \"tax\": 4.99,\n  \"totalRefunded\": 0.0,\n  \"currency\": \"USD\",\n  \"status\": \"processing\",\n  \"paymentStatus\": \"unpaid\",\n  \"coupons\": [\n    { \"code\": \"SAVE10\", \"amount\": 10.0, \"type\": \"fixed_amount\" }\n  ],\n  \"items\": [\n    {\n      \"productId\": \"SKU-12345\",\n      \"name\": \"Blue T-Shirt\",\n      \"quantity\": 2,\n      \"price\": 29.99,\n      \"total\": 59.98\n    },\n    {\n      \"productId\": \"SKU-67890\",\n      \"name\": \"Black Jeans\",\n      \"quantity\": 1,\n      \"price\": 49.99,\n      \"total\": 49.99\n    }\n  ],\n  \"source\": \"email\",\n  \"salesChannel\": \"pos\",\n  \"storeId\": \"tlv-dizengoff\",\n  \"storeName\": \"Tel Aviv - Dizengoff\",\n  \"campaignId\": \"camp_xyz789\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/orders",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "orders"
              ]
            },
            "description": "Identity uses `externalId` (yours) and `userId` (Joryio's internal 24-hex), matching every /users route. This endpoint previously reversed those two names.\n\nStore and sales channel: `salesChannel` is where the order was placed - `online`, `pos`, `app`, `phone`, `marketplace` or `other` (any letter case; any other value is a 400). Left out, a new order is `online` and an existing one keeps its channel. `storeId` (up to 100 characters) is your own id for the store the order belongs to and `storeName` (up to 255) its display name; the Orders page Store filter and the \"Bought in store\" segment condition match `storeId`. Order events carry them as `sales_channel` and `store_id`."
          }
        },
        {
          "name": "List Orders",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/orders?limit=50&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "orders"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                },
                {
                  "key": "offset",
                  "value": "0"
                },
                {
                  "key": "status",
                  "value": "pending",
                  "disabled": true
                },
                {
                  "key": "userId",
                  "value": "user-123",
                  "disabled": true
                },
                {
                  "key": "startDate",
                  "value": "2024-01-01",
                  "disabled": true
                },
                {
                  "key": "endDate",
                  "value": "2024-12-31",
                  "disabled": true
                }
              ]
            }
          }
        },
        {
          "name": "Get Order by Joryio ID",
          "description": "Get order using Joryio's internal ID",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/orders/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "orders",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c",
                  "description": "Joryio's internal order ID (24-char hex)"
                }
              ]
            }
          }
        },
        {
          "name": "Get Order by Your Order ID",
          "description": "Get order using your orderId (order number from your e-commerce platform). This is the recommended lookup method.",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/orders/by-order-id/:orderId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "orders",
                "by-order-id",
                ":orderId"
              ],
              "variable": [
                {
                  "key": "orderId",
                  "value": "ORD-2024-001",
                  "description": "Your order identifier"
                }
              ]
            }
          }
        },
        {
          "name": "Upsert Order",
          "description": "Create the order, or update it if `orderId` already exists. Use this instead of Create Order when replaying a webhook or re-running an import - re-sending the same orderId updates that order rather than creating a second one. `coupons` replaces the order's stored codes (send [] to clear them); leave it out to keep them. Send `refunds` (each with your refund `id`, `amount`, and optionally `createdAt`, `reason`, `items`) and every NEW refund emits one `Order Refunded` event for journeys and segments; a refund already reported emits nothing again. A move to `status: cancelled` emits one `Order Cancelled`. Payment: `paymentStatus` is `paid` or `unpaid` - a Cash-on-delivery or bank-transfer order is `processing` and `unpaid` until the money arrives, and shows Awaiting payment on the Orders page instead of Paid. Left out, a processing / shipped / delivered order counts as paid (an existing order keeps its value). Revenue does not depend on it. Orders in responses carry `paymentStatus` (null when no store said).",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"orderId\": \"ORD-2024-001\",\n  \"externalId\": \"user-123\",\n  \"total\": 89.97,\n  \"currency\": \"USD\",\n  \"status\": \"processing\",\n  \"paymentStatus\": \"unpaid\",\n  \"placedAt\": \"2026-06-01T10:00:00.000Z\",\n  \"salesChannel\": \"online\",\n  \"storeId\": \"web-eu\",\n  \"storeName\": \"EU web store\",\n  \"coupons\": [\n    {\n      \"code\": \"SUMMER10\",\n      \"amount\": 6.0\n    },\n    {\n      \"code\": \"FREESHIP\"\n    }\n  ],\n  \"items\": [\n    {\n      \"productId\": \"SKU-12345\",\n      \"name\": \"Blue T-Shirt\",\n      \"quantity\": 2,\n      \"price\": 29.99,\n      \"total\": 59.98\n    }\n  ],\n  \"refunds\": [\n    {\n      \"id\": \"RF-1\",\n      \"amount\": 29.99,\n      \"createdAt\": \"2026-06-03T09:00:00.000Z\",\n      \"reason\": \"Wrong size\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/orders/upsert",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "orders",
                "upsert"
              ]
            },
            "description": "Store and sales channel: `salesChannel` is where the order was placed - `online`, `pos`, `app`, `phone`, `marketplace` or `other` (any letter case; any other value is a 400). Left out, a new order is `online` and an existing one keeps its channel. `storeId` (up to 100 characters) is your own id for the store the order belongs to and `storeName` (up to 255) its display name; the Orders page Store filter and the \"Bought in store\" segment condition match `storeId`. Order events carry them as `sales_channel` and `store_id`."
          }
        },
        {
          "name": "Update Order by Your Order ID",
          "description": "Update an order using YOUR order identifier - the same key Get Order by Your Order ID reads by. This is the usual operation after creating one: status changes, fulfilment, refunds. An orderId with no matching record answers 404, matching the GET.",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"status\": \"fulfilled\",\n  \"total\": 89.97,\n  \"currency\": \"USD\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/orders/by-order-id/:orderId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "orders",
                "by-order-id",
                ":orderId"
              ],
              "variable": [
                {
                  "key": "orderId",
                  "value": "ORD-2024-001",
                  "description": "Your order identifier"
                }
              ]
            }
          }
        },
        {
          "name": "Get User Orders",
          "description": "Get all orders for a specific user",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/orders/user/:userId?limit=50",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "orders",
                "user",
                ":userId"
              ],
              "variable": [
                {
                  "key": "userId",
                  "value": "user-123"
                }
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                }
              ]
            }
          }
        },
        {
          "name": "Fulfill Order",
          "description": "Mark order as shipped with tracking info",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"trackingNumber\": \"1Z999AA10123456784\",\n  \"carrier\": \"UPS\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/orders/:id/fulfill",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "orders",
                ":id",
                "fulfill"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c"
                }
              ]
            }
          }
        },
        {
          "name": "Cancel Order",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"reason\": \"Customer requested cancellation\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/orders/:id/cancel",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "orders",
                ":id",
                "cancel"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c"
                }
              ]
            }
          },
          "description": "Cancel the order. Emits one `Order Cancelled` event (properties `order_id`, `total`, `currency`, `reason`, `cancelled_at`); cancelling an order that is already cancelled emits nothing."
        },
        {
          "name": "Refund Order",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"refundAmount\": 29.99,\n  \"reason\": \"Product defective\",\n  \"partial\": true\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/orders/:id/refund",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "orders",
                ":id",
                "refund"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c"
                }
              ]
            }
          },
          "description": "Record a refund: `refundAmount` (order currency, default the order total) is added to the order's refunded amount and nets out of revenue. Emits one `Order Refunded` event for the new amount (`total`, `refund_type` full or partial, `reason`); each call is a new refund, so for refunds made in your store prefer Upsert Order with `refunds` (keyed by your refund id)."
        },
        {
          "name": "Get Order Stats",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/orders/stats?startDate=2024-01-01&endDate=2024-01-31",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "orders",
                "stats"
              ],
              "query": [
                {
                  "key": "startDate",
                  "value": "2024-01-01"
                },
                {
                  "key": "endDate",
                  "value": "2024-01-31"
                }
              ]
            }
          }
        }
      ]
    },
    {
      "name": "E-Commerce - Carts",
      "description": "Cart tracking for abandoned cart recovery campaigns.",
      "item": [
        {
          "name": "Update Cart",
          "description": "Track or update a user's cart. `value` is the cart total as you count it (it can include shipping and tax); `subtotal` (optional) is the items subtotal before shipping and tax, computed from the items when left out - it is the cart's value on E-Commerce -> Carts and in the Cart value segment filter.",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"userId\": \"user-123\",\n  \"items\": [\n    {\n      \"productId\": \"SKU-12345\",\n      \"name\": \"Blue T-Shirt\",\n      \"price\": 29.99,\n      \"quantity\": 2,\n      \"total\": 59.98,\n      \"imageUrl\": \"https://example.com/image.jpg\"\n    }\n  ],\n  \"value\": 59.98,\n  \"subtotal\": 59.98,\n  \"currency\": \"USD\",\n  \"checkoutUrl\": \"https://store.example.com/checkout?cart=abc123\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/carts",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "carts"
              ]
            }
          }
        },
        {
          "name": "Add to Cart",
          "description": "Add an item to a user's cart",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"userId\": \"user-123\",\n  \"item\": {\n    \"productId\": \"SKU-12345\",\n    \"name\": \"Blue T-Shirt\",\n    \"price\": 29.99,\n    \"quantity\": 1,\n    \"total\": 29.99\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/carts/add",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "carts",
                "add"
              ]
            }
          }
        },
        {
          "name": "Remove from Cart",
          "description": "Remove an item from a user's cart",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"userId\": \"user-123\",\n  \"productId\": \"SKU-12345\",\n  \"quantity\": 1\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/carts/remove",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "carts",
                "remove"
              ]
            }
          }
        },
        {
          "name": "Get User Cart",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/carts/user/:userId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "carts",
                "user",
                ":userId"
              ],
              "variable": [
                {
                  "key": "userId",
                  "value": "user-123"
                }
              ]
            }
          }
        },
        {
          "name": "Clear Cart",
          "description": "Clear a user's cart (after purchase or manual clear)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"userId\": \"user-123\",\n  \"reason\": \"purchased\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/carts/clear",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "carts",
                "clear"
              ]
            }
          }
        },
        {
          "name": "Get Abandoned Carts",
          "description": "List abandoned carts for recovery campaigns",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/carts/abandoned?limit=50",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "carts",
                "abandoned"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                },
                {
                  "key": "minValue",
                  "value": "50",
                  "disabled": true
                },
                {
                  "key": "abandonedMinutesAgo",
                  "value": "60",
                  "disabled": true
                }
              ]
            }
          }
        },
        {
          "name": "Mark Cart Recovered",
          "description": "Mark abandoned cart as recovered (after purchase)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"userId\": \"user-123\",\n  \"orderId\": \"ORD-2024-001\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/carts/recovered",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "carts",
                "recovered"
              ]
            }
          }
        },
        {
          "name": "Get Cart Stats",
          "description": "Get cart and abandonment statistics",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/carts/stats",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "carts",
                "stats"
              ]
            }
          }
        }
      ]
    },
    {
      "name": "E-Commerce - RFM Analysis",
      "description": "RFM (Recency, Frequency, Monetary) customer segmentation analysis.",
      "item": [
        {
          "name": "Get User RFM Data",
          "description": "Get RFM scores and segment for a specific user",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/rfm/user/:userId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "rfm",
                "user",
                ":userId"
              ],
              "variable": [
                {
                  "key": "userId",
                  "value": "user-123"
                }
              ]
            },
            "description": "RFM scores, segment and e-commerce attributes for one person. `:userId` is the internal user id or the external id your app identifies the person with; an unknown id returns 404."
          }
        },
        {
          "name": "Get RFM Distribution",
          "description": "Get customer distribution across RFM segments",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/rfm/distribution",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "rfm",
                "distribution"
              ]
            }
          }
        },
        {
          "name": "Get RFM Segments",
          "description": "Get list of all RFM segments with descriptions",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/rfm/segments",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "rfm",
                "segments"
              ]
            }
          }
        }
      ]
    },
    {
      "name": "Segments",
      "item": [
        {
          "name": "Delete Segment (only if unreferenced)",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/segments/:segmentId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "segments",
                ":segmentId"
              ],
              "variable": [
                {
                  "key": "segmentId",
                  "value": "seg_123"
                }
              ]
            },
            "description": "Hard-delete a segment that nothing references. Returns 409 naming the campaigns, journeys, in-app campaigns or other segments still using it - archive those instead. Archive remains the default: it stops the segment appearing in pickers and membership lookups while everything already pointing at it keeps working."
          }
        },
        {
          "name": "Create Segment",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Premium Users\",\n  \"description\": \"Users on premium plan\",\n  \"filterGroups\": [\n    {\n      \"filters\": [\n        {\n          \"type\": \"attribute\",\n          \"field\": \"plan\",\n          \"operator\": \"equals\",\n          \"value\": \"premium\"\n        }\n      ],\n      \"operator\": \"AND\"\n    }\n  ],\n  \"groupOperator\": \"AND\",\n  \"includeCrawlers\": false\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/segments",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "segments"
              ]
            },
            "description": "Create a segment. Audience is defined by `filterGroups` (each group = { filters: [...], operator: 'AND'|'OR' }, max 20 groups of 50 filters) combined by the required `groupOperator`; optional `excludeFilterGroups` subtracts matches; `includeCrawlers` (default false) counts crawler/bot profiles as members. Filter `type` values include attribute, event, segment, subscription, entity, rfm, and more."
          }
        },
        {
          "name": "Get All Segments",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/segments",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "segments"
              ]
            }
          }
        },
        {
          "name": "Get Segment Size",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/segments/:segmentId/size",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "segments",
                ":segmentId",
                "size"
              ],
              "variable": [
                {
                  "key": "segmentId",
                  "value": ""
                }
              ]
            }
          }
        },
        {
          "name": "List Materialized Segments (organization)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/segments/materialization/usage",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "segments",
                "materialization",
                "usage"
              ]
            },
            "description": "The organization's allowance, how much of it is spent, and which segments spent it - across every workspace, which no workspace-scoped view can show. Each row carries when its membership was last rebuilt and why the last rebuild failed, if it did."
          }
        },
        {
          "name": "Get Segment Materialization",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/segments/:segmentId/materialization",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "segments",
                ":segmentId",
                "materialization"
              ],
              "variable": [
                {
                  "key": "segmentId",
                  "value": ""
                }
              ]
            },
            "description": "Whether this segment keeps a precomputed membership, when it was last rebuilt, how many members it holds, and how much of the organization's allowance is in use."
          }
        },
        {
          "name": "Enable Segment Materialization",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/segments/:segmentId/materialization",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "segments",
                ":segmentId",
                "materialization"
              ],
              "variable": [
                {
                  "key": "segmentId",
                  "value": ""
                }
              ]
            },
            "description": "Keep this segment's membership precomputed so reports can analyse the whole segment at any size instead of a random sample of it. The first build runs immediately; after that it rebuilds nightly. Spends one of the organization's slots (default 20, shared across its workspaces); when none are free it returns 400 naming the segments already using them."
          }
        },
        {
          "name": "Disable Segment Materialization",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/segments/:segmentId/materialization",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "segments",
                ":segmentId",
                "materialization"
              ],
              "variable": [
                {
                  "key": "segmentId",
                  "value": ""
                }
              ]
            },
            "description": "Stop precomputing this segment and delete its stored membership, freeing the slot for another segment."
          }
        },
        {
          "name": "Rebuild Segment Materialization",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/segments/:segmentId/materialization/refresh",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "segments",
                ":segmentId",
                "materialization",
                "refresh"
              ],
              "variable": [
                {
                  "key": "segmentId",
                  "value": ""
                }
              ]
            },
            "description": "Rebuild this segment's membership now rather than waiting for the nightly run. Membership is recomputed whole, so people who no longer qualify are removed as well as new ones added."
          }
        }
      ]
    },
    {
      "name": "E-Commerce Integrations",
      "description": "Connect and manage a store. Shopify, WooCommerce, Magento and PrestaShop (early access) each expose the same shape: read the connection, start OAuth, change settings, trigger a sync, disconnect.",
      "item": [
        {
          "name": "Get Shopify Connection",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "The current Shopify connection for this workspace, or null. Includes storefrontTracking: the Web Pixel status, scopes the store still has to approve, whether the legacy script tag is installed, and the theme editor link for the app embed.",
            "url": {
              "raw": "{{baseUrl}}/integrations/shopify",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "shopify"
              ]
            }
          }
        },
        {
          "name": "Start Shopify OAuth",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Returns the URL to send the merchant to. Requires integrations:write.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"shopDomain\": \"example.myshopify.com\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/integrations/shopify/oauth/initiate",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "shopify",
                "oauth",
                "initiate"
              ]
            }
          }
        },
        {
          "name": "Resolve Shopify App Launch",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "The dashboard's Shopify page posts the query Shopify appended when the merchant opened or installed the app (App URL). Verifies Shopify's HMAC and freshness (one hour), then says whether the store is connected to this workspace (connected_here), to another workspace of this organization (connected_elsewhere, with its workspaceId) or not at all (not_connected - offer to connect; never automatic). 400 when the query is unsigned, forged or expired. Requires integrations:read.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"query\": {\n    \"shop\": \"example.myshopify.com\",\n    \"host\": \"YWRtaW4uc2hvcGlmeS5jb20vc3RvcmUvZXhhbXBsZQ\",\n    \"timestamp\": \"1760000000\",\n    \"hmac\": \"<hex hmac from Shopify>\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/integrations/shopify/app-launch",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "shopify",
                "app-launch"
              ]
            }
          }
        },
        {
          "name": "Set Store Checkout Re-subscribe",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Turn the store connection's \"Let checkout opt-ins re-subscribe people who unsubscribed\" setting on or off (`:platform` = shopify, woocommerce, magento or prestashop). Off by default. When on, a checkout or customer-record opt-in from that store - guest checkouts included - dated after the contact's latest unsubscribe re-subscribes that contact; never past a spam complaint, hard bounce, SMS STOP (only a double opt-in confirmation), organization-wide block or another contact's opt-out. Audit-logged. Requires settings:write. The same route also takes the checkout opt-in safeguards (WooCommerce / Magento \"Record consent immediately\", PrestaShop checkout newsletter box) - see Set Store Checkout Opt-in Safeguards.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"checkoutResubscribe\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/integrations/shopify/consent-settings",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "shopify",
                "consent-settings"
              ]
            }
          }
        },
        {
          "name": "Set Store Checkout Opt-in Safeguards",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "WooCommerce, Magento and PrestaShop (`:platform` = woocommerce, magento or prestashop; 400 for Shopify). The \"Record consent immediately\" safeguards, and for PrestaShop (module 1.2.8+) the checkout newsletter box, which always arrives before the order (D236): `checkoutConsentConfirmEmail` - email a new guest whose checkout tick arrived before an order a double opt-in confirmation link (off by default: the address was typed at checkout and is unverified); `checkoutConsentTickLimit` - checkout opt-in ticks accepted per shopper IP, per checkout and per email address per hour, 1-100 (default 3; over it a tick is ignored). `newShopperOptIns` - \"Opt-ins from new shoppers (not yet buyers)\": `hold` (default) or `immediate`; governs every opt-in that arrives without an order from someone the workspace does not know yet (a checkout tick sent before the order, and a registration, account-page, newsletter or customer-sync opt-in). Send any of them; `checkoutResubscribe` may be sent in the same body. With `hold`, an opt-in with no order counts at once only for a known buyer or subscriber (or the store's own confirmed double opt-in); anyone else is held as pending until their order or the confirmation link. With `immediate` it counts at once (less strict - any visitor can enter any email). Unsubscribes always apply at once; the tick limit stays in force. Audit-logged. Requires settings:write.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"checkoutConsentConfirmEmail\": false,\n  \"checkoutConsentTickLimit\": 3,\n  \"newShopperOptIns\": \"hold\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/integrations/magento/consent-settings",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "magento",
                "consent-settings"
              ]
            }
          }
        },
        {
          "name": "Set Shopify Visitor Identity",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "\"Recognise visitors by the email they type\" for the workspace's Shopify store (on by default). On: an email typed at checkout or in a form identifies the browser to the contact with that email (the oldest one, or a new contact) and the visitor's history joins it - anyone who types someone else's email is recognised as them in that browser. Off (strict identity): a typed email never identifies the browser; only a signed-in customer does, and a guest's email reaches Joryio with the order. Shopify only (`:platform` = shopify): WooCommerce, Magento and PrestaShop set it in their plugin and report it (400 here); every store's current value is `visitorIdentity` on GET /integrations/:platform. Audit-logged. Requires settings:write.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"identifyByTypedEmail\": false\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/integrations/shopify/visitor-identity",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "shopify",
                "visitor-identity"
              ]
            }
          }
        },
        {
          "name": "Update Shopify Settings",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Change what is synced and how.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"syncCustomers\": true,\n  \"syncOrders\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/integrations/shopify/settings",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "shopify",
                "settings"
              ]
            }
          }
        },
        {
          "name": "Trigger Shopify Sync",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Kick off a sync run now rather than waiting for the schedule.",
            "url": {
              "raw": "{{baseUrl}}/integrations/shopify/sync",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "shopify",
                "sync"
              ]
            }
          }
        },
        {
          "name": "Re-register Shopify Storefront Tracking",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Activates (or refreshes) the Joryio Web Pixel on the store - storefront and checkout events - and writes the settings the Joryio theme app embed reads; removes the legacy script tag once the pixel is active. Idempotent. Requires integrations:write. Returns the storefront tracking state: pixel.status is active, needs_scopes (the store must approve write_pixels and read_customer_events by running Start Shopify OAuth again), error (pixel.error says why) or not_registered; also missingScopes, legacyScriptTag and appEmbed.themeEditorUrl. The same object is returned as storefrontTracking by Get Shopify Connection.",
            "url": {
              "raw": "{{baseUrl}}/integrations/shopify/storefront-tracking",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "shopify",
                "storefront-tracking"
              ]
            }
          }
        },
        {
          "name": "Disconnect Shopify",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Removes the connection. Syncs that read through it are disabled - they are not silently left pointing at nothing.",
            "url": {
              "raw": "{{baseUrl}}/integrations/shopify",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "shopify"
              ]
            }
          }
        },
        {
          "name": "Get PrestaShop Connection",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "The workspace's PrestaShop connection (early access, stage 1): connected, status, storeName, storeUrl, shopId (the PrestaShop id_shop), workspaceId, prestashopVersion, moduleVersion, syncSettings, syncStats, lastSyncAt, checkoutResubscribe. One shop per workspace - a multistore install connects each shop to its own workspace. Never returns the module key or the Webservice key. Requires integrations:read.",
            "url": {
              "raw": "{{baseUrl}}/integrations/prestashop",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "prestashop"
              ]
            }
          }
        },
        {
          "name": "Get PrestaShop Module Key",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "The workspace's jry_ps_ key (created on first call) that the merchant pastes into the Joryio PrestaShop module. Refused for a demo organization unless staff opened it. Requires integrations:write.",
            "url": {
              "raw": "{{baseUrl}}/integrations/prestashop/api-key",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "prestashop",
                "api-key"
              ]
            }
          }
        },
        {
          "name": "Update PrestaShop Settings",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Change what is synced (syncProducts, syncCustomers, syncOrders, syncCarts, historicalDays). Requires integrations:write.",
            "url": {
              "raw": "{{baseUrl}}/integrations/prestashop/settings",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "prestashop",
                "settings"
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"syncCarts\": true,\n  \"historicalDays\": 180\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Trigger PrestaShop Sync",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Start a sync through the PrestaShop Webservice now. type: full (default), delta (products, customers and orders changed since the last clean sync), products, customers, orders or carts (open carts of the last 24 hours). Returns immediately; progress shows in syncStats. Requires integrations:write.",
            "url": {
              "raw": "{{baseUrl}}/integrations/prestashop/sync",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "prestashop",
                "sync"
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"type\": \"delta\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Disconnect PrestaShop",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Disconnects the shop: the module key is revoked (the module stops sending on its next 401) and the Webservice key Joryio held is dropped. The module deletes its Webservice key in PrestaShop when the merchant clicks Disconnect there or uninstalls it. Requires integrations:write.",
            "url": {
              "raw": "{{baseUrl}}/integrations/prestashop",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations",
                "prestashop"
              ]
            }
          }
        }
      ]
    },
    {
      "name": "Integrations",
      "item": [
        {
          "name": "Get Integration Status",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Every integration on the Integrations page with its live status for the API key's workspace - the same status the dashboard cards show, resolved on the server with the same rule as everywhere Joryio reports connected channels and stores. Read-only; requires the `integrations:read` permission.\n\nEach entry: `id` (the integration, e.g. `whatsapp`, `meta_ads`, `shopify`, `snowflake`), `status` (`connected` | `not_connected` | `action_needed` | `error` | `coming_soon`), `connected` (at least one live connection - a store whose last sync failed is still connected, with `status` `error`) and `count` (connected accounts / data streams / stores, where countable; absent for WhatsApp). No credentials, account names, numbers or error texts.",
            "url": {
              "raw": "{{baseUrl}}/integrations-hub/status",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "integrations-hub",
                "status"
              ]
            }
          },
          "response": [
            {
              "name": "200 OK",
              "originalRequest": {
                "method": "GET",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{token}}"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/integrations-hub/status",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "integrations-hub",
                    "status"
                  ]
                }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"integrations\": [\n    {\n      \"id\": \"whatsapp\",\n      \"status\": \"connected\",\n      \"connected\": true\n    },\n    {\n      \"id\": \"delivyr\",\n      \"status\": \"not_connected\",\n      \"connected\": false,\n      \"count\": 0\n    },\n    {\n      \"id\": \"meta_ads\",\n      \"status\": \"error\",\n      \"connected\": true,\n      \"count\": 2\n    },\n    {\n      \"id\": \"shopify\",\n      \"status\": \"connected\",\n      \"connected\": true,\n      \"count\": 1\n    },\n    {\n      \"id\": \"snowflake\",\n      \"status\": \"connected\",\n      \"connected\": true,\n      \"count\": 1\n    },\n    {\n      \"id\": \"segment\",\n      \"status\": \"coming_soon\",\n      \"connected\": false\n    }\n  ]\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "WhatsApp",
      "description": "WhatsApp Business accounts, their senders, and the message templates Meta must approve before you can send them.",
      "item": [
        {
          "name": "List Accounts",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Every connected WhatsApp Business account in this workspace.",
            "url": {
              "raw": "{{baseUrl}}/whatsapp/accounts",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "whatsapp",
                "accounts"
              ]
            }
          }
        },
        {
          "name": "Get Account",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "One account, including which senders it carries.",
            "url": {
              "raw": "{{baseUrl}}/whatsapp/accounts/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "whatsapp",
                "accounts",
                ":id"
              ]
            }
          }
        },
        {
          "name": "Account Token Health",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Whether the stored access token still works. A silently expired token looks identical to a quiet audience.",
            "url": {
              "raw": "{{baseUrl}}/whatsapp/accounts/:id/token-health",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "whatsapp",
                "accounts",
                ":id",
                "token-health"
              ]
            }
          }
        },
        {
          "name": "Create Account",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Connect an account by credentials.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Main WABA\",\n  \"phoneNumberId\": \"\",\n  \"wabaId\": \"\",\n  \"accessToken\": \"\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/whatsapp/accounts",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "whatsapp",
                "accounts"
              ]
            }
          }
        },
        {
          "name": "Delete Account",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Disconnect. Campaigns using its senders stop rather than silently falling back to another account.",
            "url": {
              "raw": "{{baseUrl}}/whatsapp/accounts/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "whatsapp",
                "accounts",
                ":id"
              ]
            }
          }
        }
      ]
    },
    {
      "name": "Journey Approvals",
      "description": "Submitting a journey for review. Approving and rejecting are human review steps done in the dashboard: an API key cannot hold canvas:approve.",
      "item": [
        {
          "name": "List Journeys Awaiting Approval",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Journeys submitted for review and not yet approved, oldest first. Requires canvas:read.",
            "url": {
              "raw": "{{baseUrl}}/journeys/pending-approval",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys",
                "pending-approval"
              ]
            }
          }
        },
        {
          "name": "Submit Journey for Approval",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Move a draft journey to pending_approval so a reviewer can sign it off. Requires canvas:write.",
            "url": {
              "raw": "{{baseUrl}}/journeys/:canvasId/submit-for-approval",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys",
                ":canvasId",
                "submit-for-approval"
              ],
              "variable": [
                {
                  "key": "canvasId",
                  "value": ""
                }
              ]
            }
          }
        }
      ]
    },
    {
      "name": "Coupons",
      "description": "Coupon programs: personal codes created in the connected store (or by Joryio alone), shown in messages with {{ coupons.<slug>.code }}, tracked to redemption from the order sync. See the Coupons API page in the docs.",
      "item": [
        {
          "name": "List Coupon Programs",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/programs?status=&includeArchived=false",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "programs"
              ]
            },
            "description": "List the workspace's coupon programs with store, status and stats (issued, redeemed, revenue in the reporting currency). ?status=draft|active|paused|archived filters; archived programs are left out unless ?includeArchived=true. Requires campaigns:read."
          }
        },
        {
          "name": "Create Coupon Program",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/programs",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "programs"
              ]
            },
            "description": "Create a coupon program - the recipe a message issues codes from with {{ coupons.<slug>.code }}. integrationId is a connected WooCommerce, Shopify or Magento store, or null (Joryio generates and tracks the codes, no store). slug: [a-z0-9_], unique per workspace. rules.discountType percent|fixed_cart|fixed_product|free_shipping; rules.scope kind all|products (WooCommerce, Shopify)|categories (WooCommerce, Magento)|collections (Shopify) with store ids; rules.expiry mode none|fixed (date)|relative (amount + unit hours|days, endOfDay). fallback.policy skip|shared_code (needs sharedCode)|send_without. lockToPerson is accepted on WooCommerce and Magento only - 400 on Shopify (Shopify sets who can use a discount per discount, not per code). Requires campaigns:write. source: personal (default - one code per person, created when a message needs it) | bank (codes created in the store ahead of time; needs integrationId; sourceConfig {\"bankTargetSize\": 5000} keeps that many unused codes ready) | uploaded (codes you upload with POST /coupons/programs/:id/codes; integrationId optional; never created in a store; lockToPerson not allowed) | shared (one code for everyone; sourceConfig {\"sharedCode\": \"SUMMER10\", \"createInStore\": true} - createInStore needs integrationId, false only registers an existing code). lowStock {\"threshold\": 500, \"percent\": null} - bank / uploaded: alert when fewer unused codes (or percent of all loaded) remain. The source cannot change once the program has codes.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Welcome 10%\",\n  \"slug\": \"welcome10\",\n  \"description\": \"10% off the first order\",\n  \"integrationId\": \"{{integrationId}}\",\n  \"status\": \"draft\",\n  \"source\": \"personal\",\n  \"sourceConfig\": {},\n  \"rules\": {\n    \"discountType\": \"percent\",\n    \"amount\": 10,\n    \"currency\": null,\n    \"minimumSubtotal\": 50,\n    \"minimumQuantity\": null,\n    \"scope\": {\n      \"kind\": \"all\"\n    },\n    \"excludeSaleItems\": false,\n    \"usageLimit\": 1,\n    \"oncePerCustomer\": true,\n    \"combinable\": false,\n    \"expiry\": {\n      \"mode\": \"relative\",\n      \"amount\": 14,\n      \"unit\": \"days\",\n      \"endOfDay\": true\n    }\n  },\n  \"codeFormat\": {\n    \"length\": 10,\n    \"charset\": \"unambiguous\",\n    \"prefix\": \"WELCOME-\",\n    \"suffix\": \"\",\n    \"separatorEvery\": null\n  },\n  \"fallback\": {\n    \"policy\": \"skip\",\n    \"sharedCode\": null\n  },\n  \"reuseOpenCode\": true,\n  \"lockToPerson\": false,\n  \"lowStock\": {\n    \"threshold\": 500,\n    \"percent\": null\n  }\n}"
            }
          }
        },
        {
          "name": "Get Coupon Program",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/programs/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "programs",
                ":id"
              ]
            },
            "description": "One program in full: rules, code format, fallback, live (has issued real codes), sampleCode (what a preview shows), storeError and stats. Requires campaigns:read."
          }
        },
        {
          "name": "Update Coupon Program",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/programs/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "programs",
                ":id"
              ]
            },
            "description": "Change any field of the create body, or status draft|active|paused|archived. A live program (one that has issued real codes) also needs settings:write - 403 otherwise; codes already issued keep the rules they were created with (a rules change starts a new rules version). Requires campaigns:write.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"status\": \"active\",\n  \"rules\": {\n    \"discountType\": \"percent\",\n    \"amount\": 15,\n    \"scope\": {\n      \"kind\": \"all\"\n    },\n    \"usageLimit\": 1,\n    \"oncePerCustomer\": true,\n    \"combinable\": false,\n    \"expiry\": {\n      \"mode\": \"relative\",\n      \"amount\": 7,\n      \"unit\": \"days\"\n    }\n  }\n}"
            }
          }
        },
        {
          "name": "Delete Coupon Program",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/programs/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "programs",
                ":id"
              ]
            },
            "description": "Delete a program that never issued a real code (204). A live program answers 409 - archive it instead (PATCH status archived). Requires campaigns:write."
          }
        },
        {
          "name": "Get Coupon Program Report",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/programs/:id/report",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "programs",
                ":id",
                "report"
              ]
            },
            "description": "Totals (issued, redeemed, redemptionRate, revenue, discount, currency, expired, failed, redeemedBySomeoneElse), a breakdown by campaign / journey / API (bySource) and daily issued and redeemed counts. Test codes are not counted. Requires campaigns:read."
          }
        },
        {
          "name": "Issue Coupon Code",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/programs/:id/issue",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "programs",
                ":id",
                "issue"
              ]
            },
            "description": "Issue a code from an active program to one contact, for partners who show the code in their own channel. Identify the contact with contactId (internal id), externalId or email. issueKey (optional) makes the call idempotent: the same issueKey always returns the same code. With the program's reuse setting on, the person's still-open code is returned instead of a new one (reused: true). Response { issued, issue, applyUrl } or { issued: false, reason } (program_paused, store_error, store_permission_missing, ...). Requires campaigns:write.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"externalId\": \"customer-123\",\n  \"issueKey\": \"partner-order-confirmation-9876\"\n}"
            }
          }
        },
        {
          "name": "List Coupon Issues",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/issues?contactId=&programId=&status=&limit=50&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "issues"
              ]
            },
            "description": "Issued codes, newest first: { items, total }. Each item carries the code, status (pending|issued|failed|redeemed|expired|revoked), the issuing campaign / journey node, issuedAt, expiresAt, redeemedAt, redeemedOrderId and redeemedRevenue. Filter with ?contactId=, ?programId=, ?status=; ?limit= (max 200), ?offset=. Codes belong to people, so this requires users:read."
          }
        },
        {
          "name": "List Coupon Stores",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/stores",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "stores"
              ]
            },
            "description": "Connected stores a program can issue codes in, each with its capabilities (lockToPerson, perCodeExpiry, percentCap, scopes) and missingPermission - e.g. write_discounts when a Shopify store must approve the new permission first. Requires campaigns:read."
          }
        },
        {
          "name": "Get Coupon Program Stock",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/programs/:id/stock",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "programs",
                ":id",
                "stock"
              ]
            },
            "description": "Codes left in an uploaded list or code bank: available (unused, unexpired), total (every code loaded), used, level ok|low|empty against the program's lowStock, and for a bank the top-up status (targetSize, filling, lastRunAt, lastAdded, lastError). tracked is false for personal and shared programs. Requires campaigns:read."
          }
        },
        {
          "name": "Upload Coupon Codes (pasted text)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/programs/:id/codes?dryRun=true",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "programs",
                ":id",
                "codes"
              ],
              "query": [
                {
                  "key": "dryRun",
                  "value": "true"
                }
              ]
            },
            "description": "Add codes to an uploaded-list program (source: uploaded). text is CSV or one code per line: columns code (required, 3-64 characters: letters, digits and - _ . # / +), expires_at (optional, YYYY-MM-DD = the end of that day UTC, or ISO date-time), value (optional, up to 100 characters, shown as {{ coupons.<slug>.value }}). Header optional; comma, semicolon or tab. dryRun=true validates and returns the report without storing (the preview); remove it (or set false) to store. Each upload appends. Limits: 100,000 rows and 10 MB per upload, 2,000,000 codes per program. Response: uploadId, totalRows, accepted, rejected, duplicates, truncated, preview (first 10 accepted), rejectedRows (first 500: line, code, reason, message). Reasons: missing_code, invalid_code, invalid_expiry, expired, value_too_long, duplicate_in_file, already_in_program, already_exists, over_limit. Requires campaigns:write.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"text\": \"code,expires_at,value\\nSUMMER-001,2026-12-31,10%\\nSUMMER-002,2026-12-31,10%\",\n  \"fileName\": \"summer.csv\"\n}"
            }
          }
        },
        {
          "name": "Upload Coupon Codes (CSV file)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/programs/:id/codes?dryRun=false",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "programs",
                ":id",
                "codes"
              ],
              "query": [
                {
                  "key": "dryRun",
                  "value": "false"
                }
              ]
            },
            "description": "The same upload as a multipart CSV file in the field file (up to 10 MB). Use dryRun=true first to preview the report. Requires campaigns:write.",
            "body": {
              "mode": "formdata",
              "formdata": [
                {
                  "key": "file",
                  "type": "file",
                  "src": "codes.csv"
                }
              ]
            }
          }
        },
        {
          "name": "List Coupon Code Uploads",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/programs/:id/uploads",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "programs",
                ":id",
                "uploads"
              ]
            },
            "description": "The last 100 uploads of a program, newest first, with counts and the rejected-rows report of each. Requires campaigns:read."
          }
        },
        {
          "name": "Generate Coupon Bank Codes",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/coupons/programs/:id/bank/generate",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "coupons",
                "programs",
                ":id",
                "bank",
                "generate"
              ]
            },
            "description": "Create count (1-100,000) new codes in the store for a code-bank program (source: bank): Shopify adds them to the program's discount, WooCommerce creates one coupon per code, Magento generates them under the program's cart price rule. Runs in the background: answers 202 with {\"started\": true, \"requested\": n}; follow it with Get Coupon Program Stock. 400 while a top-up is already running. Requires campaigns:write.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"count\": 5000\n}"
            }
          }
        }
      ]
    },
    {
      "name": "Campaigns",
      "item": [
        {
          "name": "Create Campaign",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Welcome Email\",\n  \"description\": \"Welcome email for new users\",\n  \"channel\": \"email\",\n  \"sendType\": \"immediate\",\n  \"variants\": [\n    {\n      \"id\": \"variant-a\",\n      \"name\": \"Variant A\",\n      \"weight\": 100,\n      \"message\": {\n        \"subject\": \"Welcome to My Company!\",\n        \"from\": \"hello@example.com\",\n        \"fromName\": \"My Company\",\n        \"html\": \"<h1>Hi {{ user.firstName }}, welcome aboard!</h1>\"\n      }\n    }\n  ],\n  \"targeting\": {\n    \"filterGroups\": [\n      {\n        \"filters\": [\n          {\n            \"type\": \"attribute\",\n            \"field\": \"plan\",\n            \"operator\": \"equals\",\n            \"value\": \"premium\"\n          }\n        ],\n        \"operator\": \"AND\"\n      }\n    ]\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/campaigns",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns"
              ]
            },
            "description": "Create a campaign. Message content lives in `variants` (required for every channel except in_app): each variant has id, name, weight, and a channel-shaped `message` (email: subject/from/fromName + html or templateId; sms: body; viber: body/imageUrl/buttonCaption/buttonUrl + optional ttlSeconds and smsFallbackEnabled/smsFallbackBody; push: title/body; whatsapp: templateId/variableMapping). Audience is `targeting` with userIds and/or filterGroups (same filter shape as Segments) — there is no `segmentId` field. sendType: immediate|scheduled|triggered|intelligent|recurring|ai_optimized."
          }
        },
        {
          "name": "Create SMS Campaign (Delivyr template)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Weekend offer (US)\",\n  \"channel\": \"sms\",\n  \"sendType\": \"immediate\",\n  \"status\": \"draft\",\n  \"variants\": [\n    {\n      \"id\": \"variant-a\",\n      \"name\": \"Variant A\",\n      \"weight\": 100,\n      \"message\": {\n        \"smsTemplateId\": \":smsTemplateId\",\n        \"variableMapping\": {\n          \"first_name\": \"{{ user.first_name | default: 'there' }}\",\n          \"link\": \"https://shop.example.com/weekend\"\n        }\n      }\n    }\n  ],\n  \"targeting\": {\n    \"filterGroups\": []\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/campaigns",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns"
              ]
            },
            "description": "DELIVYR ONLY - an SMS campaign on a workspace whose SMS number is linked to a Delivyr account (approved templates only, US and Canada numbers). Give smsTemplateId (an approved template from GET /sms/templates) and one value per template variable in variableMapping - plain text or Liquid. A `link` value's URL is sent as Joryio's tracked short link. A free-text `body` on a Delivyr account is refused with 400."
          }
        },
        {
          "name": "Get All Campaigns",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns"
              ]
            }
          }
        },
        {
          "name": "List Campaigns Awaiting Approval",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Campaigns submitted for review and not yet approved, oldest first. Requires campaigns:read - an author can see that their own submission is still waiting, not only approvers.",
            "url": {
              "raw": "{{baseUrl}}/campaigns/pending-approval",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                "pending-approval"
              ]
            }
          }
        },
        {
          "name": "Campaign Performance (ranked uplift)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Per-campaign causal uplift for the workspace, ranked by INCREMENTAL CONVERSIONS on each campaign's OWN conversion goal (`conversionGoal` on the row, e.g. `email_clicked`) - the same uplift the campaign page shows: both groups are asked whether the person reached the goal within the campaign's conversion window after they were sent (or would have been), with no attribution model because the control group has no message to credit. `incrementalRevenue` is a separate figure from ORDERS (purchase revenue in each group's window), whatever the goal. Until 2026-10-14 incrementalConversions, conversionRateLift and significance counted buyers for every campaign.\n\nOnly campaigns whose measurement cleared the significance floor appear in `ranked`. Campaigns still gathering data are returned separately in `stillCollecting`: they have no verdict, and placing them below the losers would say they lost. Campaigns with no control group are counted in `noHoldout`, and campaigns with a control group but no conversion goal in `noGoal`, rather than listed.\n\nThere is deliberately NO total. Per-campaign incremental figures do not sum to the value of marketing - each campaign's control received everything except that campaign, so the effects overlap and a total would overstate the result while looking authoritative. Requires campaigns:read.",
            "url": {
              "raw": "{{baseUrl}}/campaigns/performance?sinceDays=90",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                "performance"
              ],
              "query": [
                {
                  "key": "sinceDays",
                  "value": "90",
                  "description": "Look-back window for which campaigns to include. Default 90."
                }
              ]
            }
          }
        },
        {
          "name": "Submit Campaign for Approval",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "description": "Move a draft campaign to pending_approval so a reviewer can sign it off. Requires campaigns:write - the point of the workflow is that whoever cannot send can still start the review. Approving and rejecting are human review steps done in the dashboard: an API key cannot hold campaigns:approve.",
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/submit-for-approval",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "submit-for-approval"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            }
          }
        },
        {
          "name": "Get Launch Review",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/{{campaignId}}/launch-review",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                "{{campaignId}}",
                "launch-review"
              ]
            },
            "description": "Launch Review (pre-send checks): blockers (missing unsubscribe, broken merge field, unverified or missing sender, unusable or broken link on a measured send) and recommendations (long subject, promotions-tab words, no plain-text part, a link that could not be confirmed, no link on a measured send, audience overlap with a campaign or journey - shared segment, identical audience, or same trigger and audience - frequency holds, quiet hours ignored, consent bypassed), each with a fix action. This route also fetches the send's links (up to 25, 15 s, verdicts cached for an hour); the send gate reads that cache only. With requireClean (default) and blockers, send answers 409 launch_review_blocked unless the send carries launchReviewOverride."
          }
        },
        {
          "name": "Preview Update User Custom JSON",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/journeys/preview-user-update-json",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys",
                "preview-user-update-json"
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"json\": \"{\\n  \\\"attributes\\\": { \\\"firstName\\\": \\\"{{ event.properties.name }}\\\" },\\n  \\\"subscriptions\\\": [ { \\\"listId\\\": \\\"<list id>\\\", \\\"channel\\\": \\\"email\\\", \\\"status\\\": \\\"subscribed\\\" } ],\\n  \\\"events\\\": [ { \\\"name\\\": \\\"Free Gift Requested\\\", \\\"properties\\\": { \\\"source\\\": \\\"landing-page\\\" } } ]\\n}\",\n  \"sample\": {\n    \"user\": {\n      \"firstName\": \"Dana\",\n      \"attributes\": {}\n    },\n    \"trigger\": {\n      \"properties\": {\n        \"name\": \"Dana\"\n      }\n    }\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "description": "Dry run of an Update user node's Custom JSON: validates the template (only attributes / subscriptions / events, no identifiers, at most 100 subscriptions and 25 events), renders Liquid against the posted sample, and checks literal list ids belong to this workspace. Returns { errors, preview: { errors, result } } - errors block saving or activating, preview is the sample render. Writes nothing. Requires canvas:read."
          }
        },
        {
          "name": "Send Campaign",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/send",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "send"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"launchReviewOverride\": {\n    \"reason\": \"Order confirmation - transactional, no unsubscribe line by design\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "description": "Send (or schedule) the campaign. Launch Review runs inside: with requireClean (default) and blockers it answers 409 launch_review_blocked. Body is optional; `launchReviewOverride.reason` (3-300 chars) sends past the blockers on purpose - recorded on the campaign with its content hash (an edit voids it for a scheduled send) and in the audit log as launch_review_overridden."
          }
        },
        {
          "name": "Send Test Viber Message",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"phone\": \"+306912345678\",\n  \"userData\": {\n    \"firstName\": \"Maria\"\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/send-test-viber",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "send-test-viber"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Send a test Viber message for a viber campaign to a single phone number (no audience send). Renders the first variant (body/imageUrl/buttonCaption/buttonUrl + optional smsFallbackBody) with the optional `userData` as the personalization context. The recipient must not have opted out of Viber. Requires a Viber-capable provider account (Apifon) with a registered Viber sender."
          }
        },
        {
          "name": "Render In-App for a Contact",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"userId\": \"665f1b2c9a4e3d0012ab34cd\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/render-for-contact",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "render-for-contact"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Render an in-app campaign for ONE contact and get the markup back as { html, css }. This SENDS NOTHING: in-app is pull-based, so a message reaches a device only when that app next syncs - there is nothing to deliver to at this point. Useful for automated checks (render campaign X for contact Y, assert the copy). Needs campaigns:test; a caller without campaigns:send may only target their own contact record. Formerly POST /campaigns/:campaignId/send-test-in-app, which still answers as a deprecated alias."
          }
        },
        {
          "name": "Publish Campaign Changes",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/publish",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "publish"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Apply buffered edits made to a live (active) campaign onto the running campaign atomically, so subsequent sends use the new content. Returns 400 if there are no unpublished changes."
          }
        },
        {
          "name": "Duplicate Campaign",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/duplicate",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "duplicate"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Copy a campaign as a new draft named \"<name> (Copy)\". Everything that describes the campaign is copied - content and variants, audience, channel settings, the email sender (emailConfigId), send type, the schedule's time zone, the recurring schedule (occurrence count back to 0), the trigger, A/B settings (without the source test's winner or deadline), send-time optimization, limits, UTM tags and tags. Run state is reset: status draft, no scheduledAt (a one-time date would be in the past), stats, approvals, archive and failure state. The response is the new campaign; its duplicatedFrom holds { campaignId, name, scheduledAt, scheduledTimezone, scheduledLocalAt } of the source, so a client can show the original send. Requires campaigns:write. POST /campaigns/bulk-duplicate with { \"ids\": [...] } applies the same copy to each."
          }
        },
        {
          "name": "Discard Campaign Changes",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/discard-draft",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "discard-draft"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Discard buffered edits to a live campaign so it reverts to its currently-published content. No-op if nothing is buffered."
          }
        },
        {
          "name": "Re-send Campaign (Send again)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/resend",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "resend"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Re-send a completed one-time/scheduled campaign per its resendPolicy: only_new (skip prior recipients), everyone (include them), or cooldown (everyone except those messaged in the last resendCooldownDays days). Consent + suppression always enforced; frequency caps follow ignoreTouchingRules. 400 for triggered/recurring/in-app/ai_optimized or a campaign that hasn't finished sending."
          }
        },
        {
          "name": "Get Campaign Stats",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/stats",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "stats"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Delivery, engagement and conversion figures for one campaign. `stats.openRate` / `stats.clickRate` are unique people (bots excluded) as a share of delivered - of sent for push or when nothing was reported delivered - and `stats.rateOf` names the base; the same rate the dashboard shows. `conversionStats` counts the campaign's OWN conversion goal (primaryConversion) with its conversion window and attribution model; orders and revenue are a separate figure (`revenue`) - a purchase can be credited to a campaign whose goal is not a purchase. `stats.hardBounced` / `softBounced` / `otherBounced` split `stats.bounced`; `stats.autoReplied` counts messages answered by an automatic (out-of-office) reply, never a bounce, and is `null` (not measured) when returned-mail handling is off and the campaign has none."
          }
        },
        {
          "name": "List Campaign Recovered Carts",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/recovered-carts?startDate=2026-10-01&endDate=2026-10-31&limit=25&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "recovered-carts"
              ],
              "query": [
                {
                  "key": "startDate",
                  "value": "2026-10-01",
                  "description": "Optional. Carts recovered on or after this day (or instant)"
                },
                {
                  "key": "endDate",
                  "value": "2026-10-31",
                  "description": "Optional. Carts recovered up to this day, inclusive (or before this instant)"
                },
                {
                  "key": "limit",
                  "value": "25",
                  "description": "Rows per page, 1-100"
                },
                {
                  "key": "offset",
                  "value": "0",
                  "description": "Rows to skip"
                }
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "The abandoned carts this campaign recovered - the list behind the recovered-carts figure. Requires `campaigns:read`. Paginated (`limit` 1-100, default 25; `offset`), newest recovery first. `total` is the same number as `revenue.recoveredCarts` on /stats for the same `startDate` / `endDate` (carts recovered in the range; a date-only end includes the whole day, in the workspace timezone) - the list and the count come from one query. Each row: `orderNumber` (the store's), `placedAt`, `recoveredAt`, `match` (`recovery_link` = checked out from the recovery link, `order` = the same cart was checked out, `reported` = the store reported it), `cartValue` / `cartCurrency`, `creditedRevenue` / `creditedCurrency` (net of refunds), `contact` (the cart's owner) and `buyer` (only when the order was stored under someone else). Contact names follow the pii:view rule: masked for a caller without it."
          }
        },
        {
          "name": "Get Campaign Failure Reasons",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/failure-reasons",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "failure-reasons"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Delivery-failure breakdown for a campaign: message.failed events grouped by provider DLR error code, most common first. Returns [{ code, reason, count }] (reason is a best-effort humanized label; code is the raw provider code)."
          }
        },
        {
          "name": "Get Campaign Recipients",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/recipients?limit=50&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "recipients"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50",
                  "description": "Page size; default 50, max 200."
                },
                {
                  "key": "offset",
                  "value": "0",
                  "description": "Rows to skip; default 0."
                },
                {
                  "key": "status",
                  "value": "opened",
                  "disabled": true,
                  "description": "Only people who reached this status on any of their events: sent | delivered | opened | clicked | failed | bounced | unsubscribed | skipped (skipped people are hidden unless asked for)."
                },
                {
                  "key": "skipReason",
                  "value": "control_group",
                  "disabled": true,
                  "description": "Only people whose latest skip had this reason. Implies status=skipped."
                },
                {
                  "key": "sortBy",
                  "value": "reached",
                  "disabled": true,
                  "description": "reached (furthest stage) | lastActivity | events. Any other value keeps the default order: first event, newest first. Every order ends in the person's id, so pages never repeat or skip anyone."
                },
                {
                  "key": "sortOrder",
                  "value": "desc",
                  "disabled": true,
                  "description": "asc | desc; default desc."
                },
                {
                  "key": "includeBots",
                  "value": "false",
                  "disabled": true,
                  "description": "Default false: bot, scanner and machine opens / clicks are not a stage the person reached and are not counted in eventCount - the same rule as the campaign's open and click rates."
                }
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "The people a campaign messaged, one row per person: { data: [{ userId, email, phone, name, furthestStatus, statuses, machineOnly, eventCount, firstEventAt, lastEventAt, failureReason, skipReason, simulated }], pagination }. furthestStatus is the furthest stage reached, on the ladder skipped < served < queued < sent < delivered < displayed < undelivered < failed < bounced < opened < dismissed < clicked < converted; unsubscribed is not a stage and shows only in statuses. Names and contact details are masked for a caller without pii:view."
          }
        },
        {
          "name": "Get Recipient Timeline",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/recipients/:userId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "recipients",
                ":userId"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                },
                {
                  "key": "userId",
                  "value": ""
                }
              ]
            },
            "description": "Per-user message event timeline for one campaign: { timeline: [{ eventId, eventName, timestamp, properties, channel }] }, or { status: 'unknown', reason, timeline: [] } when nothing was recorded. For a webhook campaign it also returns webhookResponse { status, contentType, body, truncated, at } when the latest FAILED call to this person (non-2xx) has a stored response: values that look like secrets are masked, the body is cut to its first 2 KB, and it is kept 30 days. A successful response body and the request headers are never stored. Returned only to a caller who may see personal data (pii:view; an API key only with that scope). Absent when nothing is stored or Joryio staff turned response storage off for the organization."
          }
        },
        {
          "name": "Get Campaign Unsubscribe Reasons",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/unsubscribe-reasons",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "unsubscribe-reasons"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Unsubscribes by reason for a campaign: one row per person who unsubscribed through the campaign's links (message.unsubscribed), by the reason on their latest unsubscribe, trimmed and lower-cased. Returns { total, reasons: [{ reason, count }], others } - the top 8 reasons, the rest summed in others; reason null = no reason given (also every unsubscribe recorded before the reason was stored on the analytics event, October 2026). Requires campaigns:read."
          }
        },
        {
          "name": "Get Campaign Skip Reasons (Not sent)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/skip-reasons",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "skip-reasons"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "The \"Not sent\" breakdown: people in the audience the campaign deliberately did not message, one row per person by the reason on their latest message.skipped event, most common first. Returns { total, reasons: [{ reason, count }] }. Reasons include control_group (held out to measure impact - intentional), global_control, frequency_cap, suppression_list, category_unsubscribed, channel_unsubscribed, email_address_suppressed, sender_group_unsubscribed, quiet_time, volume_cap, missing_contact, duplicate_recipient, consent_recheck_opted_out and coupon_unavailable. To list the people behind one reason: GET /campaigns/:campaignId/recipients?skipReason=<reason>."
          }
        },
        {
          "name": "Get Campaign Send Occurrences",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/send-occurrences",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "send-occurrences"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Per-send breakdown for a recurring campaign - the 'is this decaying?' view. Rows are keyed on SEND DATE, derived from the events themselves, so it covers campaigns that already ran. A campaign that sends more than once in a day has those sends combined in one row; `occurrence` is null while rows are date-derived (`derivedFromDates: true`). Engagement is attributed to the send that preceded it, bounded by that recipient's next send, so opens are never credited to the wrong send. Bots excluded. Rates use delivered as the denominator, falling back to sent when delivery is untracked."
          }
        },
        {
          "name": "Get Campaign Time to Engage",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/time-to-engage?startDate=2026-01-01&endDate=2026-01-31",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "time-to-engage"
              ],
              "query": [
                {
                  "key": "startDate",
                  "value": "2026-01-01",
                  "description": "Optional ISO date (inclusive)"
                },
                {
                  "key": "endDate",
                  "value": "2026-01-31",
                  "description": "Optional ISO date (inclusive)"
                }
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Distribution of how long recipients took to first open or click, measured PER RECIPIENT from their own send time - so it is meaningful for triggered campaigns, where every person has a different send moment. Bot engagements are excluded. Returns { buckets: [{ label, users, share, cumulative }], medianMinutes, engagers }; medianMinutes is interpolated from the bucket a cumulative 50% falls in, and is null when nobody has engaged."
          }
        },
        {
          "name": "Get Campaign Conversion Timing",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/conversion-timing?granularity=day&startDate=2026-01-01&endDate=2026-01-31",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "conversion-timing"
              ],
              "query": [
                {
                  "key": "granularity",
                  "value": "day",
                  "description": "Optional: day (default) or hour"
                },
                {
                  "key": "startDate",
                  "value": "2026-01-01",
                  "description": "Optional ISO date - gates the SEND, as for /stats"
                },
                {
                  "key": "endDate",
                  "value": "2026-01-31",
                  "description": "Optional ISO date - gates the SEND, as for /stats"
                }
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "When the campaign's attributed conversions happened. Uses the campaign's conversion tracking (primary goal, property conditions, window, attribution model) and the same query as conversionStats on /stats, split into buckets by the conversion's calendar day (or hour) in the workspace time zone - the buckets the engagement-over-time chart uses for opens and clicks. The buckets add up to the conversion count /stats returns for the same range. Returns { configured, granularity, total, buckets: [{ date, conversions }], majorityWithinHours }; majorityWithinHours is the smallest number of hours from the credited send within which more than half of the conversions arrived. configured is false (and nothing else is set) when the campaign has no conversion goal."
          }
        },
        {
          "name": "Get Campaign Engagement Timing",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/engagement-timing?granularity=hour&startDate=2026-01-01&endDate=2026-01-02&count=people&includeBots=false",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "engagement-timing"
              ],
              "query": [
                {
                  "key": "granularity",
                  "value": "hour",
                  "description": "Optional: day (default) or hour"
                },
                {
                  "key": "startDate",
                  "value": "2026-01-01",
                  "description": "Optional YYYY-MM-DD - start of the event range, as for /stats"
                },
                {
                  "key": "endDate",
                  "value": "2026-01-02",
                  "description": "Optional YYYY-MM-DD - end of the event range, as for /stats"
                },
                {
                  "key": "count",
                  "value": "people",
                  "description": "Optional: people (default) - each person once, in the bucket of their first open / click; events - every open and click"
                },
                {
                  "key": "includeBots",
                  "value": "false",
                  "description": "Optional: true counts bot and scanner opens / clicks too (default false)"
                }
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Sends, opens and clicks per hour or day in the workspace time zone, counted with the same rules as /stats: with count=people the opened / clicked buckets add up to the unique (human, unless includeBots) opened / clicked for the same range; with count=events, to the totals. Bucket keys match conversion-timing, so the two series line up. Returns { granularity, count, includeBots, buckets: [{ date, sent, opened, clicked }], totals: { sent, opened, clicked } }."
          }
        },
        {
          "name": "Send Transactional Message",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"userId\": \"user-123\",\n  \"channel\": \"email\",\n  \"message\": {\n    \"subject\": \"Your order confirmation\",\n    \"html\": \"<p>Thank you for your order, {{ user.firstName }}!</p>\"\n  },\n  \"triggerData\": {\n    \"name\": \"Order Completed\",\n    \"properties\": {\n      \"orderId\": \"ORD-2024-001\",\n      \"total\": 99.99\n    }\n  },\n  \"idempotencyKey\": \"order-ORD-2024-001-confirmation\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/campaigns/transactional/send",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                "transactional",
                "send"
              ]
            },
            "description": "Send a one-off message to a single user without creating a campaign. channel: email|sms|push|viber. `message` carries subject (email subject / push title), body (plain text / sms / push body), html (email). Transactional Viber requires `viberTemplateId` — an APPROVED template from the Viber template registry (Rakuten Viber mandates pre-approved templates for transactional/OTP messages since 2026-07-01); the template body IS the message (`message` may be {}), and optional `variables` fill its dynamic fields. Optional `triggerData` ({ type, name, properties, metadata }) is available to personalization; optional `idempotencyKey` dedupes retries."
          }
        }
      ]
    },
    {
      "name": "Canvas",
      "item": [
        {
          "name": "Create Canvas",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Onboarding Flow\",\n  \"description\": \"Multi-step onboarding journey\",\n  \"sendType\": \"trigger\",\n  \"entryTrigger\": {\n    \"type\": \"event\",\n    \"config\": {\n      \"eventName\": \"user_signed_up\"\n    }\n  },\n  \"nodes\": [\n    {\n      \"id\": \"start\",\n      \"type\": \"trigger\",\n      \"config\": {\n        \"type\": \"event\",\n        \"eventName\": \"user_signed_up\"\n      },\n      \"position\": {\n        \"x\": 0,\n        \"y\": 0\n      }\n    },\n    {\n      \"id\": \"delay1\",\n      \"type\": \"delay\",\n      \"config\": {\n        \"delayType\": \"duration\",\n        \"value\": 1,\n        \"unit\": \"days\"\n      },\n      \"position\": {\n        \"x\": 200,\n        \"y\": 0\n      }\n    },\n    {\n      \"id\": \"email1\",\n      \"type\": \"email\",\n      \"config\": {\n        \"subject\": \"Welcome!\",\n        \"html\": \"<p>Thanks for signing up, {{ user.firstName }}</p>\"\n      },\n      \"position\": {\n        \"x\": 400,\n        \"y\": 0\n      }\n    }\n  ],\n  \"edges\": [\n    {\n      \"id\": \"e1\",\n      \"source\": \"start\",\n      \"target\": \"delay1\"\n    },\n    {\n      \"id\": \"e2\",\n      \"source\": \"delay1\",\n      \"target\": \"email1\"\n    }\n  ]\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/journeys",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys"
              ]
            },
            "description": "Create a journey (canvas). Each node is { id, type, config, position? } — node settings live in `config`, and `entryTrigger` is { type, config } (e.g. type 'event' with config.eventName). Valid entryTrigger types: event, api, entity_change, whatsapp_inbound, sms_inbound, viber_inbound, attribute_change, subscription_status, schedule. Segment Entry is not a trigger. For entries caused by something the person did, use an Event trigger with a filter; for entries caused by time passing, use a recurring journey over the segment. Created journeys start as drafts — activate to go live."
          }
        },
        {
          "name": "Get All Canvases",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/journeys",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys"
              ]
            }
          }
        },
        {
          "name": "List Enterable Journeys",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/journeys/enterable?q=",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys",
                "enterable"
              ],
              "query": [
                {
                  "key": "q",
                  "value": "",
                  "description": "Optional: part of the journey name"
                }
              ]
            },
            "description": "The live (active) journeys a person can be added to with POST /journeys/:canvasId/enter/:userId, as { data: [{ id, name, activatedAt }] }, sorted by name, at most 100. Requires canvas:enter (or canvas:read) - a picker for integrations that may add people to journeys without reading them."
          }
        },
        {
          "name": "Enter a User into a Journey",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"context\": {\n    \"source\": \"crm-sync\"\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/journeys/:canvasId/enter/:userId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys",
                ":canvasId",
                "enter",
                ":userId"
              ],
              "variable": [
                {
                  "key": "canvasId",
                  "value": "{{canvasId}}"
                },
                {
                  "key": "userId",
                  "value": "{{userId}}"
                }
              ]
            },
            "description": "Add one contact to a live journey (the API entry path). userId is the contact's Joryio id. Optional body context is readable in the journey as context variables. The journey's re-entry policy applies; a skipped entry returns null. Requires canvas:enter (carried by canvas:activate)."
          }
        },
        {
          "name": "Get Canvas by ID",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/journeys/:canvasId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys",
                ":canvasId"
              ],
              "variable": [
                {
                  "key": "canvasId",
                  "value": "{{canvasId}}"
                }
              ]
            },
            "description": "One journey in full: trigger, nodes (messages with their content, waits, branches, conditions, exits), edges, status, versions and settings. Requires canvas:read."
          }
        },
        {
          "name": "Activate Canvas",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/journeys/:canvasId/activate",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys",
                ":canvasId",
                "activate"
              ],
              "variable": [
                {
                  "key": "canvasId",
                  "value": ""
                }
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"launchReviewOverride\": {\n    \"reason\": \"Transactional journey - no unsubscribe line by design\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "description": "Body is optional; `launchReviewOverride.reason` (3-300 chars) activates past Launch Review blockers on purpose, recorded on the journey and in the audit log. The publish route accepts the same field."
          }
        },
        {
          "name": "Get Launch Review",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/journeys/{{canvasId}}/launch-review",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys",
                "{{canvasId}}",
                "launch-review"
              ]
            },
            "description": "Launch Review for a journey: the campaign checks over every message node plus node_unreachable. Activation and publish run the same review and answer 409 launch_review_blocked when blockers stand and the workspace requires a clean list."
          }
        },
        {
          "name": "Get Journey Execution",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/journeys/{{canvasId}}/executions/:executionId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys",
                "{{canvasId}}",
                "executions",
                ":executionId"
              ],
              "variable": [
                {
                  "key": "executionId",
                  "value": ""
                }
              ]
            },
            "description": "One journey execution (one person's run) with its step history. Each webhook step in history carries webhook { delivered, status, ok, durationMs, attempts, error, savedFields, responseBodyStored }; a failed call (non-2xx) adds webhook.response { status, contentType, body, truncated } - values that look like secrets masked, the first 2 KB of the body, kept 30 days - returned only to a caller with pii:view (an API key only with that scope); others get webhook.responseHidden: true. A successful response body and the request headers are never stored."
          }
        },
        {
          "name": "Get Canvas Stats",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/journeys/:canvasId/stats",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys",
                ":canvasId",
                "stats"
              ],
              "variable": [
                {
                  "key": "canvasId",
                  "value": ""
                }
              ]
            }
          }
        },
        {
          "name": "List Journey Recovered Carts",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/journeys/:canvasId/recovered-carts?startDate=2026-10-01&endDate=2026-10-31&limit=25&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys",
                ":canvasId",
                "recovered-carts"
              ],
              "query": [
                {
                  "key": "startDate",
                  "value": "2026-10-01",
                  "description": "Optional. Carts recovered on or after this day (or instant)"
                },
                {
                  "key": "endDate",
                  "value": "2026-10-31",
                  "description": "Optional. Carts recovered up to this day, inclusive (or before this instant)"
                },
                {
                  "key": "limit",
                  "value": "25",
                  "description": "Rows per page, 1-100"
                },
                {
                  "key": "offset",
                  "value": "0",
                  "description": "Rows to skip"
                }
              ],
              "variable": [
                {
                  "key": "canvasId",
                  "value": ""
                }
              ]
            },
            "description": "The abandoned carts this journey recovered - the list behind the recovered-carts figure. Requires `canvas:read`. Paginated (`limit` 1-100, default 25; `offset`), newest recovery first. `total` is the same number as `revenue.recoveredCarts` on /stats for the same `startDate` / `endDate` (carts recovered in the range; a date-only end includes the whole day, in the workspace timezone) - the list and the count come from one query. Each row: `orderNumber` (the store's), `placedAt`, `recoveredAt`, `match` (`recovery_link` = checked out from the recovery link, `order` = the same cart was checked out, `reported` = the store reported it), `cartValue` / `cartCurrency`, `creditedRevenue` / `creditedCurrency` (net of refunds), `contact` (the cart's owner) and `buyer` (only when the order was stored under someone else). Contact names follow the pii:view rule: masked for a caller without it."
          }
        },
        {
          "name": "Test Webhook",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://api.example.com/users/{{ user.id }}\",\n  \"method\": \"POST\",\n  \"headers\": {\n    \"Content-Type\": \"application/json\"\n  },\n  \"bodyType\": \"json\",\n  \"body\": \"{\\\"email\\\": \\\"{{ user.email }}\\\", \\\"firstName\\\": \\\"{{ user.firstName }}\\\"}\",\n  \"auth\": {\n    \"type\": \"bearer\",\n    \"token\": \"your-endpoint-token\"\n  },\n  \"user\": {\n    \"userId\": \"65f0a1b2c3d4e5f6a7b8c9d0\"\n  },\n  \"context\": \"campaign\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/journeys/webhook/test",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "journeys",
                "webhook",
                "test"
              ]
            },
            "description": "Fire a webhook request once - the Send test of the journey Webhook node and the campaign webhook editor - and return the live response. Requires canvas:write; rate limited to 10 per minute. `user` is the Test contact: a real contact carries its internal id as `user.userId` and is loaded from your workspace and rendered exactly as a live send renders it (an id outside the workspace is a 404); a made-up contact (no id) has its fields merged over sample placeholders. `context` is 'journey' (default; adds the X-Joryio-Canvas-Id / Execution-Id / Node-Id headers with test values) or 'campaign' (omits them, as a campaign send does). The response carries ok, status, headers, body (capped; `truncated` when cut), durationMs, error, and `request` {method, url, headers, body} - the request actually sent, with credentials masked. SSRF-checked, no redirects."
          }
        }
      ]
    },
    {
      "name": "Landing Pages",
      "description": "Customer-built public pages with forms. A page is authored with the shared editor (drag & drop or HTML), published into a frozen document served at <pages host>/<workspace handle>/<slug>, and its form writes attributes, list subscriptions and the events 'Landing Page Viewed' / 'Landing Page Form Submitted'. Permissions: landing_pages:read, landing_pages:write, landing_pages:publish. The public routes (GET /p/:handle/:slug, POST /p/:id/view, POST /p/:id/submit) need no auth - the published page signs its own posts.",
      "item": [
        {
          "name": "List Landing Pages",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages?status=&q=&tags=&createdBy=&editedBy=&sortBy=editedAt&sortOrder=desc&limit=25&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages"
              ]
            },
            "description": "Paged list of the workspace's pages with status, stats (views, submissions), createdBy/updatedBy and public URL: { data, pagination: { total, limit, offset, hasMore } }. ?templates=true lists the workspace's own templates followed by the built-in ones. Filters: ?status=draft,published,archived (comma-separated), ?q= name search, ?tags= comma-separated tag names, ?createdBy= / ?editedBy= comma-separated user ids. Ordering across the whole list: ?sortBy=name|status|views|submissions|createdAt|editedAt|publishedAt (default editedAt), ?sortOrder=asc|desc (default desc). ?limit= (default 100, max 100), ?offset=. Requires landing_pages:read."
          }
        },
        {
          "name": "Create Landing Page",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages"
              ]
            },
            "description": "Create a page as a draft. mode dnd (html + css + grapejsData) or html. `form` is the field schema the public submit validates against: types email|phone|text|number|date|dropdown|radio|checkbox|checkbox_group|subscriptions|hidden; each field writes the attribute named by `attribute` (default: its key); email/phone identify the person; `lists` on a checkbox or subscriptions field subscribe to lists; hidden fields take a fixed value or utm_source|utm_medium|utm_campaign|utm_content|utm_term|referrer|page. `submit.action` message|redirect. Slug is unique per workspace and derived from the name when omitted. Requires landing_pages:write. `sdkEnabled` (default false) loads the workspace's Web SDK on the live page - needs an active Web app; visitors are tracked and identified on submit. `personalized` (default false) renders the page's Liquid for visitors arriving with a person token (?jp=), uncached.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Spring sale\",\n  \"slug\": \"spring-sale\",\n  \"mode\": \"html\",\n  \"html\": \"<h1>Spring sale</h1><form><input name=\\\"email\\\" type=\\\"email\\\" required placeholder=\\\"Email\\\"><input name=\\\"first_name\\\" placeholder=\\\"First name\\\"><label><input type=\\\"checkbox\\\" name=\\\"marketing\\\"> Email me offers</label><button type=\\\"submit\\\">Sign up</button></form>\",\n  \"form\": {\n    \"fields\": [\n      {\n        \"key\": \"email\",\n        \"type\": \"email\",\n        \"label\": \"Email\",\n        \"required\": true\n      },\n      {\n        \"key\": \"first_name\",\n        \"type\": \"text\",\n        \"label\": \"First name\",\n        \"attribute\": \"firstName\"\n      },\n      {\n        \"key\": \"marketing\",\n        \"type\": \"checkbox\",\n        \"label\": \"Email me offers\",\n        \"lists\": [\n          \"{{listId}}\"\n        ]\n      }\n    ],\n    \"submit\": {\n      \"action\": \"message\",\n      \"message\": \"Thanks - check your inbox.\"\n    },\n    \"identity\": \"email\"\n  },\n  \"seo\": {\n    \"title\": \"Spring sale\",\n    \"description\": \"20% off this week\"\n  },\n  \"language\": \"en\",\n  \"dir\": \"ltr\"\n}"
            }
          }
        },
        {
          "name": "Get Landing Page",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                ":id"
              ]
            },
            "description": "One page in full, with publicUrl (when published) and previewUrl. Requires landing_pages:read."
          }
        },
        {
          "name": "Landing Page History",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/:id/history?limit=50",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                ":id",
                "history"
              ]
            },
            "description": "Audit history of one page, newest first: create, update, publish, take-down (an update with status published -> draft) and delete, each with the actor (a user id or api_key:<id>). ?limit= 1-200 (default 50). Requires landing_pages:read."
          }
        },
        {
          "name": "Update Landing Page",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                ":id"
              ]
            },
            "description": "Update any of the create fields. The live page changes only on the next publish. Requires landing_pages:write.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Spring sale - week 2\",\n  \"seo\": {\n    \"title\": \"Spring sale, week 2\"\n  }\n}"
            }
          }
        },
        {
          "name": "Delete Landing Page",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                ":id"
              ]
            },
            "description": "Delete a draft (unpublish a live page first). Requires landing_pages:write."
          }
        },
        {
          "name": "Duplicate Landing Page",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/:id/duplicate",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                ":id",
                "duplicate"
              ]
            },
            "description": "Copy a page, or create a page from a template (a built-in or the workspace's own). {\"asTemplate\": true} saves the copy as a workspace template. Requires landing_pages:write.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"asTemplate\": false\n}"
            }
          }
        },
        {
          "name": "Publish Landing Page",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/:id/publish",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                ":id",
                "publish"
              ]
            },
            "description": "Freeze the sanitized document and serve it at the public URL. Fails when the form schema has problems or the markup has no <form> while fields are defined. Requires landing_pages:publish."
          }
        },
        {
          "name": "Unpublish Landing Page",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/:id/unpublish",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                ":id",
                "unpublish"
              ]
            },
            "description": "Take the page down; the public URL answers 404 and the page's signing token rotates. Requires landing_pages:publish."
          }
        },
        {
          "name": "Preview Landing Page",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/:id/preview",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                ":id",
                "preview"
              ]
            },
            "description": "The draft rendered as the live page would be (text/html); its form posts are acknowledged and discarded. Requires landing_pages:read."
          }
        },
        {
          "name": "Landing Page Submissions CSV",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/:id/submissions.csv",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                ":id",
                "submissions.csv"
              ]
            },
            "description": "Every submission of the page as a streamed CSV (text/csv, oldest first): submitted_at, one column per form field (every answer, mapped to an attribute or not), person_id, identified_by, lists_subscribed, referrer and the utm_* values. Personal data is masked without pii:view. Requires landing_pages:read."
          }
        },
        {
          "name": "Landing Page Submissions",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/:id/submissions?limit=50",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                ":id",
                "submissions"
              ]
            },
            "description": "The submissions ledger, newest first: fields, the person it was written to, referrer and UTM, lists subscribed. Personal data is masked unless the caller holds pii:view. ?limit= (max 200), ?before=<ISO date> to page. Requires landing_pages:read."
          }
        },
        {
          "name": "Landing Page Report",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/:id/report?days=30",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                ":id",
                "report"
              ],
              "query": [
                {
                  "key": "days",
                  "value": "30"
                }
              ]
            },
            "description": "Views, first-time views and submissions per UTC day over the last ?days= days (1-365, default 30), the totals and conversion rate for the range, and the lifetime counters. Requires landing_pages:read."
          }
        },
        {
          "name": "Landing Page Review",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/:id/review",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                ":id",
                "review"
              ]
            },
            "description": "Launch Review for the page: findings in the shared shape (code, severity blocker|recommendation, scope, params). Blockers stop a publish (POST .../publish answers 400 with the same findings); recommendations never block. Requires landing_pages:read."
          }
        },
        {
          "name": "Landing Pages Capabilities",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/capabilities",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                "capabilities"
              ]
            },
            "description": "What the server can add to a form: { botCheck } is true when Cloudflare Turnstile is configured, so a form may set botCheck: true. Requires landing_pages:read."
          }
        },
        {
          "name": "Get Pages Address",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/handle",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                "handle"
              ]
            },
            "description": "The workspace's pages address: { handle, host, example, customHost }. The handle is the workspace's own id, the first label of every page URL (https://<workspace id>.eu1.jrypages.com/<slug>) - opaque, permanent, never chosen or renamed. customHost is the workspace's own active domain, when it has one (page URLs then use it). Requires landing_pages:read."
          }
        },
        {
          "name": "Get Your Domain",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/domain",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                "domain"
              ]
            },
            "description": "The workspace's own landing-pages domain (pages.yourbrand.com), or null: { domain: { id, hostname, status (pending | active | failed), verifiedAt, target, records: [{ type, name, value, note }], providerConfigured, lastError? }, limit, used, pagesHost }. `records` are the DNS records to add at your DNS provider (a CNAME to the target and the certificate validation records); `limit` is how many landing-page domains your organization may have (1 by default - ask us to raise it) and `used` how many it has across its workspaces; `pagesHost` is the pages-domain host pages keep serving on. Requires landing_pages:publish."
          }
        },
        {
          "name": "Add Your Domain",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"hostname\": \"pages.yourbrand.com\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/landing-pages/domain",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                "domain"
              ]
            },
            "description": "Add the workspace's own domain for landing pages. A subdomain you own (pages.yourbrand.com - not a root domain, never a Joryio domain). Answers the domain with status `pending` and the DNS records to set; page URLs switch to it once it is `active`. 409 when the workspace already has a domain or the organization is at its limit (the message names the limit - ask us to raise it); 400 for an invalid hostname; 409 when another organization has claimed it. Requires landing_pages:publish."
          }
        },
        {
          "name": "Verify Your Domain",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/domain/:id/verify",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                "domain",
                ":id",
                "verify"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "<domain id>"
                }
              ]
            },
            "description": "Re-check the DNS records and the certificate at the edge and persist the result: the domain answers with its current status (`active` once the CNAME resolves and the certificate is issued, usually a few minutes after the records are in place; `failed` with `lastError` when validation timed out). Pending domains are also re-checked every five minutes on their own. Requires landing_pages:publish."
          }
        },
        {
          "name": "Remove Your Domain",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/landing-pages/domain/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "landing-pages",
                "domain",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "<domain id>"
                }
              ]
            },
            "description": "Remove the workspace's domain: the hostname is retired at the edge and page URLs revert to the pages domain at once; the pages themselves stay published. Answers { ok: true }. Requires landing_pages:publish."
          }
        }
      ]
    },
    {
      "name": "Custom Entities",
      "item": [
        {
          "name": "Create Entity Definition",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"products\",\n  \"displayName\": \"Products\",\n  \"description\": \"Product catalog\",\n  \"fields\": [\n    {\n      \"name\": \"name\",\n      \"displayName\": \"Product Name\",\n      \"fieldType\": \"string\",\n      \"indexed\": true\n    },\n    {\n      \"name\": \"price\",\n      \"displayName\": \"Price\",\n      \"fieldType\": \"number\"\n    }\n  ]\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/entities",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "entities"
              ]
            }
          }
        },
        {
          "name": "Create Entity Record",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": {\n    \"name\": \"Premium Widget\",\n    \"price\": 99.99,\n    \"sku\": \"WIDGET-001\"\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/entities/:entityId/records",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "entities",
                ":entityId",
                "records"
              ],
              "variable": [
                {
                  "key": "entityId",
                  "value": ""
                }
              ]
            },
            "description": "Create a single record (object body, `{ \"data\": { ... } }` envelope). This endpoint accepts two body forms: a single record object (this request - returns the full record) or a bare JSON array of record objects for bulk create (max 1000; see 'Create Entity Records (array body)')."
          }
        },
        {
          "name": "Create Entity Records (array body)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "[\n  {\n    \"data\": {\n      \"name\": \"Premium Widget\",\n      \"price\": 99.99,\n      \"sku\": \"WIDGET-001\"\n    }\n  },\n  {\n    \"data\": {\n      \"name\": \"Deluxe Widget\",\n      \"price\": 149.99,\n      \"sku\": \"WIDGET-002\"\n    }\n  }\n]"
            },
            "url": {
              "raw": "{{baseUrl}}/entities/:entityId/records?triggerAlerts=false",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "entities",
                ":entityId",
                "records"
              ],
              "query": [
                {
                  "key": "triggerAlerts",
                  "value": "false",
                  "description": "Set to true to fire relationship alerts (restock etc.) for records that transition in this import"
                }
              ],
              "variable": [
                {
                  "key": "entityId",
                  "value": ""
                }
              ]
            },
            "description": "Create MANY records in one call: POST /entities/:entityId/records with a bare JSON array body (max 1000 elements; no wrapper object). Each element uses the same `{ \"data\": { ... } }` envelope as the single-object form. Every element is validated; invalid elements are reported in failed[] by index while valid elements still process (field-definition validation applies to the whole batch). Response is an aggregate summary: {processed, inserted, insertedIds, failed: [{index, reason}]}."
          }
        },
        {
          "name": "Get Product Selection Match Count",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/entities/product-selections/:selectionId/match-count",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "entities",
                "product-selections",
                ":selectionId",
                "match-count"
              ],
              "variable": [
                {
                  "key": "selectionId",
                  "value": ""
                }
              ]
            },
            "description": "The true number of products a selection matches right now, with the preview cap: `{ matchCount, cap }`. The preview endpoint returns at most `cap` rows (the selection limit, max 50), so it cannot tell you the size of the match."
          }
        },
        {
          "name": "Get Entity Records",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/entities/:entityId/records?limit=50",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "entities",
                ":entityId",
                "records"
              ],
              "variable": [
                {
                  "key": "entityId",
                  "value": ""
                }
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                }
              ]
            }
          }
        }
      ]
    },
    {
      "name": "Warehouse Syncs",
      "description": "Scheduled imports from BigQuery, Snowflake or Redshift into the same write paths a file import uses. The source is a TABLE OR VIEW plus a watermark column, not free SQL: the query is generated each run so it can carry the incremental predicate. `columnMapping` maps a warehouse column to a target field path - `email`, `externalId`, `phone`, `attributes.<key>`, `subscriptions.<channel>`, or `subscriptions.list.<listId>.<channel>`. Setting `overrideConsent` requires compliance:write, and means more here than on a file import: a sync re-applies it every cycle.",
      "item": [
        {
          "name": "List Warehouse Syncs",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/warehouse-syncs",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "warehouse-syncs"
              ]
            },
            "description": "All configured syncs for the workspace, including each one's last run stats and current watermark."
          }
        },
        {
          "name": "Get Warehouse Sync",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/warehouse-syncs/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "warehouse-syncs",
                ":id"
              ]
            },
            "description": "One sync, with `lastRunStats` (rows read/created/updated/skipped/failed and a bounded error sample) and `watermark`. Also returns `cadence`: what the schedule means in practice. A run that outlasts its interval is refused rather than queued, so a 5-minute schedule on a 7-minute sync really runs every 7 - `cadence.throttled` says when that is happening."
          }
        },
        {
          "name": "List Sync Runs",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/warehouse-syncs/:id/runs?limit=50",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "warehouse-syncs",
                ":id",
                "runs"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                }
              ]
            },
            "description": "Recent runs, newest first. Each row carries the counters (rows read, created, updated, skipped, failed), the watermark it started from and ended at, a bounded sample of row-level errors, and a run-level error if the run itself failed. The sync object tells you whether it is working NOW; this tells you when it started failing. Kept to the 50 most recent runs per sync."
          }
        },
        {
          "name": "Preview Source Rows",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"integrationId\": \"{{warehouseIntegrationId}}\",\n  \"sourceTable\": \"analytics.public.contacts\",\n  \"limit\": 20\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/warehouse-syncs/preview",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "warehouse-syncs",
                "preview"
              ]
            },
            "description": "A bounded read of the source as it stands - no watermark, no incremental predicate - so a column mapping can be built against real column names. Send exactly one of sourceTable or sourceQuery. Runs inline and is hard-capped at 100 rows."
          }
        },
        {
          "name": "Create Warehouse Sync",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"integrationId\": \"{{warehouseIntegrationId}}\",\n  \"connectorId\": \"snowflake\",\n  \"name\": \"Contacts sync\",\n  \"syncType\": \"users\",\n  \"sourceTable\": \"analytics.public.contacts\",\n  \"watermarkColumn\": \"updated_at\",\n  \"keyColumn\": \"id\",\n  \"schedule\": \"hourly\",\n  \"enabled\": true,\n  \"columnMapping\": {\n    \"EMAIL\": \"email\",\n    \"CUSTOMER_ID\": \"externalId\",\n    \"PLAN\": \"attributes.plan\",\n    \"EMAIL_CONSENT\": \"subscriptions.email\"\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/warehouse-syncs",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "warehouse-syncs"
              ]
            },
            "description": "Create a sync. `sourceTable` and `watermarkColumn` must be plain identifiers (`table`, `schema.table`, `db.schema.table`) - anything else is rejected, because they are interpolated into generated SQL. Omit `watermarkColumn` for a full refresh every run. `syncType` is `users`, `events` or `deletions`. A `deletions` sync names people to erase: it creates erasure requests on the normal compliance path (the same one the DSR API uses) rather than deleting anything itself, and never queues a second erasure for someone already being erased. `schedule` is one of manual, every_5_min, every_15_min, hourly, every_6_hours, daily, weekly, monthly. `monthly` keeps the day of month you started on, clamped for short months - a sync anchored on the 31st runs on Feb 28 and returns to Mar 31 rather than migrating to the 28th. All schedule arithmetic is UTC. `keyColumn` is optional and recommended: a unique, orderable column (an auto-increment id or a ULID) used as the tiebreaker within one timestamp. With it the cursor becomes (watermarkColumn, keyColumn) and rows sharing a timestamp are read exactly once - neither lost nor re-read. It must be MONOTONIC for late arrivals; a content hash is stable but randomly ordered, so a row added later at the same instant would sort before the cursor and never be read. Provide exactly ONE of sourceTable or sourceQuery. A sourceQuery is a saved read-only SELECT, checked by the same guard at save time; the incremental predicate is applied by wrapping it, so the query MUST output the watermark column (and keyColumn, if set). Only client-settable fields are accepted: name, integrationId, syncType, sourceTable, sourceQuery, watermarkColumn, keyColumn, columnMapping, schedule, enabled, overrideConsent. Any other field (id, organizationId, workspaceId, status, watermark, run counters) is rejected with 400. integrationId must be a warehouse connection in the same workspace, otherwise 404. connectorId is optional and taken from the connection; if sent it must match."
          }
        },
        {
          "name": "Update Warehouse Sync",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"schedule\": \"weekly\",\n  \"enabled\": true\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/warehouse-syncs/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "warehouse-syncs",
                ":id"
              ]
            },
            "description": "Update a sync. Changing `schedule` or `enabled` recomputes when it next comes due. Only client-settable fields are accepted: name, integrationId, syncType, sourceTable, sourceQuery, watermarkColumn, keyColumn, columnMapping, schedule, enabled, overrideConsent. Any other field (id, organizationId, workspaceId, status, watermark, run counters) is rejected with 400. integrationId must be a warehouse connection in the same workspace, otherwise 404. connectorId is optional and taken from the connection; if sent it must match."
          }
        },
        {
          "name": "Run Warehouse Sync Now",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/warehouse-syncs/:id/run",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "warehouse-syncs",
                ":id",
                "run"
              ]
            },
            "description": "Enqueue a run immediately. Returns `queued: false` if the sync is already queued or running - a sync that overlaps itself re-reads the same rows and doubles the warehouse bill for no new data."
          }
        },
        {
          "name": "Rewind Warehouse Sync",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/warehouse-syncs/:id/rewind",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "warehouse-syncs",
                ":id",
                "rewind"
              ]
            },
            "description": "Clear the watermark so the next run re-reads the table from the beginning. Safe: every write is an idempotent upsert. This is the remediation when a column mapping was wrong - fix the mapping, rewind, run."
          }
        },
        {
          "name": "Delete Warehouse Sync",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/warehouse-syncs/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "warehouse-syncs",
                ":id"
              ]
            },
            "description": "Delete the sync configuration. Data already imported is untouched."
          }
        }
      ]
    },
    {
      "name": "In-App Campaigns",
      "item": [
        {
          "name": "Create In-App Campaign (Event Trigger)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Reward Modal on Purchase\",\n  \"channel\": \"in_app\",\n  \"sendType\": \"triggered\",\n  \"channelConfig\": {\n    \"type\": \"modal\",\n    \"triggers\": [\n      {\n        \"type\": \"event\",\n        \"event\": \"Order Completed\"\n      }\n    ],\n    \"frequencyCap\": {\n      \"maxImpressions\": 1,\n      \"timeWindow\": \"day\"\n    },\n    \"priority\": 5,\n    \"liquidEnabled\": true\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/campaigns",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns"
              ]
            },
            "description": "Create an in-app campaign shown when the user performs an event. channelConfig.type: modal|banner|slideup|fullscreen|custom. Trigger types: immediate|event|attribute_change|attribute_threshold|push_notification_tap. frequencyCap.timeWindow: once|session|day|week|month|lifetime."
          }
        },
        {
          "name": "Create In-App Campaign (Immediate)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Welcome Banner\",\n  \"channel\": \"in_app\",\n  \"sendType\": \"triggered\",\n  \"channelConfig\": {\n    \"type\": \"banner\",\n    \"triggers\": [\n      {\n        \"type\": \"immediate\"\n      }\n    ],\n    \"frequencyCap\": {\n      \"maxImpressions\": 3,\n      \"timeWindow\": \"week\"\n    }\n  },\n  \"targeting\": {\n    \"filterGroups\": [\n      {\n        \"filters\": [\n          {\n            \"type\": \"attribute\",\n            \"field\": \"plan\",\n            \"operator\": \"equals\",\n            \"value\": \"free\"\n          }\n        ],\n        \"operator\": \"AND\"\n      }\n    ]\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/campaigns",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns"
              ]
            },
            "description": "Create an in-app banner shown on the next eligible session. Audience is the standard campaign `targeting` object (filterGroups / userIds)."
          }
        },
        {
          "name": "Get All In-App Campaigns",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns?channel=in_app&status=active",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns"
              ],
              "query": [
                {
                  "key": "channel",
                  "value": "in_app"
                },
                {
                  "key": "status",
                  "value": "active"
                },
                {
                  "key": "page",
                  "value": "1",
                  "disabled": true
                },
                {
                  "key": "limit",
                  "value": "20",
                  "disabled": true
                }
              ]
            },
            "description": "List in-app campaigns via the unified campaigns list, filtered by channel."
          }
        },
        {
          "name": "Get In-App Campaign by ID",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            }
          }
        },
        {
          "name": "Update In-App Campaign",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"status\": \"active\",\n  \"channelConfig\": {\n    \"type\": \"modal\",\n    \"triggers\": [\n      {\n        \"type\": \"event\",\n        \"event\": \"Order Completed\"\n      }\n    ],\n    \"priority\": 8\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "Partial update via the unified campaigns API. In-app-specific settings (priority, triggers, frequency cap) are updated inside `channelConfig`."
          }
        },
        {
          "name": "Get In-App Campaign Stats",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/campaigns/:campaignId/in-app-stats",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                ":campaignId",
                "in-app-stats"
              ],
              "variable": [
                {
                  "key": "campaignId",
                  "value": ""
                }
              ]
            },
            "description": "In-app impression/click/conversion stats for one campaign."
          }
        }
      ],
      "description": "In-app messages are standard campaigns with `channel: \"in_app\"`, managed through the unified /campaigns API. In-app-specific settings (message type, triggers, frequency cap, priority) live in `channelConfig`; content comes from `templateId` or `channelConfig` rather than `variants`."
    },
    {
      "name": "Push Notifications",
      "item": [
        {
          "name": "Register Device Token",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"platform\": \"ios\",\n  \"pushToken\": \"device_token_from_apns_or_fcm\",\n  \"appId\": \"{{iosAppId}}\",\n  \"deviceInfo\": {\n    \"appVersion\": \"1.0.0\",\n    \"osVersion\": \"17.2\",\n    \"model\": \"iPhone 15 Pro\"\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/users/:userId/devices",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                ":userId",
                "devices"
              ],
              "variable": [
                {
                  "key": "userId",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c",
                  "description": "Joryio's internal user ID (24-char hex)"
                }
              ]
            },
            "description": "Register a device push token for a user. platform: ios|android|web; `deviceInfo` is a free-form object."
          }
        },
        {
          "name": "List Device Tokens",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/:userId/devices",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                ":userId",
                "devices"
              ],
              "variable": [
                {
                  "key": "userId",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c"
                }
              ]
            },
            "description": "List a user's registered devices."
          }
        },
        {
          "name": "Remove Device Token",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/users/:userId/devices/:deviceId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "users",
                ":userId",
                "devices",
                ":deviceId"
              ],
              "variable": [
                {
                  "key": "userId",
                  "value": "665f1e2a9b3c4d5e6f7a8b9c"
                },
                {
                  "key": "deviceId",
                  "value": ""
                }
              ]
            },
            "description": "Remove a registered device (e.g. when the user logs out)."
          }
        },
        {
          "name": "Send Transactional Push",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"userId\": \"user-123\",\n  \"channel\": \"push\",\n  \"message\": {\n    \"subject\": \"Your order has shipped!\",\n    \"body\": \"Track your package in real-time\"\n  },\n  \"idempotencyKey\": \"order-ORD-2024-001-shipped\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/campaigns/transactional/send",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns",
                "transactional",
                "send"
              ]
            },
            "description": "One-off push to a single user via the transactional send endpoint. For push, `message.subject` becomes the notification title and `message.body` the notification body. Requires a configured push app and a registered device token for the user."
          }
        },
        {
          "name": "Create Push Campaign",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Feature Announcement\",\n  \"channel\": \"push\",\n  \"sendType\": \"immediate\",\n  \"variants\": [\n    {\n      \"id\": \"variant-a\",\n      \"name\": \"Variant A\",\n      \"weight\": 100,\n      \"message\": {\n        \"title\": \"Exclusive Premium Feature\",\n        \"body\": \"Check out our new feature just for you!\"\n      }\n    }\n  ],\n  \"targeting\": {\n    \"filterGroups\": [\n      {\n        \"filters\": [\n          {\n            \"type\": \"attribute\",\n            \"field\": \"plan\",\n            \"operator\": \"equals\",\n            \"value\": \"premium\"\n          }\n        ],\n        \"operator\": \"AND\"\n      }\n    ]\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/campaigns",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "campaigns"
              ]
            },
            "description": "Send push to an audience via a standard campaign with channel push. Variant message: { title, body, icon?, image?, data? }."
          }
        }
      ],
      "description": "Push delivery runs through the standard campaign pipeline: create a campaign with `channel: \"push\"` (or use POST /campaigns/transactional/send with channel push) after registering device tokens against a user. Push provider (APNS/FCM) credentials are configured per app in the dashboard (Settings > Apps), not via this API."
    },
    {
      "name": "API Keys",
      "item": [
        {
          "name": "Create API Key",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Production API Key\",\n  \"description\": \"Key for production environment\",\n  \"permissions\": [\"users:read\", \"users:write\", \"events:track\"]\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/api-keys",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api-keys"
              ]
            }
          }
        },
        {
          "name": "List API Keys",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/api-keys",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api-keys"
              ]
            }
          }
        }
      ]
    },
    {
      "name": "Subscriptions",
      "description": "Per-contact consent state: channel opt-in/out, list membership, bounce clearing, and the audit history. Contact-scoped (userId = internal contact id). Reads require compliance:read, writes compliance:write.",
      "item": [
        {
          "name": "Get Contact Subscriptions",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/subscriptions/contacts/:userId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "subscriptions",
                "contacts",
                ":userId"
              ]
            },
            "description": "Return the full subscription state (all channels + lists) for a contact."
          }
        },
        {
          "name": "Update Channel Subscription",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"channel\": \"email\",\n  \"status\": \"unsubscribed\",\n  \"source\": \"api\",\n  \"consentText\": \"User unsubscribed via preference center\",\n  \"reason\": \"manual update\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/subscriptions/contacts/:userId/channels/:channel",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "subscriptions",
                "contacts",
                ":userId",
                "channels",
                ":channel"
              ]
            },
            "description": "Set a contact's status on one channel. channel path param must be one of email|sms|whatsapp|push and overrides the body channel. status is optedIn|subscribed|unsubscribed. Re-subscribing (status subscribed/optedIn) applies to THIS contact only: other contacts in the workspace with the same email/phone stay unsubscribed. An unsubscribe applies to every contact sharing the email/phone (each gets a history entry, source shared_identifier). Recorded with source api and the API key id in metadata.principal; a body source ending in _keyword or equal to shared_identifier / complaint_webhook / staff is recorded as api. A STOP-keyword opt-out (SMS, WhatsApp, Viber) CAN be overridden by this update for this contact only (history records overrodeStop and the API key); a START reply still works. SMS only: 200 OK with a non-error warnings field: [{ code: sms_stop_provider_block, channel: sms, message: \"This contact opted out by texting STOP. Your SMS provider may keep blocking messages until they text START.\" }] - the SMS provider may keep its own block. A send the SMS provider then rejects for its own STOP block (e.g. Twilio 21610, Delivyr code 1) is recorded as blocked by provider (opted out), not a failure, and the opt-out is re-applied (history: provider still blocks: re-opted out). 409 { code, message } when a re-subscribe cannot lift the block: deliverability (hard bounce or spam complaint - only Joryio support can lift it), organization_block. SMS double opt-in (workspace setting \"SMS double opt-in\" on): a NEW SMS opt-in (status subscribed/optedIn for a contact with no SMS status) is stored as status pending and the contact gets ONE confirmation text (\"<Brand>: Reply YES to receive texts. Msg & data rates may apply. Reply STOP to opt out.\"); the response shows channels.sms.status pending and channels.sms.confirmation { requestedAt, sentAt, expiresAt }. The contact becomes subscribed (optInSource double_opt_in) only by replying YES/START; a pending contact receives no campaign or journey SMS. Asking again while pending is a no-op (no second text). A contact or number that opted out (STOP or any SMS opt-out) is treated the same way: pending + ONE confirmation text, subscribed only after YES, back to unsubscribed if unanswered (no reminder); a contact who had texted STOP also gets the sms_stop_provider_block warning, and a provider STOP rejection of the text unsubscribes them again. Not applied when the workspace's SMS number is on a Delivyr account (Delivyr runs its own double opt-in)."
          }
        },
        {
          "name": "Clear Bounce Status",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"reason\": \"verified new mailbox\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/subscriptions/contacts/:userId/clear-bounce",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "subscriptions",
                "contacts",
                ":userId",
                "clear-bounce"
              ]
            },
            "description": "Clear a contact's soft-bounce state. reason is optional and stored in the audit log. Clears a SOFT bounce only. A hard bounce or spam complaint is lifted only by Joryio support: 403 with code deliverability."
          }
        },
        {
          "name": "Get List Subscriptions",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/subscriptions/contacts/:userId/lists",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "subscriptions",
                "contacts",
                ":userId",
                "lists"
              ]
            },
            "description": "Return the list/group memberships for a contact."
          }
        },
        {
          "name": "Subscribe Contact to List",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"channel\": \"email\",\n  \"source\": \"api\",\n  \"consentText\": \"Signed up via website form\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/subscriptions/contacts/:userId/lists/:listId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "subscriptions",
                "contacts",
                ":userId",
                "lists",
                ":listId"
              ]
            },
            "description": "Add a contact to a list on a given channel. channel is required; source/consentText optional (defaults source=api). Applies to this contact only (other contacts with the same email/phone stay unsubscribed from the list). Over a STOP-keyword opt-out the subscribe succeeds for this contact only (recorded as overriding a STOP); for an SMS list the 200 response carries warnings: [{ code: sms_stop_provider_block, channel: sms, message: \"This contact opted out by texting STOP. Your SMS provider may keep blocking messages until they text START.\" }]."
          }
        },
        {
          "name": "Unsubscribe Contact from List",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"channel\": \"email\",\n  \"source\": \"api\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/subscriptions/contacts/:userId/lists/:listId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "subscriptions",
                "contacts",
                ":userId",
                "lists",
                ":listId"
              ]
            },
            "description": "Remove a contact from a list. Body optional; channel defaults to email. The list unsubscribe also applies to every contact in the workspace with the same email/phone."
          }
        },
        {
          "name": "Get Subscription History",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/subscriptions/contacts/:userId/history?limit=50&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "subscriptions",
                "contacts",
                ":userId",
                "history"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                },
                {
                  "key": "offset",
                  "value": "0"
                }
              ]
            },
            "description": "Paginated audit log of subscription changes for a contact. Non-numeric limit/offset fall back to defaults (50/0). Every unsubscribe entry since 2026-10-15 carries metadata.unsubscribeSource (header_one_click = the mail app's RFC 8058 button, page_button = a person pressing Unsubscribe on the hosted page, preferences_page, page_everything, api, admin, sdk, import, keyword, complaint, esp_unsubscribe, store_sync, other) and, where the message is known, metadata.sinceSendMs. userAgent is stored sanitized (max 256 chars). Entries with metadata.pageMode 'auto' were made by the hosted page without a click (2026-10-11 to 2026-10-15). Consent proof (since 2026-11-02): every entry also carries changeKind/changeLabel (subscribed, resubscribed, unsubscribed, pending_order, pending_confirmation, withdrawn, confirmed_by_order, confirmed, confirmation_expired, bounce, complaint, import, updated), sourceKind/sourceLabel/sourcePlatform (e.g. store_checkout + woocommerce, landing_page, api, admin, import, unsubscribe_link, bounce, complaint, keyword_stop) page (where stored), location (where on the store: checkout, block_checkout, registration, customer_record, newsletter) and collectedAt (when collected, ISO-8601; null when not recorded); consentText is the exact wording shown. ipAddress is personal data: without pii:view (an API key only with that scope) it is null with ipHidden: true. Example item: { \"action\": \"subscribe\", \"previousStatus\": null, \"newStatus\": \"pending\", \"source\": \"woocommerce_checkout\", \"consentText\": \"Yes, send me news and offers by email.\", \"ipAddress\": null, \"ipHidden\": true, \"changeKind\": \"pending_order\", \"changeLabel\": \"Consent pending - awaiting order or confirmation\", \"sourceKind\": \"store_checkout\", \"sourceLabel\": \"WooCommerce checkout\", \"sourcePlatform\": \"woocommerce\", \"page\": \"https://shop.example.com\", \"createdAt\": \"2026-11-01T10:00:00.000Z\" }."
          }
        },
        {
          "name": "List Recent Unsubscribe Reasons",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/subscriptions/unsubscribe-reasons?limit=50&offset=0&days=90",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "subscriptions",
                "unsubscribe-reasons"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50",
                  "description": "Rows per page (max 100)"
                },
                {
                  "key": "offset",
                  "value": "0"
                },
                {
                  "key": "days",
                  "value": "90",
                  "description": "How far back to look (max 365)"
                },
                {
                  "key": "flagged",
                  "value": "true",
                  "description": "Only unsubscribes flagged as possibly automatic",
                  "disabled": true
                }
              ]
            },
            "description": "Workspace-wide recent unsubscribes that carry a reason or were flagged as possibly automatic, newest first, one row per unsubscribe action (channels grouped). Returns { items: [{ at, contactId, email, name, channels, listId, reason, campaignId, source, suspectedAutomatic, suspectedAutomaticReasons }], total }. name is masked for callers without pii:view. Requires compliance:read."
          }
        },
        {
          "name": "Check Channel Subscription Status",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/subscriptions/contacts/:userId/channels/:channel/status",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "subscriptions",
                "contacts",
                ":userId",
                "channels",
                ":channel",
                "status"
              ]
            },
            "description": "Returns { subscribed: boolean } for the contact on one channel (email|sms|whatsapp|push)."
          }
        },
        {
          "name": "Check Email Valid",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/subscriptions/contacts/:userId/email-valid",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "subscriptions",
                "contacts",
                ":userId",
                "email-valid"
              ]
            },
            "description": "Returns { valid: boolean } indicating whether the contact's email is deliverable (not bounced)."
          }
        }
      ]
    },
    {
      "name": "Suppressions",
      "description": "Address/number-keyed suppression ledger (channel_suppressions), scoped per workspace. On add/import, reason is clamped to manual|unsubscribe — only POST /suppressions/hard-bounce may assert a hard bounce. Suppression follows the email address or phone number itself (not a specific contact) across the workspace, and hard_bounce/complaint entries survive re-subscribe. API keys need the channel-precise scope: email_suppression:* for email, sms_suppression:* for sms/whatsapp (read for GETs, write for POST/DELETE).",
      "item": [
        {
          "name": "List Suppressions",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/suppressions?channel=email&reason=&limit=100&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "suppressions"
              ],
              "query": [
                {
                  "key": "channel",
                  "value": "email"
                },
                {
                  "key": "reason",
                  "value": ""
                },
                {
                  "key": "limit",
                  "value": "100"
                },
                {
                  "key": "offset",
                  "value": "0"
                }
              ]
            },
            "description": "List suppressed identifiers (paginated). Optional filters: channel (email|sms|whatsapp), reason (unsubscribe|hard_bounce|complaint|manual). limit 1-1000 (default 100), offset >= 0."
          }
        },
        {
          "name": "Check Suppression",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/suppressions/:identifier?channel=email",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "suppressions",
                ":identifier"
              ],
              "query": [
                {
                  "key": "channel",
                  "value": "email"
                }
              ]
            },
            "description": "Check whether a single identifier (email/phone) is suppressed on a channel. channel defaults to email."
          }
        },
        {
          "name": "Add Suppression",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"channel\": \"email\",\n  \"identifier\": \"user@example.com\",\n  \"reason\": \"manual\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/suppressions",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "suppressions"
              ]
            },
            "description": "Add a manual/consent suppression. reason is clamped to manual|unsubscribe by the service - this path can NEVER assert a hard bounce. scope global|group; listId required when scope=group. With reason unsubscribe, every contact in the workspace with that email/phone is also set to unsubscribed on the channel (or on the list for scope group)."
          }
        },
        {
          "name": "Add Hard Bounce",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"identifier\": \"dead@example.com\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/suppressions/hard-bounce",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "suppressions",
                "hard-bounce"
              ]
            },
            "description": "The ONLY API path that asserts a hard bounce. Email only; recorded with source=api. A hard_bounce entry survives re-subscribe."
          }
        },
        {
          "name": "Import Suppressions",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"channel\": \"email\",\n  \"entries\": [\n    {\n      \"identifier\": \"a@example.com\"\n    },\n    {\n      \"identifier\": \"b@example.com\",\n      \"reason\": \"unsubscribe\"\n    }\n  ]\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/suppressions/import",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "suppressions",
                "import"
              ]
            },
            "description": "Bulk import (ESP migration). Up to 5000 entries. Per-entry reason optional (manual|unsubscribe); no entry may assert a hard bounce."
          }
        },
        {
          "name": "Remove Suppression (Unsuppress)",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/suppressions/:identifier?channel=email",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "suppressions",
                ":identifier"
              ],
              "query": [
                {
                  "key": "channel",
                  "value": "email"
                }
              ]
            },
            "description": "Remove the consent suppression rows (unsubscribe, manual) for this workspace for an identifier on a channel. channel defaults to email. Never removes a STOP-keyword opt-out (removing it would lift the number for every contact sharing it - re-subscribe the one contact through the Subscriptions API, or the person texts START) or a hard_bounce / complaint row (only Joryio support lifts those); they are counted in the response: { identifier, removed, kept?: { stop_keyword?, deliverability? } }."
          }
        }
      ]
    },
    {
      "name": "AI Agents",
      "description": "Generate-only AI agents (ai_agents table): reusable objects that READ a bounded, opt-in context and EMIT validated structured output for journeys and catalog jobs to act on. An agent has NO tools/actions — it never sends, branches, or writes on its own. modelMode managed (hosted Claude, 1 credit/run) or byo (your provider key, small flat fee/run). Every run resolves to one outcome: success|fallback|timeout|rate_limited|invalid_config|budget_exceeded. API keys need ai_agents:read for GETs and ai_agents:write for writes; dashboard sessions may use settings:read/settings:write.",
      "item": [
        {
          "name": "Create Agent",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Cart subject-line writer\",\n  \"instructions\": \"Write a short, upbeat email subject line for the abandoned cart. Max 60 characters.\",\n  \"modelMode\": \"managed\",\n  \"contextSelectors\": {\n    \"attributeKeys\": [\n      \"first_name\",\n      \"cart_total\"\n    ],\n    \"includeBrandVoice\": true\n  },\n  \"outputSchema\": {\n    \"type\": \"json\",\n    \"fields\": [\n      {\n        \"name\": \"subject\",\n        \"type\": \"string\"\n      }\n    ],\n    \"includeExplanation\": true\n  },\n  \"fallbackValue\": {\n    \"subject\": \"You left something behind\"\n  },\n  \"dailyCap\": 50000,\n  \"guardrails\": {\n    \"timeoutMs\": 20000,\n    \"retryOnTransient\": true\n  },\n  \"task\": \"write\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/ai-agents",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents"
              ]
            },
            "description": "Create a generate-only AI agent. Required: name, instructions. modelMode managed|byo (default managed); provider joryio|anthropic|openai|google|azure|bedrock. outputSchema type string|number|boolean|json (json carries a fields[] list). contextSelectors is opt-in. dailyCap default 250000. Needs ai_agents:write (or settings:write). Set task to 'classify' with classification: { labels: [{ name, description }], unsureBelow } to build a Classify agent - one label from the list, one journey branch per label plus 'unsure'; ids are assigned on save."
          }
        },
        {
          "name": "List Agents",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/ai-agents?status=active",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents"
              ],
              "query": [
                {
                  "key": "status",
                  "value": "active"
                }
              ]
            },
            "description": "List the workspace's AI agents (newest-updated first). Optional status filter: active|archived. Needs ai_agents:read (or settings:read)."
          }
        },
        {
          "name": "Get Run Pricing",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/ai-agents/pricing",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                "pricing"
              ]
            },
            "description": "Price per run in micros (1,000,000 micros = 1 USD) for each task (write, classify) and hosting mode (managed, byo), from the price list with the runner's fallbacks. The builder shows it as 'about X per 1,000 runs'."
          }
        },
        {
          "name": "List Provider Keys",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/ai-agents/provider-keys",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                "provider-keys"
              ]
            },
            "description": "List registered BYO provider keys for the workspace. Credential values are NEVER returned (credentials is always {}). Needs ai_agents:read (or settings:read)."
          }
        },
        {
          "name": "Upsert Provider Key",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"credentials\": {\n    \"apiKey\": \"sk-your-openai-key\"\n  },\n  \"label\": \"Production OpenAI\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/ai-agents/provider-keys/:provider",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                "provider-keys",
                ":provider"
              ]
            },
            "description": "Create or replace the BYO key for one provider (path :provider = anthropic|openai|google|azure|bedrock; 'joryio' is rejected). credentials is provider-specific ({ apiKey } etc), encrypted at rest and never returned. One key per (workspace, provider). Needs ai_agents:write (or settings:write)."
          }
        },
        {
          "name": "Use Provider Key for Assistant",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"enabled\": true\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/ai-agents/provider-keys/:provider/assistant",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                "provider-keys",
                ":provider",
                "assistant"
              ]
            },
            "description": "Route this workspace's AI assistant and content generation through the BYO key of :provider (anthropic|openai|google|azure|bedrock) with { enabled: true }, or back to the Joryio platform key with { enabled: false }. One key per workspace at most; enabling a second key moves the flag. Runs on your key are billed a small platform fee per use (price list: ai_agent / byo / generation and assistant_turn) instead of the token cost. The assistant only runs on an Anthropic key; with another provider it stays on the platform key and only generation is routed. Returns the key row (credentials stripped) with useForAssistant. 404 when no key is saved for the provider; 400 when the key is disabled or the provider is joryio. Needs ai_agents:write (or settings:write)."
          }
        },
        {
          "name": "Delete Provider Key",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/ai-agents/provider-keys/:provider",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                "provider-keys",
                ":provider"
              ]
            },
            "description": "Delete the BYO key for the given provider (path :provider = anthropic|openai|google|azure|bedrock). Returns { success: true }. Needs ai_agents:write (or settings:write)."
          }
        },
        {
          "name": "Get Agent",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/ai-agents/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                ":id"
              ]
            },
            "description": "Fetch one AI agent by id (workspace-scoped; 404 otherwise). Needs ai_agents:read (or settings:read)."
          }
        },
        {
          "name": "Update Agent",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"dailyCap\": 100000,\n  \"guardrails\": {\n    \"timeoutMs\": 15000,\n    \"retryOnTransient\": false\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/ai-agents/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                ":id"
              ]
            },
            "description": "Partial update of an agent (send only changed fields). Accepts all create fields plus status (active|archived). Needs ai_agents:write (or settings:write)."
          }
        },
        {
          "name": "Archive Agent",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": ""
            },
            "url": {
              "raw": "{{baseUrl}}/ai-agents/:id/archive",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                ":id",
                "archive"
              ]
            },
            "description": "Soft-archive an agent: status becomes archived (halts use, keeps run history). Needs ai_agents:write (or settings:write)."
          }
        },
        {
          "name": "Delete Agent",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/ai-agents/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                ":id"
              ]
            },
            "description": "Delete an agent. Returns { success: true }. Needs ai_agents:write (or settings:write)."
          }
        },
        {
          "name": "Test Agent (Preview)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"attributes\": {\n    \"first_name\": \"Dana\",\n    \"cart_total\": 249.9\n  },\n  \"segmentMemberships\": [\n    \"vip\"\n  ]\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/ai-agents/:id/test",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                ":id",
                "test"
              ]
            },
            "description": "Dry-run the agent against a SAMPLE context (attributes, segmentMemberships, catalogRecord, engagement). Uses a fresh run key and never counts against a real journey. Returns { outcome, output, explanation }. outcome is success|fallback|timeout|rate_limited|invalid_config|budget_exceeded. Needs ai_agents:write (or settings:write)."
          }
        },
        {
          "name": "List Agent Runs",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/ai-agents/:id/runs?limit=50",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                ":id",
                "runs"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                }
              ]
            },
            "description": "Recent run traces for one agent (newest first). limit 1-200 (default 50). Trace stores input REFERENCES + output + explanation only, never the raw prompt text. Needs ai_agents:read (or settings:read)."
          }
        },
        {
          "name": "Run Catalog Enrichment",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"agentId\": \"8f0e2b3a-1c4d-4e5f-9a0b-1c2d3e4f5a6b\",\n  \"entityDefinitionId\": \"product\",\n  \"targetField\": \"ai_description\",\n  \"filter\": {\n    \"category\": \"shoes\"\n  },\n  \"limit\": 200\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/ai-agents/enrichment/run",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                "enrichment",
                "run"
              ]
            },
            "description": "Run a generate-only agent over a custom-entity's records and write each output into targetField. Required: agentId (active agent), entityDefinitionId, targetField. Optional filter (Mongo-style) and limit (up to 100k, hard-capped). ASYNC/QUEUED: this ENQUEUES a job and returns immediately with the queued job — a large catalog processes off the request path. Idempotent per record (runKey = jobId:recordId), so a retried job never re-charges an already-enriched record. Returns { jobId, status: 'queued', counts: { total, processed, succeeded, failed, skipped }, ... }; poll 'Get Enrichment Job' for progress. Needs ai_agents:write (or settings:write)."
          }
        },
        {
          "name": "Get Enrichment Job",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/ai-agents/enrichment/jobs/:jobId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                "enrichment",
                "jobs",
                ":jobId"
              ],
              "variable": [
                {
                  "key": "jobId",
                  "value": "job_9a8b7c",
                  "description": "The enrichment job id returned by Run Catalog Enrichment."
                }
              ]
            },
            "description": "Poll one enrichment job's status + counts. Returns { jobId, status: 'queued'|'running'|'completed'|'failed', counts: { total, processed, succeeded, failed, skipped }, error?, ... }. 404 if the job is not in this workspace. Needs ai_agents:read (or settings:read)."
          }
        },
        {
          "name": "List Enrichment Jobs",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/ai-agents/enrichment/jobs?limit=20",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "ai-agents",
                "enrichment",
                "jobs"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "20"
                }
              ]
            },
            "description": "List recent enrichment jobs (newest first) for progress/history. Optional limit (default 20). Each row is the same shape as Get Enrichment Job. Needs ai_agents:read (or settings:read)."
          }
        }
      ]
    },
    {
      "name": "Attributes",
      "description": "Custom contact-attribute dictionary (data dictionary). GET is open to any authenticated caller (used by pickers); create/update/delete require users:write.",
      "item": [
        {
          "name": "List Attributes",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/attributes?q=&limit=50&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "attributes"
              ],
              "query": [
                {
                  "key": "q",
                  "value": ""
                },
                {
                  "key": "limit",
                  "value": "50"
                },
                {
                  "key": "offset",
                  "value": "0"
                }
              ]
            },
            "description": "List custom attribute definitions. With no params returns the full pool; passing q/limit/offset returns a paginated envelope."
          }
        },
        {
          "name": "Create Attribute",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"loyalty_tier\",\n  \"type\": \"string\",\n  \"description\": \"Customer loyalty tier\",\n  \"defaultValue\": \"bronze\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/attributes",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "attributes"
              ]
            },
            "description": "Define a new custom contact attribute. name (<=255) and type (<=50) required; description/defaultValue optional."
          }
        },
        {
          "name": "Update Attribute",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"description\": \"Updated description\",\n  \"type\": \"string\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/attributes/:name",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "attributes",
                ":name"
              ]
            },
            "description": "Update an attribute's description/type by name."
          }
        },
        {
          "name": "Delete Attribute",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/attributes/:name",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "attributes",
                ":name"
              ]
            },
            "description": "Delete a custom attribute definition by name."
          }
        },
        {
          "name": "Rescan Attribute Types",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"eventName\": \"Order Completed\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/attributes/rescan-types",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "attributes",
                "rescan-types"
              ]
            },
            "description": "Throw away the KEPT (frozen) attribute-type decisions so they are detected again from current data. Types are decided once and kept so a segment means the same thing today as yesterday; call this only when a property's meaning has genuinely changed at the source. Optional body `eventName` scopes the rescan to that event's properties; omitted, the workspace's contact attributes are rescanned. Types your team has SET are never touched, and standard fields (price, total, lastEventTime) are typed by the platform and unaffected."
          },
          "response": []
        }
      ]
    },
    {
      "name": "Analytics",
      "description": "Read/reporting API over the event warehouse: event explorer, funnels, retention (including per-segment comparison), path analysis, and realtime. All routes require analytics:read.",
      "item": [
        {
          "name": "List Events",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/analytics/events?limit=50&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "events"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                },
                {
                  "key": "offset",
                  "value": "0"
                },
                {
                  "key": "channels",
                  "value": "email,sms,push,whatsapp,viber,in_app",
                  "description": "Optional comma list of channels. `channels=` (empty) = no message events.",
                  "disabled": true
                }
              ]
            },
            "description": "List raw events with filtering and pagination (event explorer). Optional `channels`: show message (`message.*`) and in-app (`in_app.*`) events only on these channels - email, sms, whatsapp, push, viber, webhook, wallet, in_app, connector. Events with no channel (purchases, sessions, custom events) are always counted. Omit it for no filter; an unknown channel is a 400. When `channels` is sent the response also carries `hiddenByChannel` - `[{ channel, count }]`, the message events the filter left out, per channel (e.g. webhook calls)."
          }
        },
        {
          "name": "Query Events",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"eventName\": \"Order Completed\",\n  \"startDate\": \"2026-01-01\",\n  \"endDate\": \"2026-01-31\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/analytics/events/query",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "events",
                "query"
              ]
            },
            "description": "Run a custom aggregated event query. Optional `channels`: show message (`message.*`) and in-app (`in_app.*`) events only on these channels - email, sms, whatsapp, push, viber, webhook, wallet, in_app, connector. Events with no channel (purchases, sessions, custom events) are always counted. Omit it for no filter; an unknown channel is a 400. Send it as a JSON array, e.g. `\"channels\": [\"email\", \"sms\"]`."
          }
        },
        {
          "name": "Event Trend",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"eventName\": \"Order Completed\",\n  \"startDate\": \"2026-01-01\",\n  \"endDate\": \"2026-01-31\",\n  \"timeGranularity\": \"day\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/analytics/events/trend",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "events",
                "trend"
              ]
            },
            "description": "Get an event count trend over time. timeGranularity: hour|day|week|month. Optional `channels`: show message (`message.*`) and in-app (`in_app.*`) events only on these channels - email, sms, whatsapp, push, viber, webhook, wallet, in_app, connector. Events with no channel (purchases, sessions, custom events) are always counted. Omit it for no filter; an unknown channel is a 400. Send it as a JSON array, e.g. `\"channels\": [\"email\", \"sms\"]`."
          }
        },
        {
          "name": "Get Event Properties",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/analytics/events/:eventName/properties?startDate=2026-01-01&endDate=2026-01-31",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "events",
                ":eventName",
                "properties"
              ],
              "query": [
                {
                  "key": "startDate",
                  "value": "2026-01-01"
                },
                {
                  "key": "endDate",
                  "value": "2026-01-31"
                }
              ]
            },
            "description": "List the property keys seen on a given event type over a date range."
          }
        },
        {
          "name": "Property Breakdown",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"eventName\": \"Order Completed\",\n  \"property\": \"category\",\n  \"startDate\": \"2026-01-01\",\n  \"endDate\": \"2026-01-31\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/analytics/events/property-breakdown",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "events",
                "property-breakdown"
              ]
            },
            "description": "Break down an event by the distinct values of one property."
          }
        },
        {
          "name": "List Funnels",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/analytics/funnels",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "funnels"
              ]
            },
            "description": "List saved funnel definitions."
          }
        },
        {
          "name": "Create Funnel",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Purchase funnel\",\n  \"steps\": [\n    {\n      \"id\": \"step-1\",\n      \"order\": 0,\n      \"eventName\": \"Product Viewed\"\n    },\n    {\n      \"id\": \"step-2\",\n      \"order\": 1,\n      \"eventName\": \"Product Added\"\n    },\n    {\n      \"id\": \"step-3\",\n      \"order\": 2,\n      \"eventName\": \"Order Completed\"\n    }\n  ],\n  \"conversionWindowDays\": 7\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/analytics/funnels",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "funnels"
              ]
            },
            "description": "Create a saved funnel definition. Each step requires id, order (0-based), and eventName; optional label and property filters."
          }
        },
        {
          "name": "Quick Funnel Analysis",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"steps\": [\n    {\n      \"id\": \"step-1\",\n      \"order\": 0,\n      \"eventName\": \"Product Viewed\"\n    },\n    {\n      \"id\": \"step-2\",\n      \"order\": 1,\n      \"eventName\": \"Order Completed\"\n    }\n  ],\n  \"startDate\": \"2026-01-01\",\n  \"endDate\": \"2026-01-31\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/analytics/funnels/quick-analysis",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "funnels",
                "quick-analysis"
              ]
            },
            "description": "Run an ad-hoc funnel analysis without saving it (rate-limited)."
          }
        },
        {
          "name": "Retention Analysis",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"startEvent\": \"signup_completed\",\n  \"returnEvent\": \"Order Completed\",\n  \"startDate\": \"2026-01-01\",\n  \"endDate\": \"2026-01-31\",\n  \"timeUnit\": \"week\",\n  \"periods\": 8\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/analytics/retention/analyze",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "retention",
                "analyze"
              ]
            },
            "description": "Compute a retention grid: one row per start-date bucket, one column per period. timeUnit: day|week|month; periods 1-30."
          }
        },
        {
          "name": "Path Analysis",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"startDate\": \"2026-01-01\",\n  \"endDate\": \"2026-01-31\",\n  \"endEvent\": \"Order Completed\",\n  \"direction\": \"backward\",\n  \"maxSteps\": 5,\n  \"minPathCount\": 10,\n  \"minPathPercent\": 0.5,\n  \"maxBranchesPerStep\": 5\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/analytics/paths/analyze",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "paths",
                "analyze"
              ]
            },
            "description": "Analyze common user paths. Anchor on startEvent with direction \"forward\" (what users do next) or endEvent with direction \"backward\" (how users got there). Optional maxSteps (2-10), includeEvents/excludeEvents. Frequency threshold (a path/step must clear the LARGER of the two): minPathCount (absolute users, default 1) and minPathPercent (percent of qualifying users 0-100, portable across traffic levels). maxBranchesPerStep (1-50, default 8) caps the flow diagram to the top-N next events per step so it stays readable at any volume."
          }
        },
        {
          "name": "Compare Retention Across Segments",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"segmentIds\": [\n    \"11111111-1111-1111-1111-111111111111\",\n    \"22222222-2222-2222-2222-222222222222\"\n  ],\n  \"startDate\": \"2026-01-01\",\n  \"endDate\": \"2026-01-31\",\n  \"entryEvent\": \"signup_completed\",\n  \"returnEvent\": \"Order Completed\",\n  \"timeUnit\": \"week\",\n  \"periods\": 8\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/analytics/retention/compare-segments",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "retention",
                "compare-segments"
              ]
            },
            "description": "Run one retention grid per segment and return them side by side. segmentIds: 1-10 saved segment ids (duplicates are ignored). Each segment's audience is resolved by the segment engine itself - the same rules the Segments API applies - and the retention scan is restricted to those contacts. entryEvent and returnEvent are each optional: give one and the other defaults to it (same-event retention); omit both and the request is rejected. timeUnit: day|week|month; periods 1-30. Every row carries resolvedUsers (how many people are in the segment), analyzedUsers (how many of them the curve was computed from) and truncated. A segment over the 50,000-contact analysis cap is measured on a RANDOM sample of its members - deterministic, so the same segment and window return the same curve on a reload - and the row then reads as \"measured on a random 50,000 of 480,000 members\", never as the whole segment. A segment that cannot be resolved comes back as a row with an `error` string and an empty grid - never as 0% retention."
          }
        },
        {
          "name": "Realtime Active Users",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/analytics/realtime/active-users",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "realtime",
                "active-users"
              ]
            },
            "description": "Current count of active users in the live window."
          }
        },
        {
          "name": "Global Holdout Uplift",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/analytics/global-holdout?startDate=2026-01-01&endDate=2026-01-31",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "global-holdout"
              ],
              "query": [
                {
                  "key": "startDate",
                  "value": "2026-01-01"
                },
                {
                  "key": "endDate",
                  "value": "2026-01-31"
                }
              ]
            },
            "description": "Conversion and revenue for everyone who received marketing in the period, against the randomly held-back global control group. Response is an envelope: { uplift: { treatmentRecipients, treatmentConverters, treatmentRevenue, treatmentRevenuePerRecipient, controlRecipients, controlConverters, controlRevenue, controlRevenuePerRecipient, incrementalRevenue, upliftPct, significance: { controlRate, treatmentRate, pValue, significant, controlInterval, treatmentInterval, reason, minConvertersPerArm } | null, currency, mixedCurrency, windowHours } }. { uplift: null } when no global control group is running - there is nothing to compare against, and an unmeasured result is not a zero. significance.significant tests the conversion RATE, not revenue."
          }
        },
        {
          "name": "Message Frequency Curve",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/analytics/message-frequency?startDate=2026-01-01&endDate=2026-01-31",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "message-frequency"
              ],
              "query": [
                {
                  "key": "startDate",
                  "value": "2026-01-01"
                },
                {
                  "key": "endDate",
                  "value": "2026-01-31"
                }
              ]
            },
            "description": "Purchases by number of messages received, split by whether the person was already engaged before the period. `buyers` / `purchaseRate` are an ORDERS figure, not a goal conversion: the curve covers the whole workspace, so there is no campaign or journey goal to count (`converters` / `conversionRate` are the same numbers under their old names). anchored tells you whether the zero point is real: without a global control group there is no 0 bucket at all, because people who happened to receive nothing got nothing for reasons that also predict buying."
          }
        },
        {
          "name": "Content Performance",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/analytics/content-performance?startDate=2026-01-01&endDate=2026-01-31&channel=email",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "analytics",
                "content-performance"
              ],
              "query": [
                {
                  "key": "startDate",
                  "value": "2026-01-01"
                },
                {
                  "key": "endDate",
                  "value": "2026-01-31"
                },
                {
                  "key": "channel",
                  "value": "email",
                  "description": "Optional: email, sms, push, whatsapp, viber, in_app"
                }
              ]
            },
            "description": "Open, click, goal-conversion and purchase rates by what the message is - tone, offer type, call to action, length, personalization, imagery, topic - across every template, campaign variant and journey message. Rates count unique people, bots excluded; openRate / clickRate are a share of people delivered (push: of reached, in-app: of displayed), the same rule as campaign stats; null below minReached people or on a channel that cannot measure the rate. conversionRate / converted are each message's OWN conversion goal (its campaign or journey goal, window and attribution model, credited to the variant or journey message), over goalReached - the people reached by messages that have a goal; null where no message in the row has one. purchaseRate / bought are the ORDERS figure: a purchase by the same person within conversionWindowHours after the send (a plain window, whatever the goal). Until 2026-10-14 conversionRate was that purchase rate. totals.unlabelledSent is traffic whose content has no label yet; totals.noGoalSent is traffic with no goal."
          }
        }
      ]
    },
    {
      "name": "Wallet Passes",
      "item": [
        {
          "name": "Record Pass Scan / Redemption",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"serialNumber\": \"a1b2c3d4-e5f6-4789-abcd-1234567890ab\",\n  \"properties\": {\n    \"storeId\": \"store_42\",\n    \"amount\": 19.90,\n    \"currency\": \"EUR\"\n  }\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/wallet-passes/scan",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wallet-passes",
                "scan"
              ]
            },
            "description": "Record a wallet pass scan/redemption at the point of sale (POS / merchant integration). `serialNumber` is the pass's unique id, read from its barcode/QR. `properties` is an optional free-form object of redemption metadata (store, amount, cashier...) stored on the resulting `pass.scanned` event and available in Wallet analytics. Requires the `wallet:write` permission. Returns `{ \"recorded\": true }`, or `{ \"recorded\": false }` if no pass with that serial exists in your organization. Counting is idempotent per pass: re-scanning the same serial still counts as one distinct scanned pass."
          }
        }
      ]
    },
    {
      "name": "Messages",
      "description": "Read API for the messages a contact received from you (campaign and journey sends) and sent to you (replies), across channels. Requires users:read.",
      "item": [
        {
          "name": "List Contact Messages",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/messages?userId=:userId&direction=&channel=&limit=50&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "messages"
              ],
              "query": [
                {
                  "key": "userId",
                  "value": ":userId"
                },
                {
                  "key": "direction",
                  "value": ""
                },
                {
                  "key": "channel",
                  "value": ""
                },
                {
                  "key": "limit",
                  "value": "50"
                },
                {
                  "key": "offset",
                  "value": "0"
                }
              ]
            },
            "description": "List the messages sent to a contact (`message.sent` from campaigns AND journeys, with the campaign / journey, email subject and the furthest outcome: sent, delivered, opened, clicked, failed or bounced) and the replies it sent, newest first, from the last 12 months. userId required (internal id or externalId); direction optional (`outbound` = sends, `inbound` = replies; omit for both); channel optional (e.g. email, sms, whatsapp, push). limit default 50 (1-200), offset at most 5000. Example response: {\"data\":[{\"direction\":\"outbound\",\"channel\":\"email\",\"eventName\":\"message.sent\",\"timestamp\":\"2026-10-28 10:00:00.000\",\"subject\":\"Your cart is still here\",\"messageId\":\"0100019a...\",\"source\":{\"kind\":\"journey\",\"id\":\"<canvasId>\",\"name\":\"Cart recovery\"},\"status\":\"opened\",\"body\":null,\"from\":null,\"toNumber\":null,\"account\":null,\"messageType\":null}],\"total\":1}"
          }
        },
        {
          "name": "List Inbound Messages",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/messages/inbound?userId=:userId&channel=&limit=50&offset=0",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "messages",
                "inbound"
              ],
              "query": [
                {
                  "key": "userId",
                  "value": ":userId"
                },
                {
                  "key": "channel",
                  "value": ""
                },
                {
                  "key": "limit",
                  "value": "50"
                },
                {
                  "key": "offset",
                  "value": "0"
                }
              ]
            },
            "description": "List a contact's inbound messages across all channels (newest-first). userId required; optional channel filter (e.g. sms, whatsapp). limit default 50 (1-200)."
          }
        },
        {
          "name": "List Inbound SMS",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sms/messages?userId=:userId&limit=50",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sms",
                "messages"
              ],
              "query": [
                {
                  "key": "userId",
                  "value": ":userId"
                },
                {
                  "key": "limit",
                  "value": "50"
                }
              ]
            },
            "description": "List a contact's inbound SMS messages (profile timeline, newest-first). userId required."
          }
        }
      ]
    },
    {
      "name": "SMS Templates (Delivyr only)",
      "description": "Templates of a Delivyr SMS account. DELIVYR ONLY: works only for a workspace whose SMS number is linked to a Delivyr account (Delivyr sends only templates it approved, to US and Canada numbers). Every request except List Delivyr Accounts answers 403 with code delivyr_account_required for any other workspace. Requires templates:read / templates:write.",
      "item": [
        {
          "name": "List Delivyr Accounts",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sms/templates/accounts",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sms",
                "templates",
                "accounts"
              ]
            },
            "description": "DELIVYR ONLY - The Delivyr accounts this workspace sends SMS through (a workspace can have several, e.g. one per brand). Returns [] when there are none - check this first. `key` is the accountKey the other requests take; `name` is the account's name in Joryio. Every template carries the accountKey of the account it belongs to, and a template is sent only through its own account."
          }
        },
        {
          "name": "List SMS Templates",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sms/templates?status=approved",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sms",
                "templates"
              ],
              "query": [
                {
                  "key": "status",
                  "value": "approved"
                }
              ]
            },
            "description": "DELIVYR ONLY - Templates of the workspace's Delivyr account(s): synced from the Delivyr portal, created in Joryio, and Delivyr system templates. Only approved and system templates can be sent. Optional filters: status (pending, approved, rejected, system), accountKey (one account's templates only). Each template's accountKey says which Delivyr account it belongs to."
          }
        },
        {
          "name": "Refresh SMS Templates from Delivyr",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sms/templates/sync",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sms",
                "templates",
                "sync"
              ]
            },
            "description": "DELIVYR ONLY - Pull the account's templates from Delivyr now and update their review status (Joryio also refreshes every 30 minutes). accountKey is optional.",
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Get SMS Template",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sms/templates/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sms",
                "templates",
                ":id"
              ]
            },
            "description": "DELIVYR ONLY - One template by its Joryio id."
          }
        },
        {
          "name": "Create SMS Template",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sms/templates",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sms",
                "templates"
              ]
            },
            "description": "DELIVYR ONLY - Create a template and submit it to Delivyr's review; it starts as pending. Use {{name}} placeholders; {{link}} is filled with Joryio's tracked short link at send time. accountKey is needed only when the workspace has more than one Delivyr account. Approved text cannot be changed - create a new template instead.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"title\": \"Weekend offer\",\n  \"body\": \"Hi {{first_name}}, your weekend offer is ready: {{link}} Reply STOP to opt out.\",\n  \"tags\": [\n    \"promo\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Delete SMS Template",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sms/templates/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sms",
                "templates",
                ":id"
              ]
            },
            "description": "DELIVYR ONLY - Delete the template in Delivyr and in Joryio. Delivyr system templates cannot be deleted."
          }
        }
      ]
    }
  ]
}
