# Loopfour - Full Documentation > Complete documentation for the Loopfour platform. > Deterministic, auditable finance workflow automation you can defend at audit. Generated: 2026-10-31T18:34:50.542Z # Documentation (/docs) Complete guide to building and deploying automated workflows with Loopfour ## Loopfour Documentation Welcome to the documentation for Loopfour -- a platform for deterministic, auditable financial workflow automation. Build, deploy, and monitor automated workflows that connect your billing, CRM, accounting, and communication systems. Use these docs when you need to move from a business process to a controlled workflow implementation. Start with the conceptual pages if you are mapping how Loopfour models triggers, steps, connections, runs, and audit evidence. Use the API reference when you already know the object you want to create or inspect. Use tutorials when you want a complete pattern that starts from a finance event, routes through connected systems, and ends with a measurable operational outcome. Loopfour workflows are designed for finance operations where reliability matters more than open-ended automation. A workflow should make the trigger explicit, keep each step reviewable, preserve the data passed between systems, and leave enough run history for a controller, operator, or developer to understand what happened later. The docs are organized around that lifecycle: authenticate, create a workflow, connect systems, run or deploy it, inspect results, and debug failures. ## Quick Links What is Loopfour and what can you build Create and run your first workflow in 5 minutes Explore the building blocks for workflows Connect Stripe, Salesforce, QuickBooks, and 50+ tools ## By Role Automate invoice creation, payment reconciliation, and revenue recognition Sync deals across CRM and accounting with automated workflows Build integrations using the Workflows API Use the AI Copilot to build workflows with natural language ## Core Concepts | Concept | Description | |---------|-------------| | **Workflow** | A directed graph of steps executed in response to a trigger | | **Block** | A single unit of work -- agent, code, condition, transform, integration call | | **Trigger** | The event that starts a workflow: webhook, schedule, API call, or platform event | | **Connection** | OAuth or API key credentials for external services | | **Execution** | A single run of a workflow with full logging and error handling | ## Getting Help - [Troubleshooting](/docs/troubleshooting) -- Common errors and debugging guides - [API Reference](/docs/api-reference) -- Full REST API documentation - [Tutorials](/docs/tutorials) -- Step-by-step workflow guides # AI Capabilities (/docs/ai) Where AI is used in Loopfour Studio AI is used in specific places in Loopfour Studio: the Copilot helps build workflows from natural language, the Code block offers AI-assisted code generation, the Agent block handles extraction and classification, and browser automation uses AI to navigate portals. Condition and Approval blocks add thresholds and human review to the steps around them. ## AI Features Build and modify workflows using natural language commands Connect workflows to LLMs for processing, extraction, and classification Run custom JavaScript with AI-assisted code generation AI-driven browser control for portal navigation and data extraction # AI Copilot (/docs/ai/copilot) Build and modify workflows using natural language commands The AI Copilot is your in-editor assistant for building and modifying workflows. It understands your canvas context -- the blocks, connections, and data flow -- and can add blocks, configure settings, wire data references, and suggest improvements, all from natural language instructions. ## What the Copilot Can Do **Build workflows from descriptions.** Tell the copilot what you want to automate and it will create the workflow structure, add the right blocks, and wire the connections. **Modify existing workflows.** Ask it to add a condition, change a trigger, add error handling, or restructure the flow. It operates on your current canvas. **Answer questions.** Ask about block configuration, data flow, best practices, or how to achieve a specific automation pattern. The copilot has access to the full documentation. **Debug issues.** Describe a problem with your workflow and the copilot will analyze the configuration, identify likely causes, and suggest fixes. **Build dashboards.** Ask it to add a chart, metric, or table to a dashboard, or describe a whole multi-widget board -- filters, KPIs with period-over-period deltas, a waterfall, a cohort heatmap, a budget-vs-actual table, a pivot with subtotals -- and it will assemble one against your connected tables. It reads your table schemas first, so charts reference real columns rather than guessed ones. Charts the copilot draws in chat use the same components, so **Save to dashboard** adds exactly what you saw. **Change dashboard settings by chat.** Each dashboard has one currency, locale, negative-number style, fiscal year start, and density. Ask for it in plain words, for example "show this board in EUR with brackets for negatives" or "our fiscal year starts in April". There is no settings panel; the copilot or the widget JSON sets them. See [Dashboard components](/docs/dashboards/components) for the full list. ## Example Prompts ### Building ``` Create a workflow that triggers when a Stripe invoice is paid, looks up the customer in Salesforce, and sends a Slack notification to #finance with the invoice details. ``` ``` Add an approval step before the QuickBooks invoice creation. Route to the #finance-approvals Slack channel for amounts over $5000. ``` ``` Build an open-invoices dashboard from my invoices table, with a date range filter and a breakdown by customer. ``` ``` Add an MRR overview to this dashboard: MRR, net new MRR and churn with deltas vs last month, and an MRR bridge as a waterfall. ``` ### Configuring ``` Change the agent block to use Claude Opus 4.6 instead of Sonnet. Set temperature to 0 and add an output schema for invoice extraction. ``` ``` Add retry logic to the Stripe block -- 3 attempts with exponential backoff. ``` ### Debugging ``` The workflow is failing at the transform step. The Salesforce response has the customer email nested under Account.PersonEmail but the transform is looking for contact.email. Fix the mapping. ``` ### Querying ``` What's the best way to handle partial payments in a Stripe workflow? ``` ``` How do I pass data from the webhook trigger to a downstream agent block? ``` ## How It Works The copilot operates within the workflow editor. It has access to: 1. **Your current canvas** -- All blocks, their configurations, connections, and data flow 2. **Block registry** -- All 29 available block types and their configuration options 3. **Integration schemas** -- Available actions and parameters for each integration 4. **Documentation** -- Full Loopfour Studio documentation When you ask the copilot to make changes, it generates a set of canvas operations (add block, remove block, update config, add edge, remove edge) that are applied to your workflow. You can review and undo any changes. ## Copilot Tools The copilot has access to specialized tools for workflow manipulation: | Tool | Description | |------|-------------| | **Add Block** | Place a new block on the canvas with initial configuration | | **Update Block** | Modify a block's configuration (prompts, actions, conditions) | | **Remove Block** | Delete a block and its connections | | **Add Edge** | Connect two blocks with a data flow edge | | **Remove Edge** | Disconnect two blocks | | **Read Canvas** | Inspect the current workflow structure | | **Search Docs** | Look up documentation for blocks, integrations, and patterns | | **Generate Visualization** | Draw a chart, KPI, or table in the chat from your connected tables, using the same components as dashboards | | **Dashboard Widget** | Create, update, or remove a dashboard widget -- a chart, metric, table, or a full multi-part board with its own filters -- and change dashboard settings (currency, locale, negatives, fiscal year) | ## Tips for Effective Prompting - **Be specific about what you want.** Instead of "make it better," say "add error handling to the Stripe block with 3 retries and exponential backoff." - **Reference blocks by name or type.** "Change the agent block" is clearer than "change the AI thing." - **Describe the data flow.** When building from scratch, mention where data comes from and where it should go: "when a Stripe payment is received, look up the customer in Salesforce by email, then send a Slack message." - **Ask one thing at a time.** Complex requests work better as a sequence of smaller instructions. - **Use the copilot for exploration.** Not sure which block to use? Ask "what's the best way to handle X?" before asking it to build. The copilot operates on your draft canvas. Changes are not saved until you explicitly save or deploy the workflow. ## Related Use AI models within your workflows for processing and extraction All 29 block types available for workflow building Create your first workflow step by step # API Reference (/docs/api-reference) Complete REST API documentation ## API Reference The Workflows API is a RESTful API that uses JSON for request and response bodies. For agent discovery, start with the [OpenAPI specification](https://loopfour.ai/openapi.json), then call `GET /api/v1/blocks?kind=integrations`. Follow a result's `links.self` to inspect its action IDs and input definitions. REST responses expose the specification and API catalog through their `Link` headers. See [Block and Action Discovery](/docs/api-reference/blocks). Use the API reference when you are building an internal tool, connecting Loopfour to another system, or automating workflow lifecycle operations from code. The reference is organized by the resource you manage: workflows define the graph, runs capture execution, agents handle AI-assisted steps, connections represent authenticated integrations, webhooks receive provider events, and tables store structured operational data. Each endpoint page documents request fields, response shapes, and common error states so you can validate inputs before they reach production. Most integrations should treat workflow IDs, run IDs, connection IDs, and table IDs as durable identifiers. Store them in your own system only when you need to reconcile Loopfour activity with external records such as invoices, customers, contracts, opportunities, or payment events. For finance workflows, also capture the run ID in downstream logs whenever possible. It gives operators a stable path back to execution details, step outputs, approval history, and failure diagnostics. ## Base URL ``` https://workflow.loopfour.ai/api/v1 ``` ## Authentication All requests require an API key in the `x-api-key` header: ```bash curl -H "x-api-key: wfk_your_api_key" https://workflow.loopfour.ai/api/v1/workflows ``` ## Endpoints Discover actions, input definitions, requirements, and report choices Create, read, update, delete workflows Execute and monitor workflow runs AI-powered agents for data extraction and content Manage integration connections Webhook endpoints and events Manage user-defined data tables and rows ## Response Format All responses follow this format: ```json { "success": true, "data": { } } ``` Error responses: ```json { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Invalid workflow configuration" } } ``` ## HTTP Status Codes | Code | Description | |------|-------------| | 200 | Success | | 201 | Created | | 400 | Bad Request | | 401 | Unauthorized | | 403 | Forbidden | | 404 | Not Found | | 429 | Rate Limited | | 500 | Server Error | # Agents API (/docs/api-reference/agents) AI-powered agents for data extraction, content generation, and decision making ## Agents API Agents are AI-powered execution units that can be used in workflow steps. The platform provides both **core agents** (available to all companies) and the ability to create **custom agents**. ## Core Agents Four universal core agents are available to all companies: | ID | Name | Type | Description | |----|------|------|-------------| | `agent_extraction` | Universal Extraction Agent | `extraction` | Extract structured data from documents with confidence scoring | | `agent_content` | Content Generation Agent | `content` | Generate professional emails, messages, and notifications | | `agent_transform` | Data Transformation Agent | `transform` | Transform data between formats and schemas | | `agent_decision` | Decision & Routing Agent | `decision` | Make business decisions and route based on criteria | ## Agent Scopes | Scope | Description | |-------|-------------| | `global` | Available to all companies (core agents) | | `company` | Restricted to the creating company | ## List Agents Retrieve all agents available to your company, including core agents and custom agents. ```http GET /api/v1/agents ``` ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `type` | string | - | Filter by type: `extraction`, `content`, `transform`, `decision`, `code`, `classification`, `custom` | | `status` | string | - | Filter by status: `active`, `deprecated` | | `search` | string | - | Search by agent name | | `includeBuiltIn` | boolean | true | Include core agents in results | | `limit` | number | 50 | Number of results (1-100) | | `cursor` | string | - | Cursor for pagination | ### Example ```bash curl -X GET "https://workflow.loopfour.ai/api/v1/agents?type=extraction" \ -H "x-api-key: YOUR_API_KEY" ``` ## Create Agent Create a new custom agent for your company. ```http POST /api/v1/agents ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Agent display name | | `description` | string | No | Agent description | | `type` | string | Yes | Agent type (see below) | | `provider` | string | No | AI provider: `openai` (default), `anthropic` | | `model` | string | No | Model ID (defaults to `gpt-5.6-sol`) | | `systemPrompt` | string | Yes | System prompt defining agent behavior | | `outputSchema` | object | No | JSON Schema for structured output | | `temperature` | number | No | Temperature (0.0-2.0), defaults to 0.7 | | `maxTokens` | number | No | Max tokens in response, defaults to 4096 | ### Agent Types | Type | Description | |------|-------------| | `extraction` | Extract structured data from documents/text | | `content` | Generate text content (emails, messages, etc.) | | `transform` | Transform data between formats | | `decision` | Make decisions and classify items | | `code` | Generate or analyze code | | `classification` | Classify inputs into categories | | `custom` | Custom agent with user-defined behavior | ### Supported Models | Provider | Accepted model IDs | |----------|--------------------| | OpenAI | `gpt-5.6-sol` | | Anthropic | `claude-opus-5` | Keep the provider and model paired: use `openai` with `gpt-5.6-sol`, or `anthropic` with `claude-opus-5`. The workflow canvas defaults to Claude Opus 5. ## Get Agent Retrieve details of a specific agent, including core agents. ```http GET /api/v1/agents/:id ``` ### Path Parameters | Parameter | Type | Description | |-----------|------|-------------| | `id` | string | Agent ID or core agent ID (e.g., `agent_extraction`) | ## Update Agent Update a custom agent. Each update increments the version number. ```http PUT /api/v1/agents/:id ``` Only company-owned agents can be updated. Core agents and agents from other companies cannot be modified. ## Delete Agent Soft delete an agent. The agent is marked as deprecated and will no longer appear in listings. ```http DELETE /api/v1/agents/:id ``` Core agents cannot be deleted. Only company-owned agents can be removed. ## Test Agent Test an agent with sample input to verify configuration and output. ```http POST /api/v1/agents/:id/test ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `input` | object | Yes | Input variables for the agent | | `userMessage` | string | No | Optional user message override | Core agents cannot be directly tested via API. Create a workflow with an agent step to test built-in agents. ## Execute Agent Execute an agent standalone (outside of a workflow context). ```http POST /api/v1/agents/:id/execute ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `input` | object | Yes | Input variables for the agent | | `userMessage` | string | No | User message for the agent | | `workflowRunId` | string | No | Optional workflow run ID for tracking | | `overrides` | object | No | Runtime parameter overrides | ### Overrides | Field | Type | Description | |-------|------|-------------| | `temperature` | number | Override temperature for this execution | | `maxTokens` | number | Override max tokens | | `model` | string | Override model for this execution | ## List Agent Executions Retrieve execution history for an agent. ```http GET /api/v1/agents/:id/executions ``` ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `limit` | number | 50 | Number of results (1-100) | | `cursor` | string | - | Cursor for pagination | ## Core Agent Input Variables ### Universal Extraction Agent (`agent_extraction`) | Variable | Type | Description | |----------|------|-------------| | `document` | string | Document content to extract from | | `schema` | object | JSON Schema defining fields to extract | | `fields` | array | List of field definitions (alternative to schema) | | `documentType` | string | Document type hint (invoice, receipt, contract) | | `customInstructions` | string | Additional extraction instructions | ### Content Generation Agent (`agent_content`) | Variable | Type | Description | |----------|------|-------------| | `contentType` | string | email, message, summary, notification | | `tone` | string | professional, friendly, formal, casual, urgent | | `format` | string | text, html, markdown | | `recipient` | string | Who the content is for | | `subject` | string | Subject or topic | | `context` | string | Background context | | `keyPoints` | array | Key points to include | | `customInstructions` | string | Additional instructions | ### Data Transformation Agent (`agent_transform`) | Variable | Type | Description | |----------|------|-------------| | `data` | any | Data to transform | | `sourceSchema` | object | Schema of input data | | `targetSchema` | object | Desired output schema | | `mappingRules` | array | Field mapping rules | | `defaultValues` | object | Default values for missing fields | | `customInstructions` | string | Additional instructions | ### Decision & Routing Agent (`agent_decision`) | Variable | Type | Description | |----------|------|-------------| | `data` | any | Data to evaluate | | `decisionType` | string | approval_routing, classification, priority | | `options` | array | Available decision options | | `criteria` | array | Evaluation criteria | | `thresholds` | object | Threshold values | | `context` | string | Additional context | | `customInstructions` | string | Additional instructions | ## Error Responses ### Agent Not Found (404) ```json { "success": false, "error": { "code": "NOT_FOUND", "message": "Agent not found" } } ``` ### Cannot Modify Built-in Agent (400) ```json { "success": false, "error": { "code": "BAD_REQUEST", "message": "Cannot modify built-in agents" } } ``` ### Duplicate Name (400) ```json { "success": false, "error": { "code": "BAD_REQUEST", "message": "An agent with the name \"Invoice Extractor\" already exists" } } ``` # API Authentication (/docs/api-reference/authentication) How to authenticate with the Loopfour API using API keys All API requests require authentication via an API key. Keys are scoped to a company and carry specific permissions for workflow management and execution. ## API Key Format API keys follow the format `wfk_` followed by a random string: ``` wfk_a1b2c3d4e5f6g7h8i9j0... ``` ## Using Your API Key Include the API key in the `x-api-key` header on every request: ```bash curl https://workflow.loopfour.ai/api/v1/workflows \ -H "x-api-key: wfk_your_api_key_here" ``` ```javascript const response = await fetch('https://workflow.loopfour.ai/api/v1/workflows', { headers: { 'x-api-key': process.env.WORKFLOWS_API_KEY, }, }); ``` ```python response = requests.get( 'https://workflow.loopfour.ai/api/v1/workflows', headers={'x-api-key': os.environ['WORKFLOWS_API_KEY']}, ) ``` Never expose API keys in client-side code, public repositories, or browser requests. Always use environment variables or a secrets manager. ## Key Permissions | Scope | Description | |-------|-------------| | `workflows:read` | List and get workflow details | | `workflows:write` | Create, update, delete workflows | | `workflows:execute` | Trigger workflow runs | | `runs:read` | View run status and logs | | `connections:read` | List connections | | `connections:write` | Create and manage connections | | `secrets:read` | List masked workflow secrets | | `secrets:write` | Create, rotate, and delete workflow secrets | ## Rate Limits API requests are rate-limited per API key: | Endpoint Category | Limit | |-------------------|-------| | Read operations (GET) | 1000 requests/minute | | Write operations (POST, PUT, DELETE) | 100 requests/minute | | Workflow execution | 500 requests/minute | Every response from a rate-limited endpoint carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (Unix timestamp, in seconds, when the window resets). When you send too many requests, the API returns `429 Too Many Requests` with a `Retry-After` header that gives the number of seconds to wait before you retry. The body uses the standard error envelope: ```json { "success": false, "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Limit: 100 requests per minute.", "requestId": "req_123" } } ``` ## Key Rotation To rotate an API key: 1. Generate a new key in the dashboard 2. Update your applications to use the new key 3. Verify the new key works 4. Revoke the old key Both the old and new keys remain valid until the old key is explicitly revoked. This allows zero-downtime key rotation. ## Security Best Practices - **Use environment variables** -- Store keys in `WORKFLOWS_API_KEY` env vars, never hardcode them - **Rotate keys regularly** -- Rotate at least every 90 days - **Use least-privilege keys** -- Create keys with only the scopes your application needs - **Monitor usage** -- Check the API dashboard for unusual request patterns - **Revoke compromised keys immediately** -- If a key is exposed, revoke it and generate a new one # Block and Action Discovery (/docs/api-reference/blocks) Discover workflow actions, typed inputs, requirements, and report choices through the API ## Discover blocks and actions Use this read-only catalog to discover the actions available in the deployed version of Loopfour. Responses include action IDs, input types and declared nested schemas, field requirements, static choices, and output field names. The same metadata is available to workflow chat authoring. Agents can start from the public [OpenAPI specification](https://loopfour.ai/openapi.json) or [machine-readable API catalog](https://loopfour.ai/.well-known/api-catalog). REST responses also advertise these resources through the standard `Link` header, including authentication errors. In OpenAPI, use `listBlocks` when the integration is unknown and `getBlock` when its type is already known. Both endpoints require authentication with the `x-api-key` header and the `workflows:read` scope. Session authentication is also accepted. The standard read rate limit applies. The catalog is shared across workspaces; it contains no connection credentials, company records, or workflow data, and makes no provider API calls. ```http GET /api/v1/blocks GET /api/v1/blocks/:type ``` The list accepts an optional `kind` query parameter: `core`, `integrations`, or `triggers`. It returns the complete matching catalog without pagination. Hidden internal blocks are excluded from both endpoints. Each result includes `links.self` for its detail endpoint and `links.collection` for the catalog. These are root-relative URLs: resolve them against the API origin. If you already know the integration, request `/blocks/quickbooks` directly to avoid downloading the complete catalog. ```bash curl -fsS 'https://workflow.loopfour.ai/api/v1/blocks?kind=integrations' \ -H 'x-api-key: wfk_your_api_key' \ | jq '.data[] | {type, name, actions}' ``` ## Discover QuickBooks reports ```bash curl -fsS 'https://workflow.loopfour.ai/api/v1/blocks/quickbooks' \ -H 'x-api-key: wfk_your_api_key' \ | jq '.data | {actions, inputs, outputs, report: (.subBlocks[] | select(.id == "reportType"))}' ``` The response includes `quickbooks.runReport` in `actions` and all 30 report names in the `reportType` field's `options`. For example, the Profit and Loss choice has `id: "ProfitAndLoss"`. Use the option ID in workflow configuration, rather than its display label. The list response is `{ "success": true, "data": [...] }`; the detail response is `{ "success": true, "data": { ... } }`. Every block has these fields: | Field | Meaning | |---|---| | `type`, `name`, `description` | Stable block identifier and human-readable descriptions | | `kind`, `category` | Block grouping; `category` may be absent | | `connectionRequired` | Whether the block requires a stored provider connection | | `actions` | Fully qualified action IDs, such as `quickbooks.runReport`; empty for blocks without declared actions | | `inputs` | Definitions containing `key`, value `type`, optional `description`, and optional nested `schema` | | `outputs` | Output field names declared by the block | | `subBlocks` | Field definitions with `id`, control `type`, optional `title`, requirements, conditions, and static choices | | `links` | `self` URL for this block and `collection` URL for the complete catalog | Join `subBlocks[].id` to `inputs[].key` to get a field's value type. Value types are `string`, `number`, `boolean`, `json`, `array`, and `file`. `subBlocks[].type` identifies the authoring control, such as `combobox`; it is not the value type. `visibleWhen` determines whether a field applies to the selected configuration. For QuickBooks reports it is `{ "field": "operation", "value": "runReport" }`. Compare the named field with `value`; an array matches any listed value. `not: true` negates that comparison. An optional `and` condition must also match. When a field is applicable, `required: true` makes it mandatory; `requiredWhen` instead makes it mandatory only when that condition matches. An absent requirement means optional. These are the block's authoring definitions, not exhaustive provider validation schemas. A nested `schema` is included only where declared. Connection-dependent choices are not fetched. Report and action availability can depend on the QuickBooks company's plan, permissions, or region and is checked during execution. ## Build and run the discovered action Use the discovered ID in a [workflow action step](/docs/api-reference/workflows): ```json { "id": "report", "type": "action", "action": "quickbooks.runReport", "config": { "connection": "", "reportType": "ProfitAndLoss", "startDate": "2026-01-01", "endDate": "2026-01-31" } } ``` Create the workflow with `POST /api/v1/workflows` and execute it with `POST /api/v1/workflows/:id/run`. Running a report returns `Header`, `Columns`, and nested `Rows` for downstream analysis. Discovery itself never executes an action. ## Errors | Status | Cause | |---|---| | `400` | Invalid `kind` filter | | `401` | Missing or invalid authentication | | `403` | Missing `workflows:read` scope | | `404` | Unknown or hidden block type | | `429` | Standard read rate limit exceeded | # Connections API (/docs/api-reference/connections) Manage OAuth and API key connections for integrations ## Connections API Manage OAuth connections to external services. Connections are used by workflows to authenticate with integrated providers like Stripe, Salesforce, HubSpot, and more. ## List Connections Retrieve all connections for your company. ```http GET /api/v1/connections ``` ### Query Parameters | Parameter | Type | Default | Description | | ---------- | ------ | ------- | ---------------------------------------------------------------------------- | | `provider` | string | - | Filter by provider: `stripe`, `salesforce`, `hubspot`, etc. | | `status` | string | - | Filter: `active`, `pending`, `error`, `expired`, `disconnected`, `abandoned` | | `limit` | number | 20 | Number of results (1-100) | An unfiltered list excludes `abandoned` connections. They are audit records of connect attempts that never held a credential, they accumulate without bound, and leaving them in would let them consume the page limit and hide live connections. Pass `?status=abandoned` to read them. ### Response ```json { "success": true, "data": [ { "id": "conn_550e8400-e29b-41d4-a716-446655440000", "name": "Stripe Production", "provider": "stripe", "status": "active", "externalAccountId": "acct_xxx", "lastSyncedAt": "2024-01-15T10:30:00.000Z", "createdAt": "2024-01-01T10:00:00.000Z", "updatedAt": "2024-01-15T10:30:00.000Z" }, { "id": "conn_660e8400-e29b-41d4-a716-446655440001", "name": "HubSpot CRM", "provider": "hubspot", "status": "active", "externalAccountId": "123456", "lastSyncedAt": "2024-01-15T09:00:00.000Z", "createdAt": "2024-01-05T10:00:00.000Z", "updatedAt": "2024-01-15T09:00:00.000Z" } ], "meta": { "hasMore": false } } ``` ### Example ```bash curl -X GET "https://workflow.loopfour.ai/api/v1/connections?provider=stripe" \ -H "x-api-key: YOUR_API_KEY" ``` ```javascript const response = await fetch( 'https://workflow.loopfour.ai/api/v1/connections?provider=stripe', { headers: { 'x-api-key': process.env.API_KEY } } ); const { data } = await response.json(); ``` ```python response = requests.get( 'https://workflow.loopfour.ai/api/v1/connections', params={'provider': 'stripe'}, headers={'x-api-key': api_key} ) data = response.json() ```` ## Suggest a Connection Name Find the first available display name across your company's connections, including those beyond the list endpoint's 100-row limit. The connection identified by `excludeId` is omitted, so reconnecting an existing row does not count its own name as a collision. Abandoned connect attempts are excluded. Matching ignores case and surrounding whitespace. ```http GET /api/v1/connections/name-suggestion?base=HubSpot%20Connection&excludeId=00000000-0000-0000-0000-000000000001 ```` Requires `x-api-key` with `connections:read` scope. `base` is a nonempty name of at most 240 characters; `excludeId` is a connection UUID. Invalid query fields return `400`. The read is company scoped and subject to the normal read rate limit. ```bash curl -X GET "https://workflow.loopfour.ai/api/v1/connections/name-suggestion?base=HubSpot%20Connection&excludeId=00000000-0000-0000-0000-000000000001" \ -H "x-api-key: YOUR_API_KEY" ``` ```json { "success": true, "data": { "name": "HubSpot Connection (1)" } } ``` ## Get Auth URL Generate an OAuth authorization URL for a provider. This creates a pending connection and returns the URL to redirect the user to. This endpoint accepts OAuth providers only. Direct API-key providers (`rillet`, `resend`, `sendgrid`, `airwallex`, `odoo`, and `avalara`) return `400 Bad Request`; use the API-key endpoint documented below instead. ```http GET /api/v1/connections/:provider/auth-url ``` ### Path Parameters | Parameter | Type | Description | | ---------- | ------ | -------------------------------------------------------------------------------------------------------- | | `provider` | string | Provider name: `stripe`, `salesforce`, `hubspot`, `quickbooks`, `pandadoc`, `netsuite`, `slack`, `gmail` | ### Query Parameters | Parameter | Type | Required | Description | | ---------------- | ------ | -------- | -------------------------------------- | | `redirectUrl` | string | No | URL to redirect after OAuth completion | | `connectionName` | string | No | Custom name for the connection | ### Response ```json { "success": true, "data": { "connectionId": "conn_550e8400-e29b-41d4-a716-446655440000", "provider": "stripe", "authUrl": "https://connect.stripe.com/oauth/authorize?client_id=xxx&redirect_uri=xxx&state=xxx", "isNew": true, "message": "Redirect user to authUrl to complete OAuth authorization" } } ``` ### Example ```bash curl -X GET "https://workflow.loopfour.ai/api/v1/connections/stripe/auth-url?redirectUrl=https://app.example.com/settings/connections" \ -H "x-api-key: YOUR_API_KEY" ``` ### Re-authorization If a connection already exists but is in `error` or `disconnected` status, calling this endpoint will return a new auth URL for re-authorization: ```json { "success": true, "data": { "connectionId": "conn_existing", "provider": "stripe", "authUrl": "https://connect.stripe.com/oauth/authorize?...", "isNew": false, "message": "Redirect user to authUrl to complete OAuth authorization" } } ``` ### Error Response (Active Connection Exists) ```json { "success": false, "error": { "code": "BAD_REQUEST", "message": "Active connection already exists for stripe. Disconnect first to re-authorize." } } ``` ## OAuth Callback Handle the OAuth callback from the provider. This endpoint is called by Nango after the user completes authorization. ```http GET /api/v1/connections/:provider/callback ``` This endpoint does not require authentication as it handles redirects from the OAuth provider. ### Query Parameters | Parameter | Type | Description | | -------------- | ------ | ------------------------------------- | | `connectionId` | string | The connection ID from the auth flow | | `success` | string | `"true"` or `"false"` | | `error` | string | Error message if authorization failed | | `redirectUrl` | string | URL to redirect the user to | ### Successful Authorization If `redirectUrl` is provided, redirects to: ``` https://app.example.com/settings/connections?success=true&connectionId=xxx&provider=stripe ``` Otherwise returns: ```json { "success": true, "data": { "connectionId": "conn_xxx", "provider": "stripe", "status": "active", "message": "OAuth authorization completed successfully" } } ``` ### Failed Authorization If `redirectUrl` is provided, redirects to: ``` https://app.example.com/settings/connections?error=access_denied&connectionId=xxx ``` ## Create Connection Initiate a new OAuth connection. Direct API-key providers return `400 Bad Request` and must use `POST /api/v1/connections/:provider/api-key` instead. ```http POST /api/v1/connections ``` ### Request Body | Field | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------- | | `name` | string | Yes | Display name for the connection | | `provider` | string | Yes | Provider name | | `metadata` | object | No | Additional metadata | ### Example Request ```json { "name": "Production Stripe", "provider": "stripe", "metadata": { "environment": "production" } } ``` ### Response (201 Created) ```json { "success": true, "data": { "id": "conn_550e8400-e29b-41d4-a716-446655440000", "name": "Production Stripe", "provider": "stripe", "status": "pending", "oauthUrl": "https://api.nango.dev/oauth/connect/stripe?connection_id=xxx&public_key=xxx", "message": "Redirect user to oauthUrl to complete connection" } } ``` ## Create API-Key Connection Create a direct API-key connection without starting an OAuth flow. ```http POST /api/v1/connections/:provider/api-key ``` Supported providers are `rillet`, `resend`, `sendgrid`, `airwallex`, `odoo`, and `avalara`. The request requires the same `x-api-key` authorization and `connections:write` scope as OAuth connection creation. ### Request Body | Field | Type | Required | Description | | ------------------- | ------ | -------------- | ------------------------------------------------------------------------ | | `apiKey` | string | Yes | Provider credential. For Avalara, this is the License Key. | | `name` | string | No | Display name for the connection | | `connectionId` | UUID | No | Replace credentials for this existing connection instead of creating one | | `clientId` | string | Airwallex only | Airwallex Client ID | | `externalAccountId` | string | Airwallex only | Airwallex Account ID used for webhook routing | | `domain` | string | Odoo only | Odoo instance domain, such as `mycompany.odoo.com` | | `accountId` | string | Avalara only | Avalara Account ID | | `environment` | string | Avalara only | `production` or `sandbox` | | `companyCode` | string | No | Optional Avalara company code | ### SendGrid Example ```bash curl -X POST "https://workflow.loopfour.ai/api/v1/connections/sendgrid/api-key" \ -H "x-api-key: YOUR_WORKFLOWS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"apiKey":"YOUR_SENDGRID_API_KEY","name":"SendGrid Production"}' ``` The endpoint returns `201 Created` for a new connection and `200 OK` when `connectionId` replaces an existing connection's credentials. Invalid credentials return `400 Bad Request`; an unreachable provider returns `502 Bad Gateway`. ## Get Connection Retrieve details of a specific connection. ```http GET /api/v1/connections/:id ``` ### Response ```json { "success": true, "data": { "id": "conn_550e8400-e29b-41d4-a716-446655440000", "name": "Stripe Production", "provider": "stripe", "status": "active", "externalAccountId": "acct_xxx", "lastSyncedAt": "2024-01-15T10:30:00.000Z", "metadata": { "environment": "production" }, "createdAt": "2024-01-01T10:00:00.000Z", "updatedAt": "2024-01-15T10:30:00.000Z" } } ``` ## Delete Connection Revoke and delete a connection. This also removes the connection from Nango. ```http DELETE /api/v1/connections/:id ``` ### Response ```json { "success": true, "data": { "deleted": true } } ``` Deleting a connection will cause workflows using this connection to fail. Make sure to update or pause affected workflows first. ## Refresh Connection Manually refresh connection credentials. Useful when credentials are about to expire. ```http POST /api/v1/connections/:id/refresh ``` ### Response ```json { "success": true, "data": { "id": "conn_550e8400-e29b-41d4-a716-446655440000", "lastSyncedAt": "2024-01-15T10:35:00.000Z", "message": "Connection refresh completed" } } ``` ### Error Response (Refresh Failed) If the refresh fails, the connection status is updated to `error`: ```json { "success": false, "error": { "code": "INTERNAL_ERROR", "message": "Failed to refresh credentials: Token expired" } } ``` ## Verify Connection Test connection health by verifying credentials with the provider. ```http POST /api/v1/connections/:id/verify ``` ### Response (Healthy Connection) ```json { "success": true, "data": { "healthy": true, "connectionId": "conn_550e8400-e29b-41d4-a716-446655440000", "provider": "stripe", "status": "active", "externalAccountId": "acct_xxx", "error": null, "checkedAt": "2024-01-15T10:35:00.000Z" } } ``` ### Response (Unhealthy Connection) ```json { "success": true, "data": { "healthy": false, "connectionId": "conn_550e8400-e29b-41d4-a716-446655440000", "provider": "stripe", "status": "error", "externalAccountId": null, "error": "Invalid API key provided", "checkedAt": "2024-01-15T10:35:00.000Z" } } ``` ### Response (Provider Unreachable) When the provider itself could not be reached — a timeout, a DNS failure, a transport error — the check returns `healthy: false` **without changing the stored status**: an unreachable provider is not a verdict on your credentials, and marking the connection `error` would stop every workflow step that uses it until someone re-entered a secret the UI does not display again. ```json { "success": true, "data": { "healthy": false, "connectionId": "conn_550e8400-e29b-41d4-a716-446655440000", "provider": "avalara", "status": "active", "error": "Could not reach Avalara to verify this connection. Try again in a moment.", "checkedAt": "2024-01-15T10:35:00.000Z" } } ``` `status` echoes the row's unchanged value. Retry the check once the provider is responding. ### Response (Not Yet Authorized) ```json { "success": true, "data": { "healthy": false, "connectionId": "conn_550e8400-e29b-41d4-a716-446655440000", "provider": "stripe", "status": "pending", "error": "Connection not yet authorized - complete OAuth flow first", "checkedAt": "2024-01-15T10:35:00.000Z" } } ``` ## Finances (Plaid) Countries Finances (Plaid) (provider `plaid`) connects banks in the United States (`US`) and Canada (`CA`). Each connection belongs to one country, chosen when the connection starts. ### Country on the Link Token Request `POST /api/v1/connections/plaid/link-token` accepts an optional `countryCode` (`US` or `CA`, default `US`). Plaid Link then lists only institutions in that country. ```json { "countryCode": "CA" } ``` Any other value returns `400`, and no pending connection is created. Reconnecting an existing connection always uses that connection's own country. ### Country on the Connection Response Finances (Plaid) connection responses include `countryCode`: the connection's country (`"US"` or `"CA"`), or `null` when it is not known. ### Institution Logo on the Connection Response `GET /api/v1/connections/:id/plaid/accounts` also returns `institutionLogo` and `institutionColor`. `institutionLogo` is a base64-encoded PNG with no `data:` prefix. `institutionColor` is a hex color such as `"#1F4B99"`. Each value is `null` when Plaid does not supply it. The API stores both values when a bank connects and when accounts are refreshed. ## Connection Statuses | Status | Description | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pending` | OAuth flow started but not completed | | `active` | Connection is working properly | | `error` | Connection has an issue (check `errorMessage`) | | `expired` | Needs re-authorization. For OAuth providers, the token expired or was revoked. For Finances (Plaid), the bank requires a fresh sign-in (shown as **Reconnect required**); the provider error code is `lastErrorCode` on `GET /api/v1/connections/:id/plaid/accounts` | | `disconnected` | User revoked access or connection was disconnected | | `abandoned` | A connect attempt that never completed (the user closed Link, or removed the pending attempt); retained for audit, never usable, and excluded from an unfiltered list | ## Supported Providers | Provider | OAuth | Description | | ------------ | ----- | ------------------ | | `stripe` | Yes | Payment processing | | `salesforce` | Yes | CRM | | `hubspot` | Yes | Marketing/CRM | | `quickbooks` | Yes | Accounting | | `pandadoc` | Yes | Document signing | | `netsuite` | Yes | ERP | | `slack` | Yes | Messaging | | `gmail` | Yes | Email (Google) | ## OAuth Flow The typical OAuth flow for connecting a provider: ``` 1. Your app calls GET /api/v1/connections/:provider/auth-url 2. API returns authUrl and connectionId 3. Your app redirects user to authUrl 4. User authorizes in provider's UI 5. Provider redirects to Nango callback 6. Nango redirects to /api/v1/connections/:provider/callback 7. API updates connection status to 'active' 8. User is redirected to your redirectUrl ``` ### Example Flow (JavaScript) ```javascript // Step 1: Get auth URL const authResponse = await fetch( `https://workflow.loopfour.ai/api/v1/connections/stripe/auth-url?redirectUrl=${encodeURIComponent("https://app.example.com/settings")}`, { headers: { "x-api-key": API_KEY } }, ); const { data } = await authResponse.json(); // Step 2: Redirect user to OAuth window.location.href = data.authUrl; // Step 3: Handle callback (on your redirect page) const urlParams = new URLSearchParams(window.location.search); if (urlParams.get("success") === "true") { const connectionId = urlParams.get("connectionId"); console.log("Connection created:", connectionId); } else { const error = urlParams.get("error"); console.error("OAuth failed:", error); } ``` ## Error Responses ### Connection Not Found (404) ```json { "success": false, "error": { "code": "NOT_FOUND", "message": "Connection not found" } } ``` ### Active Connection Exists (400) ```json { "success": false, "error": { "code": "BAD_REQUEST", "message": "Active connection already exists for stripe. Disconnect first to re-authorize." } } ``` ### Provider Unreachable (502) Returned by the API-key connect endpoint when the provider could not be reached to check the credential. Distinct from `BAD_REQUEST` on purpose: the credential was never judged, so this is a signal to retry, not to rotate a secret. ```json { "success": false, "error": { "code": "UPSTREAM_ERROR", "message": "Could not reach Avalara to verify this connection. Try again in a moment." } } ``` # Dashboards (/docs/api-reference/dashboards) Create dashboards, save json-render widgets, set board formatting, and read public share links with live data. Every dashboard widget is a json-render spec: a flat element tree whose figures come from `loadData` actions against [tables](/docs/api-reference/tables). The API stores the spec, and the client runs its loads when it renders. Signed-in routes need a session or API key. Reads need `workflows:read` and use the read rate limit. Writes need `workflows:write`, use the write rate limit, and require the caller to own the dashboard. ## Dashboard settings `PATCH /api/v1/dashboards/:id` The body takes optional `name`, `description`, `layout`, and `settings`. `settings` holds the board-wide formatting defaults. A component's own `format` overrides them field by field. Only the keys you send change; the server merges them over the stored settings in one locked transaction. | Field | Values | Default | | --- | --- | --- | | `locale` | BCP-47 tag, for example `en-US` or `de-CH` | `en-US` | | `currency` | ISO-4217 code, display only (no FX conversion) | `USD` | | `negative` | `minus` or `parentheses` | `minus` | | `fiscalYearStartMonth` | Integer 1 through 12 | `1` | | `density` | `compact` or `comfortable` | `compact` | ```bash curl -X PATCH 'https://API_HOST/api/v1/dashboards/DASHBOARD_ID' \ -H 'Content-Type: application/json' \ -H 'Cookie: better-auth.session_token=SESSION_TOKEN' \ --data '{"settings":{"currency":"EUR","negative":"parentheses"}}' ``` Unknown settings keys return `400`. `GET /api/v1/dashboards/:id` returns the stored settings; a missing key takes the default above. `POST /api/v1/dashboards` also takes an optional `settings` object with the same fields. The server validates it the same way and stores it with the new dashboard. ## Widgets `POST /api/v1/dashboards/:id/widgets` creates a widget. `PATCH /api/v1/dashboards/:id/widgets/:widgetId` replaces its config, its position, or both. `DELETE /api/v1/dashboards/:id/widgets/:widgetId` removes it. | Field | Requirements | | --- | --- | | `config.version` | `1` (the default) | | `config.title` | Optional string, at most 120 characters | | `config.spec` | json-render spec `{root, elements, state?}` | | `position` | POST: optional `{w, h}` size hints. PATCH: a complete `{x, y, w, h}` | PATCH replaces the whole `config`; it never merges a partial spec. Send at least one of `config` or `position`. Other keys return `400`. ```json { "config": { "version": 1, "title": "MRR", "spec": { "root": "mrr", "elements": { "mrr": { "type": "Metric", "props": { "label": "MRR", "value": { "$state": "/data/mrr/value" } }, "children": [], "on": { "mount": { "action": "loadData", "params": { "target": "/data/mrr", "tableId": "3f1c2a4e-8b7d-4c1e-9a2b-5d6e7f8a9b0c", "aggregate": { "fn": "sum", "columnId": "amount" } } } } } }, "state": { "filters": {} } } } } ``` The server validates every spec before it saves it: - The spec must match the component catalog, and `root` must name an element. - `spec.state` may contain only `filters`. Figures come only from `loadData`, never literals. - Every `tableId` must belong to the dashboard company, and every column must exist. - One widget may declare at most 50 load bindings, and one refresh may cost at most 20 table reads. - The request body is limited to 1 MB. The dashboard caps apply under a row lock, so concurrent writes cannot both pass: | Cap | Limit | Status | | --- | --- | --- | | Widgets per dashboard | 40 | `409` | | Load bindings across all widgets | 200 | `409` | | Total spec size across all widgets | 2 MB | `409` | Invalid specs return `400` with at most three issues in the message. ### Check a spec `POST /api/v1/dashboards/specs/check` Runs the same validation without saving. Send `{"spec": {...}}`. Requires `workflows:read`. ```json { "success": true, "data": { "ok": false, "issues": ["spec.root \"nope\" is not an element"] } } ``` A valid spec returns `{"ok": true}`. The dashboard caps do not apply here. ## Public share links `GET /api/v1/dashboards/share/:token` needs no authentication. It returns the dashboard `name`, `description`, `layout`, `settings`, `publishedAt`, and `widgets`. Each public spec is redacted: `loadData` bindings keep only their opaque `target`, and table IDs, column IDs, and literal filter values are removed. | Status | Meaning | | --- | --- | | `200` | Published dashboard | | `404` | Unknown or malformed token | | `410` | `error.reason` is `revoked` or `expired` | ### Load public data `POST /api/v1/dashboards/share/:token/data` Runs a public viewer's loads on the server against the stored, unredacted spec. The client names only a widget and a `target`, so it cannot choose a table, column, or aggregate. | Field | Requirements | | --- | --- | | `requests` | 1 through 50 entries | | `requests[].widgetId` | Widget UUID on this dashboard | | `requests[].target` | The redacted `/data/`, at most 64 characters | | `requests[].filters` | Optional `from`, `to`, `compareFrom`, `compareTo` (`YYYY-MM`); `compare` (`none`, `previous_period`, `previous_year`); `customer`, `plan` (at most 100 characters) | ```json { "requests": [ { "widgetId": "WIDGET_ID", "target": "/data/mrr", "filters": { "from": "2026-01", "to": "2026-09" } } ] } ``` Each result succeeds or fails on its own: ```json { "success": true, "data": { "results": [ { "widgetId": "WIDGET_ID", "target": "/data/mrr", "ok": true, "rows": [], "value": 125000, "meta": { "asOf": "2026-10-09T12:00:00.000Z", "rowCount": 412, "truncated": false, "stale": false } } ] } } ``` A failed item has `ok: false` and an `error` string. The server resolves only `/filters/` state paths from `filters`; it ignores all other client state. - The `from`..`to` range may span at most 36 months. On a public share link, a missing `to` means the current month and a missing `from` means 35 months before `to`. - On a public share link, each row carries only the fields that the components bound to that `target` display. Other source columns are removed. - On a public share link, a company runs at most 300 uncached queries per minute across all its links. Past that limit, a figure returns its last good result, or the error `too many requests`. - A result carries at most 500 rows; `meta.truncated` marks a cut. - The rate limit is 120 requests per minute per IP and 600 per minute per token. - Results are cached for 60 seconds. If a query fails, the last good result (kept for 7 days) returns with `meta.stale: true`. A figure that depends on free-text search is never cached. - Widget writes and share changes clear the cache. ## Save widget layout `PUT /api/v1/dashboards/:id/layout` Requires an authenticated user who owns the dashboard in their active company, with `workflows:write` access. Company viewers and invited viewers cannot change layout. Public share links are read-only. Send **every widget** in the dashboard, using IDs and positions from `GET /api/v1/dashboards/:id`. The grid has 12 columns. Studio offers widths of 4, 6, or 12 columns and heights of 4 or 8 rows. | Field | Requirements | | --- | --- | | `widgets` | Complete array, at most 40 entries; empty only for an empty dashboard | | `widgets[].id` | Unique widget UUID belonging to this dashboard | | `widgets[].x` | Integer from 0 through 11 | | `widgets[].y` | Nonnegative safe integer, at most `Number.MAX_SAFE_INTEGER - 12` | | `widgets[].w` | Positive integer, at most 12; `x + w` must not exceed 12 | | `widgets[].h` | Positive integer, at most 12 | ```bash curl -X PUT 'https://API_HOST/api/v1/dashboards/DASHBOARD_ID/layout' \ -H 'Content-Type: application/json' \ -H 'Cookie: better-auth.session_token=SESSION_TOKEN' \ --data '{"widgets":[{"id":"00000000-0000-0000-0000-000000000001","x":0,"y":0,"w":6,"h":8}]}' ``` The server closes vertical gaps, resolves overlaps in row/column order, and saves all positions atomically. Always apply the returned positions: they can differ from the submitted Y coordinates. Merge them into your existing widgets by ID. This endpoint never replaces widget configs and does not create a dashboard version. ```json { "success": true, "data": { "widgets": [ { "id": "00000000-0000-0000-0000-000000000001", "x": 0, "y": 0, "w": 6, "h": 8 } ], "updatedAt": "2026-09-18T12:00:00.000Z" } } ``` A successful save updates the dashboard timestamp and records one audit event. Send a request after a completed edit, rather than when rendering or polling. | Status | Meaning | | --- | --- | | `200` | Complete accepted layout saved | | `400` | Invalid coordinates, duplicate IDs, or an ID outside this dashboard | | `401` / `403` | Missing authentication or insufficient scope | | `404` | Dashboard is unavailable to the caller as its owner | | `409` | The layout omits widgets added since the client loaded it; reload before saving | | `413` | Request exceeds 1 MB | | `429` | Write rate limit reached | Invalid or stale sets save nothing. A concurrent widget creation is serialized with a layout save. Creation remains server-placed: `POST /:id/widgets` accepts optional width/height hints, and ignores requested X/Y positions. The existing `PATCH /:id/widgets/:widgetId` also accepts a complete position within the same bounds, compacts affected widgets, and keeps the config when it is omitted. # Runs API (/docs/api-reference/runs) Execute and monitor workflow runs ## Runs API Execute workflows and monitor their execution progress, including step-by-step logs. ## List Runs Retrieve all workflow runs for your company with cursor-based pagination. ```http GET /api/v1/runs ``` ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `workflowId` | string | - | Filter by workflow ID. Runs of deleted workflows are still listed and can be filtered this way; see [List Workflows With Runs](#list-workflows-with-runs). | | `status` | string | - | Filter by `pending`, `running`, `waiting_approval`, `completed`, `failed`, `suspended`, or `cancelled`. Use `active` to return `pending`, `running`, and `waiting_approval` runs together. | | `limit` | number | 50 | Number of results (1-100) | | `cursor` | string | - | Cursor for pagination | ### Response ```json { "success": true, "data": [ { "id": "run_550e8400-e29b-41d4-a716-446655440000", "workflowId": "wf_xxx", "status": "completed", "triggerType": "webhook", "startedAt": "2024-01-15T10:30:00.000Z", "completedAt": "2024-01-15T10:30:05.000Z", "durationMs": 5000, "error": null, "createdAt": "2024-01-15T10:30:00.000Z" }, { "id": "run_660e8400-e29b-41d4-a716-446655440001", "workflowId": "wf_xxx", "status": "failed", "triggerType": "api", "startedAt": "2024-01-15T10:25:00.000Z", "completedAt": "2024-01-15T10:25:02.000Z", "durationMs": 2000, "error": { "code": "STEP_ERROR", "message": "Connection timeout" }, "createdAt": "2024-01-15T10:25:00.000Z" } ], "meta": { "cursor": "eyJ0cyI6MTcwNTMxMjAwMDAwMCwiaWQiOiJydW5fNjYwZTg0MDAuLi4ifQ==", "hasMore": true } } ``` ### Example ```bash curl -X GET "https://workflow.loopfour.ai/api/v1/runs?status=failed&limit=20" \ -H "x-api-key: YOUR_API_KEY" ``` ```javascript const response = await fetch( 'https://workflow.loopfour.ai/api/v1/runs?status=failed&limit=20', { headers: { 'x-api-key': process.env.API_KEY } } ); const { data, meta } = await response.json(); ``` ```python response = requests.get( 'https://workflow.loopfour.ai/api/v1/runs', params={'status': 'failed', 'limit': 20}, headers={'x-api-key': api_key} ) data = response.json() ``` ## List Workflows With Runs List the workflows that have at least one run, so you can choose a `workflowId` for [List Runs](#list-runs). Deleted workflows are included, because their runs stay in run history. ```http GET /api/v1/runs/workflows ``` ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `search` | string | - | Case-insensitive match anywhere in the workflow name. `%` and `_` match literally. Up to 200 characters. | | `limit` | number | 100 | Number of results (1-100). There is no cursor; narrow a long list with `search`. | Results list workflows that are not deleted first, then deleted ones, each group sorted by name. ### Response ```json { "success": true, "data": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Invoice Follow-up", "status": "active", "deleted": false }, { "id": "660e8400-e29b-41d4-a716-446655440001", "name": "Invoice Follow-up (old)", "status": "archived", "deleted": true } ] } ``` ### Example ```bash curl -X GET "https://workflow.loopfour.ai/api/v1/runs/workflows?search=invoice" \ -H "x-api-key: YOUR_API_KEY" ``` ## Get Run Retrieve details of a specific workflow run. If the run is still in progress, status is automatically synced from Trigger.dev. ```http GET /api/v1/runs/:id ``` ### Path Parameters | Parameter | Type | Description | |-----------|------|-------------| | `id` | string | Run UUID | ### Response ```json { "success": true, "data": { "id": "run_550e8400-e29b-41d4-a716-446655440000", "workflowId": "wf_xxx", "triggerDevRunId": "run_xxx", "status": "completed", "triggerType": "webhook", "input": { "type": "invoice.paid", "data": { "object": { "id": "in_xxx", "amount_paid": 9900, "customer_email": "customer@example.com" } } }, "output": { "slackMessageId": "1234567890.123456", "hubspotContactId": "12345" }, "stepResults": [ { "stepId": "notify-team", "status": "success", "startedAt": "2024-01-15T10:30:01.000Z", "completedAt": "2024-01-15T10:30:02.000Z", "durationMs": 1000, "input": { "channel": "#finance", "text": "..." }, "output": { "ts": "1234567890.123456" }, "attempt": 1 }, { "stepId": "create-contact", "status": "success", "startedAt": "2024-01-15T10:30:02.000Z", "completedAt": "2024-01-15T10:30:04.000Z", "durationMs": 2000, "input": { "email": "customer@example.com", ... }, "output": { "id": "12345" }, "attempt": 1 } ], "startedAt": "2024-01-15T10:30:00.000Z", "completedAt": "2024-01-15T10:30:05.000Z", "durationMs": 5000, "error": null, "createdAt": "2024-01-15T10:30:00.000Z" } } ``` `triggerDevRunId` is the id of the Trigger.dev run that executed this workflow run, or `null` if the run was never dispatched. Quote it when reporting a run that failed without a step error (for example, one stopped by the platform for running out of memory). When a run fails at the platform level, `error.details.triggerStatus` carries the Trigger.dev run status (for example `CRASHED` or `SYSTEM_FAILURE`), and `error.details.triggerErrorCode` carries the Trigger.dev internal error code when one is reported (for example `TASK_PROCESS_OOM_KILLED` for a run that ran out of memory). ### Step Results Each step result includes: | Field | Type | Description | |-------|------|-------------| | `stepId` | string | Step identifier | | `status` | string | `success`, `error`, `skipped` | | `startedAt` | string | ISO timestamp when step started | | `completedAt` | string | ISO timestamp when step completed | | `durationMs` | number | Execution time in milliseconds | | `input` | object | Resolved input passed to the step | | `output` | object | Output returned by the step | | `error` | object | Error details if step failed | | `attempt` | number | Retry attempt number | ## Get Run Logs Retrieve execution logs for a workflow run with step-by-step details. ```http GET /api/v1/runs/:id/logs ``` ### Response ```json { "success": true, "data": { "runId": "run_550e8400-e29b-41d4-a716-446655440000", "workflowId": "wf_xxx", "triggerDevRunId": "run_xxx", "status": "completed", "logs": [ { "stepId": "notify-team", "status": "success", "startedAt": "2024-01-15T10:30:01.000Z", "completedAt": "2024-01-15T10:30:02.000Z", "durationMs": 1000, "input": { "channel": "#finance", "text": "Invoice in_xxx paid!" }, "output": { "ts": "1234567890.123456", "channel": "C1234567890" }, "error": null, "attempt": 1 }, { "stepId": "create-contact", "status": "success", "startedAt": "2024-01-15T10:30:02.000Z", "completedAt": "2024-01-15T10:30:04.000Z", "durationMs": 2000, "input": { "email": "customer@example.com", "properties": { "firstname": "John" } }, "output": { "id": "12345", "properties": { ... } }, "error": null, "attempt": 1 } ], "startedAt": "2024-01-15T10:30:00.000Z", "completedAt": "2024-01-15T10:30:05.000Z", "durationMs": 5000, "error": null } } ``` ### Failed Step Example ```json { "stepId": "sync-to-crm", "status": "error", "startedAt": "2024-01-15T10:30:02.000Z", "completedAt": "2024-01-15T10:30:05.000Z", "durationMs": 3000, "input": { "recordId": "123", ... }, "output": null, "error": { "code": "STEP_ERROR", "message": "API rate limit exceeded" }, "attempt": 3 } ``` ## Cancel Run Cancel a running or pending workflow execution. ```http POST /api/v1/runs/:id/cancel ``` ### Cancellable States | Status | Cancellable | |--------|-------------| | `pending` | Yes | | `running` | Yes | | `completed` | No | | `failed` | No | | `cancelled` | No | ### Response ```json { "success": true, "data": { "id": "run_550e8400-e29b-41d4-a716-446655440000", "status": "cancelled", "message": "Run cancellation requested" } } ``` ### Error Response (Invalid State) ```json { "success": false, "error": { "code": "INVALID_STATE", "message": "Can only cancel running or pending runs" } } ``` ## Retry Run Retry a failed workflow run from the first failed step. The original input is preserved. ```http POST /api/v1/runs/:id/retry ``` ### Retriable States | Status | Retriable | |--------|-----------| | `pending` | No | | `running` | No | | `completed` | No | | `failed` | Yes | | `timeout` | Yes | | `cancelled` | No | ### Response (202 Accepted) ```json { "success": true, "data": { "originalRunId": "run_550e8400-e29b-41d4-a716-446655440000", "newRunId": "run_770e8400-e29b-41d4-a716-446655440002", "workflowId": "wf_xxx", "restartFromStepId": "sync-to-crm", "status": "pending", "createdAt": "2024-01-15T10:35:00.000Z" } } ``` ### Error Response (Invalid State) ```json { "success": false, "error": { "code": "INVALID_STATE", "message": "Can only retry failed runs" } } ``` ## Restart Run Restart a failed or completed run from a specific step. This enables "time travel" debugging - you can re-execute from any step while preserving outputs from previous steps. ```http POST /api/v1/runs/:id/restart ``` ### Request Body ```json { "fromStepId": "sync-to-crm", "overrideInput": { "retryCount": 1 } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `fromStepId` | string | Yes | Step ID to restart from (inclusive) | | `overrideInput` | object | No | Override/merge input fields | ### Restartable States | Status | Restartable | |--------|-------------| | `pending` | No | | `running` | No | | `completed` | Yes | | `failed` | Yes | | `timeout` | Yes | | `cancelled` | No | ### Response (202 Accepted) ```json { "success": true, "data": { "originalRunId": "run_550e8400-e29b-41d4-a716-446655440000", "newRunId": "run_880e8400-e29b-41d4-a716-446655440003", "workflowId": "wf_xxx", "restartFromStepId": "sync-to-crm", "status": "pending", "createdAt": "2024-01-15T10:40:00.000Z" } } ``` ### How It Works 1. **Creates new run** linked to the original via `restartedFromRunId` 2. **Preserves step outputs** from steps before the restart point 3. **Executes from specified step** with hydrated context 4. **Supports input override** to test with modified data ### Example: Debug a Failed Step ```bash # Get the failed run to see which step failed curl -X GET "https://workflow.loopfour.ai/api/v1/runs/run_xxx" \ -H "x-api-key: YOUR_API_KEY" # Restart from the failed step with corrected input curl -X POST "https://workflow.loopfour.ai/api/v1/runs/run_xxx/restart" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "fromStepId": "sync-to-crm", "overrideInput": { "apiEndpoint": "https://api.salesforce.com/v2" } }' ``` ### Error Response (Invalid Step) ```json { "success": false, "error": { "code": "INVALID_STEP", "message": "Step not found: invalid-step-id" } } ``` ## Run Statuses | Status | Description | |--------|-------------| | `pending` | Queued for execution, waiting to start | | `running` | Currently executing steps | | `waiting_approval` | Paused at a human approval step | | `completed` | All steps completed successfully | | `failed` | Execution stopped due to an error | | `cancelled` | Manually cancelled by user | | `suspended` | Execution suspended before reaching a terminal state | ## Trigger Types | Type | Description | |------|-------------| | `api` | Triggered via API call | | `webhook` | Triggered by webhook event | | `schedule` | Triggered by schedule | | `manual` | Triggered manually (e.g., via UI) | ## Polling for Completion To wait for a run to complete, poll the GET endpoint: ```javascript async function waitForCompletion(runId, timeoutMs = 60000) { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { const response = await fetch( `https://workflow.loopfour.ai/api/v1/runs/${runId}`, { headers: { 'x-api-key': API_KEY } } ); const { data } = await response.json(); if (data.status === 'completed') { return data.output; } if (data.status === 'failed') { throw new Error(data.error?.message || 'Workflow failed'); } if (data.status === 'cancelled') { throw new Error('Workflow was cancelled'); } // Wait 1 second before polling again await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error('Timeout waiting for workflow completion'); } ``` ## Error Responses ### Run Not Found (404) ```json { "success": false, "error": { "code": "NOT_FOUND", "message": "Run not found" } } ``` ### Invalid State (400) ```json { "success": false, "error": { "code": "INVALID_STATE", "message": "Can only cancel running or pending runs" } } ``` # Workflow Secrets API (/docs/api-reference/secrets) Manage write-only secrets scoped to one workflow # Workflow Secrets API Workflow secrets store encrypted values that belong to a single workflow. Step config can reference them as `{{ secrets.KEY }}`. The API never returns plaintext or ciphertext; reads return only a masked value. All endpoints require authentication with `x-api-key` or a Studio session. API keys need `secrets:read` for listing and `secrets:write` for create, rotate, and delete. ## List Secrets ```http GET /api/v1/workflows/:workflowId/secrets ``` ### Path Parameters | Parameter | Type | Description | |-----------|------|-------------| | `workflowId` | string | Workflow UUID. The workflow must belong to the caller's company. | ### Response ```json { "success": true, "data": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "workflowId": "660e8400-e29b-41d4-a716-446655440001", "key": "SIGNING_KEY", "maskedValue": "••••1234", "lastUsedAt": null, "createdAt": "2026-06-26T10:00:00.000Z", "updatedAt": "2026-06-26T10:00:00.000Z" } ] } ``` `maskedValue` is built from a stored suffix hint when the value is long enough to keep at least one character hidden; shorter values return only the mask prefix. The read path does not decrypt the value. ### Example ```bash curl -X GET "https://workflow.loopfour.ai/api/v1/workflows/WORKFLOW_ID/secrets" \ -H "x-api-key: YOUR_API_KEY" ``` ## Create Secret ```http POST /api/v1/workflows/:workflowId/secrets ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `key` | string | Yes | Upper snake case, starts with a letter, max 64 characters. Example: `SIGNING_KEY`. | | `value` | string | Yes | Secret value, 1-8192 characters. Returned only through this write request's side effect, never in the response. | ### Example Request ```json { "key": "SIGNING_KEY", "value": "YOUR_SECRET_VALUE" } ``` ### Response ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "workflowId": "660e8400-e29b-41d4-a716-446655440001", "key": "SIGNING_KEY", "maskedValue": "••••ALUE", "lastUsedAt": null, "createdAt": "2026-06-26T10:00:00.000Z", "updatedAt": "2026-06-26T10:00:00.000Z" } } ``` Creating the same `key` twice for one workflow returns `409 CONFLICT`. ## Rotate Secret ```http PUT /api/v1/workflows/:workflowId/secrets/:key ``` Rotation is write-without-read: submit the replacement value and the API stores a new encrypted value without revealing the previous one. ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `value` | string | Yes | Replacement value, 1-8192 characters. | ### Example ```bash curl -X PUT "https://workflow.loopfour.ai/api/v1/workflows/WORKFLOW_ID/secrets/SIGNING_KEY" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"value":"NEW_SECRET_VALUE"}' ``` ## Delete Secret ```http DELETE /api/v1/workflows/:workflowId/secrets/:key ``` ### Response ```json { "success": true, "data": { "deleted": true } } ``` Deleting a secret does not rewrite workflow step config. Future runs that still reference `{{ secrets.KEY }}` fail fast with a missing-secret error until the key is recreated or the workflow is updated. ## Errors | Status | Code | When | |--------|------|------| | `400` | `VALIDATION_ERROR` | Invalid key format or empty value. | | `401` | `UNAUTHORIZED` | Missing or invalid authentication. | | `403` | `FORBIDDEN` | Authenticated caller lacks the required `secrets:*` scope. | | `404` | `NOT_FOUND` | Workflow is not owned by the caller's company, or the secret key does not exist. | | `409` | `CONFLICT` | Secret key already exists for this workflow. | # Tables API (/docs/api-reference/tables) Create, manage, and read rows in user-managed data tables ## Tables API Data tables are tenant-scoped, schema-flexible tables you can use to persist workflow state, build small lookups, or maintain queues that humans resolve out-of-band. The same tables that show up under **Data Store** in the Studio (route `/data`) and that the [Data Table block](/docs/blocks/data-table) operates on are exposed via this REST API. Every endpoint requires the standard `x-api-key` header described in [Authentication](/docs/api-reference/authentication). Reads require the `workflows:read` scope; writes require `workflows:write`. Every table is owned by exactly one company. The API never returns rows belonging to a different company than the one the API key is scoped to. ## Resource Shape A table: ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Processed Invoices", "description": "Deduplication table for Stripe invoice events", "columns": [ { "id": "stripeInvoiceId", "name": "stripeInvoiceId", "type": "text", "required": true }, { "id": "amountCents", "name": "amountCents", "type": "number" } ], "schema": { "columns": [ { "id": "stripeInvoiceId", "name": "stripeInvoiceId", "type": "text", "required": true }, { "id": "amountCents", "name": "amountCents", "type": "number" } ] }, "metadata": {}, "maxRows": 10000, "rowCount": 47, "createdAt": "2024-01-15T10:00:00.000Z", "updatedAt": "2024-01-20T08:12:00.000Z" } ``` A row: ```json { "id": "660e8400-e29b-41d4-a716-446655440001", "data": { "stripeInvoiceId": "in_1234567890", "amountCents": 9900 }, "position": null, "createdAt": "2024-01-15T10:30:00.000Z", "updatedAt": "2024-01-15T10:30:00.000Z" } ``` ### Column Types | Type | Description | |------|-------------| | `text` | Free-form string | | `number` | Numeric value | | `boolean` | True or false | | `date` | ISO date string in `YYYY-MM-DD` form | | `select` | One value from an enumerated set (provide `options: string[]`) | | `url` | URL (string) | | `email` | Email address (string) | | `json` | Arbitrary nested JSON value | Columns are advisory — the underlying storage is JSONB and any keys you write in `data` are preserved, even if they're not declared in `columns`. The Studio uses `columns` to render forms and CSV importers; workflows that write rows via the API can include extra fields without redefining the schema. ## List Tables ```http GET /api/v1/tables ``` Returns every table the API key's company owns, ordered by `createdAt` descending. ### Example ```bash curl -X GET "https://workflow.loopfour.ai/api/v1/tables" \ -H "x-api-key: YOUR_API_KEY" ``` ### Response ```json { "success": true, "data": [ { "id": "...", "name": "Processed Invoices", "...": "..." }, { "id": "...", "name": "Pending Approvals", "...": "..." } ] } ``` ## Create Table ```http POST /api/v1/tables ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Display name (1-255 chars). | | `description` | string | No | Free-form description. | | `columns` | object[] | No | Column definitions (see [Column Types](#column-types)). | | `schema` | object | No | Free-form schema metadata. When `columns` is provided it is merged into `schema.columns`. | | `metadata` | object | No | Free-form metadata (used by the Studio for view preferences). | | `maxRows` | number | No | Row cap (1 - 100,000, default 10,000). | ### Example ```bash curl -X POST "https://workflow.loopfour.ai/api/v1/tables" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Processed Invoices", "description": "Deduplicate Stripe events", "columns": [ { "id": "stripeInvoiceId", "name": "stripeInvoiceId", "type": "text", "required": true }, { "id": "amountCents", "name": "amountCents", "type": "number" } ], "maxRows": 25000 }' ``` ### Response (`201 Created`) ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Processed Invoices", "columns": [ { "id": "stripeInvoiceId", "name": "stripeInvoiceId", "type": "text", "required": true }, { "id": "amountCents", "name": "amountCents", "type": "number" } ], "maxRows": 25000, "rowCount": 0, "createdAt": "2024-01-15T10:00:00.000Z", "updatedAt": "2024-01-15T10:00:00.000Z" } } ``` An explicit, unique column `id` containing 1-255 characters is preserved. When `id` is omitted, the API assigns a generated `col_...` storage key. Always use the IDs returned by the API for row keys, filters, and sorting; display-name changes do not change those IDs. ## Import Table ```http POST /api/v1/tables/import ``` Creates a table and imports its initial rows in one transaction. Use this for CSV-style imports or other bulk table creation flows where partial imports would be unsafe. The route consumes one `write` rate-limit request for the whole import, not one request per row. ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Display name (1-255 chars). | | `description` | string | No | Free-form description. | | `columns` | object[] | Yes | Column definitions (see [Column Types](#column-types)). | | `schema` | object | No | Free-form schema metadata. `columns` is merged into `schema.columns`. | | `metadata` | object | No | Free-form metadata. | | `maxRows` | number | No | Row cap (1 - 100,000, default 10,000). The import is rejected if `rows.length` exceeds this value. | | `rows` | object[] | Yes | Row payloads. Keys beyond declared columns are preserved. | ### Example ```bash curl -X POST "https://workflow.loopfour.ai/api/v1/tables/import" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Imported Customers", "columns": [ { "id": "email", "name": "email", "type": "email", "required": true }, { "id": "tier", "name": "tier", "type": "text" } ], "rows": [ { "email": "alice@example.com", "tier": "enterprise" }, { "email": "bob@example.com", "tier": "self-serve" } ] }' ``` ### Response (`201 Created`) Returns the created table with `rowCount` set to the number of imported rows. ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Imported Customers", "columns": [ { "id": "email", "name": "email", "type": "email", "required": true }, { "id": "tier", "name": "tier", "type": "text" } ], "maxRows": 10000, "rowCount": 2, "createdAt": "2024-01-15T10:00:00.000Z", "updatedAt": "2024-01-15T10:00:00.000Z" } } ``` If row insertion fails, the table creation is rolled back. The API returns `400 BAD_REQUEST` when `rows.length` exceeds `maxRows`. Import columns must resolve to unique storage IDs containing 1-255 characters. An omitted `id` resolves to that column's `name` so existing row-object keys remain connected; if two explicit or name-derived IDs collide, the import is rejected instead of silently reminting an ID that would make its row values unreadable. For duplicate display names, supply distinct explicit IDs. ## Get Table ```http GET /api/v1/tables/:id ``` Returns a single table including its current `rowCount` and `columns`. Returns `404 NOT_FOUND` if the table does not exist or belongs to another company. ## Update Table ```http PATCH /api/v1/tables/:id ``` Patch one or more table fields. Any field omitted from the body is left unchanged. ### Request Body | Field | Type | Description | |-------|------|-------------| | `name` | string | Rename the table. | | `description` | string | Replace description. | | `columns` | object[] | Replace `schema.columns` entirely. | | `schema` | object | Replace the full `schema` JSONB. | | `metadata` | object | Replace `metadata` JSONB. | | `maxRows` | number | Adjust the row cap (1 - 100,000). | `columns` is a full replacement of `schema.columns`. To add a column, send the existing columns plus the new one — partial updates are not supported. ## Delete Table ```http DELETE /api/v1/tables/:id ``` Deletes the table and cascades to every row it contains. ```json { "success": true, "data": { "deleted": true } } ``` ## Table Folders Table folders provide one level of company-scoped organization in the Studio Data Store. They are separate from workspace-file folders. A table can belong to one folder or to the Data Store root. Folders cannot be nested, renamed, or deleted in the current Studio or API, and there is no application-defined limit on how many folders a company can create. ### List Table Folders ```http GET /api/v1/table-folders ``` Returns folders in name order. `tableIds` contains the company-owned tables currently assigned to each folder. ```json { "success": true, "data": [ { "id": "770e8400-e29b-41d4-a716-446655440002", "name": "July", "tableIds": ["550e8400-e29b-41d4-a716-446655440000"], "createdAt": "2024-07-01T10:00:00.000Z", "updatedAt": "2024-07-01T10:00:00.000Z" } ] } ``` ### Create Table Folder ```http POST /api/v1/table-folders ``` ```bash curl -X POST "https://workflow.loopfour.ai/api/v1/table-folders" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "July" }' ``` `name` is trimmed and must contain 1-255 characters. Folder names are unique within a company using case-sensitive comparison, so `Sales` and `sales` are distinct names. The response is `201 Created`; an exact duplicate returns `409 CONFLICT`. ### Move Table To A Folder Or Root ```http PATCH /api/v1/table-folders/tables/:tableId ``` Pass a company-owned folder UUID, or `null` to move the table back to the Data Store root. ```bash curl -X PATCH "https://workflow.loopfour.ai/api/v1/table-folders/tables/$TABLE_ID" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "folderId": "770e8400-e29b-41d4-a716-446655440002" }' ``` ```json { "success": true, "data": { "tableId": "550e8400-e29b-41d4-a716-446655440000", "folderId": "770e8400-e29b-41d4-a716-446655440002" } } ``` The API returns `404 NOT_FOUND` if either ID does not belong to the API key's company. ## Query Rows ```http POST /api/v1/tables/:id/rows/query ``` This is the recommended endpoint for typed filters and Studio-equivalent reads. The query is a JSON body, so selected table values do not become part of the request URL. This `POST .../query` route is a read-only JSON-body companion to the retained `GET /api/v1/tables/:id/rows` route, not a second row resource and not a row-creation operation. Both use the read scope and return the same row and pagination shape. Use the body route for new structured queries; the GET route remains for existing query-string callers. ### Request Body | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `limit` | number | 50 | 1 - 100 rows per page. | | `offset` | number | 0 | Zero-based, non-negative integer offset within the filtered result set. | | `filter` | object | None | Filters keyed by stable column ID. Structured clauses are described below. | | `sort` | string | None | Stable column ID to sort by. The API validates it against the stored schema and applies type-aware ordering. | | `order` | `asc` or `desc` | `asc` | Direction used when `sort` is present. | Filters on separate columns are combined with AND. A structured column clause can contain a `condition`, a list of exact included `values`, or exact `excludedValues`. `values` and `excludedValues` are mutually exclusive. Included values use OR within the column; an exclusion rule retains every value not present in its list. When both a condition and a value rule are present, both must match. - Omit both value lists to select every value. - `values: []` deliberately matches no rows. - `excludedValues: ["Archived"]` means every exact raw JSON value except `"Archived"`, including values outside the current distinct-value page. - `null` in a value list represents the blank bucket: a missing key, JSON `null`, or an empty string. Condition operators are type-aware: | Column type | Supported operators | |-------------|---------------------| | `text`, `email`, `url`, `select` | `contains`, `not_contains`, `equals`, `not_equals`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty` | | `number` | `equals`, `not_equals`, `greater_than`, `greater_than_or_equal`, `less_than`, `less_than_or_equal`, `is_empty`, `is_not_empty` | | `date` | `equals`, `not_equals`, `greater_than`, `greater_than_or_equal`, `less_than`, `less_than_or_equal`, `between`, `is_empty`, `is_not_empty` | | `boolean` | `equals`, `not_equals`, `is_empty`, `is_not_empty` | | `json` | `contains`, `not_contains`, `equals`, `not_equals`, `is_empty`, `is_not_empty` | Text-style condition matching is case-insensitive, including `equals` and `not_equals`. Exact `values` and `excludedValues` lists compare the stored raw JSON values instead, so they remain case-sensitive for strings. Date conditions accept `YYYY-MM-DD` operands. The `between` operator takes `{ "start": "YYYY-MM-DD", "end": "YYYY-MM-DD" }`, includes both endpoints, and rejects a start after the end. Datetime strings are rejected so offset timestamps are never compared or sorted lexically as if they represented chronological instants. In Studio, date columns use calendar inputs and date-specific labels (`Is on`, `Is before`, and `Is after`), along with inclusive **Last 30 days**, **Last 3 months**, **Last 12 months**, and **Custom range** controls. The rolling presets end on the user's current local date; the 30-day preset includes today plus the previous 29 days, while month presets preserve the day of month and clamp to the last valid day when necessary. Studio creates and edits filter clauses only through each column header menu; the retired page-level filter builder is not present. When clauses are active, the grid toolbar shows one removable `column · operator · value` chip per clause and offers **Clear all filters** when more than one clause is active. Sorting is authored in the header and summarized by a removable `Sorted by ↑/↓` chip. Without `sort`, rows are returned in `position`, `createdAt`, then row-ID order. A typed sort uses the requested column first and those same fields as stable tie-breakers. ### Filter Example ```bash curl -X POST "https://workflow.loopfour.ai/api/v1/tables/$TABLE_ID/rows/query" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "filter": { "status": { "values": ["active", "pending"] }, "amountCents": { "condition": { "operator": "greater_than", "value": 5000 } } }, "sort": "amountCents", "order": "desc", "limit": 50, "offset": 0 }' ``` The body accepts at most 20 filtered columns, 500 included or excluded values per column, 1,000 exact values across the request, 50,000 serialized filter characters, and 2,000 serialized characters per condition or exact value. The complete JSON request body is limited to 512 KiB before parsing; a larger body returns `413 PAYLOAD_TOO_LARGE`. ### Legacy GET Compatibility `GET /api/v1/tables/:id/rows` remains available for existing callers. It accepts `limit`, `offset`, `sort`, and `order` as query parameters, and its `filter` parameter is a URL-encoded JSON object string. Legacy scalar entries such as `{"status":"pend"}` retain case-insensitive substring matching. On this GET row route only, bounded scalar entries may also target non-empty JSONB row keys that are not declared in the table schema. Structured filter clauses and `sort` must reference declared column IDs, and new structured integrations should use the JSON-body query endpoint so filter data stays out of URLs. This compatibility contract applies to external GET API callers. It does not retain the retired Studio page-level top filter; Studio uses per-column filters through the structured body-query routes. ### Response ```json { "success": true, "data": [ { "id": "660e8400-e29b-41d4-a716-446655440001", "data": { "stripeInvoiceId": "in_1234567890", "amountCents": 9900 }, "position": null, "createdAt": "2024-01-15T10:30:00.000Z", "updatedAt": "2024-01-15T10:30:00.000Z" } ], "meta": { "total": 1, "limit": 50, "offset": 0, "hasMore": false } } ``` `meta.total` is counted after filters are applied, so it can be used directly for filtered pagination. An `offset` equal to or greater than `meta.total` returns an empty `data` page with the filtered total preserved and `hasMore: false`. ## Query Distinct Column Values ```http POST /api/v1/tables/:id/columns/:columnId/values/query ``` Returns counted exact values across the full company-scoped table, not just one row page. Filters for other columns remain active; any filter for `:columnId` itself is ignored so the caller can rebuild that column's checkbox list. | Body field | Type | Default | Description | |-----------|------|---------|-------------| | `filter` | object | None | Same structured filter contract as [Query Rows](#query-rows). | | `search` | string | None | Case-insensitive substring search across the returned display `label` (maximum 255 characters). | | `limit` | number | 100 | 1 - 500 distinct values. | As with the row body-query route, the complete JSON request body is limited to 512 KiB before parsing and returns `413 PAYLOAD_TOO_LARGE` when exceeded. ```bash curl -X POST "https://workflow.loopfour.ai/api/v1/tables/$TABLE_ID/columns/status/values/query" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "filter": { "region": { "values": ["West"] } }, "search": "pend", "limit": 100 }' ``` ```json { "success": true, "data": { "values": [ { "value": "Pending", "label": "Pending", "count": 12 } ], "truncated": false } } ``` Each entry includes its exact raw JSON `value`, a canonical display/search `label`, and its row `count`. Blank values use `value: null` and `label: "(Blanks)"`; searching for `(Blanks)` therefore finds that bucket. JSON objects and arrays use the same server-generated text for display and search. When more than `limit` values match—or a stored value exceeds the filter contract's safe round-trip size—`truncated` is `true`; narrow `search` and request again. The distinct-values surface is new and offers two read-only forms rather than two stored resources. `GET /api/v1/tables/:id/columns/:columnId/values` is the query-string alternative, accepting URL-encoded `filter`, `search`, and `limit` parameters; scalar filters on declared columns use substring matching. `POST .../values/query` is the structured JSON-body companion used by Studio. Unlike the row GET compatibility case, the target column and every filtered column must be declared column IDs. Both forms return the same distinct-value shape, and new structured integrations should prefer the JSON-body endpoint. ## Create Row ```http POST /api/v1/tables/:id/rows ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `data` | object | Yes | Row payload. Keys beyond declared columns are preserved. | Returns `201 Created` with the new row, or `400 BAD_REQUEST` with `"Table has reached its maximum row limit of N"` when the table is full. ### Example ```bash curl -X POST "https://workflow.loopfour.ai/api/v1/tables/$TABLE_ID/rows" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "data": { "stripeInvoiceId": "in_1234567890", "amountCents": 9900 } }' ``` ## Update Row ```http PATCH /api/v1/tables/:id/rows/:rowId ``` By default, replaces the entire `data` field with the value in the request body. Pass `"merge": true` to apply the body as a shallow patch instead: only the keys you send are written, every other key on the row is preserved, and you do not need to read the row first. A `null` value is stored as JSON null; it does not remove the key. ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `data` | object | Yes | Row payload. Replaces the existing `data` JSONB, or is shallow-merged onto it when `merge` is `true`. | | `merge` | boolean | No | Defaults to `false`. When `true`, keys absent from `data` retain their current values; nested objects are replaced at the top-level key. | ```bash curl -X PATCH "https://workflow.loopfour.ai/api/v1/tables/$TABLE_ID/rows/$ROW_ID" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "data": { "qboId": "42" }, "merge": true }' ``` Returns `404 NOT_FOUND` if the row does not belong to the given table or company. ## Delete Row ```http DELETE /api/v1/tables/:id/rows/:rowId ``` Deletes one row and decrements `rowCount` on the table. ```json { "success": true, "data": { "deleted": true } } ``` ## Error Responses | HTTP | Code | Trigger | |------|------|---------| | `400` | `BAD_REQUEST` | Validation failure or row-limit reached | | `401` | `UNAUTHORIZED` | Missing or invalid `x-api-key` | | `403` | `FORBIDDEN` | API key lacks `workflows:read` / `workflows:write` | | `404` | `NOT_FOUND` | Table or row does not exist for this company | | `409` | `CONFLICT` | A table folder with that company-scoped name already exists | | `413` | `PAYLOAD_TOO_LARGE` | A structured row or distinct-value query body exceeds 512 KiB | | `429` | `RATE_LIMITED` | Over the `read` (1000/min) or `write` (100/min) bucket | ## Audit Logging `POST /api/v1/tables`, `POST /api/v1/tables/import`, `PATCH`, and `DELETE` on table routes emit audit events with actions `table.created`, `table.updated`, and `table.deleted`. Creating a folder emits `table_folder.created`; moving a table emits `table.moved`. Row writes do not emit per-row audit events — query `/api/v1/audit-logs` for table-level and folder events. ## Use From Workflows Workflows shouldn't usually call this API directly — use the [Data Table block](/docs/blocks/data-table) instead. The block dispatches to the same storage through the workflow runtime, which handles tenant scoping, validation, and row-count bookkeeping inside a single transaction. ## Aggregate Rows `POST /api/v1/tables/:id/rows/aggregate` requires `workflows:read` and the same company-scoped authentication as row queries. Use it for a single-table chart or KPI; prepare joins and reporting metrics upstream. For customer revenue reporting, prepare one table with `customer_id`, `customer_name`, `month`, `mrr`, and `arr`. ```bash curl -X POST "https://workflow.loopfour.ai/api/v1/tables/550e8400-e29b-41d4-a716-446655440000/rows/aggregate" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"tableId":"550e8400-e29b-41d4-a716-446655440000","groupBy":"month","timeGrain":"month","aggregate":{"fn":"sum","columnId":"mrr"},"limit":100}' ``` | Field | Contract | | --- | --- | | `tableId` | UUID matching `:id`, owned by the authenticated company | | `filter` | Optional structured row filter, with the same operators and bounds as `/rows/query`; filters on `groupBy` apply too | | `groupBy`, `timeGrain` | Optional column id; grain requires a date column and is `day`, `week` (Monday start), `month`, `quarter`, or `year` | | `aggregate` | Required here: `{fn, columnId?}`; `fn` is `count`, `count_distinct`, `sum`, `avg`, `min`, or `max`; only `count` may omit the column | | `order`, `limit` | Optional `label_asc`, `label_desc`, `value_asc`, or `value_desc`; integer limit 1–500, default 100 | `sum` and `avg` require a declared number column. `min` and `max` compare declared number and date columns in their native order, otherwise text. Invalid typed values are excluded and counted; date grains accept only valid `YYYY-MM-DD` values, so ISO timestamps must be prepared upstream. Numeric aggregates are display values, not reconciled accounting figures. The response is `{success: true, data: {data: [{label, value}], rowCount, groupCount, totalGroupCount, excludedRows, truncated, asOf}}`. `rowCount` counts source rows matching the filters; `groupCount` counts returned groups and `totalGroupCount` counts groups before the limit. `asOf` is the successful fetch time. An empty grouped result returns `data: []` successfully. Unknown columns and incompatible operations return 400; missing or foreign-company tables return 404. Requests use the read rate limit, a 512 KiB body limit, and a three-second database statement timeout. ### Connect a dashboard widget Dashboard widgets are json-render specs. A spec reads table data through a `loadData` action that writes its result to a `/data/` state path; components bind `{"$state":"/data//value"}` (KPIs) or `{"$state":"/data//rows"}` (charts, tables, pivots). A `loadData` action takes `tableId` plus the query fields above: with `aggregate` it calls `/rows/aggregate`, without it `/rows/query`. Set `"limit": 500` on a rows-mode load that feeds a table, pivot, cohort heatmap, or CSV export. Widget POST/PATCH (see [Dashboards](/docs/api-reference/dashboards)) validates the spec, checks every `tableId` against the dashboard company, and rejects invalid column references. In Studio, Copilot authors these specs with the `jsonRenderWidget` tool. An open dashboard runs its loads on render, again when a filter changes, and on refresh. A failed refresh keeps the previous result and marks it stale. Public share links show live data through `POST /api/v1/dashboards/share/:token/data` when the deployment enables it. # Webhooks API (/docs/api-reference/webhooks) Receive provider events into Loopfour and deliver workflow events to your systems ## Webhooks API The Workflows API exposes two webhook surfaces: - **Inbound** — endpoints under `/webhooks/*` that receive events from external providers (Stripe, HubSpot, Nango, your own systems) and route them to matching workflow triggers. - **Outbound** — endpoints under `/api/v1/webhook-endpoints` that let you register URLs to receive notifications when workflow runs change state. Inbound webhook endpoints do not accept an `x-api-key`. Each route authenticates the caller with the provider's own signature scheme (or by tenant-scoped path for custom webhooks). Outbound management endpoints use the standard API-key auth described in [Authentication](/docs/api-reference/authentication). ## Inbound Webhooks All inbound routes are mounted at the API root, not under `/api/v1`. Every inbound POST returns JSON and the same shared response shape: ```json { "received": true, "eventId": "evt_550e8400-e29b-41d4-a716-446655440000", "workflowsTriggered": 2 } ``` | Field | Type | Description | |-------|------|-------------| | `received` | boolean | Always `true` on success | | `eventId` | string \| null | Internal event ID, when one was persisted | | `workflowsTriggered` | number | Number of workflow runs triggered by this event | The full set of inbound routes mounted today is: | Route | Purpose | Auth | |-------|---------|------| | `POST /webhooks/stripe` | Stripe events (including Stripe Connect) | `stripe-signature` header (HMAC) | | `POST /webhooks/custom/:companyId/:path` | Generic per-tenant inbound endpoint | None — path is the secret | | `POST /webhooks/:provider` | Per-provider dispatcher (Airwallex, HubSpot) | Handler-defined (Svix) | | `POST /webhooks/resend` | Inbound email (`email.received`) | Svix signature over `RESEND_WEBHOOK_SECRET` | | `POST /webhooks/nango` | Internal — OAuth completion and provider events forwarded by Nango | `x-nango-hmac-sha256` or legacy `x-nango-signature` (required) | All inbound routes share a single rate-limit bucket of 10,000 requests / minute / IP. Per-provider routes like `/webhooks/hubspot/:companyId`, `/webhooks/quickbooks/:companyId`, `/webhooks/salesforce/:companyId`, `/webhooks/pandadoc/:companyId`, `/webhooks/netsuite/:companyId`, and `/webhooks/gmail/:companyId` do **not** exist today. Earlier versions of this document advertised them — they were never implemented. Provider events from those systems arrive via the Nango forwarder path (`POST /webhooks/nango`) once you have authorized the connection through Nango Connect. ### Stripe ```http POST /webhooks/stripe ``` Receives events from Stripe directly. Supports both single-account workspaces and Stripe Connect — the connected account is read from `event.account` in the payload and resolved to a company via the `connections` table. #### Headers | Header | Required | Description | |--------|----------|-------------| | `stripe-signature` | Yes | Stripe webhook signature in the form `t=,v1=` | | `Content-Type` | Yes | `application/json` | #### Behaviour 1. Reads the raw body and `stripe-signature` header. 2. If `STRIPE_WEBHOOK_SECRET` is set, verifies the signature using HMAC-SHA256 over `${timestamp}.${rawBody}`. On mismatch the route returns `401 Invalid signature` and persists the event with `signatureVerified: "failed"`. 3. If the event has `event.account`, looks up the company that owns the matching Stripe-connected account via `connections.externalAccountId`. If no company is found the event is still recorded but no workflows are triggered. 4. Routes the event into matching workflow triggers and returns the standard response. #### Example ```bash stripe listen --forward-to localhost:3000/webhooks/stripe curl -X POST "https://workflow.loopfour.ai/webhooks/stripe" \ -H "Content-Type: application/json" \ -H "stripe-signature: t=1705312000,v1=abc123..." \ -d '{ "id": "evt_test", "type": "invoice.paid", "account": "acct_xxx", "data": { "object": { "id": "in_test", "amount_paid": 9900 } } }' ``` ### Custom (per-tenant) ```http POST /webhooks/custom/:companyId/:path ``` Receives events from any external system into a specific company. The `:path` segment becomes the event type, and a workflow matches when its `webhook` trigger's `path` field equals that segment. The comparison is case-insensitive and treats `.`, `-`, and `_` as equivalent, so `order.received` and `order-received` both match. Do not put a leading slash on `path` — it will never match. | Path Parameter | Description | |----------------|-------------| | `companyId` | UUID of the receiving company | | `path` | Free-form identifier that becomes the event type | The body is parsed as JSON when possible; non-JSON payloads are wrapped as `{ "rawBody": "..." }`. No signature verification is performed — treat the URL itself as a shared secret and serve over HTTPS only. ```bash curl -X POST "https://your-api.com/webhooks/custom/00000000-0000-0000-0000-000000000001/order-received" \ -H "Content-Type: application/json" \ -d '{ "orderId": "12345", "total": 99.99 }' ``` ### Provider Dispatcher ```http POST /webhooks/:provider ``` Generic dispatcher for providers that have a `ProviderHandler` registered in the [`@workflows/modules`](/docs/blocks) module package. Today the dispatcher accepts: | `:provider` value | Source module | |-------------------|---------------| | `airwallex` | `integrations/airwallex` | | `hubspot` | `integrations/hubspot` | Each handler implements a four-method contract: | Step | Method | Responsibility | |------|--------|----------------| | 1 | `verify(rawBody, headers)` | Validate signature; return `false` to short-circuit with `401 Invalid signature` | | 2 | `parseEvents(rawBody)` | Split the request body into one or more provider events | | 3 | `extractExternalAccountId(event)` | Pull the tenant identifier from each event (e.g. HubSpot `portalId`) | | 4 | `normalize(event, id)` | Return a `CanonicalEnvelope` with `eventType`, `externalEventId`, and `data` | Events whose `extractExternalAccountId` returns null, or that do not match any active `connections` row for the provider, are dropped with an info-level log. Successful events are routed into matching workflow triggers and counted in the response: ```json { "received": true, "count": 3 } ``` #### Unknown providers A `POST` to `/webhooks/:provider` for a provider that has no registered handler returns: ```json { "error": "Unknown provider: " } ``` with HTTP `404`. ### Nango (internal) ```http POST /webhooks/nango ``` Receives OAuth lifecycle and provider events forwarded by [Nango](https://nango.dev/). This is how Salesforce, QuickBooks, NetSuite, PandaDoc, Slack, and Gmail events reach Workflows today. | Header | Description | |--------|-------------| | `x-nango-hmac-sha256` | HMAC-SHA256 over the raw body, keyed by `NANGO_WEBHOOK_SECRET`. Preferred. | | `x-nango-signature` | Legacy form — `sha256(secret + rawBody)`. Accepted when the HMAC header is absent. | This route is fail-closed: an unset `NANGO_WEBHOOK_SECRET` returns `500`, and a request with no signature header or a signature that does not verify returns `401`. | `type` field | Behaviour | |--------------|-----------| | `auth` | OAuth flow completed — flips a pending `connections` row to `active` or `error`, fetches `externalAccountId`, and registers any provider-specific subscriptions (e.g. HubSpot webhook subscriptions) | | `sync` | Sync run finished — updates `lastSyncedAt` on the connection | | `webhook` | An external provider sent an event via Nango — resolves the connection by `nangoConnectionId`, maps to a `WebhookProvider`, and routes through the same workflow-trigger pipeline used by direct routes | This route is configured in your Nango environment and should not be called directly. ## Webhook Event Storage Every inbound webhook is persisted to `webhook_events` before workflow matching, so events can be audited, replayed, and inspected even if no workflow consumed them. ```json { "id": "evt_550e8400-e29b-41d4-a716-446655440000", "companyId": "00000000-0000-0000-0000-000000000001", "provider": "stripe", "eventType": "invoice.paid", "eventId": "evt_external_123", "payload": { "...": "..." }, "signatureVerified": "verified", "status": "processed", "workflowsTriggered": [ { "workflowId": "wf_xxx", "runId": "run_xxx" } ], "createdAt": "2024-01-15T10:30:00.000Z" } ``` | `signatureVerified` | Meaning | |---------------------|---------| | `pending` | Verification not yet attempted | | `verified` | Signature matched the configured secret | | `failed` | Signature did not match (route returned 401) | | `skipped` | No signature header sent, or no secret configured | | `status` | Meaning | |----------|---------| | `received` | Event recorded, processing has not started | | `processing` | Currently dispatching to workflow triggers | | `processed` | All matched workflows started successfully | | `failed` | Processing or signature verification failed | | `ignored` | No matching workflows | ## Error Responses | HTTP | Body | Triggered by | |------|------|--------------| | `400` | `{ "error": "Invalid JSON" }` | Body was not valid JSON when JSON was required | | `400` | `{ "error": "Invalid payload" }` | Provider handler's `parseEvents` threw | | `401` | `{ "error": "Invalid signature" }` | Signature verification failed | | `404` | `{ "error": "Unknown provider: " }` | `POST /webhooks/:provider` with no registered handler | | `429` | Standard rate-limit envelope | More than 10,000 webhook requests / min / IP | | `500` | `{ "error": "Verification error" }` | Provider handler's `verify` threw | ## Workflow Trigger Configuration To start a workflow from an inbound webhook, declare a `webhook` trigger in the workflow definition. `path` is required by the trigger schema; `provider` matches the inbound route (e.g. `stripe`, `hubspot`, `custom`). ```json { "trigger": { "type": "webhook", "provider": "stripe", "path": "stripe" } } ``` For custom webhooks, omit `provider` and set `path` to the `:path` segment of the URL: ```json { "trigger": { "type": "webhook", "path": "order-received" } } ``` Matching is on `provider` (or, for `custom`, on `path`) only — every event that reaches the matched route starts the workflow. Narrow the events at the provider's own subscription settings, or with a Condition block at the top of the workflow. See [Webhook Triggers](/docs/triggers/webhook) for the full trigger configuration reference. ## Outbound Webhooks Register outbound endpoints to receive notifications when workflow runs change state. These endpoints are managed via the standard `x-api-key` API. Outbound webhooks notify **your** systems about workflow events. This is the inverse of the inbound surface, which lets external providers trigger workflows. ### Endpoint Management | Method | Path | Purpose | |--------|------|---------| | `GET` | `/api/v1/webhook-endpoints` | List endpoints | | `POST` | `/api/v1/webhook-endpoints` | Create endpoint | | `GET` | `/api/v1/webhook-endpoints/:id` | Get endpoint | | `PUT` | `/api/v1/webhook-endpoints/:id` | Update endpoint | | `DELETE` | `/api/v1/webhook-endpoints/:id` | Delete endpoint | | `POST` | `/api/v1/webhook-endpoints/:id/rotate-secret` | Rotate signing secret | | `POST` | `/api/v1/webhook-endpoints/:id/test` | Send a test delivery | | `GET` | `/api/v1/webhook-endpoints/:id/deliveries` | List delivery attempts | | `GET` | `/api/v1/webhook-endpoints/:id/deliveries/:deliveryId` | Get one delivery | | `POST` | `/api/v1/webhook-endpoints/:id/deliveries/:deliveryId/retry` | Retry a failed delivery | ### Create Endpoint ```http POST /api/v1/webhook-endpoints ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Human-readable name | | `url` | string | Yes | HTTPS URL to deliver events to | | `events` | string[] | No | Event types to receive (omit / `null` = all) | | `workflowIds` | string[] | No | Restrict to specific workflows (omit / `null` = all) | | `headers` | object | No | Additional headers to include on each delivery | | `description` | string | No | Free-form description | Response (`201 Created`): ```json { "success": true, "data": { "id": "whe_550e8400-e29b-41d4-a716-446655440000", "name": "Invoice System", "url": "https://api.example.com/webhooks/workflows", "secret": "whsec_abc123...", "events": ["workflow.completed", "workflow.failed"], "workflowIds": ["550e8400-e29b-41d4-a716-446655440000"], "headers": { "Authorization": "Bearer your-token" }, "status": "active", "createdAt": "2024-01-15T10:00:00.000Z" } } ``` The `secret` is only returned on creation and on `rotate-secret`. Store it securely — it is required to verify outbound delivery signatures. ### Event Types | Event | Description | |-------|-------------| | `workflow.started` | A workflow run began | | `workflow.completed` | A workflow run completed successfully | | `workflow.failed` | A workflow run ended with an error | | `workflow.timeout` | A workflow run exceeded its timeout | | `workflow.cancelled` | A workflow run was cancelled | | `workflow.checkpoint` | A long-running workflow reached a checkpoint | ### Delivery Payload ```json { "id": "evt_550e8400-e29b-41d4-a716-446655440000", "type": "workflow.completed", "timestamp": "2024-01-15T10:30:00.000Z", "workflowId": "550e8400-e29b-41d4-a716-446655440000", "workflowName": "Invoice Processing", "runId": "run_660e8400-e29b-41d4-a716-446655440001", "companyId": "00000000-0000-0000-0000-000000000001", "status": "completed", "triggerType": "webhook", "startedAt": "2024-01-15T10:29:55.000Z", "completedAt": "2024-01-15T10:30:00.000Z", "durationMs": 5000, "input": { "invoiceId": "inv_123" }, "output": { "processed": true } } ``` Failed runs include an `error` object: ```json { "error": { "code": "STEP_ERROR", "message": "API rate limit exceeded", "stepId": "sync-to-crm" } } ``` ### Signature Verification Every outbound delivery is signed with HMAC-SHA256 in a Stripe-compatible format. | Header | Description | |--------|-------------| | `X-Webhook-Signature` | `t=,v1=` | | `X-Webhook-ID` | Unique event ID | | `X-Webhook-Timestamp` | Unix timestamp | | `Content-Type` | `application/json` | ```javascript function verifyWebhook(rawBody, signatureHeader, secret) { const [tPart, vPart] = signatureHeader.split(','); const timestamp = tPart.split('=')[1]; const received = vPart.split('=')[1]; const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)); } ``` Always verify against the **raw** request body — re-serializing will produce a different hash. ### Retry Policy Failed deliveries (any non-2xx response or transport error) are retried with exponential backoff: | Attempt | Delay | |---------|-------| | 1 | 10 seconds | | 2 | 30 seconds | | 3 | 1 minute | | 4 | 5 minutes | | 5 | 15 minutes | | 6 | 1 hour | | 7 | 6 hours | | 8 | 24 hours | After 8 failed attempts the delivery is marked permanently failed. After 10 consecutive failures across deliveries, the endpoint is automatically disabled and must be re-enabled by setting `status: "active"` on the endpoint. ### Test, List Deliveries, Retry ```http POST /api/v1/webhook-endpoints/:id/test ``` ```json { "eventType": "workflow.completed" } ``` Sends a synthetic delivery and returns the upstream HTTP status and response body so you can validate signing and connectivity end-to-end. ```http GET /api/v1/webhook-endpoints/:id/deliveries?status=failed ``` Returns the recent delivery attempts for an endpoint with cursor-based pagination. ```http POST /api/v1/webhook-endpoints/:id/deliveries/:deliveryId/retry ``` Re-queues a single delivery without waiting for the backoff schedule. ## Best Practices - **Be idempotent.** Providers may retry. Use `eventId` to deduplicate and write workflow actions so a repeated event has the same outcome. - **Respond fast.** Acknowledge inbound webhooks immediately — workflow execution is asynchronous, so your handler does not need to wait for the run to finish. - **Always verify when a secret is available.** Configure `STRIPE_WEBHOOK_SECRET` and `NANGO_WEBHOOK_SECRET` in production. `NANGO_WEBHOOK_SECRET` is mandatory wherever Nango webhooks are received — that route is fail-closed and returns `500` without it. The Stripe route instead skips verification when its secret is unset, which is convenient in development but should never be relied on in an environment that accepts real provider traffic. - **Monitor `signatureVerified` and `status`.** A spike in `failed` or `ignored` is a strong signal that a provider rotated a secret or that a connection is misconfigured. # Workflows API (/docs/api-reference/workflows) Create, manage, and execute workflow definitions ## Workflows API Manage workflow definitions including creation, updates, versioning, and execution. ## List Workflows Retrieve all workflows for your company with cursor-based pagination. ```http GET /api/v1/workflows ``` ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `status` | string | - | Filter by status: `draft`, `active`, `paused`, `archived`. Without it, archived workflows are left out | | `search` | string | - | Search by workflow name | | `limit` | number | 50 | Number of results (1-100) | | `cursor` | string | - | Cursor for pagination | ### Response ```json { "success": true, "data": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Invoice Automation", "description": "Process incoming invoices", "status": "active", "version": 3, "trigger": { "type": "webhook", "provider": "stripe", "events": ["invoice.paid"] }, "createdAt": "2024-01-15T10:00:00.000Z", "updatedAt": "2024-01-16T14:30:00.000Z" } ], "meta": { "cursor": "eyJ0cyI6MTcwNTMxMjAwMDAwMCwiaWQiOiI1NTBlODQwMC4uLiJ9", "hasMore": true } } ``` ### Example ```bash curl -X GET "https://workflow.loopfour.ai/api/v1/workflows?status=active&limit=10" \ -H "x-api-key: YOUR_API_KEY" ``` ```javascript const response = await fetch( 'https://workflow.loopfour.ai/api/v1/workflows?status=active&limit=10', { headers: { 'x-api-key': process.env.API_KEY } } ); const { data, meta } = await response.json(); ``` ```python response = requests.get( 'https://workflow.loopfour.ai/api/v1/workflows', params={'status': 'active', 'limit': 10}, headers={'x-api-key': api_key} ) data = response.json() ``` ## Create Workflow Create a new workflow definition. Workflows are created in `draft` status. ```http POST /api/v1/workflows ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Workflow name (1-255 chars) | | `description` | string | No | Human-readable description | | `trigger` | object | Yes | Trigger configuration | | `steps` | array | Yes | Array of workflow steps | | `inputSchema` | object | No | JSON Schema for input validation | | `outputSchema` | object | No | JSON Schema for output | | `settings` | object | No | Workflow settings | ### Example Request ```json { "name": "New Customer Onboarding", "description": "Send welcome email and create CRM contact", "trigger": { "type": "webhook", "provider": "stripe", "events": ["customer.created"] }, "steps": [ { "id": "send-welcome", "type": "email", "name": "Send Welcome Email", "config": { "action": "send", "to": "{{input.data.object.email}}", "subject": "Welcome to our platform!", "body": { "html": "

Hello {{input.data.object.name}},

Welcome aboard!

" } } }, { "id": "create-contact", "type": "action", "name": "Create HubSpot Contact", "action": "hubspot.createContact", "config": { "email": "{{input.data.object.email}}", "properties": { "firstname": "{{input.data.object.name}}", "stripe_customer_id": "{{input.data.object.id}}" } } } ], "inputSchema": { "type": "object", "required": ["data"], "properties": { "data": { "type": "object", "properties": { "object": { "type": "object", "properties": { "id": { "type": "string" }, "email": { "type": "string" }, "name": { "type": "string" } } } } } } } } ``` ### Response (201 Created) ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "New Customer Onboarding", "description": "Send welcome email and create CRM contact", "status": "draft", "version": 1, "trigger": { ... }, "steps": [ ... ], "inputSchema": { ... }, "outputSchema": null, "settings": null, "createdAt": "2024-01-15T10:00:00.000Z", "updatedAt": "2024-01-15T10:00:00.000Z" } } ``` ## Get Workflow Retrieve a specific workflow by ID. ```http GET /api/v1/workflows/:id ``` ### Path Parameters | Parameter | Type | Description | |-----------|------|-------------| | `id` | string | Workflow UUID | ### Response ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Invoice Automation", "description": "Process incoming invoices", "status": "active", "version": 3, "trigger": { "type": "webhook", "provider": "stripe", "events": ["invoice.paid"] }, "steps": [ { "id": "notify-team", "type": "action", "name": "Notify Slack", "action": "slack.sendMessage", "config": { "channel": "#finance", "text": "Invoice {{input.data.object.number}} paid!" } } ], "inputSchema": null, "outputSchema": null, "settings": null, "createdAt": "2024-01-15T10:00:00.000Z", "updatedAt": "2024-01-16T14:30:00.000Z" } } ``` ## Update Workflow Update a workflow. If the workflow is `active`, this increments the version and saves the previous version to history. An archived workflow is read-only and returns `409` (see [Archived workflows are read-only](#archived-workflows-are-read-only-409)). ```http PUT /api/v1/workflows/:id ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | No | Updated workflow name | | `description` | string | No | Updated description | | `trigger` | object | No | Updated trigger configuration | | `steps` | array | No | Updated steps array | | `inputSchema` | object | No | Updated input schema | | `outputSchema` | object | No | Updated output schema | | `settings` | object | No | Updated settings | | `changeSummary` | string | No | Description of changes (stored in version history) | | `expectedVersion` | integer | No | The `version` you read. A different current version returns `409` | | `expectedUpdatedAt` | string (ISO 8601) | No (required for MCP clients) | The `updatedAt` you read. A different current `updatedAt` returns `409`; an MCP client that omits it gets `400`. A draft keeps its version through edits, so send this to detect a draft edit made after your read | If the workflow changes between the server's read and its write, the request also returns `409` and nothing is saved. Reload the workflow and reconcile your changes before retrying. ### Example Request ```json { "name": "Invoice Automation v2", "steps": [ { "id": "notify-team", "type": "action", "name": "Notify Slack", "action": "slack.sendMessage", "config": { "channel": "#finance", "text": "Invoice {{input.data.object.number}} paid for ${{input.data.object.amount_paid / 100}}!" } } ], "changeSummary": "Added amount to Slack notification" } ``` ### Response ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Invoice Automation v2", "status": "active", "version": 4, ... } } ``` ## Save Canvas Save the visual editor's layout and optionally replace its compiled steps. Requires authentication with `workflows:write` scope; the workflow must belong to your company. ```http PATCH /api/v1/workflows/:id/canvas ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `canvasState` | object | Yes | Canvas blocks, edges, Loop/Parallel membership, and optional viewport | | `steps` | array | No | Compiled steps; omission leaves saved steps unchanged | | `triggerConfig` | object | No | Replacement trigger configuration; omission leaves it unchanged; `null` is rejected | Inactive workflows can save unfinished Loop bodies (`config.steps: []`) and static Parallel shells (`config.branches: []`). Static Parallel drafts must still use valid strategy, concurrency, and timeout values and cannot also specify `forEach`. Dynamic Parallel templates retain their existing validation. Unsupported Agent provider/model pairs are rejected in both steps and canvas metadata. An active workflow must pass full step validation before its steps can be replaced. Empty shells cannot be activated or run; add and connect their child blocks and complete configuration first. The regular create/update workflow endpoints continue to require valid executable control-flow configuration. ### Example: Save an empty Parallel draft ```bash curl -X PATCH "https://workflow.loopfour.ai/api/v1/workflows/WORKFLOW_ID/canvas" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "canvasState": { "blocks": { "parallel-1": { "id": "parallel-1", "type": "parallel", "name": "Parallel", "position": { "x": 0, "y": 0 }, "subBlocks": {}, "outputs": {}, "enabled": true, "horizontalHandles": true } }, "edges": [], "loops": {}, "parallels": { "parallel-1": { "id": "parallel-1", "nodes": ["parallel-1"], "enabled": true } } }, "steps": [{ "id": "parallel-1", "type": "parallel", "name": "Parallel", "config": { "branches": [], "strategy": "wait-all" } }] }' ``` A successful response returns `200` with `data.id`, `data.canvasState`, `data.isVisual`, and ISO timestamps `lastSavedAt`, `lastModifiedAt`, and `updatedAt`. Invalid input or incomplete steps on an active workflow return `400`; a missing workflow returns `404`. If the workflow's status, version, or steps change during a save that includes steps, the request returns `409`; reload the workflow and reconcile your changes before retrying. Saving the canvas of an archived workflow returns `409`. ## Delete Workflow Delete a workflow from any status. This is a soft delete: the workflow no longer appears in any list or lookup, its schedule is removed, and its inbound email address is deactivated. Its past runs remain available through the [Runs API](/docs/api-reference/runs). To stop a workflow but keep it viewable and restorable, [archive](#archive-workflow) it instead. ```http DELETE /api/v1/workflows/:id ``` ### Response ```json { "success": true, "data": { "deleted": true } } ``` ## Archive Workflow Stop a `draft`, `active`, or `paused` workflow from running and keep it listed under `?status=archived`. Archiving removes the workflow's schedule and deactivates its inbound email address. An archived workflow can still be read (`GET /api/v1/workflows/:id`, versions, runs, clone) but not changed until it is unarchived. Archiving a workflow that is already archived returns `200` and changes nothing. ```http POST /api/v1/workflows/:id/archive ``` ```bash curl -X POST "https://workflow.loopfour.ai/api/v1/workflows/WORKFLOW_ID/archive" \ -H "x-api-key: YOUR_API_KEY" ``` ### Response ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "archived" } } ``` A workflow that does not exist, belongs to another company, or was deleted returns `404`. If the workflow's status changes while the request runs (for example, a concurrent pause), it returns `409` and nothing is archived; retry the request. ## Unarchive Workflow Return an archived workflow to `draft`. It does not resume running: [activate](#activate-workflow) it again, which recreates its schedule. An `email_event` workflow gets a **new** inbound address on activation, because archiving retired the old one; update any forwarding rules that pointed at it. ```http POST /api/v1/workflows/:id/unarchive ``` ```bash curl -X POST "https://workflow.loopfour.ai/api/v1/workflows/WORKFLOW_ID/unarchive" \ -H "x-api-key: YOUR_API_KEY" ``` ### Response ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "draft" } } ``` A workflow that is not archived returns `400` with `error.details.currentStatus`. A missing or deleted workflow returns `404`. If another request unarchives or deletes the workflow first, it returns `409`; reload it before retrying. ## Activate Workflow Change workflow status to `active`. This enables the workflow to be triggered. ```http POST /api/v1/workflows/:id/activate ``` ### Status Transitions | From | To Active | |------|-----------| | `draft` | Allowed | | `paused` | Allowed | | `active` | Not allowed (already active) | | `archived` | Not allowed: [unarchive](#unarchive-workflow) it to `draft` first | ### Response ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "active" } } ``` For schedule triggers, the response includes schedule information: ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "active", "schedule": { "id": "sch_xxx", "triggerScheduleId": "sched_xxx", "nextRunAt": "2024-01-16T09:00:00.000Z" } } } ``` ## Pause Workflow Pause an active workflow. Paused workflows won't be triggered. If the workflow's status changes while the request runs (for example, a concurrent archive), it returns `409`; reload it before retrying. ```http POST /api/v1/workflows/:id/pause ``` ### Response ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "paused" } } ``` ## Run Workflow Trigger a workflow execution via the API. Supports both async (default) and sync execution modes. ```http POST /api/v1/workflows/:id/run ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `input` | object | No | Input data for the workflow | | `connectionId` | string | No | Specific connection to use | | `idempotencyKey` | string | No | Prevent duplicate runs (24h TTL) | | `mode` | string | No | `async` (default) or `sync` | | `maxExecutionTimeMs` | number | No | Max wait time for sync mode (1000-300000ms) | | `onTimeout` | string | No | Sync timeout behavior: `return_run_id`, `throw_error` | | `onFailure` | string | No | Sync failure behavior: `throw_error`, `switch_to_async` | ### Async Mode (Default) Returns immediately with a run ID. Poll the runs API for status updates. ```json { "input": { "customerId": "cus_xxx", "amount": 9900 } } ``` **Response (202 Accepted):** ```json { "success": true, "data": { "run_id": "run_550e8400-e29b-41d4-a716-446655440000", "status": "pending", "workflow_id": "550e8400-e29b-41d4-a716-446655440000", "mode": "async" } } ``` ### Sync Mode Waits for workflow completion and returns the result directly. Ideal for short-running workflows in request/response scenarios. ```json { "input": { "customerId": "cus_xxx", "amount": 9900 }, "mode": "sync", "maxExecutionTimeMs": 30000, "onTimeout": "return_run_id", "onFailure": "throw_error" } ``` **Sync Success Response (200):** ```json { "success": true, "data": { "run_id": "run_550e8400-e29b-41d4-a716-446655440000", "status": "completed", "workflow_id": "550e8400-e29b-41d4-a716-446655440000", "mode": "sync", "output": { "invoiceId": "inv_xxx", "status": "sent" }, "duration_ms": 2500 } } ``` **Sync Timeout Response (202):** When execution exceeds `maxExecutionTimeMs` with `onTimeout: "return_run_id"`: ```json { "success": true, "data": { "run_id": "run_550e8400-e29b-41d4-a716-446655440000", "status": "running", "workflow_id": "550e8400-e29b-41d4-a716-446655440000", "mode": "async", "timed_out": true, "message": "Execution in progress. Use GET /runs/:id to poll for results." } } ``` **Sync Failure Response (500):** When the run fails with `onFailure: "throw_error"`, `error.details` carries the run and workflow ids. When the run failed at the platform level, it also carries `triggerStatus` and, when Trigger.dev reports one, `triggerErrorCode` (see [Get Run](/docs/api-reference/runs)): ```json { "success": false, "error": { "code": "TRIGGER_ERROR", "message": "trigger.dev internal error (TASK_PROCESS_OOM_KILLED)", "details": { "run_id": "run_550e8400-e29b-41d4-a716-446655440000", "workflow_id": "550e8400-e29b-41d4-a716-446655440000", "triggerStatus": "CRASHED", "triggerErrorCode": "TASK_PROCESS_OOM_KILLED" }, "requestId": "req_xxx" } } ``` With `onFailure: "switch_to_async"`, the same `error` object is returned under `data.error` with status 202. ### Sync Mode Parameters | Parameter | Default | Description | |-----------|---------|-------------| | `maxExecutionTimeMs` | 30000 | Max wait time (1 second to 5 minutes) | | `onTimeout` | `return_run_id` | `return_run_id` returns gracefully, `throw_error` returns 408 | | `onFailure` | `throw_error` | `throw_error` returns error, `switch_to_async` returns run ID | Sync mode is best for workflows completing under 30 seconds. For longer workflows, use async mode with polling or webhooks. ### Idempotency If the same `idempotencyKey` is used within 24 hours, the existing run is returned: ```json { "success": true, "data": { "run_id": "run_existing", "status": "completed", "workflow_id": "550e8400-e29b-41d4-a716-446655440000", "message": "Existing run returned (idempotency key matched)" } } ``` ## List Workflow Runs Get runs for a specific workflow. ```http GET /api/v1/workflows/:id/runs ``` ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `status` | string | - | Filter by `pending`, `running`, `waiting_approval`, `completed`, `failed`, `suspended`, or `cancelled` | | `limit` | number | 50 | Number of results (1-100) | | `cursor` | string | - | Cursor for pagination | ### Response ```json { "success": true, "data": [ { "id": "run_xxx", "workflowId": "550e8400-e29b-41d4-a716-446655440000", "status": "completed", "triggerType": "api", "startedAt": "2024-01-15T10:30:00.000Z", "completedAt": "2024-01-15T10:30:05.000Z", "durationMs": 5000, "error": null, "createdAt": "2024-01-15T10:30:00.000Z" } ], "meta": { "cursor": "eyJ0cyI6MTcwNTMxMjAwMDAwMCwiaWQiOiJydW5feHh4In0=", "hasMore": false } } ``` ## Clone Workflow Create a copy of an existing workflow. ```http POST /api/v1/workflows/:id/clone ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Name for the cloned workflow | ### Example Request ```json { "name": "Invoice Automation (Copy)" } ``` ### Response (201 Created) ```json { "success": true, "data": { "id": "new-workflow-id", "name": "Invoice Automation (Copy)", "status": "draft", "version": 1, "clonedFrom": "550e8400-e29b-41d4-a716-446655440000", ... } } ``` ## Version Management ### List Versions Get all versions of a workflow. ```http GET /api/v1/workflows/:id/versions ``` ### Response ```json { "success": true, "data": { "workflowId": "550e8400-e29b-41d4-a716-446655440000", "currentVersion": 4, "versions": [ { "version": 4, "status": "current", "isCurrent": true }, { "version": 3, "name": "Invoice Automation", "changeSummary": "Added error handling", "status": "archived", "createdAt": "2024-01-15T10:00:00.000Z", "isCurrent": false }, { "version": 2, "name": "Invoice Automation", "changeSummary": "Added Slack notification", "status": "archived", "createdAt": "2024-01-14T10:00:00.000Z", "isCurrent": false } ] } } ``` ### Get Specific Version ```http GET /api/v1/workflows/:id/versions/:version ``` ### Response ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "version": 2, "name": "Invoice Automation", "description": "Process incoming invoices", "trigger": { ... }, "steps": [ ... ], "changeSummary": "Added Slack notification", "status": "archived", "isCurrent": false, "createdAt": "2024-01-14T10:00:00.000Z" } } ``` ### Compare Versions Compare changes between two versions. ```http GET /api/v1/workflows/:id/versions/compare?from=2&to=4 ``` ### Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `from` | number | Yes | Source version number | | `to` | number | Yes | Target version number | ### Response ```json { "success": true, "data": { "fromVersion": 2, "toVersion": 4, "changes": { "name": { "from": "Invoice Automation", "to": "Invoice Automation v2", "changed": true }, "description": { "from": "Process incoming invoices", "to": "Process incoming invoices", "changed": false }, "trigger": { "changed": false }, "steps": { "added": ["error-handler"], "removed": [], "modified": ["notify-team"] } } } } ``` ### Rollback Version Restore a workflow to a previous version. This creates a new version with the old content. An archived workflow cannot be rolled back (`409`). ```http POST /api/v1/workflows/:id/versions/:version/rollback ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `changeSummary` | string | No | Reason for rollback | ### Example Request ```json { "changeSummary": "Rolling back due to bug in v4" } ``` ### Response ```json { "success": true, "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "version": 5, "rolledBackFrom": 4, "rolledBackTo": 2, "name": "Invoice Automation", "status": "active", ... } } ``` ## Schedule Management ### Get Schedule Get schedule information for a workflow with schedule trigger. ```http GET /api/v1/workflows/:id/schedule ``` ### Response ```json { "success": true, "data": { "id": "sch_xxx", "workflowId": "550e8400-e29b-41d4-a716-446655440000", "triggerScheduleId": "sched_xxx", "scheduleType": "cron", "cronExpression": "0 9 * * 1-5", "interval": null, "timezone": "America/New_York", "enabled": true, "nextRunAt": "2024-01-16T09:00:00.000Z", "lastRunAt": "2024-01-15T09:00:00.000Z", "lastRunId": "run_xxx", "runCount": 42, "consecutiveFailures": 0, "lastError": null, "createdAt": "2024-01-01T10:00:00.000Z", "updatedAt": "2024-01-15T09:00:05.000Z" } } ``` ### Update Schedule Modify schedule configuration. An archived workflow's schedule cannot be changed (`409`). ```http PUT /api/v1/workflows/:id/schedule ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `cron` | string | No | Cron expression | | `interval` | object | No | Interval configuration | | `interval.value` | number | Yes | Interval value | | `interval.unit` | string | Yes | `minutes`, `hours`, `days`, `weeks` | | `timezone` | string | No | IANA timezone | | `enabled` | boolean | No | Enable/disable schedule | ### Example Request ```json { "cron": "0 10 * * 1-5", "timezone": "America/Los_Angeles", "enabled": true } ``` ### Response ```json { "success": true, "data": { "updated": true, "scheduleId": "sch_xxx", "nextRunAt": "2024-01-16T10:00:00.000Z" } } ``` ### Manual Schedule Trigger Manually trigger a scheduled workflow (for testing). ```http POST /api/v1/workflows/:id/schedule/trigger ``` ### Response (202 Accepted) ```json { "success": true, "data": { "runId": "run_xxx", "status": "pending", "workflowId": "550e8400-e29b-41d4-a716-446655440000", "triggeredAt": "2024-01-15T10:30:00.000Z" } } ``` ## Workflow Statuses | Status | Description | |--------|-------------| | `draft` | Initial state, not yet activated | | `active` | Running and can be triggered | | `paused` | Temporarily disabled | | `archived` | Stopped and read-only. Listed only with `?status=archived`; can be unarchived to `draft` or deleted | | From | Allowed transitions | |------|---------------------| | `draft` | `active`, `archived` | | `active` | `paused`, `archived` | | `paused` | `active`, `archived` | | `archived` | `draft` (via [unarchive](#unarchive-workflow)) | [Delete](#delete-workflow) is allowed from any status. ## Error Responses ### Workflow Not Found (404) ```json { "success": false, "error": { "code": "NOT_FOUND", "message": "Workflow not found" } } ``` ### Invalid Status Transition (400) ```json { "success": false, "error": { "code": "BAD_REQUEST", "message": "Cannot transition from 'archived' to 'active'", "details": { "currentStatus": "archived", "targetStatus": "active", "allowedTransitions": ["draft"] } } } ``` ### Archived Workflows Are Read-Only (409) `PUT /api/v1/workflows/:id`, `PATCH /api/v1/workflows/:id/canvas`, version rollback, and `PUT /api/v1/workflows/:id/schedule` reject an archived workflow: ```json { "success": false, "error": { "code": "CONFLICT", "message": "Workflow is archived; unarchive it before editing" } } ``` ### Validation Error (400) ```json { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Invalid workflow steps: Step 'send-email' references non-existent step 'process-data'" } } ``` ### Workflow Not Active (400) ```json { "success": false, "error": { "code": "WORKFLOW_NOT_ACTIVE", "message": "Workflow must be active to run" } } ``` # Blocks (/docs/blocks) The building components of your billing automation workflows Blocks are the building components you connect together to create workflows. Each block handles a specific task -- from calling the Stripe API to running AI-powered data extraction or branching logic based on invoice amounts. Loopfour Studio organizes its built-in blocks into the categories below. ## Block Categories ### Triggers Triggers define when a workflow executes. Every workflow needs at least one trigger. - **[Webhook Triggers](/docs/triggers/webhook)** -- Receive events from external systems (Stripe, HubSpot, per-tenant custom endpoints). Declared on the workflow's trigger configuration rather than dragged from the palette - **[Schedule Trigger](/docs/triggers/schedule)** -- Run on a cron schedule (daily, weekly, custom) - **[Slack Trigger](/docs/triggers/slack-events)** -- React to Slack messages, slash commands, and interactive components ### Integrations Integration blocks connect to external services. Each requires an OAuth or API key connection. **Payments:** - **[Stripe](/docs/integrations/payments/stripe)** -- Invoices, payments, subscriptions, customers **CRM:** - **[Salesforce](/docs/integrations/crm/salesforce)** -- Records, queries, platform events - **[HubSpot](/docs/integrations/crm/hubspot)** -- Contacts, deals, companies, tickets - **[Attio](/docs/integrations/crm/attio)** -- Records, lists, notes **Accounting:** - **[QuickBooks](/docs/integrations/accounting/quickbooks)** -- Invoices, payments, journal entries - **[NetSuite](/docs/integrations/accounting/netsuite)** -- Records, transactions, saved searches - **[Xero](/docs/integrations/accounting/xero)** -- Invoices, contacts, bank transactions - **[Sage Intacct](/docs/integrations/accounting/sage-intacct)** -- GL, accounts payable, accounts receivable - **[Rillet](/docs/integrations/accounting/rillet)** -- Revenue recognition, deferred revenue **Communication:** - **[Slack](/docs/integrations/communication/slack)** -- Send messages, post to channels, event triggers - **[Email](/docs/integrations/communication/email)** -- Send email through Gmail, Outlook, or Resend **Documents:** - **[DocuSign](/docs/integrations/documents/docusign)** -- Send for signature, check status - **[PandaDoc](/docs/integrations/documents/pandadoc)** -- Create proposals, send contracts - **[Dropbox Sign](/docs/integrations/documents/dropbox-sign)** -- E-signature requests **Spreadsheets:** - **[Google Sheets](/docs/blocks/google-sheets)** -- Read, write, append, and manage spreadsheet data **Files:** - **[Box](/docs/blocks/box)** -- Find recently updated files, download them, and manage folders and users ### AI - **[Agent](/docs/blocks/agent)** -- Connect to Claude Opus 5 or GPT-5.6 Sol for text processing, data extraction, classification, and tool use ### Control Flow Control flow blocks manage execution paths within your workflow. - **[Condition](/docs/blocks/condition)** -- Branch based on boolean expressions (e.g., `amount > 1000`) - **[Loop](/docs/blocks/loop)** -- Iterate over collections (e.g., process each line item) - **[Parallel](/docs/blocks/parallel)** -- Execute multiple paths concurrently ### Actions - **[Stagehand](/docs/blocks/stagehand)** -- Browser automation: navigate portals, download reports, fill forms using AI-driven browser control ### Data - **[Transform](/docs/blocks/transform)** -- Map and reshape data between systems - **[Code](/docs/blocks/code)** -- Run custom JavaScript in a sandboxed environment - **[Data Table](/docs/blocks/data-table)** -- Read and write rows in user-managed data tables - **[PDF](/docs/blocks/pdf)** -- Read text, extract selected pages, or inspect PDF documents ### Utility - **[Wait](/docs/blocks/wait)** -- Pause execution for a specified duration - **[Approval](/docs/blocks/approval)** -- Human-in-the-loop: pause for manual approval before continuing - **[Sub-workflow](/docs/blocks/subworkflow)** -- Call another workflow as a step ## How Blocks Work Each block has three components: 1. **Inputs** -- Data coming from upstream blocks or the trigger payload 2. **Configuration** -- Settings that control behavior (API action, conditions, prompts) 3. **Outputs** -- Data produced for downstream blocks to consume Blocks are connected by **edges** that define data flow. The execution engine builds a directed acyclic graph (DAG) from your connections and runs blocks in dependency order -- independent blocks execute concurrently automatically. ## Common Patterns ### Sequential Processing Chain blocks where each processes the output of the previous one: ``` Webhook Trigger -> Transform (map payload) -> Stripe (create invoice) -> Slack (notify) ``` ### Conditional Branching Use Condition blocks to route based on data: ``` Webhook -> Condition (amount > 1000?) -> Yes: Approval -> Stripe (create invoice) -> No: Stripe (create invoice) ``` ### Parallel Execution Run independent tasks concurrently: ``` Stripe (payment received) -> Parallel -> QuickBooks (record payment) -> Slack (notify finance) -> Gmail (send receipt) ``` ### Loop Processing Iterate over collections: ``` Salesforce (get line items) -> Loop (for each item) -> Stripe (create invoice line) ``` ## Block Configuration All blocks share common configuration options: | Option | Description | |--------|-------------| | **Name** | Human-readable label displayed on the canvas | | **Error Handling** | Strategy: fail, continue, or retry with backoff | | **Timeout** | Maximum execution time before the step is killed | | **Retry Config** | Max attempts, initial delay, backoff multiplier | Additionally, each block type has specific configuration. See individual block documentation for details. AI-powered text processing and data extraction Conditional branching based on expressions Data mapping between systems Human-in-the-loop approval flows Custom JavaScript execution Read text and extract selected pages from PDF documents AI-driven browser automation # Agent Block (/docs/blocks/agent) Connect workflows to AI models for text processing, data extraction, classification, and structured output The Agent block connects your workflow to Large Language Models (LLMs) through Vercel AI Gateway. Use it to extract data from documents, classify inputs, generate text, or make decisions based on unstructured data. The block supports structured output via JSON Schema, ensuring consistent, machine-readable responses. ## Configuration ### Provider and Model The canvas exposes two provider/model pairs: | Provider | Model | Model ID | |----------|-------|----------| | **Anthropic** | Claude Opus 5 | `claude-opus-5` | | **OpenAI** | GPT-5.6 Sol | `gpt-5.6-sol` | Claude Opus 5 is the default. Provider and model are separate fields, so keep them paired as shown above; an Anthropic provider with an OpenAI model ID (or the reverse) is rejected. The block does not require a workflow connection. The runtime must have Vercel AI Gateway authentication configured through `AI_GATEWAY_API_KEY`, or `VERCEL_OIDC_TOKEN` when it is available in a Vercel-hosted environment. ### System Prompt The system prompt defines the agent's role, behavior, and constraints. It is sent with every request and shapes how the agent processes input. ```markdown You are an expert invoice data extractor. Given a document, extract: - Invoice number - Vendor name - Total amount - Due date - Line items with descriptions and amounts Always return valid JSON matching the output schema. ``` ### User Prompt The primary input for the agent. This is the text or data the agent will process. Sources include: - **Static text** configured directly in the block - **Dynamic data** from upstream blocks via template variables (e.g., `{{steps.download.content}}`) - **Trigger payload** data from the workflow input ### Input Data Optional JSON object passed as additional context to the agent. Use this to pass structured data from upstream blocks: ```json { "documentContent": "{{steps.download.content}}", "documentMimetype": "application/pdf", "customerName": "{{steps.lookup.output.name}}" } ``` #### Attaching a document by URL For anything larger than a page or two, pass `documentUrl` instead of `documentContent`. The model provider fetches the file directly, so the bytes never pass through the workflow run: ```json { "documentUrl": "{{steps.get_invoice_file.url}}", "documentMimetype": "application/pdf" } ``` Base64 content is inflated ~33% by the encoding, held in memory, and retained in the step output for the entire run — a 7 MB PDF is enough to exhaust a small machine. A URL is a few hundred bytes. Pair it with `workspace_files.getFile` in `mode: "url"`. Notes: - **Only PDFs and images** can be attached by URL — those are the types the providers fetch natively. Anything else must go through `documentContent`. - **Set `documentMimetype`** unless the URL path ends in a recognizable extension. The step fails with a clear error rather than sending the model an attachment it cannot read. - The URL must be **HTTPS**, and is a **bearer credential** for its lifetime. Keep the expiry short (the default is 10 minutes). - **Mint the URL immediately before the agent step.** A signed URL created before a `wait` or `approval` step can expire while the run is paused, and the failure surfaces as an opaque provider-side fetch error. - `documentContent` is *not* scanned for URLs — use `documentUrl` explicitly. ### Output Schema Define a JSON Schema to enforce structured output. When set, the agent returns a JSON object instead of free-form text, and the runtime validates that object against the full schema: required fields, nested types, enums, and ranges such as `minimum`/`maximum`. If the response does not match, the runtime asks the model again, up to two more times. If no attempt matches, the step fails with an error that lists each violation, so a downstream step never acts on a value outside the schema. The runtime does not check `format` values (for example `email` or `date-time`) or `pattern` regexes. It checks only the top-level `required` fields, and logs a warning, when a schema uses `patternProperties`, is larger than 100,000 characters, or cannot be compiled (for example a draft 2020-12 `$schema` or an unresolved `$ref`). ```json { "type": "object", "properties": { "invoiceNumber": { "type": "string" }, "vendorName": { "type": "string" }, "totalAmount": { "type": "number" }, "dueDate": { "type": "string" }, "lineItems": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "amount": { "type": "number" } } } } }, "required": ["invoiceNumber", "vendorName", "totalAmount"] } ``` ### Temperature Temperature is a model-specific sampling hint. Lower values typically reduce variation, while higher values typically allow more varied responses: | Range | Typical behavior | Best For | |-------|----------|----------| | **0 -- 0.2** | Focused, lower variation | Data extraction, classification, structured output | | **0.3 -- 0.6** | Balanced | General processing, summarization | | **0.7 -- 1.0** | Creative, varied | Content generation, brainstorming | Default: `0`. The exact effect depends on the selected model, and temperature alone does not guarantee identical output. For billing workflows, start at 0 and use an output schema plus downstream validation for fields that must be reliable. ### Max Tokens Maximum length of the agent's response. The runtime uses `4096` when this field is omitted. Set a lower value to control costs or response length, or a higher value when the selected model's output limit allows it. ## Outputs | Output | Type | Description | |--------|------|-------------| | `result` | JSON | Structured output matching the output schema (when schema is set) | | `text` | string | Raw text response (when no schema is set) | | `usage` | JSON | Token usage statistics (prompt tokens, completion tokens, total) | ## Example Use Cases ### Invoice Data Extraction ``` Stagehand (download PDF) -> Agent (extract invoice fields) -> QuickBooks (create invoice) ``` System prompt: "Extract invoice number, vendor, total, due date, and line items from the document." Output schema: Structured JSON matching QuickBooks invoice format. ### Payment Classification ``` Stripe (payment received) -> Agent (classify payment type) -> Condition (recurring vs one-time) ``` System prompt: "Classify this payment as recurring, one-time, or refund based on the metadata." Output schema: `{ "type": "recurring" | "one-time" | "refund", "confidence": number }` ### Contract Term Extraction ``` DocuSign (signed contract) -> Agent (extract billing terms) -> Transform -> Stripe (create subscription) ``` System prompt: "Extract billing frequency, amount, start date, and payment terms from this contract." ### Email Triage ``` Gmail (new email) -> Agent (classify and extract) -> Condition (route by type) -> Invoice inquiry: Slack (notify finance) -> Payment issue: Stripe (lookup) -> Slack (notify support) ``` ## Best Practices - **Start with temperature 0 for extraction tasks.** This usually reduces variation, but it is not a determinism guarantee. Use an output schema and validate critical financial fields before acting on them. - **Always define an output schema for structured data.** This prevents the agent from returning unexpected formats and ensures downstream blocks receive the data they expect. - **Be specific in system prompts.** Instead of "extract data from this document," write "extract the invoice number (alphanumeric, usually in format INV-XXXX), vendor name, total amount in USD, and due date in ISO 8601 format." - **Use the right model for the task.** Claude Opus 5 is the default and is the better fit when native PDF handling or complex document reasoning matters. GPT-5.6 Sol is available for text, image, tool-driven, and agentic workloads and has lower current AI Gateway token rates. - **Handle PDF documents with care.** When passing PDF content, include `documentMimetype: "application/pdf"`. Claude Opus 5 accepts PDF attachments natively. The current GPT-5.6 Sol adapter handles images directly but not native PDF parts, so the runtime switches a GPT-configured PDF step to Claude Opus 5 when Gateway authentication is available. Select Claude Opus 5 directly when the executed model must stay explicit, or extract the PDF text upstream. # Approval Block (/docs/blocks/approval) Pause workflow execution for human approval before continuing The Approval block implements human-in-the-loop workflows. It pauses execution and waits for a human to approve or reject before the workflow continues. Use it for high-value transactions, sensitive operations, or compliance-required review steps. ## Configuration | Option | Type | Description | |--------|------|-------------| | `title` | string | Title displayed in the approval request | | `description` | string | Details about what is being approved (supports template variables) | | `notifyChannel` | string | Slack channel to post the approval request | | `approvers` | array | List of users who can approve | | `timeout` | string | How long to wait for a decision, for example `48h` or `7d` (default `7d`) | | `timeoutAction` | string | What happens at the timeout: `fail` (default), `reject`, `approve`, or `escalate` | ## How It Works 1. The workflow reaches the Approval block and pauses execution 2. A notification is sent to the configured channel with approve/reject buttons 3. An authorized approver clicks Approve or Reject 4. An approval continues the workflow. A rejection fails the run at this step unless the step's error handling is `continue`, in which case later steps branch on `decision.approved` ([Taking the rejected path](/docs/execution/approval-flows#taking-the-rejected-path)) ## Outputs | Output | Type | Description | |--------|------|-------------| | `decision.approved` | boolean | Whether the request was approved | | `decision.reason` | string | The approver's comment, or `Auto-rejected due to timeout` | | `decision.decidedBy` | string | Who decided (`system` for a timeout) | | `decision.decidedAt` | string | Timestamp of the decision | | `timedOut` | boolean | Whether the decision came from the timeout | ## Example Patterns ### High-Value Invoice Approval ``` Stripe (create invoice) -> Condition (amount > $5,000?) -> True: Approval (finance manager review) -> Approved: Stripe (finalize and send) -> Rejected: Slack (notify requester) -> False: Stripe (finalize and send) ``` ### Discount Authorization ``` HubSpot (deal with discount) -> Condition (discount > 20%?) -> True: Approval (VP Sales review) -> Approved: Stripe (create invoice with discount) -> Rejected: Slack (notify sales rep) ``` ## Best Practices - **Set timeouts for time-sensitive workflows.** A 48-hour timeout prevents workflows from hanging indefinitely when approvers are unavailable. - **Include context in the description.** Use template variables to show the approver exactly what they are approving: amounts, customer names, and relevant details. - **Notify via Slack for fast response.** Approval requests sent to Slack channels get faster responses than email notifications. Route based on approval thresholds Add time-based delays # Box Block (/docs/blocks/box) Find, download, and manage Box files, folders, and users through a Nango OAuth connection The **Box** block exposes 18 operations through the searchable **Action** selector. Each action ID is `box.` followed by an operation below, such as `box.listFilesUpdatedSince`. ## Connect an account Choose **Box** on the Integrations page and authorize with Box. The block can reach every file and folder the authorizing user can access. Select the account in **Box Account** on the block. **Create User**, **List Users**, and **Get User** for other users need an enterprise admin and an app with the **Manage users** scope. ## Operations | Group | Operation | What it does | | --- | --- | --- | | Files | `listFilesUpdatedSince` | Find every file modified in the last `hoursBack` hours (default 24) or since an ISO `since` date | | Files | `downloadFile` | Download a file as base64 `content`, or save it to a workspace folder with `persistToFolder` | | Files | `readPdfPages` | Read the text of selected PDF pages (`pages`, `firstPages`, `lastPages`) without downloading the whole file | | Files | `getFile` | Read file metadata | | Files | `copyFile` | Copy a file into a folder, optionally with a new name | | Files | `updateFile` | Rename, move (`parentId`), or describe a file | | Files | `deleteFile` | Move a file to the trash | | Files | `search` | Search by `query`, `type`, `ancestorFolderIds`, and `fileExtensions` | | Folders | `getFolder` | Read folder metadata. The root folder ID is `0`. | | Folders | `listFolderItems` | List a folder's items with paging, a type filter, and sort | | Folders | `createFolder` | Create a folder inside `parentId` | | Folders | `copyFolder` | Copy a folder into another folder | | Folders | `updateFolder` | Rename, move, or describe a folder | | Folders | `deleteFolder` | Move a folder to the trash. Set `recursive` to delete a folder that is not empty. | | Users | `getCurrentUser` | Read the connected user | | Users | `getUser` | Read a user by ID | | Users | `listUsers` | List enterprise users | | Users | `createUser` | Create a managed user (`login` required) or an app user (`isPlatformAccessOnly`) | ## Daily new-file workflows Use a **Schedule** trigger, then **List Files Updated Since**, then a **Loop** over `files` with **Download File**: - Box adds new and changed files to its search index after a delay of up to about 10 minutes. **List Files Updated Since** looks back 10 extra minutes, so consecutive daily runs overlap. Dedupe on file `id` and `modified_at` before a write that must happen only once, such as a QuickBooks entry. - Box search needs a `query` or `fileExtensions`. With only `fileExtensions`, the extensions become the query (`pdf OR csv`). Set `ancestorFolderIds` to limit the search to the folders that matter. - For PDFs, set **Download File**'s `persistToFolder` (for example `box/invoices`), then give the **PDF** block `fileId: {{steps.download.workspaceFileId}}`. The file bytes then stay out of the run state. - When you need only a few pages, use **Read PDF Pages** in the loop instead: give it `fileId: {{loop.item.id}}` and `pages: "1-2"`, then send its `text` to QuickBooks. It fetches only the byte ranges those pages need, so a 100 MB PDF does not have to fit in worker memory. - Box search pages stop at 10,000 results. When more files match, the action fails; narrow the folders, extensions, or query. ## Limits **Download File** refuses files larger than 3 MiB (3,145,728 bytes), before and during the download. With `persistToFolder` set, it saves the file to workspace storage instead of returning it, and the limit is 96 MiB (100,663,296 bytes), the same as the PDF block's source limit. The worker holds the whole file in memory: a 90 MB file adds about 330 MB, so for large files set the workflow's Compute Capacity above the default 0.5 GB machine. **Read PDF Pages** has no file-size limit. It stops when it has fetched 96 MiB, which only a damaged PDF needs for a few pages. It reads up to 500 pages and 10 MB of text, like the PDF block's **Read Text**. Scanned pages that hold only images return empty text. Download File also fails on a file uploaded seconds earlier that Box is not ready to serve yet; the next run picks it up. IDs must be numeric Box IDs. Box's [rate limits](https://developer.box.com/guides/api-calls/permissions-and-errors/rate-limits/) apply per user. ## Outputs Metadata reads and writes return the Box object (`id`, `type`, `name`, and more). **List Folder Items** returns `entries`, `totalCount`, `offset`, and `limit`. **Search** and **List Users** return Box's `entries` and `total_count`. **List Files Updated Since** returns `since`, `count`, and `files`. **Download File** returns `fileId` (the Box ID), `name`, `size`, `mimeType`, and `content`. With `persistToFolder`, it returns `workspaceFileId`, `filePath`, and `persisted: true` instead of `content`, and saves the file as `-`; pass `workspaceFileId` to the PDF block's `fileId`. **Read PDF Pages** returns `fileId`, `name`, `size`, `pages` (one string for each page), `text`, `pageCount`, `pagesRead`, and `bytesRead`, the bytes it fetched from Box. Deletes return the ID and `deleted: true`. # Code Block (/docs/blocks/code) Run custom JavaScript in a sandboxed environment for complex data transformations The Code block lets you run custom JavaScript within your workflow. Use it for complex data transformations, calculations, API calls, or any logic that cannot be expressed with the built-in blocks. ## Configuration ### Code Write JavaScript that processes input data and returns a result. The code runs in a sandboxed Node.js environment with access to the workflow context. ```javascript // Access input data from upstream blocks const invoice = input.data; // Transform data const lineItems = invoice.items.map(item => ({ description: item.name, amount: Math.round(item.price * 100), // Convert to cents quantity: item.quantity, total: Math.round(item.price * item.quantity * 100), })); // Calculate totals const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0); const tax = Math.round(subtotal * 0.0825); // 8.25% tax const total = subtotal + tax; // Return structured output return { lineItems, subtotal, tax, total, currency: 'usd', }; ``` ### Input Data The code block receives data from upstream blocks through the `input` object. This contains the outputs of connected blocks. ### Timeout Maximum execution time for the code block. Default: 30 seconds. Set a lower value for simple calculations, higher for external API calls. ## Outputs | Output | Type | Description | |--------|------|-------------| | `result` | JSON | The value returned by your code | ## Common Use Cases ### Currency Conversion ```javascript const amount = input.data.amount; const rate = input.data.exchangeRate; return { originalAmount: amount, convertedAmount: Math.round(amount * rate), rate: rate, }; ``` ### Data Aggregation ```javascript const invoices = input.data.invoices; return { totalAmount: invoices.reduce((sum, inv) => sum + inv.amount, 0), count: invoices.length, averageAmount: invoices.reduce((sum, inv) => sum + inv.amount, 0) / invoices.length, byStatus: invoices.reduce((acc, inv) => { acc[inv.status] = (acc[inv.status] || 0) + 1; return acc; }, {}), }; ``` ### Format Mapping ```javascript // Map Stripe invoice to QuickBooks format const stripe = input.data; return { CustomerRef: { value: stripe.customer }, Line: stripe.lines.data.map(line => ({ Amount: line.amount / 100, Description: line.description, DetailType: "SalesItemLineDetail", })), DueDate: new Date(stripe.due_date * 1000).toISOString().split('T')[0], }; ``` ## Best Practices - **Keep code focused.** Each Code block should do one thing well. Use multiple Code blocks for multi-step transformations. - **Handle edge cases.** Check for null values, empty arrays, and missing fields before processing. - **Return structured data.** Always return a JSON object so downstream blocks can reference specific fields. - **Use descriptive variable names.** Your code will be visible in logs and to other team members. The Code block runs in a sandboxed environment. External network calls, file system access, and process-level operations are restricted. Simpler data mapping without code Use AI for complex processing # Condition Block (/docs/blocks/condition) Branch workflow execution paths based on boolean expressions The Condition block evaluates a boolean expression and routes execution to one of two paths: **true** or **false**. Use it to branch workflows based on data values, thresholds, or the presence of specific fields. ## Configuration ### Expression A JavaScript expression that evaluates to `true` or `false`. The expression has access to data from upstream blocks via template variables. **Examples:** ```javascript // Amount threshold {{input.data.object.amount_paid}} > 100000 // String comparison {{steps.lookup.output.status}} === "active" // Field existence {{input.data.object.customer_email}} !== null // Compound conditions {{input.amount}} > 5000 && {{input.currency}} === "usd" ``` ### True Path / False Path Each path connects to different downstream blocks. When the expression evaluates to `true`, only the true-path blocks execute. When `false`, only the false-path blocks execute. ## Common Patterns ### High-Value Invoice Approval ``` Stripe (invoice created) -> Condition (amount > $5,000?) -> True: Approval (manager review) -> QuickBooks (create invoice) -> False: QuickBooks (create invoice) ``` ### Customer Tier Routing ``` Webhook -> Condition (customer.plan === "enterprise"?) -> True: Slack (#enterprise-support) -> False: Email (standard notification) ``` ### Error Handling ``` Stripe (create charge) -> Condition (status === "succeeded"?) -> True: QuickBooks (record payment) -> False: Slack (alert finance) + Gmail (notify customer) ``` ## Outputs The Condition block passes through the upstream data unchanged. It does not transform data -- it only controls which path executes. | Output | Type | Description | |--------|------|-------------| | `result` | boolean | The evaluation result (`true` or `false`) | ## Best Practices - **Keep expressions simple.** For complex logic, use a Code block upstream to compute a boolean value, then use the Condition block to branch on it. - **Handle both paths.** Always define behavior for both true and false outcomes to avoid silent failures. - **Use descriptive names.** Name your condition blocks after the decision: "Amount over $5K?" is clearer than "Condition 1." Iterate over collections of items Execute multiple paths concurrently Use AI for complex decision-making , <, >=, <=), logical operators (&&, ||, !), and template variables to reference data from upstream blocks. The expression must evaluate to a boolean value." }, { question: "Can I have nested conditions?", answer: "Yes. You can chain Condition blocks by connecting one Condition's output to another Condition's input. This creates nested decision trees for complex routing logic." }, { question: "What happens if the expression throws an error?", answer: "If the expression cannot be evaluated (for example, referencing a field that does not exist), the block follows the workflow's error handling strategy -- either failing the workflow, continuing with a null result, or retrying." }, { question: "Can I use AI for routing instead of boolean expressions?", answer: "Yes. If your routing logic requires natural language understanding or complex reasoning, use an Agent block with a classification prompt instead of a Condition block. The Agent can output a structured result that a downstream Condition block can branch on." }, ]} /> # Data Table Block (/docs/blocks/data-table) Create, organize, and read or write user-managed data tables from a workflow The **Data Table** block creates and organizes user-managed tables and performs row operations against them. Use it to persist workflow state across runs, build lookup tables, deduplicate records, or create a folder and its tables entirely inside a workflow. Data tables and table folders are company-scoped. Folders are flat: a table can belong to one folder or to the Data Store root, but folders cannot be nested. Every operation is tenant-isolated. A workflow running in company A cannot list, create, move, read, or write resources owned by company B. ## When to Use - **Workflow state.** Track records that have already been processed so later runs can skip them. - **Dynamic tables.** Get or create a table whose name comes from a previous step, such as one table per accounting period. - **Workflow-managed organization.** Ensure a folder exists, then create or move tables into it without a manual Studio setup step. - **Lookups.** Map external IDs to internal IDs without round-tripping to another service. - **Approval queues.** Insert a row representing pending work that a human resolves out of band. Each table is capped at `maxRows` rows (default 10,000, max 100,000). For larger volumes, frequent analytical reads, or relational joins, use a dedicated database integration. ## Configuration The block is dispatched internally through `data_table.execute`. In workflow JSON, keep `type: "data_table"` and select one of the 14 native operations with `config.operation`; do not invent a `data_table.` action name. ### Operations | Operation | Behaviour | |-----------|-----------| | `insert` | Append a new row. Fails if the table is at `maxRows`. | | `update` | Merge `data` into every row whose `matchKey` equals `matchValue`. | | `upsert` | Update matching rows, otherwise insert a row. | | `upsertMany` | Bulk upsert `rows` in one transaction. Duplicate match values collapse to the last occurrence. | | `delete` | Delete every row matching `matchKey` and `matchValue`. | | `find` | Return rows whose data contains the provided `filter`, ordered newest first with stable offset pagination. | | `getById` | Return one row by `rowId`; throws if it is not found. | | `createTable` | Get or create a company table by name and return its `tableId`. No `tableId` input is required. | | `listTables` | List all company tables, only root tables, or tables in one folder according to `folderId`. | | `listFolders` | List all flat table folders and the table IDs currently assigned to each one. | | `ensureFolder` | Idempotently get or create a folder by its trimmed, exact name. | | `ensureTable` | Idempotently get or create a table by exact company, folder, and table name. | | `createTableInFolder` | Get or create a named table in a specific existing folder. | | `assignTableToFolder` | Move a table to a folder, or move it to root with an explicit `null`. | ### Folder semantics `folderId` has operation-specific meaning: | Operation | Omitted | `null` | String or template | |-----------|---------|--------|--------------------| | `listTables` | List all company tables | List only root tables | List only tables in that folder | | `ensureTable` | Invalid | Ensure a root table | Ensure a table in that folder | | `createTableInFolder` | Invalid | Invalid | Create or reuse the named table in that folder | | `assignTableToFolder` | Invalid | Move the table to root | Move the table to that folder | In workflow JSON, root is the JSON value `null`. In the visual builder's **Folder ID** text field, enter `null` without quotes; runtime validation normalizes that text to the same root sentinel. `ensureFolder` trims `folderName`, requires 1–255 characters, and returns an existing exact-name folder rather than creating a duplicate. Folder names are case-sensitive, so `Finance` and `finance` are different folders. All table-creation operations use the same `name`, `description`, `columns`, and `maxRows` rules. `ensureTable` requires an explicit folder scope and matches the exact `(company, folder, table name)` tuple, so `Invoices` in the root is distinct from `Invoices` in a folder. Its `folderId` can be a folder ID or `null` for root. The legacy `createTable` operation retains company-wide name matching for compatibility and fails closed whenever multiple company-wide matches exist, including duplicate rows in one folder. `createTableInFolder` first reuses an exact-folder match, but otherwise rejects a same-named table elsewhere instead of creating another. Use `ensureTable` when the same name must be valid in multiple folder scopes. ### Inputs | Field | Type | Required for | Description | |-------|------|--------------|-------------| | `operation` | string | all | One of the 14 operations above. | | `tableId` | string | row operations, `assignTableToFolder` | Target table ID. A literal ID or a template such as `{{steps.create_table.tableId}}` is accepted. | | `folderId` | string or null | `ensureTable`, `createTableInFolder`, `assignTableToFolder` | Folder filter, identity scope, or destination. It is optional only for `listTables`; the visual text field accepts `null` for root. | | `folderName` | string | `ensureFolder` | Trimmed folder name, 1–255 characters. | | `name` | string | `createTable`, `ensureTable`, `createTableInFolder` | Table name, 1–255 characters. Templates are supported. | | `description` | string | optional create input | Description stored when a new table is created. | | `columns` | object[] | `createTable`, `ensureTable`, `createTableInFolder` | One or more unique, case-insensitive column names. IDs are minted server-side. | | `maxRows` | number | optional create input | Row ceiling for a newly created table (default 10,000, max 100,000). | | `data` | object | `insert`, `upsert`; optional for `update` | Row payload. Updates merge it into the stored JSON data. | | `matchKey` | string | `update`, `upsert`, `upsertMany`, `delete` | Column name used to find matching rows. | | `matchValue` | any | `update`, `upsert`, `delete` | Value compared as text. Runtime templates are supported. | | `rows` | object[] | `upsertMany` | Up to 2,000 row objects; each must carry a non-empty `matchKey` value. | | `filter` | object | optional for `find` | JSON containment predicate. Omit it to match every row. | | `limit` | number | optional for `find` | Rows per page (default 50, max 1,000). | | `offset` | number | optional for `find` | Non-negative number of matching rows to skip (default 0). | | `rowId` | string | `getById` | Row ID to retrieve. | Supported column types for create operations are `text`, `number`, `boolean`, `date`, `select`, `url`, `email`, and `json`. Omitted types default to `text`. ### Outputs Operation results are exposed directly on the step output. For example, read `{{steps.find_queue.rows}}` after `find`, not `{{steps.find_queue.result.rows}}`. | Operation | Output | |-----------|--------| | `insert`, `getById` | The row fields. | | `update` | `{ updated }` count. | | `upsert` | An inserted row, or `{ updated }` count. | | `upsertMany` | `{ total, updated, inserted }` counts. | | `delete` | `{ deleted }` count. | | `find` | `{ rows, pagination }`, where pagination includes `limit`, `offset`, `returned`, `nextOffset`, and `hasMore`. | | `createTable`, `ensureTable`, `createTableInFolder` | `{ tableId, name, folderId, created, columns }`. | | `listTables` | `{ tables, count }`. | | `listFolders` | `{ folders, count }`; each folder includes `tableIds`. | | `ensureFolder` | `{ folderId, folder, created }`. | | `assignTableToFolder` | `{ tableId, folderId, previousFolderId, changed }`. | ## Examples ### Ensure a folder, then ensure a table in it The second step templates the folder ID returned by the first step. Re-running these steps reuses both resources and reports `created: false` for each existing resource. ```json [ { "id": "ensure_reports_folder", "name": "Ensure reports folder", "type": "data_table", "config": { "operation": "ensureFolder", "folderName": "Monthly reports" } }, { "id": "ensure_invoice_table", "name": "Ensure invoice table in folder", "type": "data_table", "config": { "operation": "ensureTable", "folderId": "{{steps.ensure_reports_folder.folder.id}}", "name": "Invoices - {{input.period}}", "description": "Invoices collected for the accounting period", "columns": [ { "name": "invoiceId", "type": "text", "required": true }, { "name": "amount", "type": "number" }, { "name": "processed", "type": "boolean" } ], "maxRows": 25000 } } ] ``` Use `{{steps.ensure_invoice_table.tableId}}` in later row operations. ### List root tables and move a table back to root Root is represented by an explicit JSON `null`, not by an empty string. ```json [ { "id": "list_root_tables", "name": "List root tables", "type": "data_table", "config": { "operation": "listTables", "folderId": null } }, { "id": "move_table_to_root", "name": "Move table to root", "type": "data_table", "config": { "operation": "assignTableToFolder", "tableId": "{{steps.ensure_invoice_table.tableId}}", "folderId": null } } ] ``` Omit `folderId` from `listTables` to list tables across root and every folder. ### Find rows with offset pagination ```json { "id": "find_queue", "name": "Find pending queue rows", "type": "data_table", "config": { "operation": "find", "tableId": "{{steps.create_invoice_table.tableId}}", "filter": { "status": "pending" }, "limit": 250, "offset": 0 } } ``` Use `pagination.nextOffset` for the next page when it is non-null. Ordering uses `createdAt DESC` with the row ID as a stable tie-breaker. ## Error Handling The block throws and the step fails when: - A referenced table or folder does not exist for the current company. - A required `tableId`, `folderId`, `folderName`, name, column list, match field, or row ID is missing. - An insert would exceed `maxRows`. - `createTableInFolder` finds the same table name outside the requested folder. - `getById` cannot find the row. - The config fails schema validation, such as `operation: "merge"` or more than 1,000 requested `find` rows. An unchanged folder assignment succeeds with `changed: false`. `update`, `upsert`, and `delete` against zero matching rows also succeed and report zero rather than throwing. `matchKey` and `matchValue` use text comparison against the stored JSON value. Numeric IDs compare as their string form; pass boolean values deliberately and avoid using `null` as a match key value. ## Limits | Limit | Value | |-------|-------| | Rows per table (default) | 10,000 | | Rows per table (max) | 100,000 | | Rows per `find` page | 1,000 | | Rows per `upsertMany` call | 2,000 | | Columns per workflow create operation | 100 | | `listTables` / `listFolders` pagination | None; each call returns all matching company resources | | Folder nesting | Not supported; folders are flat | REST endpoints to create tables, manage folders and columns, and read or write rows from your own systems. Transform upstream values before writing a row. Reshape upstream output into the row payload. # Google Sheets Block (/docs/blocks/google-sheets) Create spreadsheets and read, write, append, clear, and manage Google Sheets data through a Nango OAuth connection The **Google Sheets** block exposes 20 operations through the searchable **Action** selector. Each action ID is `google-sheets.` followed by an operation below, such as `google-sheets.appendValues`. ## Connect an account Choose **Google Sheets** on the Integrations page and authorize with Google. Loopfour requests the `spreadsheets` and `drive.file` scopes; the block can read and write any spreadsheet the authorizing user can edit. Select the account in **Google Sheets Account** on the block. ## Operations | Group | Operation | What it does | | --- | --- | --- | | Spreadsheets | `createSpreadsheet` | Create a spreadsheet with an optional list of sheet titles | | Spreadsheets | `getSpreadsheet` | Read title, URL, and every sheet's numeric `sheetId` | | Spreadsheets | `getSpreadsheetByDataFilter` | Read the spreadsheet limited to matching data filters | | Sheets | `addSheet` | Add a sheet (tab) | | Sheets | `copySheet` | Copy a sheet into another spreadsheet | | Sheets | `deleteSheet` | Delete a sheet by exact title. Irreversible. | | Sheets | `insertColumn` | Insert one empty column at a zero-based index | | Values | `readValues` | Read one A1 range | | Values | `updateValues` | Overwrite an A1 range with a 2-D matrix | | Values | `appendValues` | Append rows after the table that contains the range | | Values | `insertRow` | Insert one row at a zero-based index and fill it; the sheet is addressed by `sheetId`, and an optional `sheetTitle` is verified against it | | Values | `upsertRow` | Overwrite the first row whose key column matches, otherwise append | | Values | `clearValues` | Clear one range, keeping formatting | | Batch | `batchGetValues` | Read several ranges in one call | | Batch | `batchGetValuesByDataFilter` | Read ranges matched by data filters | | Batch | `batchClearValues` | Clear several ranges | | Batch | `batchClearValuesByDataFilter` | Clear ranges matched by data filters | | Advanced | `batchUpdateSpreadsheet` | Send raw Sheets API `Request` objects | | Advanced | `searchDeveloperMetadata` | Find developer metadata by data filter | | Advanced | `updateConditionalFormatRule` | Replace or move a conditional format rule | ## Ranges and values - Ranges use A1 notation. Quote sheet titles that contain spaces or apostrophes: `'Q3 Data'!A1:C10`, `'Bob''s'!A:A`. - `values` for **Update Values** and **Append Values** is a rectangular JSON matrix: `[["Name","Amount"],["Acme",1250]]`. A single row must still be wrapped: `[["only","row"]]`. **Insert Row** and **Upsert Row** use a separate `rowValues` field holding one flat row: `["Acme",1250]`. - Cells may be strings, numbers, booleans, or `null` (skip the cell). - **Value Input Option** defaults to **RAW**, which stores values literally. Choose **USER_ENTERED** only when Google should parse formulas, dates, and numbers as if typed into the UI. - `getSpreadsheet` returns each sheet's numeric `sheetId`; `copySheet`, `insertRow`, and `insertColumn` need that number, not the title. ## Limits Google's [Sheets API usage limits](https://developers.google.com/sheets/api/limits) allow 60 read and 60 write requests per minute per user per project. Batch rows into one **Append Values** call instead of looping. Reads larger than 1.6 MB fail in Loopfour; narrow the range. **Update Values** rejects a matrix that extends past its range; use whole-column ranges such as `LTV!A:B` when the row count varies. **Batch Update Spreadsheet** sends raw Sheets API requests, including deletions and formula writes; it is irreversible and bypasses the RAW default. ## Outputs Reads return `range`, `majorDimension`, and `values` (batch reads return `valueRanges`). Writes return `updatedRange`, `updatedRows`, `updatedColumns`, and `updatedCells`; **Append Values** also returns `tableRange`. **Upsert Row** returns `upsertResult` (`updated` or `appended`). **Delete Sheet** returns `deleted: true`. # Loop Block (/docs/blocks/loop) Iterate over collections to process each item in a workflow The Loop block iterates over a collection (array) and executes its contained blocks once for each item. Use it to process line items, batch records, or iterate over API results. ## Configuration | Option | Type | Description | |--------|------|-------------| | `collection` | expression | The array to iterate over (e.g., `{{steps.fetch.output.items}}`) | | `itemVariable` | string | Name for the current item (default: `item`) | | `indexVariable` | string | Name for the current index (default: `index`) | | `breakCondition` | condition | **Stop When** in the panel. Optional. Stops the loop early once the condition is true. Checked after each item; when items run concurrently, after each batch, so set `batchSize` too or it is checked only once, after all items | | `batchSize` | number | **Batch Size** in the panel. Optional. Processes the collection in groups of this many items (1 to 1000) | | `maxIterations` | number | Safety limit to prevent infinite loops (default: 100) | ## How It Works 1. The Loop block receives an array from an upstream block 2. For each item in the array, it executes all blocks connected inside the loop 3. Each iteration has access to the current `item` and `index` 4. After all iterations complete, downstream blocks receive the collected results ## Example Patterns ### Process Invoice Line Items ``` Salesforce (get line items) -> Loop (for each line item) -> Stripe (create invoice item) -> End Loop -> Stripe (finalize invoice) ``` ### Batch Customer Notifications ``` QuickBooks (list overdue invoices) -> Loop (for each invoice) -> Slack (send reminder to customer channel) ``` ### Multi-Record Sync ``` HubSpot (list updated deals) -> Loop (for each deal) -> Transform (map to QB format) -> QuickBooks (update record) ``` ## Best Practices - **Set `maxIterations` appropriately.** For known-size collections, set it to the expected max. This prevents runaway execution. - **Keep loop bodies lightweight.** Heavy operations inside loops multiply execution time. Consider using Parallel blocks for independent iterations. - **Handle empty collections.** If the input array is empty, the loop body never executes. Downstream blocks receive an empty results array. Execute multiple paths concurrently Branch based on item properties inside loops # PandaDoc Block (/docs/blocks/pandadoc) Create, send, manage, and download PandaDoc documents and use the complete public business API The **PandaDoc** block exposes 123 REST business operations and two MCP actions through the searchable **Action** selector. Humans and workflow agents use the same action names and conditional input fields. Each action ID is `pandadoc.` followed by an operation below, such as `pandadoc.createDocument`. ## Connect an account Choose **API key** (the default) or **MCP** when connecting PandaDoc, then select the account in **PandaDoc Account**: | Connection | Actions | Authorization | | --- | --- | --- | | API key | 123 REST business operations | PandaDoc sandbox or production API key | | MCP | List MCP Tools, Call MCP Tool | PandaDoc OAuth | MCP opens PandaDoc's authorization flow. Select the account you want to authorize and complete the connection. Plain REST OAuth is not offered. Never put API keys or OAuth tokens into action configuration. Workflows uses the selected account to authorize each action. A connected account alone does not prove access to every PandaDoc endpoint: workspace roles, plan entitlements, and sandbox restrictions still apply. If a request returns 403, check the selected account's authorization and PandaDoc permissions. Verify a read such as **List Templates** for an API-key account or **List MCP Tools** for an MCP account before testing writes. Local tests do not establish production access; verify the deployed runtime with a read against its own connection. ## Action coverage Coverage follows PandaDoc's [OpenAPI 8.17.0 specification](https://developers.pandadoc.com/openapi/pandadoc-public-api.json), checked September 16, 2026. It publishes 124 operations; OAuth token exchange is excluded from workflow actions, leaving 123 REST operations. Previously, the selector exposed five operations: `createDocument`, `sendDocument`, `getDocumentStatus`, `listDocuments`, and `downloadDocument`. Thirteen additional existing executors are now selectable, and 105 new business operations complete this API inventory. Existing action names remain valid. | API group | Count | Workflows operations | | --- | ---: | --- | | Documents | 23 | `listDocuments`, `createDocument`, `bulkDeleteDocuments`, `createDocumentFromUpload`, `getDocumentStatus`, `deleteDocument`, `updateDocument`, `documentESignDisclosure`, `updateDocumentStatus`, `changeDocumentStatusWithUpload`, `documentRevertToDraft`, `getDocument`, `sendDocument`, `createDocumentEditingSession`, `createDocumentLink`, `downloadDocument`, `downloadProtectedDocument`, `transferDocumentOwnership`, `transferAllDocumentsOwnership`, `documentMoveToFolder`, `appendContentLibraryItemToDocument`, `createExportDocxTask`, `getDocxExportTask` | | Document Reminders | 4 | `updateDocumentAutoReminderSettings`, `getDocumentAutoReminderSettings`, `statusDocumentAutoReminder`, `createManualReminder` | | Document Link to CRM | 4 | `listDocumentsByLinkedObject`, `listLinkedObjects`, `createLinkedObject`, `deleteLinkedObject` | | Document Attachments | 6 | `listDocumentAttachments`, `createDocumentAttachment`, `createDocumentAttachmentFromFileUpload`, `detailsDocumentAttachment`, `deleteDocumentAttachment`, `downloadDocumentAttachment` | | Document Fields | 3 | `listDocumentFields`, `updateDocumentFieldsAssignment`, `createDocumentFields` | | Document Audit Trail | 1 | `listDocumentAuditTrail` | | Document Settings | 2 | `documentSettingsGet`, `documentSettingsUpdate` | | Document Recipients | 4 | `addDocumentRecipient`, `deleteDocumentRecipient`, `editDocumentRecipient`, `reassignDocumentRecipient` | | Document Sections (Bundles) | 6 | `listSections`, `uploadSection`, `uploadSectionWithUpload`, `sectionDetails`, `sectionInfo`, `deleteSection` | | Content Library Items | 5 | `listContentLibraryItems`, `createContentLibraryItem`, `createContentLibraryItemFromUpload`, `statusContentLibraryItem`, `detailsContentLibraryItem` | | Templates | 9 | `listTemplates`, `createTemplate`, `createTemplateWithUpload`, `duplicateTemplate`, `getTemplate`, `deleteTemplate`, `statusTemplate`, `updateTemplate`, `createTemplateEditingSession` | | Template Settings | 4 | `templateSettingsGet`, `templateSettingsUpdate`, `getTemplateShares`, `updateTemplateSharingSettings` | | Forms | 1 | `listForm` | | Folders | 6 | `listFolders`, `createFolder`, `renameFolder`, `listTemplateFolders`, `createTemplateFolder`, `renameTemplateFolder` | | API Logs | 4 | `listLogs`, `detailsLog`, `listLogsV2`, `detailsLogV2` | | Contacts | 5 | `listContacts`, `createContact`, `detailsContact`, `deleteContact`, `updateContact` | | Members | 4 | `listMembers`, `detailsCurrentMember`, `detailsMember`, `createMemberToken` | | Webhook subscriptions | 6 | `listWebhookSubscriptions`, `createWebhookSubscription`, `detailsWebhookSubscription`, `updateWebhookSubscription`, `deleteWebhookSubscription`, `updateWebhookSubscriptionSharedKey` | | Webhook events | 2 | `listWebhookEvent`, `detailsWebhookEvent` | | Notary | 5 | `listNotaries`, `listNotarizationRequests`, `createNotarizationRequest`, `notarizationRequestDetails`, `deleteNotarizationRequest` | | Product catalog | 5 | `searchCatalogItems`, `createCatalogItem`, `getCatalogItem`, `updateCatalogItem`, `deleteCatalogItem` | | Quotes | 2 | `quoteUpdate`, `quotesBatchUpdate` | | User and Workspace management | 11 | `getWorkspacesList`, `createWorkspace`, `deactivateWorkspace`, `listUsers`, `createUser`, `detailsUser`, `addMember`, `removeMember`, `changeMemberRole`, `createApiKey`, `updateWorkspace` | | Communication Preferences | 1 | `listRecentSmsOptOuts` | The existing Workflows names `getDocumentStatus`, `getDocument`, `updateDocumentStatus`, `getTemplate`, `listFolders`, `createFolder`, and `renameFolder` correspond to PandaDoc's `statusDocument`, `detailsDocument`, `changeDocumentStatus`, `detailsTemplate`, `listDocumentFolders`, `createDocumentFolder`, and `renameDocumentFolder` operation IDs. Folder aliases manage document folders; template folders have separate actions. ## MCP tools The MCP connection uses the [official PandaDoc MCP server](https://developers.pandadoc.com/docs/getting-started-with-mcp). The current connection choice uses the global server. 1. Connect with **MCP** and select that account. 2. Run **List MCP Tools** (`pandadoc.listMcpTools`). It returns `tools` with each tool's name, description, and `inputSchema`. If `nextCursor` is present, supply it as **Cursor** for the next page. 3. Run **Call MCP Tool** (`pandadoc.callMcpTool`) with the exact **Tool Name** and **Arguments (JSON)** matching that schema. It returns the provider's `content` and, when supplied, `structuredContent`. The MCP catalog depends on PandaDoc and the authorized account; it is separate from the 123 REST operations and is discovered at runtime. Tool calls can mutate or send documents. Check tool descriptions before execution. REST actions reject MCP connections, and MCP actions require an MCP connection. Each action initializes a session, performs one listing page or tool call, and closes the session. Requests time out after 30 seconds; session cleanup has a separate 2-second timeout. Inputs are capped at 1 MiB and streamed responses at 4 MiB. Transport retries and automatic reconnection are disabled. A failed or timed-out write may already have completed: inspect its outcome before retrying or enabling workflow-level retries. Provider error bodies are omitted from errors. Tool outputs remain private workflow data and can include document access links. ## Configure an action IDs and query parameters appear as conditional controls for the selected action. New operations accept their API request body in **Data (JSON)**, using PandaDoc's field names, including required nested fields. Expand **Request fields** beneath that control to see the selected action's schema guidance; agents receive the same parameter descriptions. Path/query controls use camelCase names such as `documentId`, `templateId`, and `workspaceId`. Refer to the [API reference](https://developers.pandadoc.com/reference/about) for the selected operation's request schema. Existing actions keep their named inputs. Their **Data (JSON)** field accepts additional API properties; explicitly supplied named inputs take precedence. **Query (JSON)** accepts additional API query parameters. JSON controls accept a JSON value or a JSON-encoded string, so values can come from an earlier step. For example, create a document from a template: ```json { "id": "create_contract", "type": "action", "name": "Create contract", "action": "pandadoc.createDocument", "config": { "operation": "createDocument", "templateId": "", "name": "Contract for Example Co", "recipients": [{ "email": "signer@example.com", "role": "Client" }], "tokens": [{ "name": "Client.Company", "value": "Example Co" }], "fields": { "ContractValue": { "value": "5000" } } } } ``` Use the template's actual role, token, and field names. Supply either **Template ID** or a public source **URL**. **Name** is required for URL creation; template creation can use the template name. Configure recipients when creating the document; an omitted template recipient list is sent as an empty array. **Send Document** sends to the existing recipients and accepts `subject`, `message`, `silent`, and `forwarding_allowed`; its recipients are not supplied again. For a new operation with a nested request body: ```json { "id": "create_attachment", "type": "action", "name": "Attach supporting file", "action": "pandadoc.createDocumentAttachment", "config": { "operation": "createDocumentAttachment", "documentId": "{{steps.create_contract.id}}", "data": { "name": "Supporting information", "source": "https://example.com/supporting-information.pdf" } } } ``` Both examples require a selected PandaDoc connection in the workflow. Sending, reminders, recipient changes, and document sharing can notify recipients; use disposable sandbox records for testing. Contact creation supports email, phone, and other contact fields without requiring email. Contact updates expose only fields supported by the current API; email and country are creation fields. ## Status, uploads, and downloads Creation and uploads are asynchronous. A newly created document commonly returns `document.uploaded`; use **Get Document Status** or a document-status webhook to wait for `document.draft` before sending. Section uploads and DOCX exports return task identifiers: use **Section Details** or **Get Docx Export Task** to observe completion before reading their result. A successful task-creation response is not the completed file. **List Documents** accepts numeric `status` values from 0 through 14; `0` means draft, `1` sent, and `2` completed. **Update Document Status** permits `2` (completed), `10` (paid), `11` (voided, shown as Expired in PandaDoc), and `12` (declined), subject to PandaDoc's transition rules. Paid requires a connected payment app. Existing `document.*` status strings are normalized for legacy workflows. `notifySigner` controls PandaDoc's `notify_recipients` field. **Create Document Link** requires an existing recipient email and accepts an optional positive `lifetime` in seconds. Upload actions expose `fileBase64` and `filename`, plus optional `contentType`. Put the endpoint's accompanying metadata in **Data (JSON)**; attachment upload uses its **Name** input. File content must be base64 bytes, not a URL. Uploads are buffered in worker memory and subject to the endpoint limit (50 or 100 MiB); large uploads require a worker with sufficient memory. JSON responses preserve PandaDoc's properties at the top level, such as `id`, `status`, and `results`, so subsequent steps can reference `{{steps.create_contract.id}}`. Download actions return `status: "ready"`, `contentBase64`, `contentType`, `size`, and `retryAfterSeconds` rather than a download URL. A 202 response returns `status: "pending"` with null file content; wait for `retryAfterSeconds` when present and call the download action again. With `separateFiles: true`, a document bundle can return a ZIP archive instead of a single PDF. Wait for the provider's required document state before downloading. Downloads are limited to 14 MiB of raw bytes to bound memory and retained workflow output; larger responses fail explicitly without truncation. **Quotes Batch Update** reports each quote's result independently inside `results`; HTTP success does not mean every quote update succeeded. Inspect each result before continuing. ## Credential-producing operations **Create API Key**, **Create Member Token**, **Create Webhook Subscription**, and **Update Webhook Subscription Shared Key** require a new **Secret Name**. Their server-side executor verifies workflow ownership and encrypted-secret storage before contacting PandaDoc. The credential is stored in the workflow's encrypted secrets; results contain only a secret reference and nonsecret metadata. Existing secret names cannot be overwritten by these operations. Use the returned secret in a **subsequent workflow run**: the runtime resolves secrets at run start, so a newly saved secret is not automatically available later in the same run. The runtime requires a configured `ENCRYPTION_KEY`. If a provider mutation succeeds but persistence fails, the action reports the need to recover or revoke the provider credential before retrying; it never returns the plaintext credential as a fallback. Webhook list/detail/update responses omit shared keys. API log detail responses omit API keys and request/response bodies that may contain credentials. Signing and editing sessions intentionally return document-scoped bearer session values for embedding; use short lifetimes and treat these results as private access links. ## Sandbox and production limits PandaDoc's [sandbox-key restrictions](https://developers.pandadoc.com/reference/sandbox-key) apply to test connections. During the free trial, sandbox send requests require sender and recipient email addresses from the same organization domain. **Download Protected Document** requires a production key; PandaDoc documents a 401 response for sandbox keys. Workspace administration, notary, catalog, and beta features also depend on the connected account's access. The block exposes available API operations; account permission and live sandbox or production execution must be verified separately. The action inventory is tied to the specification version above and does not promise access to features added later. # Parallel Block (/docs/blocks/parallel) Execute multiple workflow paths concurrently for faster processing The Parallel block executes multiple workflow paths concurrently. Use it when you need to perform independent operations simultaneously -- like updating multiple systems, sending notifications to different channels, or processing data in parallel. ## Configuration | Option | Type | Description | |--------|------|-------------| | `branches` | edges | Multiple output connections, each defining a parallel path | | `waitForAll` | boolean | Wait for all branches to complete before continuing (default: true) | ## How It Works 1. When the Parallel block executes, all connected downstream paths start simultaneously 2. Each branch runs independently with access to the same upstream data 3. When all branches complete, the Parallel block collects results and passes them downstream 4. If any branch fails, the error handling strategy determines the behavior ## Example Patterns ### Multi-System Update ``` Stripe (payment received) -> Parallel -> QuickBooks (record payment) -> Salesforce (update opportunity) -> Slack (notify #finance) -> Gmail (send receipt to customer) ``` ### Parallel Data Fetch ``` Start -> Parallel -> Stripe (get customer) -> QuickBooks (get invoices) -> Salesforce (get account) -> Transform (combine all data) -> Agent (analyze) ``` ## Best Practices - **Use for independent operations.** Parallel is most effective when branches do not depend on each other's results. - **Be mindful of API rate limits.** Running many API calls in parallel can trigger rate limiting on external services. - **Keep branches balanced.** A parallel block completes when its slowest branch finishes. One slow branch delays the entire block. Iterate over collections sequentially Branch based on conditions (one path only) # PDF Block (/docs/blocks/pdf) Read text, extract selected pages, or inspect PDF files in a workflow The **PDF** block reads text from a document, creates a smaller PDF from selected pages, or reports basic file information. It is a core block and does not require a connection or API key. Use a workspace file ID when possible. The block also accepts a public HTTP(S) URL or inline base64 content; when more than one source is set, `fileId` takes precedence, followed by `url`, then `content`. PDF sources are limited to 64 MiB. URL downloads are time-bounded, revalidate every redirect, and refuse private-network destinations. For reliable large-file workflows, save the document to workspace files first and pass its `fileId`. ## Operations | Operation | Behaviour | |-----------|-----------| | `readText` | Extract text from selected pages. Reads at most 500 pages and returns at most 10 MiB of text. | | `extractPages` | Create a PDF containing the selected pages. Saves to workspace files by default. | | `getInfo` | Return page count, filename, byte size, and encryption status without extracting text. | ## Source and Page Selection All operations accept one source: | Field | Description | |-------|-------------| | `fileId` | ID returned by a workspace-file or persisted-download step. Preferred for large files. | | `url` | Public `http` or `https` URL. Embedded URL credentials and private-network targets are rejected. | | `content` | Inline PDF content. `contentEncoding` defaults to `base64`; use `text` only when the input is literal text bytes. | `readText` and `extractPages` share the same 1-based page selectors: - `pages`: explicit pages and ranges, such as `1-5, 8, 11-13` - `firstPages`: take pages from the start - `lastPages`: take pages from the end Selectors combine and duplicate pages are removed. Leave all three blank to use the entire document. A range beyond the end is clamped; an individual out-of-range page is ignored. ## Read Text `readText` returns both `pages` (one string per selected page) and `text` (the same strings joined in order). `pagesRead` reports the selected 1-based page numbers, while `pageCount` remains the source document's total. ```json { "id": "read_invoice", "type": "action", "name": "Read invoice summary", "action": "pdf.readText", "config": { "operation": "readText", "fileId": "{{steps.download_invoice.fileId}}", "pages": "1", "lastPages": 3 } } ``` ## Extract Pages `extractPages` defaults to `output: "file"`. It saves the result in workspace files and returns `fileId`, `filePath`, `fileName`, `sizeBytes`, `pageCount`, and source metadata. Set `baseName`, `folderPath`, or `folderId` to control the destination. When both folder fields are set, `folderPath` is created beneath `folderId`. If the selection covers every page, an existing workspace source is reused only when no destination or filename override was supplied. URL and inline sources are still saved when file output is requested. ```json { "id": "trim_invoice", "type": "action", "name": "Keep invoice summary pages", "action": "pdf.extractPages", "config": { "operation": "extractPages", "fileId": "{{steps.download_invoice.fileId}}", "pages": "1", "lastPages": 3, "output": "file", "baseName": "invoice-{{input.invoiceId}}", "folderPath": "Invoices/Prepared" } } ``` Set `output: "content"` only for a small direct handoff. Inline output is base64 and is retained in the run state, so its raw PDF size has an immutable 14 MiB ceiling even when `maxSizeBytes` is larger. Saved-file output avoids that retained payload. ## Get Info Use `getInfo` before conditional routing when you only need document facts: ```json { "id": "inspect_pdf", "type": "action", "name": "Inspect PDF", "action": "pdf.getInfo", "config": { "operation": "getInfo", "fileId": "{{steps.download_invoice.fileId}}" } } ``` # Stagehand Block (/docs/blocks/stagehand) AI-driven browser automation for portal navigation, downloads, and form filling The Stagehand block provides AI-driven browser automation powered by [Stagehand](https://docs.stagehand.dev) and [Browserbase](https://docs.browserbase.com). Use it to navigate web portals, download reports, fill forms, and extract data from websites that do not have APIs. Stagehand uses computer vision and AI to understand web pages, making it resilient to UI changes. Actions are described in natural language rather than brittle CSS selectors. ## Available Actions ### Navigate Open a URL in the browser session. | Option | Type | Description | |--------|------|-------------| | `url` | string | URL to navigate to | | `contextId` | string | Browserbase context ID for auth persistence (optional) | ### Act Execute a browser action described in natural language. | Option | Type | Description | |--------|------|-------------| | `instruction` | string | What to do (e.g., "Click the login button") | | `timeoutMs` | number | Action timeout in milliseconds (default: 30000) | | `agentFallback` | boolean | Fall back to agent mode if act fails (default: false) | | `observeBeforeAct` | boolean | Require exactly one observed action, then execute it without another model call | | `retryAttempts` | number | Additional retries for invalid AI structured output (default: 2, maximum: 4) | ### Extract Extract structured data from the current page. | Option | Type | Description | |--------|------|-------------| | `instruction` | string | What to extract (e.g., "Get the invoice total and due date") | ### Download Download a file triggered by a browser action. | Option | Type | Description | |--------|------|-------------| | `instruction` | string | Action that triggers the download (e.g., "Click the PDF download link") | | `downloadWaitMs` | number | Wait time before polling for the file (default: 3000) | | `observeBeforeAct` | boolean | Require exactly one observed click action before triggering the download | | `retryAttempts` | number | Additional retries for invalid AI output, missing downloads, canceled downloads, or MIME mismatches (default: 2, maximum: 4) | | `persistToFolder` | string | Write the file straight to this workspace folder and return `{ fileId, filePath }` instead of inline content. Keeps large downloads out of the step result. | | `expectedMimeType` | string | Optional. Leave empty to accept **any** content type — the stored MIME is then derived from the file's bytes. | Set `expectedMimeType` only when the site always serves the same type. For a sniffable type (`application/pdf`, `image/png`, `image/jpeg`, `application/zip`) it is a hard assertion against the downloaded bytes: if they disagree the step fails even though the file downloaded correctly, and a site that sometimes serves a `.zip` instead of a PDF will fail every time. Prefer leaving it unset and validating the artifact in a later step with the `workspace_files` **extract archive** operation. ### Screenshot Capture the current page state. | Option | Type | Description | |--------|------|-------------| | `fullPage` | boolean | Capture entire page vs viewport only (default: false) | ### Agent Run an autonomous multi-step browser task. | Option | Type | Description | |--------|------|-------------| | `instruction` | string | High-level goal (e.g., "Log in and download the monthly report") | | `maxSteps` | number | Maximum actions the agent can take (default: 10) | ## Example Patterns ### Download Accounting Reports ``` Schedule (monthly) -> Stagehand (navigate to portal) -> Stagehand (act: log in) -> Stagehand (act: navigate to reports) -> Stagehand (download: click PDF download) -> Agent (extract data from PDF) -> QuickBooks (record entries) ``` ### Fill Tax Forms ``` Trigger -> Stagehand (navigate to tax portal) -> Stagehand (act: fill in company name) -> Stagehand (act: fill in EIN) -> Stagehand (act: submit form) -> Stagehand (screenshot: confirmation) -> Slack (share confirmation) ``` ## Session Management Browser sessions are cached per workflow run. One Browserbase session is shared across all Stagehand steps in a single execution, with a 15-minute TTL. **Auth persistence:** Use Browserbase Contexts to persist cookies and login state across workflow runs. Create a context once, log in, and reuse the `contextId` in subsequent runs to skip login steps. ## Best Practices - **Use `contextId` for authenticated portals.** This avoids re-authenticating on every run. - **Set `agentFallback: true` for unreliable UI elements.** If an `act` instruction fails (e.g., button not found), the agent mode can recover by exploring the page. - **Use `observeBeforeAct` for high-value controls.** The runtime requires exactly one selector-backed action and fails without clicking when the result is missing or ambiguous. - **Keep download retries bounded.** Each retry takes a fresh Browserbase download baseline, so a bad first file cannot be returned as the next attempt's result. - **Use `screenshot` for debugging.** Take screenshots before and after critical actions to verify the browser state. - **Keep instructions specific.** "Click the blue Download PDF button in the Reports section" is better than "download the report." Process downloaded documents with AI Map extracted data to target format # Sub-workflow Block (/docs/blocks/subworkflow) Call another workflow as a step for composable automation The Sub-workflow block calls another workflow as a step within the current workflow. Use it to build reusable workflow components -- for example, a standard invoice creation workflow that can be called from multiple trigger workflows. ## Configuration | Option | Type | Description | |--------|------|-------------| | `workflowId` | string | The ID of the workflow to call | | `input` | JSON object | Passed to the sub-workflow as its `input`, so the child reads it as `{{input.}}` | | `waitForCompletion` | boolean | Wait for the sub-workflow to finish (default: true) | | `timeoutMinutes` | number | How long to wait for the sub-workflow when `waitForCompletion` is on (default: 5, maximum: 1440) | `input` must be a JSON object. The step fails before starting the sub-workflow if `input` is text, an array, or a single value, or if it leaves out (or leaves empty) a field the sub-workflow's input schema marks as required. Without that check, every `{{input.}}` in the sub-workflow would resolve to an empty value and the run would fail later, at a step far from the cause. Workflows saved before this field was renamed may still use `inputMapping` (for `input`) or `timeout` in milliseconds (for `timeoutMinutes`). Both are still read, and the next save in the editor moves them to the current names. A value that cannot be moved (it differs from the current field, or it is a templated timeout) appears in the editor as a Legacy field until you clear it. ## Outputs | Output | Type | Description | |--------|------|-------------| | `result` | JSON | The sub-workflow's final output | | `runId` | string | The run ID of the sub-workflow execution | | `status` | string | Final status: `completed` or `failed` | ## Example Pattern ### Reusable Invoice Creator Main workflow: ``` Salesforce (deal won) -> Transform (extract deal data) -> Sub-workflow (Standard Invoice Creator) -> Slack (notify finance) ``` Sub-workflow ("Standard Invoice Creator"): ``` Start -> Stripe (create customer) -> Stripe (create invoice) -> Loop (add line items) -> Stripe (finalize) -> Stripe (send) ``` # Transform Block (/docs/blocks/transform) Map and reshape data between systems using declarative field mapping The Transform block maps data from one shape to another using declarative field mapping. Use it to convert between system formats -- for example, mapping a Stripe invoice to a QuickBooks invoice structure. ## Configuration ### Mapping Define field mappings as key-value pairs where keys are output field names and values are template expressions referencing input data. ```json { "customerEmail": "{{input.data.object.customer_email}}", "amount": "{{input.data.object.amount_paid}}", "invoiceNumber": "{{input.data.object.number}}", "paidAt": "{{input.data.object.status_transitions.paid_at}}" } ``` ### Computed Fields Mappings support expressions for simple transformations: ```json { "amountDollars": "{{input.data.object.amount_paid / 100}}", "displayName": "{{input.data.object.customer_name || 'Unknown'}}", "isPaid": "{{input.data.object.status === 'paid'}}" } ``` ## Outputs | Output | Type | Description | |--------|------|-------------| | `result` | JSON | The transformed data object with all mapped fields | ## When to Use Transform vs Code | Scenario | Use | |----------|-----| | Simple field renaming | Transform | | Nested object restructuring | Transform | | Basic calculations | Transform | | Loops, conditionals, aggregations | Code block | | External API calls | Integration block or Code block | ## Best Practices - **Name output fields descriptively.** Use camelCase and match the target system's naming convention. - **Handle missing fields.** Use the `||` operator to provide fallback values: `{{input.field || 'default'}}`. - **Keep transforms focused.** One Transform block per system-to-system mapping. Do not try to handle all transformations in a single block. Use code for complex transformations Use AI for unstructured data transformation , <), logical operators (&&, ||), and the ternary operator for inline conditionals." }, { question: "Can I access nested fields?", answer: "Yes. Use dot notation to access deeply nested properties: {{input.data.object.customer.address.city}}. Array indexing is also supported: {{input.data.items[0].amount}}." }, { question: "What happens if a referenced field does not exist?", answer: "If a referenced field is undefined, the output field is set to null. Use the || operator to provide fallback values and avoid null outputs." }, { question: "Can I output arrays from a Transform block?", answer: "The Transform block outputs a flat JSON object by default. For array transformations, use the Code block which has full JavaScript array methods available." }, ]} /> # Wait Block (/docs/blocks/wait) Pause workflow execution for a specified duration The Wait block pauses workflow execution for a specified duration. Use it to add delays between steps -- for example, waiting before retrying a failed API call, spacing out notification emails, or implementing cooling-off periods. ## Configuration | Option | Type | Description | |--------|------|-------------| | `duration` | number | Wait time value | | `unit` | string | Time unit: `seconds`, `minutes`, `hours`, `days` | ## Example Patterns ### Retry with Delay ``` Stripe (create charge) -> Condition (failed?) -> True: Wait (30 seconds) -> Stripe (retry charge) ``` ### Spaced Notifications ``` Invoice Created -> Email (send invoice) -> Wait (3 days) -> Condition (paid?) -> False: Email (send reminder) ``` ### Cooling-Off Period ``` Contract Signed -> Wait (24 hours) -> Condition (not cancelled?) -> True: Stripe (process payment) ``` # Brand Guidelines (/docs/brand-guidelines) Loopfour visual identity, design tokens, and usage guidelines ## Loopfour Brand Guidelines This document outlines the visual identity system for Loopfour, including colors, typography, logo usage, and animation principles. ## Brand Philosophy **Design Principles:** - **Precision over decoration** — Every element serves a purpose - **Quiet confidence** — The interface feels assured, not flashy - **Negative space as a feature** — Let content breathe - **Systematic consistency** — Predictable patterns build trust **Aesthetic References:** - Cursor.ai: Deep blacks, subtle borders, monospace elegance - Linear: Clean surfaces, precise spacing, purposeful animation - Vercel: High contrast, typographic hierarchy --- ## Color System Loopfour uses a **monochrome palette** optimized for both light and dark modes. ### Light Mode | Token | Value | Usage | |-------|-------|-------| | `--lf-gray-50` | `#FAFAFA` | Subtle backgrounds | | `--lf-gray-100` | `#F5F5F5` | Page background | | `--lf-gray-200` | `#EBEBEB` | Elevated surfaces | | `--lf-gray-300` | `#DEDEDE` | Borders, dividers | | `--lf-gray-400` | `#B3B3B3` | Disabled text | | `--lf-gray-500` | `#8C8C8C` | Placeholder text | | `--lf-gray-600` | `#666666` | Secondary text | | `--lf-gray-700` | `#404040` | Body text | | `--lf-gray-800` | `#262626` | Headings | | `--lf-gray-900` | `#171717` | Primary text | | `--lf-gray-950` | `#0A0A0A` | Maximum contrast | ### Dark Mode | Token | Value | Usage | |-------|-------|-------| | `--lf-gray-50` | `#0F0F0F` | Deepest surfaces | | `--lf-gray-100` | `#141414` | Page background | | `--lf-gray-200` | `#1C1C1C` | Elevated surfaces | | `--lf-gray-300` | `#292929` | Borders | | `--lf-gray-400` | `#474747` | Strong borders | | `--lf-gray-500` | `#737373` | Placeholder | | `--lf-gray-600` | `#999999` | Secondary text | | `--lf-gray-700` | `#BFBFBF` | Body text | | `--lf-gray-800` | `#E0E0E0` | Headings | | `--lf-gray-900` | `#F2F2F2` | Primary text | | `--lf-gray-950` | `#FAFAFA` | Maximum contrast | --- ## Typography ### Font Family **Primary:** Geist Sans (by Vercel) - Modern, geometric sans-serif - Excellent screen rendering - Free and open source **Monospace:** Geist Mono - Perfect pairing with Geist Sans - Optimized for code display ### Type Scale | Class | Size | Weight | Usage | |-------|------|--------|-------| | `.lf-heading-display` | 3rem | 700 | Hero headlines | | `.lf-heading-1` | 2.5rem | 700 | Page titles | | `.lf-heading-2` | 2rem | 600 | Section titles | | `.lf-heading-3` | 1.5rem | 600 | Subsection titles | | `.lf-body-large` | 1.125rem | 400 | Lead paragraphs | | `.lf-body` | 1rem | 400 | Body text | | `.lf-body-small` | 0.875rem | 400 | Captions | | `.lf-label` | 0.75rem | 500 | Labels, uppercase | --- ## Logo ### The Mark The Loopfour logo consists of **4 circles arranged in a pinwheel pattern**, connected by lines to a central hub. This represents: - **4 workflow stages:** Trigger → Action → Transform → Output - **Continuous loop:** Data flows endlessly through the system - **Connected systems:** Everything is integrated ### Usage Guidelines **Clear Space:** - Minimum padding: equal to the diameter of one circle **Minimum Sizes:** - Mark alone: 16px (favicon) - Mark + wordmark: 80px width **Color Usage:** - Primary: Full contrast (black on light, white on dark) - Never use color tints - Always use solid fills ### Logo Variants ```tsx // Standard logo with wordmark // Mark only (for favicon, compact spaces) // Animated mark (for interactive elements) ``` --- ## Spacing & Layout ### Spacing Scale Uses a 4px base unit: | Token | Value | |-------|-------| | `--lf-space-1` | 4px | | `--lf-space-2` | 8px | | `--lf-space-3` | 12px | | `--lf-space-4` | 16px | | `--lf-space-6` | 24px | | `--lf-space-8` | 32px | | `--lf-space-12` | 48px | | `--lf-space-16` | 64px | ### Border Radius | Token | Value | Usage | |-------|-------|-------| | `--lf-radius-sm` | 2px | Subtle rounding | | `--lf-radius` | 4px | Default (inputs, buttons) | | `--lf-radius-md` | 6px | Cards | | `--lf-radius-lg` | 8px | Larger containers | | `--lf-radius-xl` | 12px | Hero sections | --- ## Animation Principles ### Core Philosophy - **Scroll-scrub preferred:** User controls animation pace via scroll - **Single focal point:** One element captures attention at a time - **Subtle, purposeful:** Motion reinforces meaning, not decoration - **Respect reduced motion:** Always provide static fallback ### Hero Animation The hero animation visualizes a workflow loop: 1. Pulse spawns at TRIGGER node 2. Travels through ACTION, TRANSFORM, OUTPUT 3. Completes the loop 4. "Woah moment": entire system illuminates ```tsx ``` ### GSAP Configuration ```javascript // Standard scroll-scrub setup const timeline = gsap.timeline({ scrollTrigger: { trigger: container, start: 'top top', end: '+=2500', // 2500px scroll distance scrub: 1.2, // Smooth 1.2s lag pin: true, }, }); ``` --- ## Implementation Checklist - [ ] Install dependencies: `bun add geist gsap` - [ ] Import Geist fonts in `layout.tsx` - [ ] Add CSS variables from `globals.css` - [ ] Extend Tailwind config with `lf-*` tokens - [ ] Use `Logo` component in navigation - [ ] Add `HeroAnimation` to landing page --- ## Files Reference | Purpose | Location | |---------|----------| | CSS Design Tokens | `app/globals.css` | | Tailwind Config | `tailwind.config.js` | | Logo Component | `components/brand/Logo.tsx` | | Hero Animation | `components/brand/HeroAnimation.tsx` | | Brand Exports | `components/brand/index.ts` | # Connections (/docs/connections) How data flows between blocks and how to manage integration credentials Connections in Loopfour have two meanings: the **edges** that link blocks together on the canvas (defining data flow), and the **integration connections** that authenticate your workflow with external services like Stripe, Salesforce, and Slack. ## Data Flow (Edges) Edges are the lines connecting blocks on the canvas. They define two things: 1. **Execution order** -- a block only runs after all its upstream blocks complete 2. **Data access** -- downstream blocks can reference outputs from upstream blocks via template variables ### Creating Edges Click an output port on a block and drag to an input port on another block. The edge appears as a line with an arrow indicating data flow direction. ### Template Variables Access data from upstream blocks using template variable syntax: | Context | Syntax | Example | |---------|--------|---------| | Trigger payload | `{{input.field}}` | `{{input.data.object.amount}}` | | Step output | `{{steps.stepId.output.field}}` | `{{steps.lookup.output.email}}` | | Loop variables (empty outside a loop) | `{{variables.name}}` | `{{variables.item}}` | | System values | `{{now}}`, `{{runId}}` | Current timestamp, execution ID | ### Data Flow Principles - **Directional** -- data flows from outputs to inputs, never backwards - **Automatic** -- the execution engine resolves all template variables before a block runs - **Concurrent** -- blocks with no dependency on each other run in parallel automatically - **Conditional** -- Condition blocks activate only one downstream path per execution ## Integration Connections Integration connections authenticate your workflows with external services. They are managed through **Nango** and support OAuth 2.0 and API key authentication. ### Setting Up a Connection 1. Open the **Integrations** page in the studio 2. Click **Connect** on the provider (Stripe, Salesforce, etc.) 3. Complete the OAuth flow or enter your API key -- API-key providers accept an optional connection name at this step 4. The connection is now available in all workflow blocks for that provider ### Multiple Accounts per Provider A company can hold several accounts for the same provider -- for example two QuickBooks companies or a production and a sandbox Stripe account. Once a provider has at least one connection, its row on the Integrations page shows a **Manage** button that opens the account list for that provider. From there you can: - **Add account** -- connect another account via OAuth or API key - **Rename** -- give any connection a display name (names are optional; a default label is generated when you skip it) - **Set as default** -- exactly one connection per provider is the default, marked with a star - **Verify / Refresh / Disconnect** -- per-account health check, credential refresh, and removal - **Update API key** (API-key providers) -- replace the credentials of one specific account; submitting a new key without choosing an account creates a new connection instead of overwriting an existing one Workflow steps that don't select a specific account -- and the `{{connections.}}` template variable -- resolve to the provider's **default** connection. Every add, remove, rename, default change, and credential refresh is recorded in the audit log. ### Choosing an Account per Workflow Step Integration blocks include a **connection selector** in the workflow editor. It lists every account you hold for the block's provider, marks the default with a star, and the account you pick is stored on the step and used at run time -- company-scoped and provider-checked, so a step can only ever run against one of your own accounts for its own provider. Steps without a selection keep using the provider's default account. If a selected account is later disconnected, the editor shows a warning on the step prompting you to reselect; until you do, runs log a warning and fall back to the provider's default account. AI agent steps can hold a selection **per tool provider**: the agent block's "Tool accounts" section shows one selector for each provider its tools use (e.g. NetSuite tools against a specific subsidiary while Slack tools use the default workspace). The account choice always comes from the workflow configuration -- an agent's tool call cannot switch accounts on its own. ### Security - All credentials are encrypted at rest with **AES-256-GCM** - OAuth tokens are automatically refreshed when they expire - Credentials are never stored in workflow definitions or exposed in logs - Each connection is scoped to a specific company ### Supported Providers | Provider | Auth Type | Connection Setup | |----------|-----------|-----------------| | Stripe | OAuth 2.0 | Authorize via Stripe Connect | | Salesforce | OAuth 2.0 | Authorize via Salesforce | | HubSpot | OAuth 2.0 | Authorize via HubSpot | | QuickBooks | OAuth 2.0 | Authorize via Intuit | | Xero | OAuth 2.0 | Authorize via Xero | | NetSuite | Token-Based Auth | Enter credentials | | Sage Intacct | API Key | Enter web services credentials | | Slack | OAuth 2.0 | Authorize via Slack | | Gmail | OAuth 2.0 | Authorize via Google | | Outlook | OAuth 2.0 | Authorize via Microsoft | | DocuSign | OAuth 2.0 | Authorize via DocuSign | | PandaDoc | API Key | Enter API key | | Attio | API Key | Enter API key | | Dropbox Sign | API Key | Enter API key | | Rillet | API Key | Enter API key | | Zoho Inventory | OAuth 2.0 | Authorize via Zoho | See all 16 provider integrations Learn about all block types Configure webhook and event triggers # Dashboard Components (/docs/dashboards/components) The components every dashboard, chat chart, and share page renders, and the settings that control them. Every dashboard widget, every chart the copilot draws in chat, and every public share page render through one component library. A widget stores a JSON spec that lists components and binds each figure to a query on one of your tables. The copilot writes the spec for you; you can also edit it as JSON. There is no visual settings panel. Ask the copilot ("show negatives in brackets", "make the chart taller") or edit the widget JSON. ## Components | Component | Use it for | | --- | --- | | `Metric` | A KPI tile: value, delta against a comparison period, optional sparkline and target bar | | `BarChart` | Comparisons by category or period; stacked, horizontal, or signed (negatives below zero) | | `LineChart` | Trends over time, with comparison, budget, and forecast series | | `AreaChart` | Cumulative or stacked trends over time | | `ComboChart` | Bars and lines on one chart, with an optional right axis | | `PieChart` | Share of a total, as a donut with at most 5 slices plus Other | | `BarList` | Rankings: top customers, vendors, or spend categories | | `WaterfallChart` | Bridges: MRR movements, cash bridge, budget-to-actual walk | | `DataTable` | Row-level tables and statement layouts with subtotals and totals | | `PivotTable` | Cross-tabs with row groups, subtotals, and totals | | `VarianceTable` | Budget vs actual with variance and variance percent | | `CohortHeatmap` | Retention or revenue by cohort and period | | `DateRangeFilter` | Period presets (MTD, QTD, YTD, fiscal) and a compare toggle | | `SearchInput`, `Select` | Filters that other components read | | `Stack`, `Grid`, `Card`, `Heading`, `Text`, `Badge`, `Callout`, `Button` | Layout and text | ## Header Every titled figure has the same header: - Title, with an info icon when `definition` is set. - One muted line: period, currency, and `basis` (for example `Jan–Sep 2026 · USD · Accrual`), plus the comparison period when compare is on. - `as of HH:MM` for the last load, and a **Stale** chip when the figure shows the last good result after a failed load. - Actions: **Export CSV**, and **Open data** (not shown on public share pages). Header props: `subtitle`, `definition`, `basis`, `showAsOf`, `actions` (`export`, `open`). ## Number formats A `format` is a legacy string (`currency`, `compact_currency`, `percent`, `number`) or an object: | Field | Values | | --- | --- | | `kind` | `currency`, `percent`, `pp`, `number`, `integer`, `months`, `days`, `ratio` | | `currency` | ISO-4217 code, for example `EUR` | | `scale` | `auto` (`$1.24M`), `none` (exact), `thousands`, `millions`, `billions` | | `decimals` | 0 to 4 | | `negative` | `minus` (`-$1,200`) or `parentheses` (`($1,200)`) | | `signDisplay` | `auto` or `always` | Missing values show `—`. A percent change on a zero base shows `N/M`. KPI tiles and chart axes use compact scale; tables show exact values. ## Colors Specs name color tokens, never hex values: - Series: `series-1` to `series-8` (`chart-1` to `chart-5` still work). - Status: `positive`, `negative`, `neutral`, `warning`. Use these for deltas, variances, and thresholds. A series can use them only with `role: 'variance'`. - Roles: `compare` (gray, dashed) and `forecast` (dashed after the forecast start). `thresholds` color a value when it matches a rule: `{ op: 'lt'|'lte'|'gt'|'gte'|'between', value, value2?, color, label? }`, at most 8. ## Dashboard settings Each dashboard has one set of display defaults. A component `format` overrides them field by field. | Setting | Values | Default | | --- | --- | --- | | `locale` | BCP-47 tag, for example `en-GB` | `en-US` | | `currency` | ISO-4217 code | `USD` | | `negative` | `minus` or `parentheses` | `minus` | | `fiscalYearStartMonth` | 1 to 12 | 1 | | `density` | `compact` or `comfortable` | `compact` | The currency setting changes display only. It does not convert amounts. ## Public share links A published dashboard shows live data to anyone with the link. The public page does not receive table IDs, filter values, or queries. Each figure asks the server for its data by widget, and the server runs only the queries stored in that widget. Results are cached for 60 seconds, and the page refreshes every 5 minutes. Revoking the link stops all public loads. ## Adding a component For contributors adding a component to the library: 1. Add the props schema and description to the catalog group in `packages/json-render/src/schemas/components/`, and register it in `catalog.ts`. Reuse the shared settings vocabulary (`numberFormat`, `colorToken`, `thresholds`, header props) instead of new enums. 2. List any prop that holds data in `FIGURE_PROPS` (`check.ts`) so specs with static figures are rejected, and add lint rules in `lint.ts` when the component has usage rules. 3. Add the Studio component under `packages/json-render/src/react/components/` and register it in `registry.tsx`. Use `Panel`, `EmptyState`, `LoadError`, and the format context so headers, states, and numbers stay consistent. 4. Add a rule to the catalog prompt (`prompt.ts`) only when the model needs guidance, and keep the prompt-budget test green. 5. Add table-driven tests for the schema, lint rules, and component. # Execution (/docs/execution) How workflows execute, monitoring runs, and debugging failures When a workflow is triggered, the execution engine compiles the canvas into a directed acyclic graph (DAG) and runs each block in dependency order. Independent blocks execute concurrently. This section covers how execution works, how to monitor runs, and how to debug failures. ## Sections How the execution engine processes your workflow Monitor runs with detailed step-by-step logs Diagnose and fix failing workflows Version and deploy workflows for production Human-in-the-loop workflow execution ## Execution Overview ### How It Works 1. **Trigger fires** -- A webhook, schedule, event, or API call starts the workflow 2. **DAG compilation** -- The canvas is compiled into an ordered graph of steps 3. **Step execution** -- Each step runs when all its dependencies are satisfied 4. **Concurrent processing** -- Independent steps run in parallel automatically 5. **Result collection** -- Outputs are captured and passed to downstream steps 6. **Completion** -- The workflow run is marked as completed or failed ### Run States | State | Description | |-------|-------------| | `pending` | Run created, waiting to start | | `running` | Currently executing steps | | `suspended` | Paused for approval or external event | | `completed` | All steps finished successfully | | `failed` | A step failed after all retries | | `cancelled` | Manually cancelled via API or dashboard | ### Error Handling Every step can be configured with error handling: | Strategy | Behavior | |----------|----------| | `fail` | Stop the workflow immediately (default) | | `continue` | Log the error and proceed to the next step | | `retry` | Retry with exponential backoff (configurable attempts) | ### Retry Configuration ```json { "retryConfig": { "maxAttempts": 3, "initialDelayMs": 1000, "maxDelayMs": 30000, "backoffMultiplier": 2 } } ``` ## Key Principles - **Dependency-based execution** -- Blocks run only when all upstream blocks complete - **Automatic parallelization** -- Independent blocks run concurrently without configuration - **Idempotency** -- Design workflows to be safely re-run with the same input - **Observability** -- Every step captures inputs, outputs, timing, and errors in execution logs - **Cycle protection** -- Sub-workflow call chains are limited to 25 hops to prevent infinite loops # Approval Flows (/docs/execution/approval-flows) Human-in-the-loop workflow execution Some workflows need a human decision before continuing -- approving a high-value invoice, authorizing a discount, or signing off on a sensitive operation. Approval flows pause a run, wait for a person to approve or reject, then resume along the chosen path. This is implemented with the [Approval block](/docs/blocks/approval). ## How Suspension Works When a run reaches an Approval block, the execution engine moves it into the `suspended` state and stops advancing downstream steps. A notification with approve/reject actions is sent to the configured channel. The run stays suspended -- consuming no compute -- until an authorized approver acts or the optional timeout expires. | Run State | Meaning | |-----------|---------| | `running` | Executing steps normally | | `suspended` | Paused, waiting for an approval decision or external event | | `completed` | Resumed and finished successfully | | `failed` | A step failed, or the request was rejected and the Approval step does not continue on rejection | ## The Approval Lifecycle 1. **Reach the block** -- the run reaches an Approval block and suspends 2. **Notify** -- an interactive request is posted to the configured Slack channel with approve/reject buttons 3. **Wait** -- the run remains `suspended` (optionally with a timeout) 4. **Decision** -- an authorized approver approves or rejects 5. **Resume** -- an approval continues the run; a rejection fails the run at the Approval step unless the workflow defines a rejected path (see [Taking the rejected path](#taking-the-rejected-path)) The channel gets one request per approval, which lists every approver. Only a listed approver can decide: Slack matches the person who clicks by their Slack user ID, or by their Slack profile email against an approver email. Anyone else in the channel sees "You are not an approver for this request." and the request stays open. The email match needs the Slack app's `users:read.email` scope. On Enterprise Grid, list approvers by email. The first approver to click Approve or Reject decides it, and the message then shows the decision and who made it as a Slack mention. Email, when configured, still goes to each approver separately. A Slack request sent privately (when Slack refuses the channel post) and the email fallback include a **View approval** link. A request posted in a channel does not, because anyone who has the link can decide without Slack. It opens a recipient-token page on the API with the full reason and Approve and Reject buttons, including when the reason was shortened in Slack or Slack's interactive buttons are unavailable. Recipients do not need a Studio account. Opening the page only reads the request; a decision is recorded when the recipient submits a button. The link is personal to that recipient and should not be forwarded. The page is `GET /api/v1/approvals/recipients/:token` (HTML, no session required; the recipient token authorizes access). Its forms submit `action=approve` or `action=reject` to `POST /api/v1/approvals/recipients/:token/decide`. Decisions submitted on this page are recorded as API decisions, regardless of whether the link arrived by Slack or email. An invalid token returns 404; the page returns 400 once the request is no longer pending. A suspended run does not count against execution time. It can wait for minutes or days depending on your timeout configuration. ## Configuring an Approval Approval behavior is configured on the [Approval block](/docs/blocks/approval): | Option | Type | Description | |--------|------|-------------| | `title` | string | Title shown in the approval request | | `description` | string | Context about what is being approved (supports templates) | | `notifyChannel` | string | Slack channel for the request | | `approvers` | array | Users authorized to approve | | `timeout` | string | How long to wait for a decision, for example `48h` or `7d` (default `7d`) | | `timeoutAction` | string | What happens at the timeout: `fail` (default), `reject`, `approve`, or `escalate` | The block outputs the decision so downstream steps can branch on it: | Output | Type | Description | |--------|------|-------------| | `decision.approved` | boolean | Whether the request was approved | | `decision.reason` | string | The approver's comment, or `Auto-rejected due to timeout` | | `decision.decidedBy` | string | Who decided (`system` for a timeout) | | `decision.decidedAt` | string | Timestamp of the decision | | `timedOut` | boolean | Whether the decision came from the timeout | ## Taking the Rejected Path By default a rejection stops the run: the Approval step is recorded as failed (`APPROVAL_REJECTED`, or `APPROVAL_TIMEOUT` for a timeout auto-reject) and the run ends `failed` without running any later step. To handle a rejection inside the workflow, set the Approval step's error handling to continue and branch on its decision right after it: ```json { "id": "approval_gate", "type": "approval", "config": { "...": "..." }, "errorHandling": { "strategy": "continue" } }, { "id": "check_approved", "type": "condition", "config": { "conditions": { "left": "{{steps.approval_gate.decision.approved}}", "operator": "eq", "right": "true" }, "then": ["send_invoice"], "else": ["record_rejection"] } }, { "id": "record_rejection", "type": "data_table", "terminal": true, "config": { "...": "..." } } ``` With `continue`, a rejection or a timeout auto-reject (`timeoutAction: reject`) keeps the run going. The Approval step is still recorded as failed with the same error code, so the run history shows the rejection, and `steps.approval_gate.decision` holds the real decision and reason. Mark the last step of the rejected branch `terminal` so the run ends there instead of reaching the approved steps that follow it in the list. The output also carries `error: true` and a `message`, as it did before rejections kept the decision, so a step that checks `steps.approval_gate.error` still sees the rejection. A `fallback` in the error handling is used whenever the step itself fails rather than returning a decision: a delivery or configuration error, a timeout with `timeoutAction: fail`, an escalation that also times out, or an error saving a decision after someone acted (so even an approval can end up on the fallback). Its fields replace the step's output and an `originalError` field holds the error message; a real rejection never has `originalError`. Give the fallback `decision.approved: false` so these cases also stay off the approved path, and check `originalError` if the workflow must tell a failed request apart from a rejection. Restarting a run from a step after an Approval step that did not succeed runs the Approval step again, and every step after it, even ones that succeeded before. ## Resuming Suspended Runs A suspended run resumes when: - **An approver acts** -- clicking Approve or Reject in Slack resumes the run along the matching path - **The API is called** -- approvals can be resolved programmatically via the REST API for custom approval UIs - **The timeout expires** -- what happens follows `timeoutAction`; with `reject`, the request is auto-rejected and handled like any other rejection Without a timeout, a suspended run waits indefinitely. Set `timeout` and `timeoutAction` for time-sensitive workflows so runs cannot hang when an approver is unavailable. ## Example High-value invoice approval before finalizing: ``` Stripe (create invoice) -> Condition (amount > $5,000?) -> True: Approval (finance manager review) -> Approved: Stripe (finalize and send) -> Rejected: Slack (notify requester) -> False: Stripe (finalize and send) ``` ## Best Practices - **Set timeouts.** Prevent runs from hanging when approvers are away. - **Include context in the description.** Use template variables to show amounts, customer names, and details so approvers can decide without leaving Slack. - **Notify via Slack for speed.** Interactive Slack requests get faster responses than email. - **Chain blocks for multi-level approval.** The block resolves on the first authorized decision; chain multiple Approval blocks for tiered sign-off. ## Next Steps Configure the approval step Version and deploy workflows for production How the execution engine processes your workflow # Debugging Workflows (/docs/execution/debugging) Diagnose and fix workflow failures using execution logs, step inspection, and common error patterns When a workflow fails, the execution log tells you exactly which step failed, what input it received, and what error occurred. This guide covers the debugging process and common failure patterns. ## Debugging Process ### 1. Find the Failed Step Open the run in the visual builder or query the Runs API. The failed step has `status: "failed"` with an error message: ```json { "id": "create-invoice", "status": "failed", "error": { "code": "INTEGRATION_ERROR", "message": "QuickBooks: Customer not found for ID cus_missing", "step": "create-invoice" } } ``` ### 2. Check the Step Input The input field shows exactly what data reached the step. Common issues: - Template variables resolved to `null` or `undefined` - Upstream step output was in an unexpected format - Data type mismatch (string vs number) ### 3. Check Upstream Steps Walk backward through the execution log. If a Transform step produced unexpected output, the issue is in the transform mapping, not the downstream step that consumed it. ### 4. Fix and Re-run After identifying the issue, update the workflow and run it again with the same input. ## Common Error Patterns ### Template Variable Errors **Symptom:** Step receives `null` or `undefined` instead of expected data. **Cause:** The template variable references a step that does not exist, or the output field name is wrong. ```json // Wrong: step name mismatch "to": "{{steps.fetchCustomer.output.email}}" // Correct: matches the actual step ID "to": "{{steps.fetch-customer.output.email}}" ``` ### Connection Expired **Symptom:** Integration step fails with `401 Unauthorized` or `Token expired`. **Fix:** Re-authenticate the connection in the Connections panel. OAuth tokens expire periodically and need refreshing. ### Rate Limiting **Symptom:** Integration step fails with `429 Too Many Requests`. **Fix:** Add a Wait block before the integration step, or reduce the concurrency of parallel loops. Most APIs allow 100-300 requests per minute. ### Timeout **Symptom:** Step fails with `TIMEOUT` error. **Fix:** Increase the step timeout in the configuration. For long-running operations (browser automation, large data extracts), set timeouts of 60-120 seconds. ### JSON Parse Error **Symptom:** Agent block returns text that cannot be parsed as JSON. **Fix:** Add an output schema to the Agent block. This constrains the model to return valid JSON matching your schema. ### Missing Required Fields **Symptom:** Integration step fails with `400 Bad Request` -- required field missing. **Fix:** Check the step input in the execution log. Ensure all required fields are populated via template variables or static configuration. ## Debugging Agent Blocks Agent blocks can be tricky because the output depends on the AI model. Common debugging steps: 1. **Check the system prompt** -- Is it specific enough? Vague prompts lead to inconsistent output. 2. **Check the input data** -- Did the upstream block pass the expected content? 3. **Check the output schema** -- Is it valid JSON Schema? Does it match what you expect? 4. **Lower the temperature** -- Set to 0 for deterministic output during debugging. 5. **Check token limits** -- If output is truncated, increase max tokens. ## Debugging Stagehand (Browser) Blocks Browser automation failures are often caused by page changes or timing issues: 1. **Use screenshots** -- Add a `stagehand.screenshot` step before and after the failing action. 2. **Increase timeouts** -- Set `timeoutMs` to 60000 or higher for slow-loading pages. 3. **Check the instruction** -- Be specific: "Click the blue Download PDF button" is better than "Click download." 4. **Enable agent fallback** -- Set `agentFallback: true` so the system switches to autonomous agent mode when a simple action fails. ## Using the Run Inspector The visual builder includes a run inspector that shows: - **Step timeline** -- Visual representation of step execution order and duration - **Input/Output panels** -- Raw JSON for each step's input and output - **Error details** -- Full error message, stack trace (when available), and retry history - **Template resolution** -- Shows how template variables were resolved The run inspector is available for all runs, including completed and failed runs. Open it from the Runs panel in the sidebar. ## Best Practices - **Start small.** Test workflows with a single step before building the full chain. Verify each step produces the expected output. - **Use the Transform block for data inspection.** Add a Transform block to log intermediate data during development. Remove it before deploying. - **Set up error notifications.** Add a Condition block that checks for failures and sends a Slack or email alert. - **Keep workflows focused.** A workflow that does one thing well is easier to debug than a workflow that does ten things. # Deployment (/docs/execution/deployment) Version and deploy workflows for production Workflows are versioned. Each time you publish, the current canvas is captured as an immutable version and promoted to production. New triggers run against the deployed version, while in-progress runs continue on the version they started with. This section covers how versioning and deployment work. ## Versioning Model Every workflow has two tracks: - **Draft** -- the working copy you edit on the canvas. Changes here do not affect production. - **Deployed** -- the published version that live triggers execute against. When you publish a draft, it becomes a new immutable version with an incrementing version number. Previous versions are retained so you can roll back. | State | Description | |-------|-------------| | `draft` | Unpublished working copy, safe to edit | | `deployed` | Current production version, executed by triggers | | `archived` | A previous version, retained for rollback and audit | ## Publishing a Workflow Publishing captures the draft, validates it, and promotes it to production. 1. **Validate** -- the DAG is checked for cycles, missing connections, and invalid block configuration 2. **Version** -- a new immutable version is created with an incremented number 3. **Promote** -- live triggers begin executing the new version 4. **Retain** -- the prior version is archived for rollback In-flight runs are not affected by a new deployment. A run always completes on the version it started with. Only new trigger events use the newly deployed version. ## Deployment and Triggers How a deployed version starts running depends on the trigger type: | Trigger | Behavior after deploy | |---------|-----------------------| | Webhook | Next incoming webhook uses the new version | | Schedule | Next scheduled fire uses the new version | | Event | Next platform event uses the new version | | API | Next API invocation uses the new version | Disabling a workflow stops new triggers from starting runs without deleting any versions. ## Rollback If a deployed version misbehaves, roll back to a previous version. Rollback re-promotes an archived version to `deployed`; new triggers immediately use it. Runs already in progress are unaffected. Rolling back does not retroactively fix runs that already failed on the bad version. Re-run affected inputs after rolling back if reprocessing is required. ## Best Practices - **Test in draft before publishing.** Use the draft track and manual runs to validate changes before promoting to production. - **Publish small, focused changes.** Smaller deployments are easier to reason about and roll back. - **Design for idempotency.** Because runs can overlap across versions and triggers guarantee at-least-once delivery, workflows should be safe to re-run with the same input. - **Keep a rollback plan.** Know which version was last known-good before deploying a risky change. ## Next Steps How the execution engine processes your workflow Human-in-the-loop workflow execution Diagnose and fix failing workflows # Execution Logs (/docs/execution/logs) Monitor workflow runs with step-by-step execution logs, timing data, and output capture Every workflow run generates detailed execution logs. Each step records its input, output, timing, and status -- giving you full visibility into what happened and why. ## Viewing Logs ### In the Visual Builder Open a completed run from the **Runs** panel in the sidebar. The canvas highlights each step with its status (green for success, red for failure). Click any step to see its execution details. ### Via API ```bash curl https://workflow.loopfour.ai/api/v1/runs/{RUN_ID} \ -H "x-api-key: YOUR_API_KEY" ``` ```javascript const response = await fetch( `https://workflow.loopfour.ai/api/v1/runs/${runId}`, { headers: { 'x-api-key': process.env.WORKFLOWS_API_KEY } } ); const { data } = await response.json(); console.log('Steps:', JSON.stringify(data.steps, null, 2)); ``` ## Log Structure ```json { "id": "run_abc123", "workflowId": "wf_xyz789", "status": "completed", "startedAt": "2024-01-15T09:00:01.000Z", "completedAt": "2024-01-15T09:00:04.500Z", "durationMs": 3500, "steps": [ { "id": "fetch-invoices", "name": "Fetch Overdue Invoices", "type": "action", "status": "completed", "startedAt": "2024-01-15T09:00:01.200Z", "completedAt": "2024-01-15T09:00:02.800Z", "durationMs": 1600, "input": { "filter": "Balance > 0" }, "output": { "invoices": [...], "count": 12 } }, { "id": "send-slack", "name": "Notify Finance", "type": "slack", "status": "completed", "startedAt": "2024-01-15T09:00:02.900Z", "completedAt": "2024-01-15T09:00:04.400Z", "durationMs": 1500, "input": { "channel": "#finance", "text": "12 overdue invoices" }, "output": { "messageId": "msg_123", "channel": "C01234" } } ] } ``` ## Step Status Values | Status | Description | |--------|-------------| | `pending` | Waiting to execute | | `running` | Currently executing | | `completed` | Finished successfully | | `failed` | Error occurred | | `skipped` | Condition evaluated to false, step was not executed | | `retrying` | Failed and retrying (shows attempt number) | ## Filtering Runs List runs with filters: ```bash # Get failed runs from the last 24 hours curl "https://workflow.loopfour.ai/api/v1/runs?workflowId=wf_xyz&status=failed&since=24h" \ -H "x-api-key: YOUR_API_KEY" ``` | Parameter | Type | Description | |-----------|------|-------------| | `workflowId` | string | Filter by workflow | | `status` | string | Filter by status (completed, failed, running) | | `since` | string | Time window (1h, 24h, 7d, 30d) | | `limit` | number | Max results (default 50, max 200) | ## Token Usage Tracking Agent block logs include token usage for cost monitoring: ```json { "id": "extract-data", "type": "agent", "output": { "result": { ... }, "usage": { "promptTokens": 1250, "completionTokens": 340, "totalTokens": 1590, "model": "claude-opus-5", "estimatedCostUsd": 0.01475 } } } ``` ## Log Retention | Plan | Retention | |------|-----------| | Free | 7 days | | Pro | 30 days | | Enterprise | 90 days (configurable) | Logs older than the retention period are permanently deleted. Export critical logs via the API before they expire. # Running Workflows (/docs/execution/running) How to execute workflows via API, webhook, schedule, or the visual builder Workflows can be executed through four methods: API calls, webhook events, scheduled triggers, or directly from the visual builder. Each execution creates a run with a unique ID, full step-by-step logging, and output capture. ## Execution Methods ### From the Visual Builder Click the **Run** button in the top-right corner of the canvas to execute the current workflow. You can provide test input data in the run dialog. ### Via API ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{WORKFLOW_ID}/run \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": {"customerId": "cus_123"}}' ``` ```javascript const response = await fetch( `https://workflow.loopfour.ai/api/v1/workflows/${workflowId}/run`, { method: 'POST', headers: { 'x-api-key': process.env.WORKFLOWS_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ input: { customerId: 'cus_123' } }), } ); const { data } = await response.json(); console.log('Run ID:', data.runId); ``` ### Via Webhook External systems trigger workflows by sending HTTP POST requests to your webhook endpoint. See [Webhook Triggers](/docs/triggers/webhook) for configuration details. ### Via Schedule Workflows with schedule triggers execute automatically at configured intervals. See [Schedule Triggers](/docs/triggers/schedule) for cron syntax and timezone configuration. ## Run Lifecycle Every workflow run progresses through these states: ``` pending -> running -> completed -> failed -> timed_out ``` | Status | Description | |--------|-------------| | `pending` | Run queued, waiting for execution slot | | `running` | Currently executing steps | | `completed` | All steps finished successfully | | `failed` | A step failed and error handling did not recover | | `timed_out` | Run exceeded the configured timeout | ## Checking Run Status ```bash curl https://workflow.loopfour.ai/api/v1/runs/{RUN_ID} \ -H "x-api-key: YOUR_API_KEY" ``` ```javascript const response = await fetch( `https://workflow.loopfour.ai/api/v1/runs/${runId}`, { headers: { 'x-api-key': process.env.WORKFLOWS_API_KEY } } ); const { data } = await response.json(); console.log('Status:', data.status); console.log('Output:', data.output); ``` ## Error Handling Each step supports three error handling modes: | Mode | Behavior | |------|----------| | **fail** | Stop the workflow immediately. The run status becomes `failed`. | | **continue** | Log the error and proceed to the next step. | | **retry** | Retry the step with exponential backoff (up to 3 attempts by default). | Configure per step: ```json { "id": "send-invoice", "type": "action", "errorHandling": "retry", "retryConfig": { "maxAttempts": 3, "backoffMs": 1000 } } ``` ## Timeouts Set a maximum execution time for the entire workflow or individual steps: ```json { "settings": { "timeoutMs": 300000 } } ``` Individual step timeouts override the workflow-level timeout. ## Concurrency Control how many instances of a workflow can run simultaneously: ```json { "settings": { "concurrencyLimit": 1 } } ``` Setting `concurrencyLimit: 1` ensures runs execute sequentially, which is important for workflows that modify shared state. # Getting Started (/docs/getting-started) Get up and running with the Workflows API ## Getting Started This section will help you get up and running with the Workflows API quickly. Start here if you are new to Loopfour or if you need a minimal end-to-end path before designing a larger automation. The guides introduce the core setup sequence: create or locate an API key, understand how authentication works, define a small workflow, run it with test input, and inspect the result. That flow is intentionally narrow because it proves the important pieces before you add real integrations, approval paths, or production data. For a finance team, a good first workflow is usually one that is easy to verify manually. Examples include echoing a payload, transforming a small invoice object, posting a controlled notification, or running a simple approval branch. Once the basic run lifecycle is clear, expand toward your real workflow by adding the trigger, connected systems, validation rules, and exception handling. Keep each step named clearly so developers and finance operators can debug the same run history without translating internal implementation details. If you are evaluating Loopfour with a team, agree on one owner for credentials, one owner for workflow logic, and one owner for business approval. That keeps setup fast while preserving the controls you will need later. ## Prerequisites - A Loopfour account with API access - Node.js 20+ (for SDK usage) - Basic understanding of REST APIs ## Guides Get your first workflow running in 5 minutes Learn how to authenticate with the API Create and run your first workflow # Authentication (/docs/getting-started/authentication) Learn how to authenticate with the Workflows API ## Authentication The Workflows API uses API keys for authentication. All API requests must include your API key in the `x-api-key` header. ## API Key Format API keys follow this format: ``` wfk_[32 character random string] ``` **Example:** `wfk_abc123def456ghi789jkl012mno345pq` ## Using Your API Key Include your API key in the `x-api-key` header with every request: ```bash curl https://workflow.loopfour.ai/api/v1/workflows \ -H "x-api-key: wfk_your_api_key_here" ``` ```javascript const API_KEY = process.env.WORKFLOWS_API_KEY; const response = await fetch('https://workflow.loopfour.ai/api/v1/workflows', { headers: { 'x-api-key': API_KEY } }); ``` ```typescript const API_KEY = process.env.WORKFLOWS_API_KEY as string; const response = await fetch('https://workflow.loopfour.ai/api/v1/workflows', { headers: { 'x-api-key': API_KEY } }); ``` ```python API_KEY = os.environ.get('WORKFLOWS_API_KEY') response = requests.get( 'https://workflow.loopfour.ai/api/v1/workflows', headers={'x-api-key': API_KEY} ) ``` ## API Key Scopes API keys can be configured with different permission scopes: | Scope | Description | Endpoints | |-------|-------------|-----------| | `workflows:read` | Read workflow definitions | `GET /workflows`, `GET /workflows/:id` | | `workflows:write` | Create and modify workflows | `POST /workflows`, `PUT /workflows/:id` | | `workflows:execute` | Run workflows | `POST /workflows/:id/run` | | `runs:read` | Read workflow run history | `GET /runs`, `GET /runs/:id` | | `connections:read` | View connections | `GET /connections` | | `connections:write` | Manage connections | `POST /connections`, `DELETE /connections/:id` | | `secrets:read` | View masked workflow secrets | `GET /workflows/:workflowId/secrets` | | `secrets:write` | Manage workflow secrets | `POST /workflows/:workflowId/secrets`, `PUT /workflows/:workflowId/secrets/:key`, `DELETE /workflows/:workflowId/secrets/:key` | ## Security Best Practices Never expose your API key in client-side code, public repositories, or logs. ### Do's - Store API keys in environment variables - Use different keys for development and production - Rotate keys periodically (every 90 days recommended) - Use the minimum required scopes - Revoke compromised keys immediately ### Don'ts - Don't commit API keys to version control - Don't share keys via email or chat - Don't use production keys in development - Don't log API keys in application logs ## Environment Variables We recommend storing your API key in an environment variable: ```bash # .env (add to .gitignore!) WORKFLOWS_API_KEY=wfk_your_api_key_here ``` Then access it in your code: ```javascript const apiKey = process.env.WORKFLOWS_API_KEY; ``` ```python api_key = os.environ.get('WORKFLOWS_API_KEY') ``` ```bash export WORKFLOWS_API_KEY=wfk_your_api_key_here curl -H "x-api-key: $WORKFLOWS_API_KEY" ... ``` ## Rate Limits API requests are rate limited per API key using a sliding window algorithm: | Category | Limit | Endpoints | |----------|-------|-----------| | **Read** | 1000/min | `GET` requests | | **Write** | 100/min | `POST`, `PUT`, `DELETE` requests | | **Execute** | 500/min | Workflow run, cancel, retry | | **Webhooks** | 10000/min | Webhook ingestion (by IP) | ### Rate Limit Headers Every authenticated response includes rate limit headers: ```http X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 950 X-RateLimit-Reset: 1702531200 ``` | Header | Description | |--------|-------------| | `X-RateLimit-Limit` | Maximum requests allowed in window | | `X-RateLimit-Remaining` | Requests remaining in current window | | `X-RateLimit-Reset` | Unix timestamp when window resets | ### Handling Rate Limits When you exceed the rate limit, you'll receive a `429 Too Many Requests` response: ```json { "success": false, "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Limit: 100 requests per minute." } } ``` **Best practices for handling rate limits:** 1. Check rate limit headers proactively 2. Implement exponential backoff on 429 errors 3. Queue non-urgent requests 4. Cache responses where appropriate ```javascript async function fetchWithRetry(url, options, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { const response = await fetch(url, options); if (response.status === 429) { const resetTime = response.headers.get('X-RateLimit-Reset'); const waitMs = resetTime ? (parseInt(resetTime) * 1000) - Date.now() : Math.pow(2, i) * 1000; await new Promise(resolve => setTimeout(resolve, waitMs)); continue; } return response; } throw new Error('Max retries exceeded'); } ``` ## Error Responses ### 401 Unauthorized Missing or invalid API key: ```json { "success": false, "error": { "code": "UNAUTHORIZED", "message": "Invalid or missing API key" } } ``` ### 403 Forbidden API key lacks required permissions: ```json { "success": false, "error": { "code": "FORBIDDEN", "message": "Insufficient permissions for this operation" } } ``` ## Managing API Keys ### List API Keys ```bash curl https://workflow.loopfour.ai/api/v1/auth/api-keys \ -H "x-api-key: YOUR_API_KEY" ``` ### Create API Key ```bash curl -X POST https://workflow.loopfour.ai/api/v1/auth/api-keys \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Production API Key", "scopes": ["workflows:read", "workflows:write", "workflows:execute"] }' ``` ### Revoke API Key ```bash curl -X DELETE https://workflow.loopfour.ai/api/v1/auth/api-keys/{KEY_ID} \ -H "x-api-key: YOUR_API_KEY" ``` ## Next Steps Create your first workflow Build a real workflow with integrations # Your First Real Workflow (/docs/getting-started/first-workflow) Build a workflow that responds to webhooks and sends notifications ## Your First Real Workflow In this tutorial, you'll build a real-world workflow that: 1. Triggers when a Stripe payment is received 2. Transforms the payment data 3. Sends a Slack notification ## Prerequisites - Completed the [Quickstart](/docs/getting-started/quickstart) - A Slack workspace (for notifications) - Optional: Stripe account (for live testing) ## What We're Building ```mermaid graph LR A[Stripe Webhook] --> B[Transform Data] B --> C[Send Slack Message] ``` When a payment is received in Stripe, the workflow will send a message to your Slack channel with payment details. ## Step 1: Create the Workflow Create a workflow with a webhook trigger: ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Payment Notification", "description": "Send Slack notification when payment received", "trigger": { "type": "webhook", "provider": "stripe", "events": ["payment_intent.succeeded", "charge.succeeded"] }, "steps": [ { "id": "transform-payment", "type": "transform", "name": "Format Payment Data", "config": { "mapping": { "amount": "{{input.data.object.amount}}", "currency": "{{input.data.object.currency}}", "customer_email": "{{input.data.object.receipt_email}}", "payment_id": "{{input.data.object.id}}", "formatted_amount": "${{input.data.object.amount / 100}}" } } }, { "id": "notify-slack", "type": "slack", "name": "Send Slack Notification", "config": { "action": "sendMessage", "channel": "#payments", "text": "Payment received! {{steps.transform-payment.output.formatted_amount}} from {{steps.transform-payment.output.customer_email}}" } } ] }' ``` ```javascript const workflow = { name: 'Payment Notification', description: 'Send Slack notification when payment received', trigger: { type: 'webhook', provider: 'stripe', events: ['payment_intent.succeeded', 'charge.succeeded'] }, steps: [ { id: 'transform-payment', type: 'transform', name: 'Format Payment Data', config: { mapping: { amount: '{{input.data.object.amount}}', currency: '{{input.data.object.currency}}', customer_email: '{{input.data.object.receipt_email}}', payment_id: '{{input.data.object.id}}', formatted_amount: '${{input.data.object.amount / 100}}' } } }, { id: 'notify-slack', type: 'slack', name: 'Send Slack Notification', config: { action: 'sendMessage', channel: '#payments', text: 'Payment received! {{steps.transform-payment.output.formatted_amount}} from {{steps.transform-payment.output.customer_email}}' } } ] }; const response = await fetch('https://workflow.loopfour.ai/api/v1/workflows', { method: 'POST', headers: { 'x-api-key': 'YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify(workflow) }); const { data } = await response.json(); console.log('Workflow ID:', data.id); ``` Save the workflow ID from the response. ## Step 2: Connect Slack Before activating, connect your Slack workspace: ```bash # Get the OAuth URL for Slack curl "https://workflow.loopfour.ai/api/v1/connections/slack/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` This returns a URL to complete the Slack OAuth flow: ```json { "success": true, "data": { "url": "https://slack.com/oauth/v2/authorize?client_id=..." } } ``` Visit the URL, authorize your workspace, and you'll be redirected back. ## Step 3: Activate the Workflow ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{WORKFLOW_ID}/activate \ -H "x-api-key: YOUR_API_KEY" ``` ## Step 4: Configure Stripe Webhook Add your webhook URL to Stripe's dashboard: ``` https://your-domain.com/webhooks/stripe/{COMPANY_ID} ``` Or for local development with a tunnel like ngrok: ``` https://your-ngrok-id.ngrok.io/webhooks/stripe/{COMPANY_ID} ``` Select the events: - `payment_intent.succeeded` - `charge.succeeded` ## Step 5: Test the Workflow You can test without Stripe by manually triggering the workflow: ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{WORKFLOW_ID}/run \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": { "type": "payment_intent.succeeded", "data": { "object": { "id": "pi_test123", "amount": 9900, "currency": "usd", "receipt_email": "customer@example.com" } } } }' ``` You should see a message in your `#payments` Slack channel! ## Understanding the Workflow ### Trigger Configuration ```json { "type": "webhook", "provider": "stripe", "events": ["payment_intent.succeeded", "charge.succeeded"] } ``` - `type: "webhook"` - Triggered by incoming webhooks - `provider: "stripe"` - Expects Stripe webhook format and signature verification - `events` - Only trigger on these specific event types ### Template Variables The workflow uses template variables to access data: | Variable | Description | |----------|-------------| | `{{input.*}}` | Access webhook payload data | | `{{steps.{id}.output.*}}` | Access output from previous steps | | `{{now}}` | Current timestamp | ### Transform Step ```json { "type": "transform", "config": { "mapping": { "formatted_amount": "${{input.data.object.amount / 100}}" } } } ``` The transform step maps input data to a new structure. You can: - Rename fields - Compute values (like dividing cents by 100) - Combine fields ### Slack Step ```json { "type": "slack", "config": { "action": "sendMessage", "channel": "#payments", "text": "..." } } ``` The Slack building block supports: - `sendMessage` - Send to channel or DM - `sendEphemeral` - Visible to one user only - `addReaction` - Add emoji reaction - And more (see [Slack Block docs](/docs/workflows/building-blocks/slack-block)) ## Adding Conditional Logic Let's enhance the workflow to only notify for payments over $100: ```json { "steps": [ { "id": "transform-payment", "type": "transform", "config": { "mapping": { "amount_dollars": "{{input.data.object.amount / 100}}", "customer_email": "{{input.data.object.receipt_email}}" } } }, { "id": "check-amount", "type": "condition", "name": "Check if High Value", "config": { "conditions": { "left": "{{steps.transform-payment.output.amount_dollars}}", "operator": "gte", "right": "100" }, "then": ["notify-slack"], "else": [] } }, { "id": "notify-slack", "type": "slack", "config": { "action": "sendMessage", "channel": "#high-value-payments", "text": "High value payment: ${{steps.transform-payment.output.amount_dollars}} from {{steps.transform-payment.output.customer_email}}" } } ] } ``` ## Viewing Run History Check workflow run history: ```bash curl "https://workflow.loopfour.ai/api/v1/workflows/{WORKFLOW_ID}/runs" \ -H "x-api-key: YOUR_API_KEY" ``` Get details for a specific run: ```bash curl "https://workflow.loopfour.ai/api/v1/runs/{RUN_ID}" \ -H "x-api-key: YOUR_API_KEY" ``` The response shows each step's status, timing, and output: ```json { "success": true, "data": { "id": "run_xyz...", "status": "completed", "steps": [ { "id": "transform-payment", "status": "completed", "output": { "amount_dollars": 99, "customer_email": "customer@example.com" } }, { "id": "notify-slack", "status": "completed", "output": { "ts": "1234567890.123456", "channel": "C123ABC" } } ] } } ``` ## Next Steps Learn about webhook, schedule, and API triggers Explore actions, conditions, transforms, and waits Connect Stripe, Salesforce, HubSpot, and more More real-world workflow examples # Quickstart (/docs/getting-started/quickstart) Get your first workflow running in 5 minutes ## Quickstart This guide will help you create and run your first workflow in under 5 minutes. ## Prerequisites - An API key (see [Authentication](/docs/getting-started/authentication)) - `curl` or any HTTP client ## Step 1: Verify Your API Key First, verify your API key is working by listing workflows: ```bash curl https://workflow.loopfour.ai/api/v1/workflows \ -H "x-api-key: YOUR_API_KEY" ``` ```javascript const response = await fetch('https://workflow.loopfour.ai/api/v1/workflows', { headers: { 'x-api-key': 'YOUR_API_KEY' } }); const data = await response.json(); console.log(data); ``` ```typescript interface WorkflowResponse { success: boolean; data: Workflow[]; } const response = await fetch('https://workflow.loopfour.ai/api/v1/workflows', { headers: { 'x-api-key': 'YOUR_API_KEY' } }); const data: WorkflowResponse = await response.json(); console.log(data); ``` You should see: ```json { "success": true, "data": [] } ``` ## Step 2: Create a Workflow Create a simple workflow that transforms input data: ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "My First Workflow", "description": "A simple echo workflow", "trigger": { "type": "api" }, "steps": [{ "id": "echo", "type": "transform", "name": "Echo Input", "config": { "mapping": { "message": "{{input.message}}", "timestamp": "{{now}}" } } }] }' ``` ```javascript const workflow = { name: 'My First Workflow', description: 'A simple echo workflow', trigger: { type: 'api' }, steps: [{ id: 'echo', type: 'transform', name: 'Echo Input', config: { mapping: { message: '{{input.message}}', timestamp: '{{now}}' } } }] }; const response = await fetch('https://workflow.loopfour.ai/api/v1/workflows', { method: 'POST', headers: { 'x-api-key': 'YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify(workflow) }); const data = await response.json(); console.log('Created workflow:', data.data.id); ``` ```typescript interface CreateWorkflowRequest { name: string; description?: string; trigger: { type: 'api' | 'webhook' | 'schedule' }; steps: Array<{ id: string; type: string; name: string; config: Record; }>; } const workflow: CreateWorkflowRequest = { name: 'My First Workflow', description: 'A simple echo workflow', trigger: { type: 'api' }, steps: [{ id: 'echo', type: 'transform', name: 'Echo Input', config: { mapping: { message: '{{input.message}}', timestamp: '{{now}}' } } }] }; const response = await fetch('https://workflow.loopfour.ai/api/v1/workflows', { method: 'POST', headers: { 'x-api-key': 'YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify(workflow) }); const data = await response.json(); console.log('Created workflow:', data.data.id); ``` **Response:** ```json { "success": true, "data": { "id": "wf_abc123...", "name": "My First Workflow", "status": "draft", "version": 1, "trigger": { "type": "api" }, "steps": [...], "createdAt": "2024-01-15T10:00:00.000Z", "updatedAt": "2024-01-15T10:00:00.000Z" } } ``` Save the workflow `id` from the response - you'll need it for the next steps. ## Step 3: Activate the Workflow Workflows start in `draft` status and must be activated before they can run: ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{WORKFLOW_ID}/activate \ -H "x-api-key: YOUR_API_KEY" ``` ```javascript const response = await fetch( `https://workflow.loopfour.ai/api/v1/workflows/${workflowId}/activate`, { method: 'POST', headers: { 'x-api-key': 'YOUR_API_KEY' } } ); const data = await response.json(); console.log('Workflow status:', data.data.status); // "active" ``` **Response:** ```json { "success": true, "data": { "id": "wf_abc123...", "status": "active" } } ``` ## Step 4: Run the Workflow Now trigger your workflow with some input data: ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{WORKFLOW_ID}/run \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": { "message": "Hello, Workflows!" } }' ``` ```javascript const response = await fetch( `https://workflow.loopfour.ai/api/v1/workflows/${workflowId}/run`, { method: 'POST', headers: { 'x-api-key': 'YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ input: { message: 'Hello, Workflows!' } }) } ); const data = await response.json(); console.log('Run ID:', data.data.runId); ``` **Response:** ```json { "success": true, "data": { "runId": "run_xyz789...", "status": "pending", "workflowId": "wf_abc123...", "workflowVersion": 1 } } ``` ## Step 5: Check the Run Status Check the status of your workflow run: ```bash curl https://workflow.loopfour.ai/api/v1/runs/{RUN_ID} \ -H "x-api-key: YOUR_API_KEY" ``` ```javascript const response = await fetch( `https://workflow.loopfour.ai/api/v1/runs/${runId}`, { headers: { 'x-api-key': 'YOUR_API_KEY' } } ); const data = await response.json(); console.log('Status:', data.data.status); console.log('Output:', data.data.output); ``` **Response (completed):** ```json { "success": true, "data": { "id": "run_xyz789...", "status": "completed", "output": { "message": "Hello, Workflows!", "timestamp": "2024-01-15T10:00:05.000Z" }, "startedAt": "2024-01-15T10:00:00.000Z", "completedAt": "2024-01-15T10:00:05.000Z" } } ``` ## What's Next? You've successfully created and run your first workflow! Here's what to explore next: Learn about API keys and security Build a workflow with integrations Explore webhook, schedule, and Slack triggers Connect Stripe, Salesforce, HubSpot, and more # Integrations (/docs/integrations) Connect to external services ## Integrations Connect your workflows to external services for powerful automation. Integrations let Loopfour read from and write to the systems where finance work already happens. A typical workflow may start from a CRM event, look up customer or contract data, validate invoice details, route an approval in Slack or email, then create or update records in an accounting system. The integration docs explain which providers are available, how authentication is configured, and where provider-specific behavior affects workflow design. Before connecting a provider, identify the object that should be the source of truth for the workflow. For example, a closed opportunity, signed contract, received invoice, payment failure, or customer update may each require a different trigger and reconciliation path. Also decide which system should receive the final write and what evidence needs to stay attached to the run. Clear ownership prevents duplicate records, stale data, and approval steps that are hard to audit after the workflow runs. ## Categories QuickBooks, NetSuite, Sage Intacct, Xero Finances (Plaid): bank and credit-card accounts Salesforce, HubSpot, Attio PandaDoc, DocuSign, Dropbox Sign Slack, Email (Gmail, Outlook, Resend) ## Connecting Integrations Most integrations use OAuth for authentication: 1. Navigate to **Connections** in your dashboard 2. Click **Add Connection** 3. Select the integration 4. Complete the OAuth flow 5. The connection is ready to use in workflows ## Connection Status | Status | Description | |--------|-------------| | `active` | Connection is working | | `expired` | Token needs refresh | | `error` | Connection has issues | | `revoked` | Access was revoked | # Accounting Integrations (/docs/integrations/accounting) QuickBooks, NetSuite, Sage Intacct, Xero ## Accounting Integrations Connect to accounting systems for invoice, payment, and financial data automation. ## QuickBooks Online **Available Actions:** - `createCustomer`, `getCustomer`, `updateCustomer`, `findCustomerByEmail` - `createInvoice`, `getInvoice`, `sendInvoice`, `voidInvoice` - `createPayment`, `getPayment` - `query` - Custom queries [Full QuickBooks Documentation →](/docs/integrations/accounting/quickbooks) ## NetSuite **Available Actions:** - `createCustomer`, `getCustomer`, `updateCustomer` - `createInvoice`, `getInvoice`, `updateInvoice` - `createVendorBill`, `getVendorBill` - `createJournalEntry`, `getJournalEntry` - `suiteql` - Execute SuiteQL queries [Full NetSuite Documentation →](/docs/integrations/accounting/netsuite) ## Sage Intacct **Available Actions:** - Customer management - Invoice operations - Payment processing - Journal entries [Full Sage Intacct Documentation →](/docs/integrations/accounting/sage-intacct) ## Xero **Available Actions:** - Contact management - Invoice operations - Payment processing - Bank reconciliation [Full Xero Documentation →](/docs/integrations/accounting/xero) # NetSuite (/docs/integrations/accounting/netsuite) ERP integration for customers, invoices, bills, and GL entries ## NetSuite Connect to NetSuite for comprehensive ERP automation including customers, invoices, vendor bills, journal entries, and SuiteQL queries. ## Overview NetSuite is Oracle's cloud ERP platform. The integration supports: - **Customers** - Customer master records - **Invoices** - Customer invoices (AR) - **Vendor Bills** - Vendor bills (AP) - **Journal Entries** - GL journal entries - **Vendors** - Vendor master records - **Generic Records** - Any NetSuite record type - **SuiteQL** - SQL-like queries ## Prerequisites - NetSuite account with SuiteCloud enabled - Token-Based Authentication (TBA) configured - REST Web Services enabled - Appropriate role permissions ## Authentication NetSuite uses OAuth 1.0a Token-Based Authentication via Nango. ```bash curl "https://workflow.loopfour.ai/api/v1/connections/netsuite/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` ### Required Permissions | Permission | Description | |------------|-------------| | `REST Web Services` | Access REST API | | Record-level permissions | Based on records accessed | | `SuiteAnalytics Workbook` | For SuiteQL queries | ## Available Actions ### Customer Actions #### createCustomer Create a new customer. ```json { "id": "create-customer", "type": "action", "action": "netsuite.createCustomer", "config": { "entityId": "CUST-{{input.customerNumber}}", "companyName": "{{input.companyName}}", "subsidiaryId": "1", "email": "{{input.email}}", "phone": "{{input.phone}}", "url": "{{input.website}}", "isPerson": false, "termsId": "2", "creditLimit": 50000, "currencyId": "1", "taxable": true, "billingAddress": { "line1": "{{input.address.street}}", "city": "{{input.address.city}}", "state": "{{input.address.state}}", "postalCode": "{{input.address.zip}}", "country": "US" }, "externalId": "{{input.externalId}}", "customFields": { "custentity_source": "Workflow" } } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `entityId` | string | No | Customer ID/number | | `companyName` | string | Yes* | Company name (*or firstName/lastName) | | `subsidiaryId` | string | Yes | Subsidiary internal ID | | `email` | string | No | Email address | | `phone` | string | No | Phone number | | `isPerson` | boolean | No | Individual vs company | | `firstName` | string | No | First name (if isPerson) | | `lastName` | string | No | Last name (if isPerson) | | `termsId` | string | No | Payment terms internal ID | | `creditLimit` | number | No | Credit limit | | `currencyId` | string | No | Currency internal ID | | `taxable` | boolean | No | Is taxable | | `billingAddress` | object | No | Billing address | | `externalId` | string | No | External system ID | | `customFields` | object | No | Custom field values | #### getCustomer Get a customer by internal ID. ```json { "action": "netsuite.getCustomer", "config": { "customerId": "{{input.customerId}}", "expandSubResources": true } } ``` #### updateCustomer Update an existing customer. ```json { "action": "netsuite.updateCustomer", "config": { "customerId": "{{input.customerId}}", "email": "{{input.newEmail}}", "creditLimit": 75000, "customFields": { "custentity_last_order": "{{now | date: 'YYYY-MM-DD'}}" } } } ``` #### listCustomers Search customers using SuiteQL. ```json { "action": "netsuite.listCustomers", "config": { "companyName": "Acme", "email": "contact@acme.com", "subsidiaryId": "1", "isInactive": false, "limit": 100, "offset": 0 } } ``` ### Invoice Actions #### createInvoice Create a customer invoice. ```json { "id": "create-invoice", "type": "action", "action": "netsuite.createInvoice", "config": { "customerId": "{{steps.customer.output.id}}", "subsidiaryId": "1", "invoiceDate": "{{now | date: 'YYYY-MM-DD'}}", "dueDate": "{{now | dateAdd: 30, 'day' | date: 'YYYY-MM-DD'}}", "invoiceNumber": "INV-{{input.invoiceNumber}}", "termsId": "2", "memo": "Services for {{input.period}}", "currencyId": "1", "lines": [ { "itemId": "100", "quantity": 10, "rate": 150.00, "description": "Professional Services", "department": "1", "class": "1" }, { "itemId": "101", "quantity": 1, "amount": 500.00, "description": "Software License" } ], "departmentId": "1", "classId": "1", "poNumber": "{{input.poNumber}}", "externalId": "{{input.externalId}}", "customFields": { "custbody_source": "Workflow" } } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `customerId` | string | Yes | Customer internal ID | | `subsidiaryId` | string | No | Subsidiary internal ID | | `invoiceDate` | string | No | Transaction date (YYYY-MM-DD) | | `dueDate` | string | No | Due date | | `invoiceNumber` | string | No | Document number | | `termsId` | string | No | Payment terms ID | | `memo` | string | No | Internal memo | | `currencyId` | string | No | Currency ID | | `lines` | array | Yes | Line items | | `lines[].itemId` | string | Yes | Item internal ID | | `lines[].quantity` | number | No | Quantity (default: 1) | | `lines[].rate` | number | No | Unit rate | | `lines[].amount` | number | No | Line amount | | `lines[].description` | string | No | Line description | | `lines[].taxCode` | string | No | Tax code ID | | `lines[].department` | string | No | Department ID | | `lines[].class` | string | No | Class ID | | `lines[].location` | string | No | Location ID | | `departmentId` | string | No | Header department | | `classId` | string | No | Header class | | `locationId` | string | No | Header location | | `poNumber` | string | No | Customer PO number | | `salesRepId` | string | No | Sales rep ID | | `externalId` | string | No | External ID | | `customFields` | object | No | Custom field values | #### getInvoice Get an invoice by internal ID. ```json { "action": "netsuite.getInvoice", "config": { "invoiceId": "{{input.invoiceId}}", "expandSubResources": true } } ``` #### updateInvoice Update an invoice. ```json { "action": "netsuite.updateInvoice", "config": { "invoiceId": "{{input.invoiceId}}", "dueDate": "{{input.newDueDate}}", "memo": "{{input.newMemo}}" } } ``` #### listInvoices Search invoices using SuiteQL. ```json { "action": "netsuite.listInvoices", "config": { "customerId": "{{input.customerId}}", "status": "open", "tranDateAfter": "2024-01-01", "tranDateBefore": "2024-12-31", "subsidiaryId": "1", "limit": 100, "offset": 0 } } ``` ### Vendor Bill Actions #### createVendorBill Create a vendor bill (accounts payable). ```json { "id": "create-bill", "type": "action", "action": "netsuite.createVendorBill", "config": { "vendorId": "{{input.vendorId}}", "subsidiaryId": "1", "billDate": "{{now | date: 'YYYY-MM-DD'}}", "dueDate": "{{now | dateAdd: 30, 'day' | date: 'YYYY-MM-DD'}}", "billNumber": "{{input.vendorInvoiceNumber}}", "termsId": "2", "memo": "{{input.description}}", "lines": [ { "accountId": "54", "amount": 1500.00, "description": "Office Supplies", "department": "1", "class": "1" }, { "itemId": "200", "quantity": 5, "rate": 100.00, "description": "Inventory Item", "customerId": "123", "isBillable": true } ], "externalId": "{{input.externalId}}" } } ``` **Line Types:** - **Expense lines**: Use `accountId` for expense account - **Item lines**: Use `itemId` for inventory/service items - **Billable expenses**: Set `customerId` and `isBillable: true` #### getVendorBill Get a vendor bill by internal ID. ```json { "action": "netsuite.getVendorBill", "config": { "billId": "{{input.billId}}", "expandSubResources": true } } ``` #### listVendorBills Search vendor bills using SuiteQL. ```json { "action": "netsuite.listVendorBills", "config": { "vendorId": "{{input.vendorId}}", "status": "open", "tranDateAfter": "2024-01-01", "limit": 100 } } ``` ### Journal Entry Actions #### createJournalEntry Create a GL journal entry. ```json { "id": "create-journal", "type": "action", "action": "netsuite.createJournalEntry", "config": { "subsidiaryId": "1", "tranDate": "{{now | date: 'YYYY-MM-DD'}}", "tranId": "JE-{{input.journalNumber}}", "memo": "Accrual adjustment - {{input.period}}", "currencyId": "1", "approved": true, "lines": [ { "accountId": "54", "debit": 1000.00, "memo": "Accrued expense", "department": "1", "class": "1" }, { "accountId": "120", "credit": 1000.00, "memo": "Accrual liability", "department": "1", "class": "1" } ], "externalId": "{{input.externalId}}" } } ``` Journal entries must balance. Total debits must equal total credits. **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `subsidiaryId` | string | Yes | Subsidiary ID | | `tranDate` | string | No | Transaction date | | `tranId` | string | No | Document number | | `memo` | string | No | Header memo | | `currencyId` | string | No | Currency ID | | `approved` | boolean | No | Auto-approve (default: true) | | `lines` | array | Yes | Journal lines (min 2) | | `lines[].accountId` | string | Yes | GL account ID | | `lines[].debit` | number | Conditional | Debit amount | | `lines[].credit` | number | Conditional | Credit amount | | `lines[].memo` | string | No | Line memo | | `lines[].entityId` | string | No | Customer/vendor ID | | `lines[].department` | string | No | Department ID | | `lines[].class` | string | No | Class ID | | `lines[].location` | string | No | Location ID | #### getJournalEntry Get a journal entry by internal ID. ```json { "action": "netsuite.getJournalEntry", "config": { "journalId": "{{input.journalId}}", "expandSubResources": true } } ``` ### Vendor Actions #### createVendor Create a new vendor. ```json { "action": "netsuite.createVendor", "config": { "entityId": "VEND-{{input.vendorNumber}}", "companyName": "{{input.companyName}}", "subsidiaryId": "1", "email": "{{input.email}}", "phone": "{{input.phone}}", "termsId": "2", "currencyId": "1", "taxIdNum": "{{input.taxId}}", "is1099Eligible": true, "externalId": "{{input.externalId}}" } } ``` #### getVendor Get a vendor by internal ID. ```json { "action": "netsuite.getVendor", "config": { "vendorId": "{{input.vendorId}}", "expandSubResources": true } } ``` #### listVendors Search vendors using SuiteQL. ```json { "action": "netsuite.listVendors", "config": { "companyName": "{{input.searchTerm}}", "email": "{{input.email}}", "isInactive": false, "limit": 100 } } ``` ### Generic Record Actions Work with any NetSuite record type. #### getRecord Get any record by type and ID. ```json { "action": "netsuite.getRecord", "config": { "recordType": "salesOrder", "internalId": "{{input.salesOrderId}}", "expandSubResources": true } } ``` **Common Record Types:** | Type | Description | |------|-------------| | `customer` | Customer | | `vendor` | Vendor | | `invoice` | Customer invoice | | `vendorBill` | Vendor bill | | `salesOrder` | Sales order | | `purchaseOrder` | Purchase order | | `journalEntry` | Journal entry | | `payment` | Customer payment | | `item` | Inventory item | | `account` | GL account | #### createRecord Create any record type. ```json { "action": "netsuite.createRecord", "config": { "recordType": "task", "data": { "title": "Follow up with customer", "assigned": { "id": "123" }, "duedate": "2024-02-01", "company": { "id": "{{input.customerId}}" } } } } ``` #### updateRecord Update any record type. ```json { "action": "netsuite.updateRecord", "config": { "recordType": "salesOrder", "internalId": "{{input.salesOrderId}}", "data": { "memo": "Updated via workflow" } } } ``` #### deleteRecord Delete any record. ```json { "action": "netsuite.deleteRecord", "config": { "recordType": "task", "internalId": "{{input.taskId}}" } } ``` ### SuiteQL Actions #### searchRecords Search records with filters. ```json { "action": "netsuite.searchRecords", "config": { "recordType": "transaction", "fields": ["id", "tranid", "trandate", "entity", "total"], "filters": { "type": "SalesOrd", "status": "open" }, "orderBy": "trandate DESC", "limit": 100, "offset": 0 } } ``` #### suiteql Execute a raw SuiteQL query. ```json { "id": "custom-query", "type": "action", "action": "netsuite.suiteql", "config": { "query": "SELECT t.id, t.tranid, t.trandate, c.companyname, t.foreigntotal FROM transaction t INNER JOIN customer c ON t.entity = c.id WHERE t.type = 'CustInvc' AND t.status = 'open' AND t.trandate >= TO_DATE('2024-01-01', 'YYYY-MM-DD') ORDER BY t.trandate DESC", "limit": 1000, "offset": 0 } } ``` **SuiteQL Notes:** - SQL-like syntax with NetSuite-specific functions - Use `TO_DATE()` for date comparisons - Boolean fields use `'T'` and `'F'` - Join tables using internal field names - Max 1000 results per query ## Webhook Triggers NetSuite can trigger workflows via RESTlets or scheduled scripts. ```json { "trigger": { "type": "webhook", "provider": "netsuite", "events": ["record.create", "record.update"] } } ``` ## Example Workflow Stripe to NetSuite invoice sync: ```json { "name": "Sync Stripe Invoice to NetSuite", "trigger": { "type": "webhook", "provider": "stripe", "events": ["invoice.paid"] }, "steps": [ { "id": "find-customer", "type": "action", "action": "netsuite.listCustomers", "config": { "email": "{{input.data.object.customer_email}}" } }, { "id": "check-customer", "type": "condition", "config": { "conditions": { "left": "{{steps.find-customer.output.items.length}}", "operator": "gt", "right": 0 }, "then": ["create-invoice"], "else": ["create-customer"] } }, { "id": "create-customer", "type": "action", "action": "netsuite.createCustomer", "config": { "companyName": "{{input.data.object.customer_name}}", "email": "{{input.data.object.customer_email}}", "subsidiaryId": "1", "externalId": "stripe_{{input.data.object.customer}}" } }, { "id": "create-invoice", "type": "action", "action": "netsuite.createInvoice", "config": { "customerId": "{{steps.find-customer.output.items[0].id || steps.create-customer.output.id}}", "subsidiaryId": "1", "lines": [ { "itemId": "100", "amount": "{{input.data.object.amount_paid / 100}}", "description": "{{input.data.object.lines.data[0].description}}" } ], "externalId": "stripe_{{input.data.object.id}}" } } ] } ``` ## Rate Limits | Limit | Value | |-------|-------| | REST API | 10 concurrent requests | | SuiteQL | 1000 results per query | | Batch operations | Varies by record type | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `INVALID_LOGIN_ATTEMPT` | Auth failed | Check TBA credentials | | `RCRD_DSNT_EXIST` | Record not found | Verify internal ID | | `INVALID_FLD_VALUE` | Invalid field value | Check field constraints | | `INSUFFICIENT_PERMISSION` | Missing role permission | Update role access | | `UNEXPECTED_ERROR` | Server error | Retry with backoff | ### Field Names NetSuite uses camelCase field names in REST API. Custom fields use `custentity_`, `custbody_`, `custcol_` prefixes. ### Multi-Subsidiary For multi-subsidiary accounts, always specify `subsidiaryId` on transactions. # QuickBooks Online (/docs/integrations/accounting/quickbooks) Accounting integration for invoices, payments, and financial data ## QuickBooks Online Connect to QuickBooks Online to automate invoice creation, payment processing, customer management, and financial reporting. ## Overview The existing Accounting connection exposes **198 searchable actions** in the canvas and workflow builder. Chat receives the same action names, parameters, and outputs. The catalog covers the [Intuit Accounting API reference](https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/account), including 30 report endpoints. Availability depends on the connected company's region, subscription, and enabled features. QuickBooks Payments uses a separate API and authorization scope. | Area | Available operations | |---|---| | Accounting records | Customers, vendors, accounts, items, invoices, payments, bills, bill payments, journal entries, purchases, purchase orders, credits, deposits, transfers, estimates and sales/refund receipts | | Company and setup | Company info, preferences, currencies, exchange rates, classes, departments, employees, terms, payment methods, taxes and entitlements | | Advanced records | Budgets, change orders, inventory adjustments, recurring transactions, time activities and reimbursement charges where Intuit supports them | | Documents and bulk work | PDF export, attachment upload/download, note attachments, batch requests, change data capture and queries | | Reporting | All 30 documented report endpoint names, with complete headers, columns and nested rows available to later workflow steps | ### Configure an action Choose **QuickBooks**, select the connected account, search **Action**, and set **Company (Realm) ID**. The form shows fields for that operation. Existing action IDs and input fields remain compatible with saved workflows. New read actions use `recordId`. New create, update and delete actions use a **Data** JSON field with Intuit's field names. For example, **Create Estimate** accepts: ```json { "CustomerRef": { "value": "123" }, "Line": [{ "Amount": 100, "DetailType": "SalesItemLineDetail", "SalesItemLineDetail": { "ItemRef": { "value": "1" }, "Qty": 1, "UnitPrice": 100 } }] } ``` Use IDs from the connected company. Updates and deletes require the record's current `Id` and `SyncToken`. Full updates replace omitted fields; use `sparse: true` only for actions whose Intuit reference supports sparse updates. The existing customer, invoice, bill, payment and journal-entry actions retain their individual fields and automatic token reads where already supported. **Create Item** takes the item in **Data**. `Name` is required. A `NonInventory` or `Service` item also needs `IncomeAccountRef`; an `Inventory` item needs Intuit's inventory fields as well (`TrackQtyOnHand`, `QtyOnHand`, `InvStartDate`, `AssetAccountRef`, `ExpenseAccountRef`). QuickBooks requires the name to be unique across items, so query by name before creating one. For example: ```json { "Name": "Gateway 2.0", "Type": "NonInventory", "IncomeAccountRef": { "value": "358" }, "Sku": "GW-2", "Description": "Gateway 2.0", "UnitPrice": 399.99, "Taxable": true } ``` **Batch** accepts 1–30 `batchItems` with unique `bId` values. Inspect every `BatchItemResponse` for `Fault`, even when the HTTP request succeeds. **Change Data Capture** accepts `entities` and a `changedSince` ISO timestamp within the previous 30 days. PDF actions return `contentBase64`, `contentType`, and `size`. **Upload Attachment** accepts a filename, MIME type, base64 content (up to 20 MB), and optional QBO attachment metadata. **Download Attachment** returns a temporary `downloadUrl`. **Get Entitlements** preserves the XML response as `entitlementsXml`. ## Prerequisites - QuickBooks Online account (Plus, Essentials, or Simple Start) - Admin access to configure OAuth connection - Company file (Realm ID) for API access ## Authentication QuickBooks uses OAuth 2.0 for authentication via Nango. ### Connect QuickBooks ```bash # Get OAuth URL curl "https://workflow.loopfour.ai/api/v1/connections/quickbooks/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` ### Required Scopes | Scope | Description | |-------|-------------| | `com.intuit.quickbooks.accounting` | Full accounting access | ## Available Actions ### Customer Actions #### createCustomer Create a new customer in QuickBooks. ```json { "id": "create-customer", "type": "action", "action": "quickbooks.createCustomer", "config": { "displayName": "{{input.companyName}}", "firstName": "{{input.firstName}}", "lastName": "{{input.lastName}}", "companyName": "{{input.companyName}}", "email": "{{input.email}}", "phone": "{{input.phone}}", "billingAddress": { "line1": "{{input.address.street}}", "city": "{{input.address.city}}", "state": "{{input.address.state}}", "postalCode": "{{input.address.zip}}", "country": "{{input.address.country}}" } } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `displayName` | string | Yes | Display name (unique identifier) | | `firstName` | string | No | First name | | `lastName` | string | No | Last name | | `companyName` | string | No | Company name | | `email` | string | No | Primary email address | | `phone` | string | No | Phone number | | `mobile` | string | No | Mobile number | | `billingAddress` | object | No | Billing address | | `notes` | string | No | Internal notes | | `active` | boolean | No | Active status (default: true) | #### getCustomer Get a customer by ID. ```json { "action": "quickbooks.getCustomer", "config": { "customerId": "{{input.customerId}}" } } ``` #### updateCustomer Update an existing customer. ```json { "action": "quickbooks.updateCustomer", "config": { "customerId": "{{steps.lookup.output.id}}", "email": "{{input.newEmail}}" } } ``` #### findCustomerByEmail Find a customer by email address. ```json { "action": "quickbooks.findCustomerByEmail", "config": { "email": "{{input.email}}" } } ``` #### listCustomers List customers with optional filters. ```json { "action": "quickbooks.listCustomers", "config": { "active": true, "companyName": "Acme", "limit": 50, "offset": 0 } } ``` ### Invoice Actions #### createInvoice Create a new invoice. ```json { "id": "create-invoice", "type": "action", "action": "quickbooks.createInvoice", "config": { "customerId": "{{steps.customer.output.Customer.Id}}", "lines": [ { "description": "Professional Services", "amount": 1500.00, "quantity": 10, "unitPrice": 150.00 }, { "description": "Software License", "amount": 500.00, "quantity": 1, "unitPrice": 500.00, "itemId": "123" } ], "dueDate": "2024-02-15", "invoiceDate": "2024-01-15", "invoiceNumber": "INV-001", "customerMemo": "Thank you for your business!", "email": "{{input.customerEmail}}" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `customerId` | string | Yes | Customer ID | | `lines` | array | Yes | Line items (at least one) | | `lines[].description` | string | No | Line item description | | `lines[].amount` | number | Yes | Line amount | | `lines[].quantity` | number | No | Quantity (default: 1) | | `lines[].unitPrice` | number | No | Unit price | | `lines[].itemId` | string | No | Product/Service ID | | `lines[].taxCodeId` | string | No | Tax code (`TAX`, `NON`, or a tax-code id) → `SalesItemLineDetail.TaxCodeRef`; requires `txnTaxDetail` | | `dueDate` | string | No | Due date (YYYY-MM-DD) | | `invoiceDate` | string | No | Invoice date (default: today) | | `invoiceNumber` | string | No | Custom invoice number | | `customerMemo` | string | No | Memo for customer | | `privateNote` | string | No | Internal note | | `email` | string | No | Email to send invoice to | | `txnTaxDetail` | object | No | `{ totalTax, txnTaxCodeId }` — both required → `TxnTaxDetail` (see below) | | `shipAddr` | object | No | `{ line1, line2?, city?, countrySubDivisionCode?, postalCode?, country? }` → `ShipAddr` | | `billAddr` | object | No | Same shape → `BillAddr` | **Externally computed sales tax.** A company on US Automated Sales Tax lets QuickBooks compute tax from the ship-to address. To carry a tax computed elsewhere (for example by Avalara) instead, send the override as one unit: `txnTaxDetail.totalTax`, `txnTaxDetail.txnTaxCodeId`, and a `taxCodeId` on every line. Intuit only honours the override when `TxnTaxDetail.TotalTax` and `TxnTaxDetail.TxnTaxCodeRef` are both present — a `totalTax`-only body returns 200 and QuickBooks silently recomputes the tax — so each of these is rejected before the request is sent: - `txnTaxDetail` without `txnTaxCodeId`; - `txnTaxDetail` with any line missing `taxCodeId` (a sparse update replaces the whole `Line` array, which would strand a stale `TxnTaxDetail`); - a line `taxCodeId` without `txnTaxDetail`; - a `totalTax` larger than the sum of the line amounts. `totalTax` must be a finite number ≥ 0, each address field must be a string or number, and each address needs at least `line1`. All three fields are optional and leave the request unchanged when absent. The `Invoice.TxnTaxDetail.TotalTax` **returned by the write** is then compared against what was submitted — both rounded to cents, so they must agree to within one cent — and the step fails on a mismatch rather than reporting a success QuickBooks did not honour. QuickBooks returns the persisted transaction in its create/update response, so a recompute shows up there and no second fetch is needed. ```json { "action": "quickbooks.createInvoice", "config": { "customerId": "{{steps.customer.Customer.Id}}", "lines": [{ "amount": 100, "itemId": "12", "taxCodeId": "TAX" }], "shipAddr": { "line1": "1 Main St", "city": "Austin", "countrySubDivisionCode": "TX", "postalCode": "78701", "country": "US" }, "txnTaxDetail": { "totalTax": "{{steps.avalara.totalTax}}", "txnTaxCodeId": "5" } } } ``` Intuit documents this override at create time. The update path of `upsertInvoiceByDocNumber` sends the same fields but has not been verified against a live company; a taxed invoice also cannot later be un-taxed through the upsert, because both fields are now required together. #### getInvoice Get an invoice by ID. ```json { "action": "quickbooks.getInvoice", "config": { "invoiceId": "{{input.invoiceId}}" } } ``` #### sendInvoice Send an invoice to the customer via email. ```json { "action": "quickbooks.sendInvoice", "config": { "invoiceId": "{{steps.create-invoice.output.Invoice.Id}}", "email": "{{input.alternateEmail}}" } } ``` #### voidInvoice Void an invoice. ```json { "action": "quickbooks.voidInvoice", "config": { "invoiceId": "{{input.invoiceId}}" } } ``` #### updateInvoice Update an existing invoice by its QuickBooks id. The request is sparse, so only the supplied fields change — except `lines`, which replaces the whole `Line` array (QuickBooks has no per-line patch). ```json { "action": "quickbooks.updateInvoice", "config": { "invoiceId": "{{input.invoiceId}}", "dueDate": "2024-03-01", "customerMemo": "Updated payment terms" } } ``` | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `invoiceId` | string | Yes | QuickBooks invoice Id | | `lines` | array | No | Replaces the entire `Line` array when supplied | | `lines[].taxCodeId` | string | No | Tax code (`TAX`, `NON`, or a tax-code id) → `SalesItemLineDetail.TaxCodeRef`; requires `txnTaxDetail` | | `dueDate` | string | No | Due date (YYYY-MM-DD) | | `customerMemo` | string | No | Memo for customer | | `privateNote` | string | No | Internal note | | `email` | string | No | Billing email | | `txnTaxDetail` | object | No | `{ totalTax, txnTaxCodeId }` — both required → `TxnTaxDetail`; requires `lines` | | `shipAddr` | object | No | `{ line1, line2?, city?, countrySubDivisionCode?, postalCode?, country? }` → `ShipAddr` | | `billAddr` | object | No | Same shape → `BillAddr` | Because the sparse update replaces the whole `Line` array, an update that re-sends lines without `taxCodeId` and without `txnTaxDetail` would drop tax a previous create attached and let Automated Sales Tax recompute it. Four of the rules below are `createInvoice`'s coupling rules, and they run *before* the invoice is read, so bad input never costs a request: - `txnTaxDetail` with no `lines` is rejected — there would be no lines to code; - `txnTaxDetail` with any line missing `taxCodeId` is rejected; - a line `taxCodeId` with no `txnTaxDetail` is rejected; - a `totalTax` larger than the line total (compared in cents) is rejected. Each of those four needs a tax field to be *present*, so on their own they do nothing about the case that actually loses the tax: an update carrying `lines` and **no** tax input at all. The fifth rule is the one that covers it, and it reads the stored invoice rather than the config: - supplying `lines` with no `txnTaxDetail` against an invoice that shows **any** sign of stored tax is **rejected after the read and before the write**. The error names the field it read and the remedy: resend `txnTaxDetail` together with a per-line `taxCodeId`, both recomputed for the new lines. This costs no extra request — the read that fetches the SyncToken returns the whole invoice. Any *one* of these signals is enough, because QuickBooks does not populate them together: | Signal | Why it alone is not enough | | ------ | -------------------------- | | `TxnTaxDetail.TotalTax` greater than zero | An invoice can carry a `TaxLine` with no `TotalTax` key at all. | | Any `TxnTaxDetail.TaxLine[].Amount` greater than zero | Intuit's non-US automated-sales-tax sample pairs a `TaxLine` of `3.33` with `TotalTax: 0` (in the request; its sample response corrects `TotalTax`). A record that ever shows that pair must not read as untaxed. | | Any stored line whose `SalesItemLineDetail.TaxCodeRef.value` is not `NON` | Catches an invoice carrying tax-coded lines and no `TxnTaxDetail`. | The line-code rule reads the code's **value**, never the mere presence of `TaxCodeRef`: QuickBooks stamps `NON` on non-taxable lines, so keying on presence would treat every untaxed invoice as taxed. `NON` is compared case-insensitively, and it is the only value that means "no tax" — US companies get the `TAX`/`NON` pseudo-codes, non-US ones an opaque tax-code Id, so any other value has to read as a real code. A `TxnTaxDetail` that is present but cannot be parsed into the above — not an object, a non-numeric `TotalTax`, a `TaxLine` that is not an array, a `TaxLine` entry with no readable `Amount`, a `TaxCodeRef` with no readable `value` — is **also rejected**, with an error saying the field could not be read. Ambiguity fails closed: the state at risk lives on the server, so a field the action cannot parse is not evidence that there is no tax. `GlobalTaxCalculation` is deliberately **not** a signal. Intuit documents it as required for every non-US company and documents no value that means "no tax" (`NotApplicable`'s trigger conditions are never explained and it appears in no sample), so keying on it would refuse every non-US line replacement whether or not the invoice is taxed. The shapes it would have caught are already covered: a `TaxExcluded` invoice with tax-coded lines by the line-code rule, a `TaxInclusive` one by its `TaxLine`. What still goes through unchanged: an invoice with no `TxnTaxDetail` and no coded lines; one whose `TotalTax` is `0` with no positive `TaxLine` and every line coded `NON`; and any update that supplies full tax input. An update with no `lines` is unaffected — a sparse body leaves `Line` alone, so the stored tax is not at risk and a metadata-only edit still goes through. The same five rules govern the update path of [`upsertInvoiceByDocNumber`](#upsertinvoicebydocnumber), which also replaces the whole `Line` array — on both of its branches, whether the target was resolved from the caller's stored `invoiceId` or found by the DocNumber probe. Its create path has no stored tax to protect and is not affected. The `TotalTax` returned by the update is checked against the submitted one, to the cent, exactly as on `createInvoice`. Supplying none of the tax or address fields against an **untaxed** invoice leaves the request byte-identical to what this action sent before they existed. #### listInvoices List invoices with optional filters. ```json { "action": "quickbooks.listInvoices", "config": { "customerId": "{{input.customerId}}", "balance": 0, "balanceOperator": ">", "txnDateAfter": "2024-01-01", "limit": 100 } } ``` #### findInvoiceByDocNumber Look up an invoice by its DocNumber (the invoice number / reference no.). Returns a normalized result rather than the raw `QueryResponse`, because QuickBooks omits the entity array entirely when nothing matches. ```json { "action": "quickbooks.findInvoiceByDocNumber", "config": { "realmId": "{{input.realmId}}", "docNumber": "5314-5050 BL" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `realmId` | string | Yes | QuickBooks company (realm) ID | | `docNumber` | string | Yes | Invoice number to look up | **Output:** ```json { "found": true, "id": "239", "syncToken": "0", "entity": { "...": "the matching Invoice object, or null when found is false" } } ``` A zero-match lookup returns `{ "found": false, "id": null, "syncToken": null, "entity": null }`. #### upsertInvoiceByDocNumber Create-or-update an invoice keyed on its DocNumber. The DocNumber probe runs first, so a rerun over the same period updates the invoice a previous attempt created instead of creating a duplicate — even when the caller no longer has the stored QuickBooks id. QuickBooks replaces the whole `Line` array on update, so every line must be supplied on every run. ```json { "id": "upsert-payroll-invoice", "type": "action", "action": "quickbooks.upsertInvoiceByDocNumber", "config": { "realmId": "{{input.realmId}}", "docNumber": "5314-5050 BL", "customerId": "{{steps.find-customer.output.id}}", "lines": [ { "description": "Gross Payroll", "amount": 2496.27, "quantity": 1, "unitPrice": 2496.27, "itemId": "12" } ], "invoiceDate": "2026-07-15", "dueDate": "2026-08-14", "privateNote": "Payroll invoice for pay day 2026-07-15" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `realmId` | string | Yes | QuickBooks company (realm) ID | | `docNumber` | string | Yes | Invoice number — the idempotency key | | `customerId` | string | Yes | Customer ID | | `lines` | array | Yes | Invoice lines (at least one) | | `lines[].description` | string | No | Line description | | `lines[].amount` | number | Yes | Line amount | | `lines[].quantity` | number | No | Quantity (default: 1) | | `lines[].unitPrice` | number | No | Unit price (default: the line amount) | | `lines[].itemId` | string | No | Product/Service ID | | `lines[].taxCodeId` | string | No | Tax code (`TAX`, `NON`, or a tax-code id) → `SalesItemLineDetail.TaxCodeRef`; requires `txnTaxDetail` | | `invoiceDate` | string | No | Transaction date, YYYY-MM-DD (default: today) | | `dueDate` | string | No | Due date (YYYY-MM-DD) | | `customerMemo` | string | No | Memo for customer | | `privateNote` | string | No | Internal note | | `txnTaxDetail` | object | No | `{ totalTax, txnTaxCodeId }` — both required → `TxnTaxDetail`; suppresses US Automated Sales Tax (see `createInvoice`) | | `shipAddr` | object | No | `{ line1, line2?, city?, countrySubDivisionCode?, postalCode?, country? }` → `ShipAddr` | | `billAddr` | object | No | Same shape → `BillAddr` | The three tax/address fields are sent on both the create and the update path. The four input-shape rejections listed under `createInvoice` run before the DocNumber probe, so bad input never costs a query, and on both paths the `TotalTax` **returned by the write** is checked against the submitted one, to the cent. One further rejection is specific to the update path and necessarily runs *after* the probe: because this action always replaces the whole `Line` array, an update onto an invoice whose stored `TxnTaxDetail.TotalTax` is greater than zero is **refused when no `txnTaxDetail` is supplied** — the replacement lines would otherwise drop the tax and let Automated Sales Tax recompute it against a 200. The error names the stored tax; resend `txnTaxDetail` and a per-line `taxCodeId` recomputed for the new lines. The create path has no stored tax and is unaffected, as is an invoice stored with zero tax (a `NON`-coded line is not treated as taxed). See [`updateInvoice`](#updateinvoice) for the full rule set. That update path has still not been exercised against a live Automated Sales Tax company — see the note under `createInvoice`. **Output:** ```json { "operation": "created", "invoiceId": "238", "syncToken": "0", "totalAmount": 100.0, "docNumber": "5314-5050 BL", "realmId": "9341454792746027", "Invoice": { "...": "the QuickBooks Invoice object from the write response" } } ``` `operation` is `"created"` or `"updated"`. `syncToken` and `totalAmount` are `null` when the response omits them. ### Journal Entry Actions A journal entry needs at least one debit line and one credit line, and the two columns must total the same amount. Each line names an account either by `accountId` (the QuickBooks Account Id) or by `accountNumber` (the account's number in the chart of accounts) — account numbers are resolved with a single chart-of-accounts query per call, and an unresolvable number fails the step. #### createJournalEntry Post a new journal entry. Returns the raw QuickBooks response. ```json { "id": "post-payroll-je", "type": "action", "action": "quickbooks.createJournalEntry", "config": { "realmId": "{{input.realmId}}", "docNumber": "5314-5050 BL", "txnDate": "2026-07-15", "privateNote": "Net payroll costs for pay day 2026-07-15", "journalLines": [ { "accountNumber": "400001", "postingType": "debit", "amount": 1222.92, "description": "Net Payroll Costs" }, { "accountId": "44", "postingType": "credit", "amount": 1222.92, "description": "Payroll Clearing" } ] } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `realmId` | string | Yes | QuickBooks company (realm) ID | | `journalLines` | array | Yes | Debit/credit distribution lines (at least one) | | `journalLines[].amount` | number | Yes | Line amount | | `journalLines[].postingType` | string | No | `debit` or `credit` — anything other than `credit` posts as a debit | | `journalLines[].accountId` | string | No | QuickBooks Account Id (takes precedence) | | `journalLines[].accountNumber` | string | No | Account number, resolved against the chart of accounts when `accountId` is absent | | `journalLines[].description` | string | No | Line description | | `docNumber` | string | No | Reference number for the entry | | `txnDate` | string | No | Transaction date, YYYY-MM-DD (default: today) | | `privateNote` | string | No | Internal note | The executor also accepts `lines` as an alias for `journalLines`, so hand-authored workflow JSON can use the same key as the invoice actions. **Output:** ```json { "time": "2015-06-29T12:45:32.183-07:00", "JournalEntry": { "...": "the created QuickBooks JournalEntry object" } } ``` #### findJournalEntryByDocNumber Look up a journal entry by its DocNumber. Normalized the same way as `findInvoiceByDocNumber`. ```json { "action": "quickbooks.findJournalEntryByDocNumber", "config": { "realmId": "{{input.realmId}}", "docNumber": "5314-5050 BL" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `realmId` | string | Yes | QuickBooks company (realm) ID | | `docNumber` | string | Yes | Journal entry reference number to look up | **Output:** ```json { "found": true, "id": "227", "syncToken": "1", "entity": { "...": "the matching JournalEntry object, or null when found is false" } } ``` #### upsertJournalEntryByDocNumber Create-or-update a journal entry keyed on its DocNumber. Idempotent by construction: the DocNumber probe runs first, so a retried step updates the entry a previous attempt created instead of posting a duplicate. Lines are always sent in full because QuickBooks replaces the `Line` array wholesale on update. ```json { "id": "upsert-payroll-je", "type": "action", "action": "quickbooks.upsertJournalEntryByDocNumber", "config": { "realmId": "{{input.realmId}}", "docNumber": "5314-5050 BL", "txnDate": "2026-07-15", "privateNote": "Net payroll costs for pay day 2026-07-15", "journalLines": [ { "accountNumber": "400001", "postingType": "debit", "amount": 1222.92, "description": "Net Payroll Costs" }, { "accountId": "44", "postingType": "credit", "amount": 1222.92, "description": "Payroll Clearing" } ] } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `realmId` | string | Yes | QuickBooks company (realm) ID | | `docNumber` | string | Yes | Reference number — the idempotency key | | `journalLines` | array | Yes | Debit/credit distribution lines (at least one) | | `journalLines[].amount` | number | Yes | Line amount | | `journalLines[].postingType` | string | No | `debit` or `credit` — anything other than `credit` posts as a debit | | `journalLines[].accountId` | string | No | QuickBooks Account Id (takes precedence) | | `journalLines[].accountNumber` | string | No | Account number, resolved against the chart of accounts when `accountId` is absent | | `journalLines[].description` | string | No | Line description | | `txnDate` | string | No | Transaction date, YYYY-MM-DD (default: today) | | `privateNote` | string | No | Internal note | **Output:** ```json { "operation": "created", "journalEntryId": "228", "syncToken": "0", "docNumber": "5314-5050 BL", "realmId": "9341454792746027", "JournalEntry": { "...": "the QuickBooks JournalEntry object from the write response" } } ``` `operation` is `"created"` or `"updated"`. `syncToken` is `null` when the response omits it. ### Payment Actions #### createPayment Record a customer payment. `invoiceIds` accepts one invoice; the full payment amount is allocated to it. For multiple invoices, use **Batch** with a `Payment` create operation and explicit `Line` amounts for each allocation. ```json { "id": "record-payment", "type": "action", "action": "quickbooks.createPayment", "config": { "customerId": "{{input.customerId}}", "amount": 1500.00, "paymentDate": "2024-01-20", "paymentMethodId": "1", "invoiceIds": ["{{input.invoiceId}}"], "referenceNumber": "CHK-12345" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `customerId` | string | Yes | Customer ID | | `amount` | number | Yes | Payment amount | | `paymentDate` | string | No | Payment date (default: today) | | `paymentMethodId` | string | No | Payment method reference | | `depositAccountId` | string | No | Account to deposit to | | `invoiceIds` | array | No | Invoices to apply payment to | | `referenceNumber` | string | No | Check/reference number | #### getPayment Get a payment by ID. ```json { "action": "quickbooks.getPayment", "config": { "paymentId": "{{input.paymentId}}" } } ``` #### voidPayment Void a payment. ```json { "action": "quickbooks.voidPayment", "config": { "paymentId": "{{input.paymentId}}" } } ``` ### Bill (AP) Actions #### createBill Create a vendor bill (accounts payable). ```json { "id": "create-bill", "type": "action", "action": "quickbooks.createBill", "config": { "vendorId": "{{input.vendorId}}", "lines": [ { "description": "Office Supplies", "amount": 250.00, "accountId": "54", "billableStatus": "NotBillable" } ], "dueDate": "2024-02-28", "billNumber": "BILL-001" } } ``` #### getBill / updateBill / deleteBill ```json { "action": "quickbooks.getBill", "config": { "billId": "{{input.billId}}" } } ``` ### Vendor Actions #### createVendor Create a new vendor. ```json { "action": "quickbooks.createVendor", "config": { "displayName": "{{input.vendorName}}", "companyName": "{{input.companyName}}", "email": "{{input.email}}", "is1099": true, "taxId": "{{input.taxId}}" } } ``` ### Account Actions #### getAccountList Get the chart of accounts. ```json { "action": "quickbooks.getAccountList", "config": { "active": true, "accountType": "Expense", "classification": "Expense" } } ``` **Account Types:** Bank, Accounts Receivable, Other Current Asset, Fixed Asset, Accounts Payable, Credit Card, Other Current Liability, Long Term Liability, Equity, Income, Cost of Goods Sold, Expense, Other Income, Other Expense #### createAccount Create a new account in the chart of accounts. ```json { "action": "quickbooks.createAccount", "config": { "name": "Software Subscriptions", "accountType": "Expense", "accountSubType": "SubscriptionFees", "description": "SaaS and software subscriptions" } } ``` ### Journal Entry Actions Journal entries post directly to the general ledger. Every line carries an account, an amount, and a posting side; total debits must equal total credits. The executors validate that balance before calling QuickBooks, so an unbalanced entry fails with an explicit message instead of a generic QuickBooks fault. `accountId` is the QuickBooks internal account **Id**, not the account number. Resolve numbers to Ids first with `getAccountList` or `query` (`SELECT Id, Name, AcctNum FROM Account`) — Ids differ between QuickBooks companies. #### createJournalEntry Create a journal entry. ```json { "id": "post-journal-entry", "type": "action", "action": "quickbooks.createJournalEntry", "config": { "txnDate": "2026-07-17", "docNumber": "5322-5217 JE", "privateNote": "Payroll passthrough", "lines": [ { "accountId": "92", "amount": 1222.92, "postingType": "Debit", "description": "Net payroll costs" }, { "accountId": "33", "amount": 1222.92, "postingType": "Credit", "description": "Payroll clearing" } ] } } ``` **Line fields:** `accountId` (required), `amount` (required, non-negative), `postingType` (required, `Debit` or `Credit`), `description`, `entityType` (`Customer` / `Vendor` / `Employee`) with `entityId`, `classId`, `departmentId`. **Limits:** at least two lines, with at least one debit and one credit; `docNumber` is capped at 21 characters by QuickBooks; the balance check tolerates rounding differences up to $0.005. Omit `txnDate` to let QuickBooks date the entry with the company's own today — passing the caller's date is only correct if it is already the intended accounting day. **Accounts Receivable / Accounts Payable lines require an entity.** QuickBooks rejects a journal entry line posting to an AR account without a `Customer`, or to an AP account without a `Vendor`, with a generic business-validation fault — a common cause of a failed create. Set `entityType` and `entityId` on those lines. #### getJournalEntry Fetch a journal entry by its QuickBooks Id. ```json { "action": "quickbooks.getJournalEntry", "config": { "journalEntryId": "{{steps.post-journal-entry.JournalEntry.Id}}" } } ``` #### updateJournalEntry Update a journal entry. The current `SyncToken` is read automatically before the write. Omitted fields are preserved; supplying `lines` replaces the entry's lines wholesale (QuickBooks has no per-line patch) and re-validates the balance. ```json { "action": "quickbooks.updateJournalEntry", "config": { "journalEntryId": "148", "privateNote": "Corrected workers comp margin", "lines": [ { "accountId": "92", "amount": 100.0, "postingType": "Debit" }, { "accountId": "33", "amount": 100.0, "postingType": "Credit" } ] } } ``` #### deleteJournalEntry Delete a journal entry. QuickBooks offers no void operation for journal entries — delete is the only reversal. ```json { "action": "quickbooks.deleteJournalEntry", "config": { "journalEntryId": "148" } } ``` #### listJournalEntries List journal entries, optionally filtered by document number or transaction date range. ```json { "action": "quickbooks.listJournalEntries", "config": { "txnDateAfter": "2026-07-01", "txnDateBefore": "2026-07-31", "limit": 100 } } ``` #### findJournalEntryByDocNumber Check whether a journal entry with a given document number already exists — the duplicate guard to run before posting. Returns `{ exists, count, journalEntries }` rather than the raw QuickBooks envelope, so a condition step can branch on `exists` directly. ```json { "action": "quickbooks.findJournalEntryByDocNumber", "config": { "docNumber": "5322-5217 JE" } } ``` ### Report Actions #### runReport Run a financial report. ```json { "id": "monthly-pnl", "type": "action", "action": "quickbooks.runReport", "config": { "reportType": "ProfitAndLoss", "startDate": "2024-01-01", "endDate": "2024-01-31", "accountingMethod": "Accrual", "summarizeBy": "Month" } } ``` The searchable **Report** selector supports these Intuit endpoint names: `AccountList`, `AgedPayableDetail`, `AgedPayables`, `AgedReceivableDetail`, `AgedReceivables`, `BalanceSheet`, `CashFlow`, `CustomerBalance`, `CustomerBalanceDetail`, `CustomerIncome`, `GeneralLedger`, `InventoryValuationDetail`, `InventoryValuationSummary`, `JournalReport`, `ProfitAndLoss`, `ProfitAndLossDetail`, `ClassSales`, `CustomerSales`, `DepartmentSales`, `ItemSales`, `TaxSummary`, `TransactionList`, `TransactionListByVendor`, `TransactionListByCustomer`, `TransactionListWithSplits`, `TrialBalanceFR`, `TrialBalance`, `VendorBalance`, `VendorBalanceDetail`, `VendorExpenses`. Use `reportParams` for report-specific scalar query parameters, such as `{"account":"1,2","summarize_column_by":"Month"}`. These override the equivalent dedicated fields. Unsupported filters and reports return Intuit's error for that company. The response preserves `Header`, `Columns`, and `Rows`, including nested sections and summaries. To analyze the report, add an Agent or Code step after `monthly-pnl` and reference `{{steps.monthly-pnl.output.Header}}`, `{{steps.monthly-pnl.output.Columns}}`, and `{{steps.monthly-pnl.output.Rows}}`. Keep column metadata with the rows so the analysis can distinguish labels, periods, and amounts. Chat can create the same workflow, for example: "Run January's Profit and Loss report in QuickBooks, then analyze expenses using the report's columns and rows." ### Company Actions #### getCompanyInfo Read the connected company's profile. Use it as a pre-flight guard: a workflow can assert, before writing anything, that the `(connection, realmId)` pair it was handed really is the company it expects. ```json { "id": "assert-company", "type": "action", "action": "quickbooks.getCompanyInfo", "config": { "realmId": "{{input.realmId}}" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `realmId` | string | Yes | QuickBooks company (realm) ID | **Output:** ```json { "realmId": "9341454792746027", "companyName": "Larry's Bakery", "legalName": "Larry's Bakery", "CompanyInfo": { "...": "the full QuickBooks CompanyInfo object" } } ``` `companyName` and `legalName` are `null` when the response omits them. ### Query Action #### query Execute a custom QuickBooks query. ```json { "action": "quickbooks.query", "config": { "query": "SELECT * FROM Invoice WHERE Balance > '0' ORDER BY TxnDate DESC MAXRESULTS 50" } } ``` ## Webhook Triggers QuickBooks webhooks can trigger workflows on data changes. ```json { "trigger": { "type": "webhook", "provider": "quickbooks", "events": ["Invoice", "Customer", "Payment"] } } ``` **Event Types:** Customer, Invoice, Payment, Bill, Vendor, Account, Purchase, SalesReceipt, Estimate ## Example Workflow Complete invoice workflow triggered by Stripe payment: ```json { "name": "Stripe to QuickBooks Invoice", "trigger": { "type": "webhook", "provider": "stripe", "events": ["invoice.paid"] }, "steps": [ { "id": "find-customer", "type": "action", "action": "quickbooks.findCustomerByEmail", "config": { "email": "{{input.data.object.customer_email}}" } }, { "id": "check-customer", "type": "condition", "config": { "conditions": { "left": "{{steps.find-customer.output.QueryResponse.Customer}}", "operator": "exists" }, "then": ["create-invoice"], "else": ["create-customer"] } }, { "id": "create-customer", "type": "action", "action": "quickbooks.createCustomer", "config": { "displayName": "{{input.data.object.customer_name}}", "email": "{{input.data.object.customer_email}}" } }, { "id": "create-invoice", "type": "action", "action": "quickbooks.createInvoice", "config": { "customerId": "{{steps.find-customer.output.QueryResponse.Customer[0].Id || steps.create-customer.output.Customer.Id}}", "lines": [ { "description": "{{input.data.object.lines.data[0].description}}", "amount": "{{input.data.object.amount_paid / 100}}" } ] } }, { "id": "record-payment", "type": "action", "action": "quickbooks.createPayment", "config": { "customerId": "{{steps.find-customer.output.QueryResponse.Customer[0].Id || steps.create-customer.output.Customer.Id}}", "amount": "{{input.data.object.amount_paid / 100}}", "invoiceIds": ["{{steps.create-invoice.output.Invoice.Id}}"], "referenceNumber": "{{input.data.object.id}}" } } ] } ``` ## Rate Limits | Limit | Value | |-------|-------| | API calls per minute | 500 | | Batch operations | 30 records per batch | | Query results | 1000 records max | | Concurrent connections | 10 | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `realmId is required` | Missing company ID | Set `config.realmId` on the step (the block's **Company (Realm) ID** field) | | `SyncToken` mismatch | Concurrent updates | Refetch record before update | | `Business validation error` | Invalid data | Check field formats and requirements | | `Throttled` | Rate limit exceeded | Implement exponential backoff | ### Configuration: realmId Every QuickBooks action needs `realmId` (the company ID) to build its request path, and **step config is the standard way to supply it**. Set `config.realmId` on every QuickBooks step: ```json { "action": "quickbooks.getCompanyInfo", "config": { "realmId": "9341454792746027" } } ``` On the canvas this is the **Company (Realm) ID** field on the QuickBooks block, which is required. A step without it fails with `realmId is required for QuickBooks API calls`. The action resolver also accepts trusted `variables.realmId` as a fallback for runtime callers. The canvas does not automatically populate this value from the selected connection; set the Company (Realm) ID explicitly when authoring a workflow. # Rillet (/docs/integrations/accounting/rillet) Modern ERP for invoices, contracts, revenue, and the general ledger ## Rillet Connect to Rillet for accounting and ERP automation -- customers, invoices, contracts, usage records, bills, credit memos, journal entries, and the general ledger. Rillet is a modern, AI-native ERP built for finance teams. The Rillet block currently surfaces only the **Send Email** action in the Studio dropdown. The additional operations below are available through the `rillet.` dispatch layer (used by programmatic and API-defined workflows) but are not yet exposed as Studio dropdown options. Field tables reflect the executor requirements, not a Studio form. ## Prerequisites - Rillet account - Rillet API key (`RILLET_API_KEY`) or `apiKey` passed in step config - Optional `RILLET_BASE_URL` (defaults to the Rillet sandbox) ## Authentication Rillet uses direct HTTP with an API key (not Nango). Set the `RILLET_API_KEY` environment variable or pass `apiKey` in the step config. POST requests automatically include an idempotency key. ## Available Operations Operations are invoked as `rillet.`. List operations are paginated (cursor-based). ### Customers | Operation | Description | |-----------|-------------| | `listCustomers` | List customers (paginated) | | `getCustomer` | Get a customer by `customerId` | | `createCustomer` | Create a customer | | `updateCustomer` | Update a customer by `customerId` | | `setupCustomerAutoPayment` | Configure auto-payment for a customer | | `getCustomerPaymentMethod` | Get a customer's payment method | ```json { "type": "action", "action": "rillet.createCustomer", "config": { "name": "{{input.companyName}}", "email": "{{input.email}}" } } ``` ### Invoices | Operation | Description | |-----------|-------------| | `listInvoices` | List invoices (paginated) | | `getInvoice` | Get an invoice by `invoiceId` | | `createInvoice` | Create an invoice | | `addInvoiceTax` | Add tax to an invoice | | `updateInvoiceSentStatus` | Update an invoice's sent status | ```json { "type": "action", "action": "rillet.createInvoice", "config": { "customerId": "{{input.customerId}}", "lineItems": [ { "description": "Subscription", "quantity": 1, "unitAmount": "{{input.amount}}" } ] } } ``` ### Invoice Payments | Operation | Description | |-----------|-------------| | `listInvoicePayments` | List invoice payments (paginated) | | `createInvoicePayment` | Record an invoice payment | ### Products | Operation | Description | |-----------|-------------| | `listProducts` | List products (paginated) | | `getProduct` | Get a product by `productId` | | `createProduct` | Create a product | ### Contracts | Operation | Description | |-----------|-------------| | `listContracts` | List contracts (paginated) | | `getContract` | Get a contract by `contractId` | | `createContract` | Create a contract | | `amendContract` | Amend a contract | | `endContract` | End a contract | ### Contract Item Usage | Operation | Description | |-----------|-------------| | `upsertContractItemUsage` | Upsert usage records for a contract item | | `listContractItemUsageRecords` | List usage records for a contract item | | `deleteContractItemUsageRecord` | Delete a usage record | ```json { "type": "action", "action": "rillet.upsertContractItemUsage", "config": { "contractItemId": "{{input.contractItemId}}", "usageRecords": [ { "date": "{{input.date}}", "quantity": "{{input.units}}" } ] } } ``` ### Bills | Operation | Description | |-----------|-------------| | `listBills` | List bills (paginated) | | `getBill` | Get a bill by `billId` | | `createBill` | Create a bill | | `uploadBillDocument` | Upload a document to a bill | ### Credit Memos | Operation | Description | |-----------|-------------| | `listCreditMemos` | List credit memos (paginated) | | `getCreditMemo` | Get a credit memo by `creditMemoId` | | `createCreditMemo` | Create a credit memo | | `addCreditMemoTax` | Add tax to a credit memo | ### Vendors | Operation | Description | |-----------|-------------| | `listVendors` | List vendors (paginated) | | `getVendor` | Get a vendor by `vendorId` | | `createVendor` | Create a vendor | ### General Ledger | Operation | Description | |-----------|-------------| | `listJournalEntries` | List journal entries (paginated) | | `getJournalEntry` | Get a journal entry | | `createJournalEntry` | Create a journal entry | | `listAccounts` | List the chart of accounts | | `listBankAccounts` / `getBankAccount` | Bank accounts | | `listCharges` / `getCharge` | Charges | | `listReimbursements` / `getReimbursement` | Reimbursements | | `listSubsidiaries` / `getSubsidiary` | Subsidiaries | ### Custom Fields & Utility | Operation | Description | |-----------|-------------| | `listFields` / `createField` / `updateField` | Manage custom fields | | `getApiKeyInfo` | Get info about the current API key | | `healthCheck` | Check API connectivity | ## Example Workflow Create a Rillet invoice when a Stripe invoice is paid: ```json { "name": "Sync Invoice to Rillet", "trigger": { "type": "webhook", "provider": "stripe", "events": ["invoice.paid"] }, "steps": [ { "id": "create-rillet-invoice", "type": "action", "action": "rillet.createInvoice", "config": { "customerId": "{{input.data.object.customer}}", "currency": "{{input.data.object.currency}}", "lineItems": [ { "description": "Subscription", "quantity": 1, "unitAmount": "{{input.data.object.amount_paid | divided_by: 100}}" } ] } } ] } ``` ## Troubleshooting | Error | Cause | Solution | |-------|-------|----------| | `Rillet API key not found` | No API key | Set `RILLET_API_KEY` or pass `apiKey` in step config | | `customerId is required` | Missing required ID | Provide the required ID field for the operation | | `Rillet API error (4xx)` | Bad request/auth | Check the API key, base URL, and request payload | ## Next Steps Invoices, payments, journal entries Records, transactions, saved searches Invoices, contacts, bank transactions # Sage Intacct (/docs/integrations/accounting/sage-intacct) Enterprise financial management for mid-market companies ## Sage Intacct Connect to Sage Intacct for enterprise financial management including AR, AP, GL, and project accounting. ## Overview Sage Intacct is a cloud financial management platform. The integration supports: - **Customers** - Customer master records - **Vendors** - Vendor/supplier records - **Invoices (AR)** - Accounts receivable invoices - **Bills (AP)** - Accounts payable bills - **Payments** - AR and AP payments - **Journal Entries** - General ledger entries - **Accounts** - Chart of accounts - **Projects** - Project tracking and accounting ## Prerequisites - Sage Intacct subscription - Web Services subscription enabled - API credentials (Sender ID, Password) - Session-based authentication configured ## Authentication Sage Intacct uses session-based XML-RPC authentication via Nango. ```bash curl "https://workflow.loopfour.ai/api/v1/connections/sage-intacct/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` ## Available Actions ### Customer Actions #### getCustomer Get customer by ID. ```json { "action": "sage-intacct.getCustomer", "config": { "customerId": "{{input.customerId}}", "fields": "*" } } ``` #### createCustomer Create a new customer. ```json { "id": "create-customer", "type": "action", "action": "sage-intacct.createCustomer", "config": { "customerId": "CUST-{{input.accountNumber}}", "name": "{{input.companyName}}", "displayContact": { "name": "{{input.contactName}}", "printAs": "{{input.companyName}}", "companyName": "{{input.companyName}}", "phone": "{{input.phone}}", "email": "{{input.email}}", "address": { "address1": "{{input.address.street}}", "city": "{{input.address.city}}", "state": "{{input.address.state}}", "zip": "{{input.address.zip}}", "country": "{{input.address.country}}" } }, "status": "active", "paymentTerm": "Net 30", "customerType": "{{input.customerType}}", "currency": "USD", "creditLimit": "{{input.creditLimit}}" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `customerId` | string | No | Customer ID (auto-generated if blank) | | `name` | string | Yes | Customer name | | `displayContact` | object | No | Primary contact info | | `status` | string | No | `active` or `inactive` | | `paymentTerm` | string | No | Payment term name | | `customerType` | string | No | Customer classification | | `currency` | string | No | Default currency | | `creditLimit` | number | No | Credit limit amount | #### updateCustomer Update an existing customer. ```json { "action": "sage-intacct.updateCustomer", "config": { "customerId": "{{input.customerId}}", "name": "{{input.newName}}", "creditLimit": "{{input.newCreditLimit}}" } } ``` #### listCustomers List customers with optional filters. ```json { "action": "sage-intacct.listCustomers", "config": { "filter": "STATUS = 'active'", "fields": "CUSTOMERID,NAME,TOTALDUE", "pageSize": 100 } } ``` ### Vendor Actions #### getVendor Get vendor by ID. ```json { "action": "sage-intacct.getVendor", "config": { "vendorId": "{{input.vendorId}}", "fields": "*" } } ``` #### createVendor Create a new vendor. ```json { "id": "create-vendor", "type": "action", "action": "sage-intacct.createVendor", "config": { "vendorId": "VEND-{{input.vendorNumber}}", "name": "{{input.vendorName}}", "displayContact": { "name": "{{input.contactName}}", "printAs": "{{input.vendorName}}", "companyName": "{{input.vendorName}}", "phone": "{{input.phone}}", "email": "{{input.email}}", "address": { "address1": "{{input.address.street}}", "city": "{{input.address.city}}", "state": "{{input.address.state}}", "zip": "{{input.address.zip}}", "country": "{{input.address.country}}" } }, "status": "active", "paymentTerm": "Net 30", "vendorType": "{{input.vendorType}}", "currency": "USD", "taxId": "{{input.taxId}}", "form1099Type": "MISC", "form1099Box": "7" } } ``` #### listVendors List vendors. ```json { "action": "sage-intacct.listVendors", "config": { "filter": "STATUS = 'active' AND VENDTYPE = 'Supplier'", "pageSize": 100 } } ``` ### Invoice (AR) Actions #### getInvoice Get AR invoice by ID. ```json { "action": "sage-intacct.getInvoice", "config": { "invoiceId": "{{input.invoiceId}}", "fields": "*" } } ``` #### createInvoice Create an AR invoice. ```json { "id": "create-invoice", "type": "action", "action": "sage-intacct.createInvoice", "config": { "customerId": "{{input.customerId}}", "invoiceDate": "{{input.invoiceDate}}", "dueDate": "{{input.dueDate}}", "invoiceNumber": "INV-{{input.invoiceNumber}}", "poNumber": "{{input.poNumber}}", "description": "{{input.description}}", "currency": "USD", "lineItems": [ { "accountNumber": "4000", "amount": "{{input.items[0].amount}}", "memo": "{{input.items[0].description}}", "locationId": "{{input.locationId}}", "departmentId": "{{input.departmentId}}", "projectId": "{{input.projectId}}" } ] } } ``` **Line Item Parameters:** | Field | Type | Description | |-------|------|-------------| | `accountNumber` | string | GL account number | | `amount` | number | Line amount | | `memo` | string | Line description | | `locationId` | string | Location dimension | | `departmentId` | string | Department dimension | | `projectId` | string | Project dimension | | `customerId` | string | Customer dimension | | `itemId` | string | Item ID | | `quantity` | number | Quantity | | `price` | number | Unit price | #### postInvoice Post an invoice. ```json { "action": "sage-intacct.postInvoice", "config": { "invoiceId": "{{input.invoiceId}}" } } ``` #### listInvoices List AR invoices. ```json { "action": "sage-intacct.listInvoices", "config": { "filter": "STATE = 'Posted' AND TOTALDUE > 0", "fields": "RECORDNO,CUSTOMERID,TOTALENTERED,TOTALDUE,WHENCREATED", "pageSize": 100 } } ``` ### Bill (AP) Actions #### getBill Get AP bill by ID. ```json { "action": "sage-intacct.getBill", "config": { "billId": "{{input.billId}}", "fields": "*" } } ``` #### createBill Create an AP bill. ```json { "id": "create-bill", "type": "action", "action": "sage-intacct.createBill", "config": { "vendorId": "{{input.vendorId}}", "billDate": "{{input.billDate}}", "dueDate": "{{input.dueDate}}", "billNumber": "{{input.vendorInvoiceNumber}}", "vendorDocNumber": "{{input.vendorDocNumber}}", "description": "{{input.description}}", "currency": "USD", "lineItems": [ { "accountNumber": "6000", "amount": "{{input.amount}}", "memo": "{{input.description}}", "locationId": "{{input.locationId}}", "departmentId": "{{input.departmentId}}" } ] } } ``` #### listBills List AP bills. ```json { "action": "sage-intacct.listBills", "config": { "filter": "PAYMENTSTATUS = 'open'", "pageSize": 100 } } ``` ### Payment Actions #### createArPayment Record an AR payment. ```json { "id": "record-payment", "type": "action", "action": "sage-intacct.createArPayment", "config": { "customerId": "{{input.customerId}}", "paymentMethod": "Check", "receiptDate": "{{input.paymentDate}}", "bankAccountId": "{{input.bankAccountId}}", "referenceNumber": "{{input.checkNumber}}", "invoices": [ { "invoiceKey": "{{input.invoiceKey}}", "amount": "{{input.paymentAmount}}" } ] } } ``` #### createApPayment Create an AP payment. ```json { "id": "pay-vendor", "type": "action", "action": "sage-intacct.createApPayment", "config": { "vendorId": "{{input.vendorId}}", "paymentMethod": "ACH", "paymentDate": "{{input.paymentDate}}", "bankAccountId": "{{input.bankAccountId}}", "documentNumber": "{{input.paymentRef}}", "description": "Payment for bills", "bills": [ { "billKey": "{{input.billKey}}", "amount": "{{input.paymentAmount}}" } ] } } ``` ### Journal Entry Actions #### createJournalEntry Create a journal entry. ```json { "id": "create-je", "type": "action", "action": "sage-intacct.createJournalEntry", "config": { "journal": "GJ", "batchDate": "{{input.entryDate}}", "description": "{{input.description}}", "reverseDate": "{{input.reversalDate}}", "lines": [ { "accountNumber": "1000", "type": 1, "amount": "{{input.debitAmount}}", "description": "Debit entry", "locationId": "{{input.locationId}}", "departmentId": "{{input.departmentId}}" }, { "accountNumber": "2000", "type": -1, "amount": "{{input.creditAmount}}", "description": "Credit entry", "locationId": "{{input.locationId}}", "departmentId": "{{input.departmentId}}" } ] } } ``` **Line Type Values:** - `1` = Debit - `-1` = Credit #### listJournalEntries List journal entries. ```json { "action": "sage-intacct.listJournalEntries", "config": { "filter": "JOURNAL = 'GJ' AND BATCH_DATE >= '01/01/2024'", "pageSize": 100 } } ``` ### Account Actions #### getAccount Get GL account. ```json { "action": "sage-intacct.getAccount", "config": { "accountNo": "{{input.accountNumber}}", "fields": "*" } } ``` #### listAccounts List chart of accounts. ```json { "action": "sage-intacct.listAccounts", "config": { "filter": "ACCOUNTTYPE = 'balancesheet'", "pageSize": 500 } } ``` ### Project Actions #### getProject Get project by ID. ```json { "action": "sage-intacct.getProject", "config": { "projectId": "{{input.projectId}}", "fields": "*" } } ``` #### createProject Create a project. ```json { "id": "create-project", "type": "action", "action": "sage-intacct.createProject", "config": { "projectId": "PROJ-{{input.projectNumber}}", "name": "{{input.projectName}}", "description": "{{input.description}}", "category": "{{input.category}}", "status": "Active", "customerId": "{{input.customerId}}", "managerId": "{{input.managerId}}", "beginDate": "{{input.startDate}}", "endDate": "{{input.endDate}}", "currency": "USD", "budgetAmount": "{{input.budget}}" } } ``` #### listProjects List projects. ```json { "action": "sage-intacct.listProjects", "config": { "filter": "PROJECTSTATUS = 'Active'", "pageSize": 100 } } ``` ### Generic Query #### query Execute a generic query on any object. ```json { "action": "sage-intacct.query", "config": { "object": "GLACCOUNT", "fields": "ACCOUNTNO,TITLE,ACCOUNTTYPE,NORMALBALANCE", "filter": "STATUS = 'active'", "orderBy": "ACCOUNTNO", "pageSize": 500 } } ``` ## Common Objects | Object | Description | |--------|-------------| | `CUSTOMER` | Customers | | `VENDOR` | Vendors | | `ARINVOICE` | AR invoices | | `APBILL` | AP bills | | `ARPAYMENT` | AR payments | | `APPAYMENT` | AP payments | | `GLBATCH` | Journal entries | | `GLACCOUNT` | GL accounts | | `PROJECT` | Projects | | `EMPLOYEE` | Employees | | `ITEM` | Items | | `LOCATION` | Locations | | `DEPARTMENT` | Departments | | `CLASS` | Classes | ## Webhook Triggers Sage Intacct can trigger workflows via platform events. ```json { "trigger": { "type": "webhook", "provider": "sage-intacct", "events": ["ARINVOICE.created", "APPAYMENT.created"] } } ``` ## Example Workflow Invoice creation from HubSpot deal: ```json { "name": "Create Sage Invoice from Won Deal", "trigger": { "type": "webhook", "provider": "hubspot", "events": ["deal.propertyChange"] }, "steps": [ { "id": "check-stage", "type": "condition", "config": { "conditions": { "and": [ { "left": "{{input[0].propertyName}}", "operator": "eq", "right": "dealstage" }, { "left": "{{input[0].propertyValue}}", "operator": "eq", "right": "closedwon" } ] }, "then": ["get-deal"], "else": [] } }, { "id": "get-deal", "type": "action", "action": "hubspot.getDeal", "config": { "dealId": "{{input[0].objectId}}", "properties": ["dealname", "amount", "closedate"] } }, { "id": "get-company", "type": "action", "action": "hubspot.getAssociations", "config": { "objectType": "deals", "objectId": "{{input[0].objectId}}", "toObjectType": "companies" } }, { "id": "find-customer", "type": "action", "action": "sage-intacct.listCustomers", "config": { "filter": "NAME = '{{steps.get-company.output.results[0].properties.name}}'" } }, { "id": "create-invoice", "type": "action", "action": "sage-intacct.createInvoice", "config": { "customerId": "{{steps.find-customer.output.data[0].CUSTOMERID}}", "invoiceDate": "{{now | date: 'MM/DD/YYYY'}}", "dueDate": "{{now | dateAdd: 30, 'day' | date: 'MM/DD/YYYY'}}", "description": "{{steps.get-deal.output.properties.dealname}}", "lineItems": [ { "accountNumber": "4000", "amount": "{{steps.get-deal.output.properties.amount}}", "memo": "Professional Services - {{steps.get-deal.output.properties.dealname}}" } ] } }, { "id": "notify-finance", "type": "action", "action": "slack.sendMessage", "config": { "channel": "#finance", "text": "Invoice created in Sage Intacct for {{steps.get-deal.output.properties.dealname}} (${{steps.get-deal.output.properties.amount}})" } } ] } ``` ## Dimensions Sage Intacct uses dimensions for reporting segmentation: | Dimension | Description | |-----------|-------------| | `LOCATIONID` | Location/entity | | `DEPARTMENTID` | Department | | `PROJECTID` | Project | | `CUSTOMERID` | Customer | | `VENDORID` | Vendor | | `EMPLOYEEID` | Employee | | `ITEMID` | Item | | `CLASSID` | Class | ## Rate Limits | Limit | Value | |-------|-------| | API calls/minute | 300 | | Concurrent sessions | 5 per company | | Records per request | 100 | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `customerId is required` | Missing customer ID | Provide customer ID | | `BL01001973` | Duplicate record | Check for existing record | | `BL01001074` | Invalid reference | Verify dimension IDs | | `WS0001` | Authentication failed | Check credentials | | `XL03000009` | Session expired | Refresh session | ### XML API Notes Sage Intacct uses an XML-RPC style API. All requests are wrapped in XML format automatically by the connector. Field names use ALL CAPS (e.g., `CUSTOMERID`, `NAME`). ### Filter Syntax Filters use SQL-like syntax: ``` STATUS = 'active' TOTALDUE > 0 WHENCREATED >= '01/01/2024' NAME LIKE 'Acme%' ``` # Xero (/docs/integrations/accounting/xero) Cloud accounting for invoices, payments, and bank reconciliation ## Xero Connect to Xero for cloud accounting automation including invoices, bills, payments, and bank transactions. ## Overview Xero is a cloud-based accounting platform. The integration supports: - **Contacts** - Customers and suppliers - **Invoices** - Sales invoices (AR) - **Bills** - Purchase invoices (AP) - **Payments** - Record payments against invoices - **Bank Transactions** - Spend and receive money - **Accounts** - Chart of accounts - **Credit Notes** - Customer and supplier credits - **Items** - Products and services - **Reports** - Financial reports ## Prerequisites - Xero account (any plan) - Xero Developer account - OAuth app configured - Organisation (tenant) access ## Authentication Xero uses OAuth 2.0 for authentication via Nango. ```bash curl "https://workflow.loopfour.ai/api/v1/connections/xero/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` ### Multi-Organization Support Xero requires a tenant ID for all API calls. Get connected tenants first: ```json { "action": "xero.getConnections", "config": {} } ``` Pass `tenantId` to all subsequent actions. ## Available Actions ### Connection Actions #### getConnections Get connected organizations (tenants). ```json { "action": "xero.getConnections", "config": {} } ``` ### Contact Actions #### getContact Get contact by ID. ```json { "action": "xero.getContact", "config": { "tenantId": "{{input.tenantId}}", "contactId": "{{input.contactId}}" } } ``` #### createContact Create a new contact. ```json { "id": "create-contact", "type": "action", "action": "xero.createContact", "config": { "tenantId": "{{input.tenantId}}", "name": "{{input.companyName}}", "firstName": "{{input.firstName}}", "lastName": "{{input.lastName}}", "emailAddress": "{{input.email}}", "accountNumber": "{{input.accountNumber}}", "contactStatus": "ACTIVE", "isCustomer": true, "isSupplier": false, "defaultCurrency": "USD", "taxNumber": "{{input.taxId}}", "addresses": [ { "AddressType": "POBOX", "AddressLine1": "{{input.address.street}}", "City": "{{input.address.city}}", "Region": "{{input.address.state}}", "PostalCode": "{{input.address.zip}}", "Country": "{{input.address.country}}" } ], "phones": [ { "PhoneType": "DEFAULT", "PhoneNumber": "{{input.phone}}" } ], "website": "{{input.website}}", "paymentTerms": { "Sales": { "Day": 30, "Type": "DAYSAFTERBILLDATE" } } } } ``` #### updateContact Update an existing contact. ```json { "action": "xero.updateContact", "config": { "tenantId": "{{input.tenantId}}", "contactId": "{{input.contactId}}", "emailAddress": "{{input.newEmail}}", "isCustomer": true } } ``` #### archiveContact Archive a contact (soft delete). ```json { "action": "xero.archiveContact", "config": { "tenantId": "{{input.tenantId}}", "contactId": "{{input.contactId}}" } } ``` #### listContacts List contacts with filters. ```json { "action": "xero.listContacts", "config": { "tenantId": "{{input.tenantId}}", "where": "ContactStatus==\"ACTIVE\"", "order": "Name ASC", "page": 1, "includeArchived": false } } ``` ### Invoice Actions #### getInvoice Get invoice by ID. ```json { "action": "xero.getInvoice", "config": { "tenantId": "{{input.tenantId}}", "invoiceId": "{{input.invoiceId}}" } } ``` #### createInvoice Create a sales invoice (ACCREC). ```json { "id": "create-invoice", "type": "action", "action": "xero.createInvoice", "config": { "tenantId": "{{input.tenantId}}", "type": "ACCREC", "contactId": "{{input.contactId}}", "date": "{{input.invoiceDate}}", "dueDate": "{{input.dueDate}}", "lineAmountTypes": "Exclusive", "invoiceNumber": "INV-{{input.invoiceNumber}}", "reference": "{{input.poNumber}}", "currencyCode": "USD", "status": "DRAFT", "lineItems": [ { "Description": "{{input.items[0].description}}", "Quantity": "{{input.items[0].quantity}}", "UnitAmount": "{{input.items[0].unitPrice}}", "AccountCode": "200", "TaxType": "OUTPUT" } ] } } ``` **Invoice Types:** - `ACCREC` - Accounts Receivable (Sales Invoice) - `ACCPAY` - Accounts Payable (Bill) **Line Amount Types:** - `Exclusive` - Amounts exclude tax - `Inclusive` - Amounts include tax - `NoTax` - No tax applied #### updateInvoice Update an invoice. ```json { "action": "xero.updateInvoice", "config": { "tenantId": "{{input.tenantId}}", "invoiceId": "{{input.invoiceId}}", "dueDate": "{{input.newDueDate}}", "status": "SUBMITTED" } } ``` #### voidInvoice Void an invoice. ```json { "action": "xero.voidInvoice", "config": { "tenantId": "{{input.tenantId}}", "invoiceId": "{{input.invoiceId}}" } } ``` #### deleteInvoice Delete a draft invoice. ```json { "action": "xero.deleteInvoice", "config": { "tenantId": "{{input.tenantId}}", "invoiceId": "{{input.invoiceId}}" } } ``` #### listInvoices List invoices with filters. ```json { "action": "xero.listInvoices", "config": { "tenantId": "{{input.tenantId}}", "where": "Type==\"ACCREC\" AND Status==\"AUTHORISED\"", "order": "Date DESC", "page": 1, "statuses": ["DRAFT", "SUBMITTED", "AUTHORISED"] } } ``` #### emailInvoice Send invoice via email. ```json { "action": "xero.emailInvoice", "config": { "tenantId": "{{input.tenantId}}", "invoiceId": "{{input.invoiceId}}" } } ``` ### Bill Actions #### createBill Create a bill (accounts payable). ```json { "action": "xero.createBill", "config": { "tenantId": "{{input.tenantId}}", "contactId": "{{input.vendorId}}", "date": "{{input.billDate}}", "dueDate": "{{input.dueDate}}", "lineItems": [ { "Description": "{{input.description}}", "Quantity": 1, "UnitAmount": "{{input.amount}}", "AccountCode": "400" } ], "status": "AUTHORISED" } } ``` ### Payment Actions #### getPayment Get payment by ID. ```json { "action": "xero.getPayment", "config": { "tenantId": "{{input.tenantId}}", "paymentId": "{{input.paymentId}}" } } ``` #### createPayment Record a payment against an invoice. ```json { "id": "record-payment", "type": "action", "action": "xero.createPayment", "config": { "tenantId": "{{input.tenantId}}", "invoiceId": "{{input.invoiceId}}", "accountId": "{{input.bankAccountId}}", "amount": "{{input.paymentAmount}}", "date": "{{input.paymentDate}}", "reference": "{{input.transactionRef}}" } } ``` #### deletePayment Delete an unreconciled payment. ```json { "action": "xero.deletePayment", "config": { "tenantId": "{{input.tenantId}}", "paymentId": "{{input.paymentId}}" } } ``` #### listPayments List payments. ```json { "action": "xero.listPayments", "config": { "tenantId": "{{input.tenantId}}", "where": "Date>=DateTime(2024,01,01)", "order": "Date DESC", "page": 1 } } ``` ### Bank Transaction Actions #### getBankTransaction Get bank transaction by ID. ```json { "action": "xero.getBankTransaction", "config": { "tenantId": "{{input.tenantId}}", "bankTransactionId": "{{input.transactionId}}" } } ``` #### createBankTransaction Create a bank transaction (spend or receive). ```json { "id": "record-expense", "type": "action", "action": "xero.createBankTransaction", "config": { "tenantId": "{{input.tenantId}}", "type": "SPEND", "contactId": "{{input.vendorId}}", "bankAccountId": "{{input.bankAccountId}}", "date": "{{input.date}}", "reference": "{{input.reference}}", "lineAmountTypes": "Exclusive", "lineItems": [ { "Description": "{{input.description}}", "Quantity": 1, "UnitAmount": "{{input.amount}}", "AccountCode": "429" } ], "status": "AUTHORISED" } } ``` **Transaction Types:** - `SPEND` - Money out (expense) - `RECEIVE` - Money in (income) #### updateBankTransaction Update a bank transaction. ```json { "action": "xero.updateBankTransaction", "config": { "tenantId": "{{input.tenantId}}", "bankTransactionId": "{{input.transactionId}}", "reference": "{{input.newReference}}" } } ``` #### listBankTransactions List bank transactions. ```json { "action": "xero.listBankTransactions", "config": { "tenantId": "{{input.tenantId}}", "bankAccountId": "{{input.bankAccountId}}", "where": "Type==\"SPEND\"", "page": 1 } } ``` ### Account Actions #### getAccount Get account by ID. ```json { "action": "xero.getAccount", "config": { "tenantId": "{{input.tenantId}}", "accountId": "{{input.accountId}}" } } ``` #### createAccount Create a new account. ```json { "action": "xero.createAccount", "config": { "tenantId": "{{input.tenantId}}", "name": "Software Subscriptions", "code": "425", "type": "EXPENSE", "description": "Monthly SaaS subscriptions", "taxType": "INPUT" } } ``` **Account Types:** `BANK`, `CURRENT`, `CURRLIAB`, `DEPRECIATN`, `DIRECTCOSTS`, `EQUITY`, `EXPENSE`, `FIXED`, `INVENTORY`, `LIABILITY`, `NONCURRENT`, `OTHERINCOME`, `OVERHEADS`, `PREPAYMENT`, `REVENUE`, `SALES`, `TERMLIAB` #### listAccounts List chart of accounts. ```json { "action": "xero.listAccounts", "config": { "tenantId": "{{input.tenantId}}", "where": "Type==\"EXPENSE\"", "order": "Code" } } ``` ### Credit Note Actions #### getCreditNote Get credit note by ID. ```json { "action": "xero.getCreditNote", "config": { "tenantId": "{{input.tenantId}}", "creditNoteId": "{{input.creditNoteId}}" } } ``` #### createCreditNote Create a credit note. ```json { "action": "xero.createCreditNote", "config": { "tenantId": "{{input.tenantId}}", "type": "ACCRECCREDIT", "contactId": "{{input.contactId}}", "date": "{{input.date}}", "lineAmountTypes": "Exclusive", "lineItems": [ { "Description": "Refund for {{input.reason}}", "Quantity": 1, "UnitAmount": "{{input.amount}}", "AccountCode": "200" } ], "status": "AUTHORISED" } } ``` **Credit Note Types:** - `ACCRECCREDIT` - Customer credit (AR) - `ACCPAYCREDIT` - Supplier credit (AP) ### Item Actions #### getItem Get item by ID. ```json { "action": "xero.getItem", "config": { "tenantId": "{{input.tenantId}}", "itemId": "{{input.itemId}}" } } ``` #### createItem Create a product/service item. ```json { "action": "xero.createItem", "config": { "tenantId": "{{input.tenantId}}", "code": "PROD-001", "name": "{{input.productName}}", "description": "{{input.description}}", "isSold": true, "isPurchased": false, "salesDetails": { "UnitPrice": "{{input.price}}", "AccountCode": "200" } } } ``` #### listItems List items. ```json { "action": "xero.listItems", "config": { "tenantId": "{{input.tenantId}}", "where": "IsSold==true" } } ``` ### Organisation Actions #### getOrganisation Get organisation details. ```json { "action": "xero.getOrganisation", "config": { "tenantId": "{{input.tenantId}}" } } ``` ### Report Actions #### getReport Get a financial report. ```json { "action": "xero.getReport", "config": { "tenantId": "{{input.tenantId}}", "reportType": "ProfitAndLoss", "fromDate": "2024-01-01", "toDate": "2024-12-31", "periods": 12, "timeframe": "MONTH" } } ``` **Report Types:** - `BalanceSheet` - `ProfitAndLoss` - `TrialBalance` - `AgedPayablesByContact` - `AgedReceivablesByContact` - `BudgetSummary` ## Webhook Triggers Xero webhooks trigger workflows on accounting events. ```json { "trigger": { "type": "webhook", "provider": "xero", "events": ["INVOICE.CREATED", "PAYMENT.CREATED"] } } ``` **Event Types:** | Event | Description | |-------|-------------| | `INVOICE.CREATED` | Invoice created | | `INVOICE.UPDATED` | Invoice updated | | `CONTACT.CREATED` | Contact created | | `CONTACT.UPDATED` | Contact updated | | `PAYMENT.CREATED` | Payment recorded | ## Example Workflow Invoice sync workflow: ```json { "name": "Sync Stripe Invoice to Xero", "trigger": { "type": "webhook", "provider": "stripe", "events": ["invoice.finalized"] }, "steps": [ { "id": "find-contact", "type": "action", "action": "xero.listContacts", "config": { "tenantId": "", "where": "EmailAddress==\"{{input.data.object.customer_email}}\"" } }, { "id": "check-contact", "type": "condition", "config": { "conditions": { "left": "{{steps.find-contact.output.Contacts.length}}", "operator": "gt", "right": 0 }, "then": ["create-invoice"], "else": ["create-contact"] } }, { "id": "create-contact", "type": "action", "action": "xero.createContact", "config": { "tenantId": "", "name": "{{input.data.object.customer_name}}", "emailAddress": "{{input.data.object.customer_email}}", "isCustomer": true } }, { "id": "create-invoice", "type": "action", "action": "xero.createInvoice", "config": { "tenantId": "", "type": "ACCREC", "contactId": "{{steps.find-contact.output.Contacts[0].ContactID || steps.create-contact.output.Contacts[0].ContactID}}", "date": "{{input.data.object.created | date: 'YYYY-MM-DD'}}", "dueDate": "{{input.data.object.due_date | date: 'YYYY-MM-DD'}}", "reference": "Stripe {{input.data.object.number}}", "lineItems": [ { "Description": "{{input.data.object.lines.data[0].description}}", "Quantity": 1, "UnitAmount": "{{input.data.object.amount_due | divided_by: 100}}", "AccountCode": "200" } ], "status": "AUTHORISED" } } ] } ``` ## Rate Limits | Limit | Value | |-------|-------| | API calls/minute | 60 | | API calls/day | 5,000 | | Concurrent connections | 5 per app | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `tenantId is required` | Missing organization ID | Call getConnections first | | `contactId is required` | No contact specified | Provide contactId | | `INVALID_INVOICE_STATUS` | Wrong status transition | Check current status | | `VALIDATION_ERROR` | Missing required fields | Review required parameters | | `ORGANIZATION_OFFLINE` | Org temporarily unavailable | Retry later | ### Where Filter Syntax Xero uses OData-style filters: ``` // String comparison where: "ContactStatus==\"ACTIVE\"" // Date comparison where: "Date>=DateTime(2024,01,01)" // Numeric comparison where: "AmountDue>0" // Multiple conditions (AND) where: "Type==\"ACCREC\" AND Status==\"AUTHORISED\"" ``` # Communication Integrations (/docs/integrations/communication) Slack, Email (Gmail, Outlook, Resend) ## Communication Integrations Connect to messaging and email platforms. ## Slack **Available Actions:** - `sendMessage`, `sendEphemeral`, `updateMessage`, `deleteMessage` - `addReaction`, `removeReaction` - `openModal`, `updateModal`, `publishHomeTab` - `uploadFile`, `getFile`, `deleteFile` - `getUser`, `listUsers`, `lookupUserByEmail` - `getChannel`, `listChannels`, `createChannel` [Full Slack Documentation →](/docs/integrations/communication/slack) ## Email Unified email integration supporting multiple providers: - **Gmail** - Google Workspace email via OAuth - **Outlook** - Microsoft 365 email via OAuth - **Resend** - Developer-first transactional email **Available Actions:** - `send` - Send email immediately - `sendTemplate` - Accepted by the schema, but no available provider offers server-side templates - `schedule` - Schedule email for later (Resend) **Features:** - Handlebars templating for dynamic content - HTML and plain text support - File attachments - CC/BCC recipients [Full Email Documentation →](/docs/integrations/communication/email) # Email (/docs/integrations/communication/email) Multi-provider email integration for Gmail, Outlook, and Resend ## Email Send emails through multiple providers with unified configuration and Handlebars template support. ## Overview The Email integration is a building block that supports multiple email providers: - **Gmail** - Google Workspace email via OAuth - **Outlook** - Microsoft 365 email via OAuth - **Resend** - Developer-friendly email API ## Prerequisites | Provider | Requirements | |----------|--------------| | Gmail | Google Workspace account, OAuth connection via Nango | | Outlook | Microsoft 365 account, OAuth connection via Nango | | Resend | Resend account, API key | ## Authentication ### Gmail / Outlook (OAuth) ```bash # Gmail curl "https://workflow.loopfour.ai/api/v1/connections/gmail/auth-url" \ -H "x-api-key: YOUR_API_KEY" # Outlook curl "https://workflow.loopfour.ai/api/v1/connections/microsoft-graph/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` ### Resend (API Key) Set the `RESEND_API_KEY` environment variable or pass `api_key` in the step config. ## Email Step Configuration The email step uses type `email` in workflow definitions: ```json { "id": "send-notification", "type": "email", "name": "Send Invoice Email", "config": { "action": "send", "provider": "gmail", "to": "{{input.customer_email}}", "subject": "Invoice #{{input.invoice_number}}", "body": { "html": "

Hello {{input.customer_name}},

Your invoice is ready.

" } } } ``` ## Available Actions ### send Send an email immediately. ```json { "id": "send-email", "type": "email", "config": { "action": "send", "provider": "gmail", "to": "{{input.recipient}}", "cc": ["manager@company.com"], "bcc": ["archive@company.com"], "from": "notifications@company.com", "reply_to": "support@company.com", "subject": "Your Order #{{input.order_number}} Has Shipped", "body": { "html": "

Order Shipped!

Hi {{input.customer_name}},

Your order is on its way.

", "text": "Order Shipped! Hi {{input.customer_name}}, Your order is on its way." }, "attachments": [ { "filename": "shipping-label.pdf", "content": "{{input.label_base64}}", "content_type": "application/pdf" } ], "headers": { "X-Order-ID": "{{input.order_id}}" }, "priority": "1" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `action` | string | Yes | Must be `send` | | `provider` | string | Yes | `gmail`, `outlook`, `resend` | | `to` | string/array | Yes | Recipient email(s) | | `cc` | array | No | CC recipients | | `bcc` | array | No | BCC recipients | | `from` | string | No | Sender address | | `reply_to` | string | No | Reply-to address | | `subject` | string | Yes | Email subject (supports templates) | | `body.html` | string | No | HTML body (supports templates) | | `body.text` | string | No | Plain text body (supports templates) | | `attachments` | array | No | File attachments | | `headers` | object | No | Custom email headers | | `priority` | string | No | Priority (1=high, 3=normal, 5=low) | | `template_variables` | object | No | Additional template variables | ### sendTemplate Send using a server-side template. The `send_template` action is accepted by the step schema, but Resend has no server-side template API, so the action fails at run time on every available provider with `does not support server-side templates`. **Note:** Gmail and Outlook do not support server-side templates. Use inline Handlebars templates with the `send` action instead. ### schedule Schedule an email for later delivery. ```json { "id": "schedule-reminder", "type": "email", "config": { "action": "schedule", "provider": "resend", "to": "{{input.email}}", "subject": "Reminder: Your appointment tomorrow", "body": { "html": "

Hi {{input.name}}, this is a reminder about your appointment.

" }, "send_at": "{{input.reminder_time}}" } } ``` **Scheduling Support:** | Provider | Native Scheduling | |----------|-------------------| | Resend | ✅ Yes | | Gmail | ❌ No (use Trigger.dev scheduled tasks) | | Outlook | ❌ No (use Trigger.dev scheduled tasks) | ## Handlebars Templates The email integration uses Handlebars for dynamic content: ### Basic Variables ```handlebars Hello {{input.customer_name}}, Your order #{{input.order_number}} has been confirmed. ``` ### Conditionals ```handlebars {{#if input.is_premium}}

Thank you for being a Premium member!

{{else}}

Upgrade to Premium for exclusive benefits.

{{/if}} ``` ### Loops ```handlebars

Order Items:

    {{#each input.items}}
  • {{this.name}} - ${{this.price}} x {{this.quantity}}
  • {{/each}}
``` ### Nested Properties ```handlebars Shipping to: {{input.shipping.address.city}}, {{input.shipping.address.state}} ``` ## Provider-Specific Features ### Gmail - Uses RFC 2822 message format - Supports threading via `in_reply_to` and `references` - Full attachment support with base64 encoding ```json { "config": { "provider": "gmail", "in_reply_to": "{{input.original_message_id}}", "references": ["{{input.thread_message_id}}"] } } ``` ### Outlook - Uses Microsoft Graph API - Saves sent emails to Sent Items - Supports importance levels ```json { "config": { "provider": "outlook", "priority": "1" } } ``` ### Resend - Simple API-first design - Direct API calls (no Nango required) - Supports scheduling ```json { "config": { "provider": "resend", "api_key": "{{ secrets.RESEND_API_KEY }}" } } ``` ## Example Workflow Invoice email workflow: ```json { "name": "Send Invoice Email", "trigger": { "type": "webhook", "provider": "stripe", "events": ["invoice.finalized"] }, "steps": [ { "id": "get-customer", "type": "action", "action": "stripe.getCustomer", "config": { "customerId": "{{input.data.object.customer}}" } }, { "id": "send-invoice-email", "type": "email", "name": "Email Invoice to Customer", "config": { "action": "send", "provider": "gmail", "to": "{{steps.get-customer.output.email}}", "subject": "Invoice #{{input.data.object.number}} from {{company.name}}", "body": { "html": "

Invoice Ready

Hi {{steps.get-customer.output.name}},

Your invoice for ${{input.data.object.amount_due | divided_by: 100}} is ready.

View Invoice

Due date: {{input.data.object.due_date | date: 'MMMM D, YYYY'}}

" } } }, { "id": "notify-finance", "type": "action", "action": "slack.sendMessage", "config": { "channel": "#finance", "text": "Invoice #{{input.data.object.number}} sent to {{steps.get-customer.output.email}}" } } ] } ``` ## Attachments ### Base64 Content ```json { "attachments": [ { "filename": "report.pdf", "content": "{{steps.generate-pdf.output.base64}}", "content_type": "application/pdf" } ] } ``` ### Multiple Attachments ```json { "attachments": [ { "filename": "invoice.pdf", "content": "{{input.invoice_pdf}}", "content_type": "application/pdf" }, { "filename": "terms.pdf", "content": "", "content_type": "application/pdf" } ] } ``` ## Rate Limits | Provider | Limit | |----------|-------| | Gmail | 2,000 messages/day (free), higher for Workspace | | Outlook | 10,000 messages/day | | Resend | Based on plan | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `connectionId is required` | No OAuth connection | Set up connection via Nango | | `Resend API key not found` | Missing API key | Set `RESEND_API_KEY` env var | | `template is required` | Using sendTemplate without template | Provide template.id | | `provider does not support server-side templates` | Gmail/Outlook with sendTemplate | Use `send` action with inline templates | | `provider does not support native scheduling` | Gmail/Outlook with schedule | Use Trigger.dev scheduled tasks | ### Template Rendering Issues - Ensure all template variables exist in the context - Use `{{#if variable}}` for optional fields - Check for typos in variable paths # Slack (/docs/integrations/communication/slack) Team communication for messages, channels, and interactive workflows ## Slack Connect to Slack for team messaging, channel management, and interactive workflows. ## Overview Slack is a team collaboration platform. The integration supports: - **Messages** - Send, update, delete, and schedule messages - **Channels** - Create, manage, and list channels - **Users** - Look up and manage users - **Files** - Upload and manage files - **Reactions** - Add and remove emoji reactions - **Modals** - Open interactive modal views - **Home Tab** - Publish app home views ## Prerequisites - Slack workspace - Slack app with required scopes - Bot token or OAuth credentials ## Authentication Slack uses OAuth 2.0 for authentication via Nango. ```bash curl "https://workflow.loopfour.ai/api/v1/connections/slack/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` ### Required Scopes | Scope | Description | |-------|-------------| | `chat:write` | Send messages | | `channels:read` | List channels | | `channels:manage` | Create/archive channels | | `users:read` | Read user info | | `files:write` | Upload files | | `reactions:write` | Add reactions | ## Available Actions ### Message Actions #### sendMessage Send a message to a channel or DM. ```json { "id": "notify-team", "type": "action", "action": "slack.sendMessage", "config": { "channel": "#finance", "text": "New invoice paid: {{input.amount}}", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Invoice Paid*\nCustomer: {{input.customerName}}\nAmount: ${{input.amount}}" } }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View Invoice" }, "url": "{{input.invoiceUrl}}" } ] } ], "unfurlLinks": false } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `channel` | string | Yes | Channel ID or name (#channel) | | `text` | string | Conditional | Message text (required if no blocks) | | `blocks` | array | No | Block Kit blocks | | `threadTs` | string | No | Thread timestamp for replies | | `replyBroadcast` | boolean | No | Also post to channel | | `unfurlLinks` | boolean | No | Unfurl URLs (default: true) | | `unfurlMedia` | boolean | No | Unfurl media (default: true) | | `mrkdwn` | boolean | No | Parse as mrkdwn (default: true) | | `attachments` | array | No | Legacy attachments | | `metadata` | object | No | Message metadata | #### sendEphemeral Send a message visible only to one user. ```json { "action": "slack.sendEphemeral", "config": { "channel": "{{input.channelId}}", "user": "{{input.userId}}", "text": "Your request has been submitted", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "Request #{{input.requestId}} submitted successfully" } } ] } } ``` #### scheduleMessage Schedule a message for later. ```json { "action": "slack.scheduleMessage", "config": { "channel": "#announcements", "text": "Weekly team update", "postAt": "{{input.scheduledTime}}", "blocks": [...] } } ``` **Note:** `postAt` can be a Unix timestamp or ISO date string. #### updateMessage Update an existing message. ```json { "action": "slack.updateMessage", "config": { "channel": "{{steps.send.output.channel}}", "ts": "{{steps.send.output.ts}}", "text": "Updated: {{input.newContent}}", "blocks": [...] } } ``` #### deleteMessage Delete a message. ```json { "action": "slack.deleteMessage", "config": { "channel": "{{input.channelId}}", "ts": "{{input.messageTs}}" } } ``` #### getPermalink Get a permanent link to a message. ```json { "action": "slack.getPermalink", "config": { "channel": "{{input.channelId}}", "messageTs": "{{input.ts}}" } } ``` ### Reaction Actions #### addReaction Add an emoji reaction to a message. ```json { "action": "slack.addReaction", "config": { "channel": "{{input.channelId}}", "timestamp": "{{input.messageTs}}", "name": "white_check_mark" } } ``` **Note:** Emoji name without colons (e.g., `thumbsup` not `:thumbsup:`). #### removeReaction Remove an emoji reaction. ```json { "action": "slack.removeReaction", "config": { "channel": "{{input.channelId}}", "timestamp": "{{input.messageTs}}", "name": "thumbsup" } } ``` ### File Actions #### uploadFile Upload a file to Slack. ```json { "action": "slack.uploadFile", "config": { "channels": ["C1234567890", "C0987654321"], "content": "{{steps.report.output.csvContent}}", "filename": "monthly-report.csv", "title": "Monthly Financial Report", "initialComment": "Here's the latest report" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `channels` | string/array | No | Channel IDs to share to (e.g. `C1234567890`). Find the ID in Slack: right-click the channel → View channel details. Channel names starting with `#` are not accepted. | | `channel` | string | No | One channel ID. Use instead of `channels` for a single channel. | | `file` | string | Conditional | File bytes, base64-encoded. Use for PDFs, images, and spreadsheets. Up to 50 MB. | | `content` | string | Conditional | Plain-text content such as CSV or JSON. Ignored when `file` is set. | | `filename` | string | No | File name with extension (e.g. `invoice.pdf`), so Slack shows a preview | | `title` | string | No | Title shown above the file. Defaults to the filename. | | `initialComment` | string | No | Message posted together with the file | | `threadTs` | string | No | Parent message timestamp to reply in a thread (single channel only) | **Sending a PDF:** binary files must go in `file`, not `content`. `content` is encoded as UTF-8 text, so a PDF placed there arrives corrupt. Workspace Files **Get File** with mode `content` returns the base64 bytes you need: ```json [ { "id": "get_file", "action": "workspace_files.getFile", "config": { "fileId": "{{input.fileId}}", "mode": "content" } }, { "id": "post_invoice", "action": "slack.uploadFile", "config": { "channel": "C1234567890", "file": "{{steps.get_file.content}}", "filename": "{{steps.get_file.fileName}}", "initialComment": "Here is the March invoice." } } ] ``` #### getFile Get file information. ```json { "action": "slack.getFile", "config": { "fileId": "{{input.fileId}}" } } ``` #### deleteFile Delete a file. ```json { "action": "slack.deleteFile", "config": { "fileId": "{{input.fileId}}" } } ``` ### Modal Actions #### openModal Open a modal view (requires trigger_id from interaction). ```json { "action": "slack.openModal", "config": { "triggerId": "{{input.trigger_id}}", "view": { "type": "modal", "callback_id": "approval-modal", "title": { "type": "plain_text", "text": "Approve Request" }, "submit": { "type": "plain_text", "text": "Approve" }, "close": { "type": "plain_text", "text": "Cancel" }, "blocks": [ { "type": "input", "block_id": "comment", "element": { "type": "plain_text_input", "action_id": "comment_input", "multiline": true }, "label": { "type": "plain_text", "text": "Comment" } } ] } } } ``` #### updateModal Update an existing modal. ```json { "action": "slack.updateModal", "config": { "viewId": "{{input.view.id}}", "view": { "type": "modal", "callback_id": "updated-modal", "title": { "type": "plain_text", "text": "Updated" }, "blocks": [...] } } } ``` #### pushModal Push a new view onto the modal stack. ```json { "action": "slack.pushModal", "config": { "triggerId": "{{input.trigger_id}}", "view": {...} } } ``` #### publishHomeTab Publish a view to the app Home tab. ```json { "action": "slack.publishHomeTab", "config": { "userId": "{{input.userId}}", "view": { "type": "home", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "Welcome to Workflows! You have *{{input.pendingCount}}* pending approvals." } }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View Approvals" }, "action_id": "view_approvals" } ] } ] } } } ``` ### User Actions #### getUser Get user information. ```json { "action": "slack.getUser", "config": { "userId": "U12345678", "includeLocale": true } } ``` #### listUsers List workspace users. ```json { "action": "slack.listUsers", "config": { "limit": 200, "cursor": "{{steps.previous.output.response_metadata.next_cursor}}", "includeLocale": true } } ``` #### lookupUserByEmail Find a user by email address. ```json { "action": "slack.lookupUserByEmail", "config": { "email": "{{input.email}}" } } ``` ### Channel Actions #### getChannel Get channel information. ```json { "action": "slack.getChannel", "config": { "channelId": "C12345678", "includeNumMembers": true } } ``` #### listChannels List workspace channels. ```json { "action": "slack.listChannels", "config": { "types": "public_channel,private_channel", "excludeArchived": true, "limit": 200 } } ``` #### createChannel Create a new channel. ```json { "action": "slack.createChannel", "config": { "name": "project-{{input.projectName | slugify}}", "isPrivate": false } } ``` #### inviteToChannel Invite users to a channel. ```json { "action": "slack.inviteToChannel", "config": { "channelId": "{{steps.create-channel.output.channel.id}}", "users": ["U12345678", "U87654321"] } } ``` #### archiveChannel Archive a channel. ```json { "action": "slack.archiveChannel", "config": { "channelId": "{{input.channelId}}" } } ``` #### setChannelTopic Set the channel topic. ```json { "action": "slack.setChannelTopic", "config": { "channelId": "{{input.channelId}}", "topic": "Q1 Planning - Updated {{now | date: 'MMM D'}}" } } ``` #### getChannelHistory Get channel message history. ```json { "action": "slack.getChannelHistory", "config": { "channelId": "{{input.channelId}}", "limit": 100, "oldest": "{{input.startTimestamp}}", "latest": "{{input.endTimestamp}}" } } ``` ### Bookmark Actions #### addBookmark Add a bookmark to a channel. ```json { "action": "slack.addBookmark", "config": { "channelId": "{{input.channelId}}", "title": "Project Dashboard", "type": "link", "link": "{{input.dashboardUrl}}" } } ``` ## Slack Triggers Slack events can trigger workflows via the Events API. ### Event Trigger ```json { "trigger": { "type": "slack_event", "eventType": "message", "keywords": ["invoice", "payment"] } } ``` **Event Types:** | Event | Description | |-------|-------------| | `message` | Message in channel/DM | | `app_mention` | Bot @mentioned | | `reaction_added` | Emoji reaction added | | `channel_created` | New channel created | | `member_joined_channel` | User joined channel | | `file_shared` | File uploaded | ### Slash Command Trigger ```json { "trigger": { "type": "slack_slash_command", "command": "/invoice" } } ``` ### Interactive Trigger ```json { "trigger": { "type": "slack_interactive", "interactionType": "block_actions", "actionId": "approve_button" } } ``` ## Block Kit Slack uses Block Kit for rich message layouts. ### Common Block Types ```json { "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Invoice Notification" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Amount:* ${{input.amount}}" }, "accessory": { "type": "button", "text": { "type": "plain_text", "text": "View" }, "url": "{{input.invoiceUrl}}" } }, { "type": "divider" }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "Approve" }, "style": "primary", "action_id": "approve", "value": "{{input.invoiceId}}" }, { "type": "button", "text": { "type": "plain_text", "text": "Reject" }, "style": "danger", "action_id": "reject", "value": "{{input.invoiceId}}" } ] }, { "type": "context", "elements": [ { "type": "mrkdwn", "text": "Submitted by <@{{input.userId}}>" } ] } ] } ``` ## Example Workflow Approval workflow with Slack interaction: ```json { "name": "Expense Approval", "trigger": { "type": "api" }, "steps": [ { "id": "send-approval", "type": "action", "action": "slack.sendMessage", "config": { "channel": "#approvals", "text": "New expense request from {{input.requester}}", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Expense Request*\n*Amount:* ${{input.amount}}\n*Category:* {{input.category}}\n*Description:* {{input.description}}" } }, { "type": "actions", "block_id": "approval_actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "Approve" }, "style": "primary", "action_id": "approve_expense", "value": "{{input.expenseId}}" }, { "type": "button", "text": { "type": "plain_text", "text": "Reject" }, "style": "danger", "action_id": "reject_expense", "value": "{{input.expenseId}}" } ] } ] } }, { "id": "add-reaction", "type": "action", "action": "slack.addReaction", "config": { "channel": "{{steps.send-approval.output.channel}}", "timestamp": "{{steps.send-approval.output.ts}}", "name": "hourglass" } } ] } ``` ## Rate Limits | Limit | Value | |-------|-------| | Web API (Tier 1) | 1 req/sec | | Web API (Tier 2) | 20 req/min | | Web API (Tier 3) | 50 req/min | | Web API (Tier 4) | 100 req/min | | Incoming webhooks | 1 req/sec | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `channel_not_found` | Invalid channel ID | Verify channel exists and bot has access | | `not_in_channel` | Bot not in channel | Invite bot to channel | | `invalid_blocks` | Malformed Block Kit | Validate with Block Kit Builder | | `message_not_found` | Invalid message ts | Verify timestamp is correct | | `ratelimited` | Too many requests | Implement backoff, check Retry-After | # CRM Integrations (/docs/integrations/crm) Salesforce, HubSpot, Attio ## CRM Integrations Connect to CRM systems for contact, deal, and account automation. ## Salesforce **Available Actions:** - `getAccount`, `listAccounts`, `searchAccounts` - `createOpportunity`, `getOpportunity`, `updateOpportunity`, `closeOpportunity` - `createContact`, `getContact`, `updateContact` - `query` - Execute SOQL queries [Full Salesforce Documentation →](/docs/integrations/crm/salesforce) ## HubSpot **Available Actions:** - `createContact`, `getContact`, `updateContact`, `searchContacts` - `createDeal`, `getDeal`, `updateDeal` - `createCompany`, `getCompany`, `updateCompany` [Full HubSpot Documentation →](/docs/integrations/crm/hubspot) ## Attio **Available Actions:** - Company management - Contact operations - Deal tracking - Custom objects [Full Attio Documentation →](/docs/integrations/crm/attio) # Attio (/docs/integrations/crm/attio) Modern CRM for managing people, companies, and custom objects ## Attio Connect to Attio for flexible CRM automation with custom objects, lists, notes, and tasks. ## Overview Attio is a modern, data-driven CRM. The integration supports: - **Objects** - Custom and standard object schemas - **Records** - People, companies, and custom records - **Notes** - Notes attached to records - **Tasks** - Tasks with assignees and deadlines - **Lists** - Kanban boards and pipelines - **Workspace** - Team and member management ## Prerequisites - Attio workspace - API access enabled - OAuth app or API key configured ## Authentication Attio uses OAuth 2.0 for authentication via Nango. ```bash curl "https://workflow.loopfour.ai/api/v1/connections/attio/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` ## Available Actions ### Object Actions #### listObjects List all objects (schema) in the workspace. ```json { "action": "attio.listObjects", "config": {} } ``` #### getObject Get object definition by slug. ```json { "action": "attio.getObject", "config": { "objectSlug": "people" } } ``` ### Record Actions #### getRecord Get record by ID. ```json { "action": "attio.getRecord", "config": { "objectSlug": "companies", "recordId": "{{input.companyId}}" } } ``` #### createRecord Create a new record. ```json { "action": "attio.createRecord", "config": { "objectSlug": "custom_object", "data": { "values": { "name": [{ "value": "{{input.name}}" }], "email": [{ "email_address": "{{input.email}}" }] } }, "matchingAttribute": "email" } } ``` **Note:** Attio uses a values-based format where each attribute contains an array of value objects. #### updateRecord Update an existing record. ```json { "action": "attio.updateRecord", "config": { "objectSlug": "companies", "recordId": "{{input.companyId}}", "data": { "values": { "industry": [{ "value": "Technology" }], "employee_count": [{ "value": 150 }] } } } } ``` #### deleteRecord Delete a record. ```json { "action": "attio.deleteRecord", "config": { "objectSlug": "companies", "recordId": "{{input.companyId}}" } } ``` #### listRecords List records for an object with filters. ```json { "action": "attio.listRecords", "config": { "objectSlug": "people", "filter": { "email_addresses": { "$contains": "@company.com" } }, "sorts": [ { "attribute": "created_at", "direction": "desc" } ], "limit": 50, "offset": 0 } } ``` #### searchRecords Search records using text query. ```json { "action": "attio.searchRecords", "config": { "objectSlug": "people", "query": "{{input.searchTerm}}" } } ``` ### People Actions #### getPerson Get person by ID. ```json { "action": "attio.getPerson", "config": { "personId": "{{input.personId}}" } } ``` #### createPerson Create a new person with formatted data. ```json { "id": "create-person", "type": "action", "action": "attio.createPerson", "config": { "name": "{{input.firstName}} {{input.lastName}}", "emailAddresses": ["{{input.email}}"], "phoneNumbers": ["{{input.phone}}"], "jobTitle": "{{input.title}}", "description": "Created via workflow", "matchingAttribute": "email_addresses", "customAttributes": { "source": [{ "value": "workflow" }] } } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `name` | string/object | Full name or `{first_name, last_name}` | | `emailAddresses` | string/array | Email address(es) | | `phoneNumbers` | string/array | Phone number(s) | | `jobTitle` | string | Job title | | `description` | string | Description | | `matchingAttribute` | string | Attribute for deduplication | | `customAttributes` | object | Custom attributes in Attio format | #### updatePerson Update an existing person. ```json { "action": "attio.updatePerson", "config": { "personId": "{{input.personId}}", "jobTitle": "{{input.newTitle}}", "phoneNumbers": ["{{input.newPhone}}"] } } ``` #### listPeople List people with filters. ```json { "action": "attio.listPeople", "config": { "filter": { "job_title": { "$contains": "CEO" } }, "limit": 100 } } ``` ### Company Actions #### getCompany Get company by ID. ```json { "action": "attio.getCompany", "config": { "companyId": "{{input.companyId}}" } } ``` #### createCompany Create a new company. ```json { "id": "create-company", "type": "action", "action": "attio.createCompany", "config": { "name": "{{input.companyName}}", "domains": ["{{input.domain}}"], "description": "{{input.description}}", "industry": "{{input.industry}}", "employeeCount": "{{input.employees}}", "matchingAttribute": "domains" } } ``` #### updateCompany Update an existing company. ```json { "action": "attio.updateCompany", "config": { "companyId": "{{input.companyId}}", "industry": "Technology", "employeeCount": 500 } } ``` #### listCompanies List companies with filters. ```json { "action": "attio.listCompanies", "config": { "filter": { "industry": { "$eq": "Technology" } }, "limit": 50 } } ``` ### Note Actions #### createNote Create a note on a record. ```json { "action": "attio.createNote", "config": { "parentObject": "people", "parentRecordId": "{{input.personId}}", "title": "Meeting Notes", "content": "Discussed {{input.topic}}. Next steps: {{input.nextSteps}}" } } ``` #### getNote Get note by ID. ```json { "action": "attio.getNote", "config": { "noteId": "{{input.noteId}}" } } ``` #### listNotes List notes for a record. ```json { "action": "attio.listNotes", "config": { "parentObject": "companies", "parentRecordId": "{{input.companyId}}", "limit": 20 } } ``` #### deleteNote Delete a note. ```json { "action": "attio.deleteNote", "config": { "noteId": "{{input.noteId}}" } } ``` ### Task Actions #### createTask Create a task. ```json { "id": "create-task", "type": "action", "action": "attio.createTask", "config": { "content": "Follow up with {{input.contactName}}", "deadline": "{{input.dueDate}}", "assignees": ["{{input.ownerId}}"], "linkedRecords": [ { "target_object": "people", "target_record_id": "{{input.personId}}" } ] } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `content` | string | Task description | | `deadline` | string | ISO 8601 date or null | | `assignees` | array | User/member IDs | | `linkedRecords` | array | Records to link | | `isCompleted` | boolean | Completion status | #### getTask Get task by ID. ```json { "action": "attio.getTask", "config": { "taskId": "{{input.taskId}}" } } ``` #### updateTask Update a task. ```json { "action": "attio.updateTask", "config": { "taskId": "{{input.taskId}}", "isCompleted": true, "deadline": "{{input.newDeadline}}" } } ``` **Note:** Attio's API only allows updating `is_completed`, `deadline_at`, `linked_records`, and `assignees`. #### completeTask Mark task as completed. ```json { "action": "attio.completeTask", "config": { "taskId": "{{input.taskId}}" } } ``` #### listTasks List tasks with filters. ```json { "action": "attio.listTasks", "config": { "linkedRecordId": "{{input.companyId}}", "isCompleted": false, "assigneeId": "{{input.userId}}", "limit": 50 } } ``` #### deleteTask Delete a task. ```json { "action": "attio.deleteTask", "config": { "taskId": "{{input.taskId}}" } } ``` ### List Actions #### getList Get list (pipeline/kanban) by ID. ```json { "action": "attio.getList", "config": { "listId": "{{input.listId}}" } } ``` #### listLists List all lists. ```json { "action": "attio.listLists", "config": {} } ``` #### getListEntries Get entries from a list. ```json { "action": "attio.getListEntries", "config": { "listId": "{{input.listId}}", "filter": { "stage": { "$eq": "qualified" } }, "limit": 100 } } ``` #### addListEntry Add record to a list. ```json { "action": "attio.addListEntry", "config": { "listId": "{{input.pipelineId}}", "parentObject": "companies", "parentRecordId": "{{input.companyId}}", "entryValues": { "stage": [{ "status": "new_lead" }], "deal_value": [{ "value": 50000 }] } } } ``` #### updateListEntry Update a list entry. ```json { "action": "attio.updateListEntry", "config": { "listId": "{{input.listId}}", "entryId": "{{input.entryId}}", "entryValues": { "stage": [{ "status": "qualified" }] } } } ``` #### deleteListEntry Remove entry from list. ```json { "action": "attio.deleteListEntry", "config": { "listId": "{{input.listId}}", "entryId": "{{input.entryId}}" } } ``` ### Workspace Actions #### getWorkspace Get current workspace info. ```json { "action": "attio.getWorkspace", "config": {} } ``` #### listWorkspaceMembers List workspace members. ```json { "action": "attio.listWorkspaceMembers", "config": {} } ``` ## Filter Operators | Operator | Description | |----------|-------------| | `$eq` | Equal to | | `$neq` | Not equal to | | `$contains` | Contains text | | `$not_contains` | Doesn't contain | | `$gt` | Greater than | | `$gte` | Greater than or equal | | `$lt` | Less than | | `$lte` | Less than or equal | | `$is_empty` | Is empty | | `$is_not_empty` | Is not empty | | `$or` | OR condition | | `$and` | AND condition | ## Example Workflow Lead enrichment workflow: ```json { "name": "Enrich New Lead", "trigger": { "type": "webhook", "provider": "attio", "events": ["record.created"] }, "steps": [ { "id": "check-object", "type": "condition", "config": { "conditions": { "left": "{{input.data.object}}", "operator": "eq", "right": "people" }, "then": ["get-company"], "else": [] } }, { "id": "get-company", "type": "action", "action": "attio.searchRecords", "config": { "objectSlug": "companies", "query": "{{input.data.record.values.email_addresses[0].domain}}" } }, { "id": "link-to-company", "type": "condition", "config": { "conditions": { "left": "{{steps.get-company.output.data.length}}", "operator": "gt", "right": 0 }, "then": ["create-note"], "else": [] } }, { "id": "create-note", "type": "action", "action": "attio.createNote", "config": { "parentObject": "people", "parentRecordId": "{{input.data.record.id.record_id}}", "title": "Company Match Found", "content": "Matched to company: {{steps.get-company.output.data[0].values.name[0].value}}" } }, { "id": "create-followup", "type": "action", "action": "attio.createTask", "config": { "content": "Review new lead from {{steps.get-company.output.data[0].values.name[0].value}}", "deadline": "{{now | dateAdd: 1, 'day'}}", "linkedRecords": [ { "target_object": "people", "target_record_id": "{{input.data.record.id.record_id}}" } ] } } ] } ``` ## Rate Limits | Limit | Value | |-------|-------| | API calls | 10 requests/second | | Bulk operations | 100 records per request | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `objectSlug is required` | Missing object type | Specify the object slug | | `recordId is required` | Missing record ID | Provide the record ID | | `data is required` | Missing record data | Include data object | | Invalid attribute format | Wrong value structure | Use Attio's values array format | ### Attribute Value Format Attio uses a specific format for attribute values: ```json { "values": { "text_field": [{ "value": "text" }], "email_field": [{ "email_address": "email@example.com" }], "phone_field": [{ "phone_number": "+1234567890" }], "domain_field": [{ "domain": "example.com" }], "number_field": [{ "value": 123 }], "name_field": [{ "first_name": "John", "last_name": "Doe", "full_name": "John Doe" }] } } ``` # HubSpot (/docs/integrations/crm/hubspot) CRM integration for contacts, companies, deals, and marketing automation ## HubSpot Connect to HubSpot for CRM automation including contacts, companies, deals, tickets, and engagement activities. ## Overview HubSpot is an all-in-one CRM platform. The integration supports: - **Contacts** - Individual contact records - **Companies** - Organization records - **Deals** - Sales pipeline and deals - **Tickets** - Customer support tickets - **Associations** - Relationships between objects - **Engagements** - Notes, tasks, and activities - **Custom Objects** - Any CRM object type ## Prerequisites - HubSpot account (Free, Starter, Professional, or Enterprise) - Private app with required scopes - API access enabled ## Authentication HubSpot uses OAuth 2.0 for authentication via Nango. ```bash curl "https://workflow.loopfour.ai/api/v1/connections/hubspot/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` ### Required Scopes | Scope | Description | |-------|-------------| | `crm.objects.contacts.read/write` | Contact access | | `crm.objects.companies.read/write` | Company access | | `crm.objects.deals.read/write` | Deal access | | `crm.objects.owners.read` | Owner access | ## Available Actions ### Quote Actions #### getQuote Fetch a quote by `quoteId`. Set `quoteAdjustments` to `true` to read its associated discounts, fees, and taxes. The response includes `quoteAdjustments`, an array of `{ kind, id, value }` entries, where `value` is the object's `hs_value`. This makes adjustments visible even when they cancel in the quote total. Association read failures stop the action; a missing value remains visible to the workflow for validation. ### Contact Actions #### getContact Get a contact by ID. ```json { "action": "hubspot.getContact", "config": { "contactId": "12345", "properties": ["email", "firstname", "lastname", "phone", "company"] } } ``` #### createContact Create a new contact. ```json { "id": "create-contact", "type": "action", "action": "hubspot.createContact", "config": { "email": "{{input.email}}", "firstName": "{{input.firstName}}", "lastName": "{{input.lastName}}", "phone": "{{input.phone}}", "company": "{{input.companyName}}", "website": "{{input.website}}", "jobTitle": "{{input.title}}", "lifecycleStage": "lead", "leadStatus": "NEW", "address": "{{input.address.street}}", "city": "{{input.address.city}}", "state": "{{input.address.state}}", "zip": "{{input.address.zip}}", "country": "{{input.address.country}}", "customProperties": { "source_workflow": "{{workflowId}}" } } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `email` | string | No | Email address | | `firstName` | string | No | First name | | `lastName` | string | No | Last name | | `phone` | string | No | Phone number | | `company` | string | No | Company name | | `website` | string | No | Website URL | | `jobTitle` | string | No | Job title | | `lifecycleStage` | string | No | Lifecycle stage | | `leadStatus` | string | No | Lead status | | `customProperties` | object | No | Custom property values | #### updateContact Update an existing contact. ```json { "action": "hubspot.updateContact", "config": { "contactId": "{{input.contactId}}", "lifecycleStage": "customer", "customProperties": { "last_workflow_run": "{{now}}" } } } ``` #### deleteContact Delete a contact. ```json { "action": "hubspot.deleteContact", "config": { "contactId": "{{input.contactId}}" } } ``` #### searchContacts Search contacts with filters. ```json { "action": "hubspot.searchContacts", "config": { "filterGroups": [ { "filters": [ { "propertyName": "lifecyclestage", "operator": "EQ", "value": "lead" }, { "propertyName": "createdate", "operator": "GTE", "value": "{{input.startDate}}" } ] } ], "sorts": [ { "propertyName": "createdate", "direction": "DESCENDING" } ], "properties": ["email", "firstname", "lastname", "company"], "limit": 100, "after": "{{steps.previous.output.paging.next.after}}" } } ``` ### Company Actions #### getCompany Get a company by ID. ```json { "action": "hubspot.getCompany", "config": { "companyId": "12345", "properties": ["name", "domain", "industry", "numberofemployees"] } } ``` #### createCompany Create a new company. ```json { "id": "create-company", "type": "action", "action": "hubspot.createCompany", "config": { "name": "{{input.companyName}}", "domain": "{{input.domain}}", "description": "{{input.description}}", "industry": "{{input.industry}}", "phone": "{{input.phone}}", "website": "{{input.website}}", "numberOfEmployees": "{{input.employees}}", "annualRevenue": "{{input.revenue}}", "lifecycleStage": "lead", "type": "{{input.companyType}}" } } ``` #### updateCompany / deleteCompany ```json { "action": "hubspot.updateCompany", "config": { "companyId": "{{input.companyId}}", "industry": "Technology", "lifecycleStage": "customer" } } ``` #### searchCompanies ```json { "action": "hubspot.searchCompanies", "config": { "filterGroups": [ { "filters": [ { "propertyName": "industry", "operator": "EQ", "value": "TECHNOLOGY" } ] } ], "properties": ["name", "domain", "industry"], "limit": 50 } } ``` ### Deal Actions #### getDeal Get a deal by ID. ```json { "action": "hubspot.getDeal", "config": { "dealId": "12345", "properties": ["dealname", "dealstage", "amount", "closedate"] } } ``` #### createDeal Create a new deal. ```json { "id": "create-deal", "type": "action", "action": "hubspot.createDeal", "config": { "dealName": "{{input.dealName}}", "dealStage": "appointmentscheduled", "pipeline": "default", "amount": "{{input.dealValue}}", "closeDate": "{{input.expectedCloseDate}}", "dealType": "newbusiness", "description": "{{input.notes}}", "priority": "high", "associations": [ { "to": { "id": "{{input.contactId}}" }, "types": [{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 3 }] } ] } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `dealName` | string | Yes | Deal name | | `dealStage` | string | Yes | Pipeline stage ID | | `pipeline` | string | No | Pipeline ID (default: default) | | `amount` | number | No | Deal value | | `closeDate` | string | No | Expected close date | | `dealType` | string | No | Deal type | | `description` | string | No | Description | | `priority` | string | No | Priority level | | `associations` | array | No | Associated records | #### updateDeal / deleteDeal ```json { "action": "hubspot.updateDeal", "config": { "dealId": "{{input.dealId}}", "dealStage": "closedwon", "amount": "{{input.finalAmount}}" } } ``` #### searchDeals ```json { "action": "hubspot.searchDeals", "config": { "filterGroups": [ { "filters": [ { "propertyName": "dealstage", "operator": "IN", "values": ["presentationscheduled", "decisionmakerboughtin"] } ] } ], "properties": ["dealname", "amount", "closedate"], "limit": 50 } } ``` ### Ticket Actions #### getTicket Get a ticket by ID. ```json { "action": "hubspot.getTicket", "config": { "ticketId": "12345", "properties": ["subject", "content", "hs_pipeline_stage"] } } ``` #### createTicket Create a support ticket. ```json { "id": "create-ticket", "type": "action", "action": "hubspot.createTicket", "config": { "subject": "{{input.subject}}", "content": "{{input.description}}", "pipeline": "default", "pipelineStage": "1", "priority": "HIGH", "category": "{{input.category}}", "associations": [ { "to": { "id": "{{input.contactId}}" }, "types": [{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 16 }] } ] } } ``` #### updateTicket / deleteTicket ```json { "action": "hubspot.updateTicket", "config": { "ticketId": "{{input.ticketId}}", "pipelineStage": "4" } } ``` ### Association Actions #### createAssociation Create a relationship between objects. ```json { "action": "hubspot.createAssociation", "config": { "fromObjectType": "contacts", "fromObjectId": "{{input.contactId}}", "toObjectType": "companies", "toObjectId": "{{input.companyId}}", "associationType": "contact_to_company" } } ``` #### getAssociations Get associations for an object. ```json { "action": "hubspot.getAssociations", "config": { "objectType": "contacts", "objectId": "{{input.contactId}}", "toObjectType": "deals" } } ``` #### deleteAssociation ```json { "action": "hubspot.deleteAssociation", "config": { "fromObjectType": "contacts", "fromObjectId": "{{input.contactId}}", "toObjectType": "companies", "toObjectId": "{{input.companyId}}", "associationType": "contact_to_company" } } ``` ### Engagement Actions #### createNote Create a note on a record. ```json { "action": "hubspot.createNote", "config": { "body": "{{input.noteContent}}", "ownerId": "{{input.ownerId}}", "associations": [ { "to": { "id": "{{input.contactId}}" }, "types": [{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 202 }] } ] } } ``` #### createTask Create a task. ```json { "action": "hubspot.createTask", "config": { "subject": "Follow up call", "body": "{{input.taskDescription}}", "status": "NOT_STARTED", "priority": "HIGH", "taskType": "CALL", "ownerId": "{{input.ownerId}}", "reminderDate": "{{input.dueDate}}", "associations": [ { "to": { "id": "{{input.contactId}}" }, "types": [{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 204 }] } ] } } ``` #### updateTask ```json { "action": "hubspot.updateTask", "config": { "taskId": "{{input.taskId}}", "status": "COMPLETED" } } ``` ### Owner Actions #### getOwner Get owner details. ```json { "action": "hubspot.getOwner", "config": { "ownerId": "12345" } } ``` #### listOwners List all owners. ```json { "action": "hubspot.listOwners", "config": { "email": "{{input.email}}", "limit": 100 } } ``` ### Product Actions #### batchReadProducts Read up to 100 products by id in one request. Duplicate ids are removed before the 100 cap is checked. The response is HubSpot's batch result as-is: `status`, `results` (one object per product found, with `id` and the requested `properties`), `startedAt` and `completedAt`, plus `errors` and `numErrors` when some ids could not be read. ```json { "action": "hubspot.batchReadProducts", "config": { "productIds": ["1234567", "7654321"], "productProperties": ["name", "price", "hs_sku", "hs_status"] } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `productIds` | array | Yes | Numeric product ids: an array, a JSON array string, or comma-separated; 1–100 after de-duplication (`inputs[].id`) | | `productProperties` | array | No | Property names to return: an array or comma-separated (`properties`) | ### Generic Object Actions Work with any HubSpot CRM object. #### getObject / createObject / updateObject / deleteObject ```json { "action": "hubspot.getObject", "config": { "objectType": "products", "objectId": "12345", "properties": ["name", "price"] } } ``` #### searchObjects ```json { "action": "hubspot.searchObjects", "config": { "objectType": "products", "filterGroups": [ { "filters": [ { "propertyName": "price", "operator": "GTE", "value": "100" } ] } ], "properties": ["name", "price", "description"], "limit": 50 } } ``` ## Search Operators | Operator | Description | |----------|-------------| | `EQ` | Equal to | | `NEQ` | Not equal to | | `LT` | Less than | | `LTE` | Less than or equal | | `GT` | Greater than | | `GTE` | Greater than or equal | | `BETWEEN` | Between two values | | `IN` | In list of values | | `NOT_IN` | Not in list | | `HAS_PROPERTY` | Property exists | | `NOT_HAS_PROPERTY` | Property doesn't exist | | `CONTAINS_TOKEN` | Contains text | ## Webhook Triggers HubSpot webhooks trigger workflows on CRM changes. ```json { "trigger": { "type": "webhook", "provider": "hubspot", "events": ["contact.creation", "deal.propertyChange"] } } ``` **Event Types:** - `contact.creation`, `contact.deletion`, `contact.propertyChange` - `company.creation`, `company.deletion`, `company.propertyChange` - `deal.creation`, `deal.deletion`, `deal.propertyChange` - `quote.creation`, `quote.propertyChange` **Triggering on published quotes:** `quote.propertyChange` fires on every `hs_status` transition (`DRAFT`, `PENDING_APPROVAL`, `APPROVED`, `REJECTED`, ...). For automations that must only run when a quote is published — especially billing flows that create invoices or subscriptions — narrow the trigger to the property and its new value. Set `propertyName` to `hs_status` so the trigger only reacts to status changes, and add a `filter` whose keys are dot-notation paths into the normalized event. The new status value lives at `data.propertyValue`: ```json { "trigger": { "type": "hubspot_event", "eventKind": "quote.propertyChange", "propertyName": "hs_status", "filter": { "data.propertyValue": "APPROVED" } } } ``` Filter keys are paths into the normalized event, not raw HubSpot property names — `data.propertyName` holds which property changed and `data.propertyValue` holds its new value. The API also accepts the synthetic `quote.published` event kind, which matches `hs_status` transitions to `APPROVED`, `APPROVAL_NOT_NEEDED`, or `PUBLISHED` without a manual filter. It is not yet selectable in the Studio event dropdown; use it when authoring workflow JSON directly. ## Example Workflow Lead to deal workflow: ```json { "name": "Convert Lead to Deal", "trigger": { "type": "webhook", "provider": "hubspot", "events": ["contact.propertyChange"] }, "steps": [ { "id": "check-lifecycle", "type": "condition", "config": { "conditions": { "left": "{{input[0].propertyName}}", "operator": "eq", "right": "lifecyclestage" }, "then": ["check-stage"], "else": [] } }, { "id": "check-stage", "type": "condition", "config": { "conditions": { "left": "{{input[0].propertyValue}}", "operator": "eq", "right": "salesqualifiedlead" }, "then": ["get-contact"], "else": [] } }, { "id": "get-contact", "type": "action", "action": "hubspot.getContact", "config": { "contactId": "{{input[0].objectId}}", "properties": ["email", "firstname", "lastname", "company"] } }, { "id": "create-deal", "type": "action", "action": "hubspot.createDeal", "config": { "dealName": "{{steps.get-contact.output.properties.company}} - New Opportunity", "dealStage": "appointmentscheduled", "associations": [ { "to": { "id": "{{input[0].objectId}}" }, "types": [{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 3 }] } ] } }, { "id": "create-task", "type": "action", "action": "hubspot.createTask", "config": { "subject": "Initial discovery call with {{steps.get-contact.output.properties.firstname}}", "status": "NOT_STARTED", "priority": "HIGH", "associations": [ { "to": { "id": "{{steps.create-deal.output.id}}" }, "types": [{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 216 }] } ] } } ] } ``` ## Rate Limits | Limit | Value | |-------|-------| | API calls per day | 250,000 (Professional) | | API calls per second | 150 | | Batch operations | 100 records per batch | | Search results | 10,000 max | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `PROPERTY_DOESNT_EXIST` | Invalid property name | Check property API name | | `CONFLICT` | Duplicate record | Use search to find existing | | `RATE_LIMIT` | Too many requests | Implement backoff | | `OBJECT_NOT_FOUND` | Invalid object ID | Verify ID exists | # Salesforce (/docs/integrations/crm/salesforce) CRM integration for accounts, contacts, opportunities, and custom objects ## Salesforce Connect to Salesforce for comprehensive CRM automation including accounts, contacts, leads, opportunities, and custom objects. ## Overview Salesforce is the world's leading CRM platform. The integration supports: - **Accounts** - Company/organization records - **Contacts** - Individual contact records - **Leads** - Potential customer leads - **Opportunities** - Sales deals and pipeline - **Tasks** - Activities and to-dos - **Custom Objects** - Any sObject type - **SOQL/SOSL Queries** - Custom queries and search ## Prerequisites - Salesforce account (any edition with API access) - Connected App configured for OAuth - Appropriate user permissions for API access ## Authentication Salesforce uses OAuth 2.0 for authentication via Nango. ```bash curl "https://workflow.loopfour.ai/api/v1/connections/salesforce/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` ### Required Scopes | Scope | Description | |-------|-------------| | `api` | REST API access | | `refresh_token` | Offline access | ## Available Actions ### Account Actions #### getAccount Get an account by ID. ```json { "action": "salesforce.getAccount", "config": { "accountId": "001xxx" } } ``` #### createAccount Create a new account. ```json { "id": "create-account", "type": "action", "action": "salesforce.createAccount", "config": { "name": "{{input.companyName}}", "industry": "{{input.industry}}", "website": "{{input.website}}", "phone": "{{input.phone}}", "billingStreet": "{{input.address.street}}", "billingCity": "{{input.address.city}}", "billingState": "{{input.address.state}}", "billingPostalCode": "{{input.address.zip}}", "billingCountry": "{{input.address.country}}", "type": "Customer", "description": "Created via workflow", "annualRevenue": "{{input.revenue}}", "numberOfEmployees": "{{input.employees}}" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Account name | | `industry` | string | No | Industry picklist value | | `website` | string | No | Website URL | | `phone` | string | No | Phone number | | `billingStreet` | string | No | Billing street | | `billingCity` | string | No | Billing city | | `billingState` | string | No | Billing state | | `billingPostalCode` | string | No | Billing postal code | | `billingCountry` | string | No | Billing country | | `type` | string | No | Account type | | `description` | string | No | Description | | `annualRevenue` | number | No | Annual revenue | | `numberOfEmployees` | number | No | Employee count | #### updateAccount Update an existing account. ```json { "action": "salesforce.updateAccount", "config": { "accountId": "{{input.accountId}}", "website": "{{input.newWebsite}}", "type": "Customer" } } ``` #### deleteAccount Delete an account. ```json { "action": "salesforce.deleteAccount", "config": { "accountId": "{{input.accountId}}" } } ``` #### listAccounts List accounts with filters. ```json { "action": "salesforce.listAccounts", "config": { "industry": "Technology", "type": "Customer", "name": "Acme", "createdAfter": "2024-01-01T00:00:00Z", "orderBy": "CreatedDate", "orderDirection": "DESC", "limit": 100, "offset": 0, "fields": ["Id", "Name", "Website", "Industry"] } } ``` #### searchAccounts Full-text search using SOSL. ```json { "action": "salesforce.searchAccounts", "config": { "searchTerm": "Acme Corporation", "industry": "Technology", "limit": 25, "fields": ["Id", "Name", "Website", "Phone"] } } ``` ### Contact Actions #### getContact Get a contact by ID. ```json { "action": "salesforce.getContact", "config": { "contactId": "003xxx" } } ``` #### createContact Create a new contact. ```json { "id": "create-contact", "type": "action", "action": "salesforce.createContact", "config": { "firstName": "{{input.firstName}}", "lastName": "{{input.lastName}}", "email": "{{input.email}}", "phone": "{{input.phone}}", "mobilePhone": "{{input.mobile}}", "title": "{{input.jobTitle}}", "department": "{{input.department}}", "accountId": "{{steps.account.output.id}}", "mailingStreet": "{{input.address.street}}", "mailingCity": "{{input.address.city}}", "mailingState": "{{input.address.state}}", "mailingPostalCode": "{{input.address.zip}}", "mailingCountry": "{{input.address.country}}", "description": "Created via workflow" } } ``` #### updateContact / deleteContact ```json { "action": "salesforce.updateContact", "config": { "contactId": "{{input.contactId}}", "email": "{{input.newEmail}}" } } ``` ### Lead Actions #### getLead Get a lead by ID. ```json { "action": "salesforce.getLead", "config": { "leadId": "00Qxxx" } } ``` #### createLead Create a new lead. ```json { "id": "create-lead", "type": "action", "action": "salesforce.createLead", "config": { "firstName": "{{input.firstName}}", "lastName": "{{input.lastName}}", "company": "{{input.company}}", "email": "{{input.email}}", "phone": "{{input.phone}}", "title": "{{input.title}}", "industry": "{{input.industry}}", "status": "New", "leadSource": "{{input.source}}", "website": "{{input.website}}", "description": "{{input.notes}}" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `firstName` | string | No | First name | | `lastName` | string | Yes | Last name | | `company` | string | Yes | Company name | | `email` | string | No | Email address | | `phone` | string | No | Phone number | | `title` | string | No | Job title | | `industry` | string | No | Industry | | `status` | string | No | Lead status (default: New) | | `leadSource` | string | No | Lead source | | `website` | string | No | Website URL | | `description` | string | No | Description | #### updateLead / deleteLead ```json { "action": "salesforce.updateLead", "config": { "leadId": "{{input.leadId}}", "status": "Qualified" } } ``` #### convertLead Convert a lead to account/contact/opportunity. ```json { "action": "salesforce.convertLead", "config": { "leadId": "{{input.leadId}}", "convertedStatus": "Closed - Converted", "doNotCreateOpportunity": false, "opportunityName": "{{input.opportunityName}}" } } ``` ### Opportunity Actions #### getOpportunity Get an opportunity by ID. ```json { "action": "salesforce.getOpportunity", "config": { "opportunityId": "006xxx" } } ``` #### createOpportunity Create a new opportunity. ```json { "id": "create-opportunity", "type": "action", "action": "salesforce.createOpportunity", "config": { "name": "{{input.dealName}}", "accountId": "{{steps.account.output.id}}", "stageName": "Prospecting", "closeDate": "{{input.expectedCloseDate}}", "amount": "{{input.dealValue}}", "probability": 20, "type": "New Business", "leadSource": "{{input.source}}", "description": "{{input.notes}}", "nextStep": "Initial call scheduled" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Opportunity name | | `accountId` | string | No | Associated account ID | | `stageName` | string | Yes | Stage name | | `closeDate` | string | Yes | Expected close date (YYYY-MM-DD) | | `amount` | number | No | Deal amount | | `probability` | number | No | Win probability (0-100) | | `type` | string | No | Opportunity type | | `leadSource` | string | No | Lead source | | `nextStep` | string | No | Next step description | | `description` | string | No | Description | #### updateOpportunity Update an opportunity. ```json { "action": "salesforce.updateOpportunity", "config": { "opportunityId": "{{input.opportunityId}}", "stageName": "Negotiation", "amount": "{{input.newAmount}}", "probability": 75 } } ``` #### closeOpportunity Close an opportunity as Won or Lost. ```json { "action": "salesforce.closeOpportunity", "config": { "opportunityId": "{{input.opportunityId}}", "won": true, "amount": "{{input.finalAmount}}", "closeDate": "{{now | date: 'YYYY-MM-DD'}}", "reason": "Competitive pricing and features" } } ``` #### listOpportunities List opportunities with filters. ```json { "action": "salesforce.listOpportunities", "config": { "accountId": "{{input.accountId}}", "stageName": "Negotiation", "isClosed": false, "closeDateAfter": "2024-01-01", "closeDateBefore": "2024-12-31", "minAmount": 10000, "orderBy": "Amount", "orderDirection": "DESC", "limit": 50 } } ``` ### Task Actions #### getTask Get a task by ID. ```json { "action": "salesforce.getTask", "config": { "taskId": "00Txxx" } } ``` #### createTask Create a new task. ```json { "id": "create-task", "type": "action", "action": "salesforce.createTask", "config": { "subject": "Follow up on proposal", "status": "Not Started", "priority": "High", "whoId": "{{steps.contact.output.id}}", "whatId": "{{steps.opportunity.output.id}}", "activityDate": "{{input.dueDate}}", "description": "Review proposal and schedule call", "type": "Call" } } ``` #### updateTask / deleteTask ```json { "action": "salesforce.updateTask", "config": { "taskId": "{{input.taskId}}", "status": "Completed" } } ``` ### Query Actions #### query Execute a SOQL query. ```json { "action": "salesforce.query", "config": { "query": "SELECT Id, Name, Industry, Website FROM Account WHERE Industry = 'Technology' AND CreatedDate = THIS_YEAR ORDER BY CreatedDate DESC LIMIT 100" } } ``` #### search Execute a SOSL search. ```json { "action": "salesforce.search", "config": { "search": "FIND {Acme} IN ALL FIELDS RETURNING Account(Id, Name, Website), Contact(Id, Name, Email)" } } ``` ### Generic sObject Actions Work with any Salesforce object type. #### getRecord ```json { "action": "salesforce.getRecord", "config": { "sObjectType": "Custom_Object__c", "recordId": "a0Bxxx", "fields": ["Id", "Name", "Custom_Field__c"] } } ``` #### createRecord ```json { "action": "salesforce.createRecord", "config": { "sObjectType": "Custom_Object__c", "data": { "Name": "{{input.name}}", "Custom_Field__c": "{{input.value}}" } } } ``` #### updateRecord / deleteRecord ```json { "action": "salesforce.updateRecord", "config": { "sObjectType": "Custom_Object__c", "recordId": "{{input.recordId}}", "data": { "Custom_Field__c": "{{input.newValue}}" } } } ``` ## Webhook Triggers Salesforce events can trigger workflows via outbound messages or Change Data Capture. ```json { "trigger": { "type": "webhook", "provider": "salesforce", "events": ["Account", "Contact", "Opportunity"] } } ``` **Event Types:** Account, Contact, Lead, Opportunity, Task, Case, or any custom object ## Example Workflow Lead qualification workflow: ```json { "name": "Lead Qualification", "trigger": { "type": "webhook", "provider": "salesforce", "events": ["Lead"] }, "steps": [ { "id": "check-score", "type": "condition", "config": { "conditions": { "left": "{{input.payload.Lead_Score__c}}", "operator": "gte", "right": 80 }, "then": ["qualify-lead"], "else": ["nurture-lead"] } }, { "id": "qualify-lead", "type": "action", "action": "salesforce.updateLead", "config": { "leadId": "{{input.payload.Id}}", "status": "Qualified" } }, { "id": "create-task", "type": "action", "action": "salesforce.createTask", "config": { "subject": "Follow up with qualified lead", "whoId": "{{input.payload.Id}}", "priority": "High", "activityDate": "{{now | dateAdd: 1, 'day' | date: 'YYYY-MM-DD'}}" } }, { "id": "nurture-lead", "type": "action", "action": "salesforce.updateLead", "config": { "leadId": "{{input.payload.Id}}", "status": "Working - Contacted" } } ] } ``` ## Rate Limits | Limit | Value | |-------|-------| | API calls per day | Based on edition | | Concurrent API limit | 25 (per user) | | Bulk API | 10,000 records per batch | | Query results | 2,000 records per request | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `INVALID_SESSION_ID` | Token expired | Refresh OAuth connection | | `REQUIRED_FIELD_MISSING` | Missing required field | Check field requirements | | `DUPLICATE_VALUE` | Duplicate external ID | Use upsert or check existing | | `ENTITY_IS_DELETED` | Record was deleted | Verify record exists | | `INSUFFICIENT_ACCESS` | Missing permissions | Check user profile permissions | ### Field API Names Salesforce uses API names (e.g., `FirstName`, `Custom_Field__c`) rather than labels. Custom fields end with `__c`. # Document Integrations (/docs/integrations/documents) PandaDoc, DocuSign, Dropbox Sign ## Document Integrations Connect to document signing and management platforms. ## PandaDoc **Available Actions:** - `listTemplates`, `getTemplate` - `createDocument`, `getDocument`, `getDocumentStatus` - `sendDocument`, `createDocumentLink` - `downloadDocument`, `deleteDocument` - `listContacts`, `createContact` [Full PandaDoc Documentation →](/docs/integrations/documents/pandadoc) ## DocuSign **Available Actions:** - Envelope management - Document sending - Signature status - Template operations [Full DocuSign Documentation →](/docs/integrations/documents/docusign) ## Dropbox Sign **Available Actions:** - Signature requests - Template management - Document status - Team operations [Full Dropbox Sign Documentation →](/docs/integrations/documents/dropbox-sign) # DocuSign (/docs/integrations/documents/docusign) Electronic signature platform for contracts and documents ## DocuSign Connect to DocuSign for electronic signatures, envelope management, and document automation. ## Overview DocuSign is an industry-leading e-signature platform. The integration supports: - **Envelopes** - Create, send, and track signature requests - **Templates** - Use pre-built document templates - **Recipients** - Manage signers and routing - **Documents** - Upload, download, and manage documents - **Embedded Signing** - Generate signing URLs for in-app experiences - **Audit** - Track envelope events and history ## Prerequisites - DocuSign account (Developer, Standard, or Enterprise) - DocuSign Developer account for sandbox testing - Connected App configured for OAuth - Account ID from DocuSign admin ## Authentication DocuSign uses OAuth 2.0 for authentication via Nango. ```bash # Sandbox curl "https://workflow.loopfour.ai/api/v1/connections/docusign-sandbox/auth-url" \ -H "x-api-key: YOUR_API_KEY" # Production curl "https://workflow.loopfour.ai/api/v1/connections/docusign/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` ### Getting Account ID Call `getUserInfo` first to retrieve your account ID: ```json { "action": "docusign.getUserInfo", "config": {} } ``` ## Available Actions ### Account Actions #### getUserInfo Get user info and available accounts. ```json { "action": "docusign.getUserInfo", "config": { "providerConfigKey": "docusign-sandbox" } } ``` ### Template Actions #### listTemplates List available templates. ```json { "action": "docusign.listTemplates", "config": { "accountId": "{{input.accountId}}", "searchText": "NDA", "count": 50, "startPosition": 0, "order": "desc", "orderBy": "modified" } } ``` #### getTemplate Get template details. ```json { "action": "docusign.getTemplate", "config": { "accountId": "{{input.accountId}}", "templateId": "{{input.templateId}}" } } ``` ### Envelope Actions #### createEnvelope Create a new envelope (full configuration). ```json { "id": "create-envelope", "type": "action", "action": "docusign.createEnvelope", "config": { "accountId": "{{input.accountId}}", "templateId": "{{input.templateId}}", "emailSubject": "Please sign: {{input.documentName}}", "emailBlurb": "Please review and sign this document.", "status": "sent", "templateRoles": [ { "email": "{{input.signerEmail}}", "name": "{{input.signerName}}", "roleName": "Signer", "routingOrder": "1" }, { "email": "{{input.ccEmail}}", "name": "{{input.ccName}}", "roleName": "CC" } ], "customFields": { "textCustomFields": [ { "name": "ContractValue", "value": "{{input.contractValue}}" } ] }, "notification": { "useAccountDefaults": false, "reminders": { "reminderEnabled": true, "reminderDelay": 2, "reminderFrequency": 2 }, "expirations": { "expireEnabled": true, "expireAfter": 30, "expireWarn": 5 } } } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `accountId` | string | Yes | DocuSign account ID | | `templateId` | string | No | Template to use | | `documents` | array | No | Documents (if not using template) | | `recipients` | object | No | Recipients object | | `templateRoles` | array | No | Template role assignments | | `emailSubject` | string | No | Email subject line | | `emailBlurb` | string | No | Email body message | | `status` | string | No | `created` (draft) or `sent` | | `customFields` | object | No | Custom metadata fields | | `notification` | object | No | Reminder/expiration settings | | `brandId` | string | No | Branding theme ID | #### createEnvelopeFromTemplate Simplified envelope creation from template. ```json { "action": "docusign.createEnvelopeFromTemplate", "config": { "accountId": "{{input.accountId}}", "templateId": "{{input.templateId}}", "recipients": [ { "email": "{{input.email}}", "name": "{{input.name}}", "roleName": "Signer" } ], "emailSubject": "Contract for {{input.companyName}}", "status": "sent" } } ``` #### getEnvelope Get envelope details. ```json { "action": "docusign.getEnvelope", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}", "include": "recipients,documents" } } ``` #### getEnvelopeStatus Get envelope status. ```json { "action": "docusign.getEnvelopeStatus", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{steps.create.output.envelopeId}}" } } ``` **Envelope Statuses:** | Status | Description | |--------|-------------| | `created` | Draft, not yet sent | | `sent` | Sent for signature | | `delivered` | Viewed by recipients | | `signed` | Signed by all recipients | | `completed` | Finished and closed | | `declined` | Declined by recipient | | `voided` | Voided by sender | #### listEnvelopes List envelopes with filters. ```json { "action": "docusign.listEnvelopes", "config": { "accountId": "{{input.accountId}}", "fromDate": "2024-01-01", "toDate": "2024-12-31", "status": "completed", "searchText": "Contract", "count": 50, "order": "desc", "orderBy": "last_modified" } } ``` #### updateEnvelope Update envelope properties. ```json { "action": "docusign.updateEnvelope", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}", "emailSubject": "Updated: {{input.newSubject}}", "resend": true } } ``` #### sendEnvelope Send a draft envelope. ```json { "action": "docusign.sendEnvelope", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}" } } ``` #### voidEnvelope Void an envelope. ```json { "action": "docusign.voidEnvelope", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}", "voidedReason": "Contract terms changed" } } ``` #### sendReminder Send reminder to recipients. ```json { "action": "docusign.sendReminder", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}" } } ``` ### Document Actions #### listDocuments List documents in an envelope. ```json { "action": "docusign.listDocuments", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}" } } ``` #### downloadDocument Download a specific document. ```json { "action": "docusign.downloadDocument", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}", "documentId": "1", "certificate": false, "watermark": false } } ``` #### downloadCombinedDocument Download all documents as one PDF. ```json { "action": "docusign.downloadCombinedDocument", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}", "certificate": true } } ``` ### Recipient Actions #### getRecipients Get envelope recipients. ```json { "action": "docusign.getRecipients", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}", "includeTabs": true, "includeExtended": true } } ``` #### updateRecipients Update envelope recipients. ```json { "action": "docusign.updateRecipients", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}", "recipients": { "signers": [ { "recipientId": "1", "email": "{{input.newEmail}}", "name": "{{input.newName}}" } ] }, "resendEnvelope": true } } ``` #### deleteRecipients Remove recipients from envelope. ```json { "action": "docusign.deleteRecipients", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}", "recipients": { "signers": [ { "recipientId": "2" } ] } } } ``` ### Embedded Signing Actions #### createRecipientView Create embedded signing URL. ```json { "id": "get-signing-url", "type": "action", "action": "docusign.createRecipientView", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{steps.create-envelope.output.envelopeId}}", "email": "{{input.signerEmail}}", "userName": "{{input.signerName}}", "clientUserId": "{{input.userId}}", "authenticationMethod": "none", "returnUrl": "https://yourapp.com/signing-complete", "pingUrl": "https://yourapp.com/webhook/docusign-status", "pingFrequency": 60 } } ``` **Response:** ```json { "url": "https://demo.docusign.net/Signing/...", "expires_at": "2024-01-15T12:00:00Z" } ``` #### createSenderView Create embedded sending URL. ```json { "action": "docusign.createSenderView", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}", "returnUrl": "https://yourapp.com/sending-complete" } } ``` #### createEditView Create embedded edit URL. ```json { "action": "docusign.createEditView", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}", "returnUrl": "https://yourapp.com/edit-complete" } } ``` #### createCorrectView Create embedded correction URL. ```json { "action": "docusign.createCorrectView", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}", "returnUrl": "https://yourapp.com/correct-complete", "suppressNavigation": false } } ``` ### Audit Actions #### getAuditEvents Get envelope audit trail. ```json { "action": "docusign.getAuditEvents", "config": { "accountId": "{{input.accountId}}", "envelopeId": "{{input.envelopeId}}" } } ``` ## Webhook Triggers DocuSign Connect webhooks trigger workflows on envelope events. ```json { "trigger": { "type": "webhook", "provider": "docusign", "events": ["envelope-completed", "envelope-declined"] } } ``` **Event Types:** | Event | Description | |-------|-------------| | `envelope-sent` | Envelope sent | | `envelope-delivered` | Envelope viewed | | `envelope-completed` | All signatures collected | | `envelope-declined` | Recipient declined | | `envelope-voided` | Envelope voided | | `recipient-completed` | Individual recipient signed | ## Example Workflow Contract signing workflow: ```json { "name": "Send Contract on Deal Close", "trigger": { "type": "webhook", "provider": "hubspot", "events": ["deal.propertyChange"] }, "steps": [ { "id": "check-stage", "type": "condition", "config": { "conditions": { "and": [ { "left": "{{input[0].propertyName}}", "operator": "eq", "right": "dealstage" }, { "left": "{{input[0].propertyValue}}", "operator": "eq", "right": "closedwon" } ] }, "then": ["get-deal"], "else": [] } }, { "id": "get-deal", "type": "action", "action": "hubspot.getDeal", "config": { "dealId": "{{input[0].objectId}}", "properties": ["dealname", "amount", "closedate"] } }, { "id": "get-contact", "type": "action", "action": "hubspot.getAssociations", "config": { "objectType": "deals", "objectId": "{{input[0].objectId}}", "toObjectType": "contacts" } }, { "id": "create-contract", "type": "action", "action": "docusign.createEnvelopeFromTemplate", "config": { "accountId": "", "templateId": "", "recipients": [ { "email": "{{steps.get-contact.output.results[0].email}}", "name": "{{steps.get-contact.output.results[0].firstname}} {{steps.get-contact.output.results[0].lastname}}", "roleName": "Signer" } ], "emailSubject": "Service Agreement - {{steps.get-deal.output.properties.dealname}}", "status": "sent" } }, { "id": "notify-sales", "type": "action", "action": "slack.sendMessage", "config": { "channel": "#sales", "text": "Contract sent for {{steps.get-deal.output.properties.dealname}} (${{steps.get-deal.output.properties.amount}})" } } ] } ``` ## Rate Limits | Limit | Value | |-------|-------| | API calls | 1,000/hour (standard) | | Envelopes/day | Based on plan | | Embedded signing URLs | 5 per recipient per envelope | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `accountId is required` | Missing account ID | Call getUserInfo first | | `templateId is required` | No template specified | Provide templateId or documents | | `AUTHENTICATION_FAILED` | Invalid credentials | Check OAuth connection | | `ENVELOPE_NOT_IN_CORRECT_STATE` | Wrong envelope status | Check status before operation | | `RECIPIENT_NOT_FOUND` | Invalid recipient ID | Verify recipient exists | ### Sandbox vs Production Use different provider config keys: - Sandbox: `docusign-sandbox` - Production: `docusign` Configure both in your Nango setup for proper testing. # Dropbox Sign (/docs/integrations/documents/dropbox-sign) E-signature solution for document signing and templates ## Dropbox Sign Connect to Dropbox Sign (formerly HelloSign) for electronic signatures and document management. ## Overview Dropbox Sign is a user-friendly e-signature platform. The integration supports: - **Signature Requests** - Send and manage signature requests - **Templates** - Use and manage reusable templates - **Embedded Signing** - In-app signing experiences - **Teams** - Team member management - **Unclaimed Drafts** - Create drafts for later claiming ## Prerequisites - Dropbox Sign account (Free, Essentials, or Enterprise) - API access enabled - OAuth app or API key configured ## Authentication Dropbox Sign uses OAuth 2.0 for authentication via Nango. ```bash curl "https://workflow.loopfour.ai/api/v1/connections/dropbox-sign/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` ## Available Actions ### Signature Request Actions #### getSignatureRequest Get signature request by ID. ```json { "action": "dropbox-sign.getSignatureRequest", "config": { "signatureRequestId": "{{input.signatureRequestId}}" } } ``` #### listSignatureRequests List signature requests with filters. ```json { "action": "dropbox-sign.listSignatureRequests", "config": { "accountId": "{{input.accountId}}", "page": 1, "pageSize": 20, "query": "status:awaiting_signature" } } ``` #### sendSignatureRequest Send a new signature request. ```json { "id": "send-for-signature", "type": "action", "action": "dropbox-sign.sendSignatureRequest", "config": { "title": "Service Agreement - {{input.companyName}}", "subject": "Please sign the Service Agreement", "message": "Hi {{input.signerName}},\n\nPlease review and sign the attached agreement.", "signers": [ { "email_address": "{{input.signerEmail}}", "name": "{{input.signerName}}", "order": 1 }, { "email_address": "{{input.witnessEmail}}", "name": "{{input.witnessName}}", "order": 2 } ], "ccEmailAddresses": ["legal@company.com"], "fileUrls": ["{{input.documentUrl}}"], "metadata": { "deal_id": "{{input.dealId}}", "source": "workflow" }, "signingOptions": { "default_type": "draw" }, "testMode": false, "allowDecline": true, "allowReassign": true, "signingRedirectUrl": "https://yourapp.com/signed", "requestingRedirectUrl": "https://yourapp.com/requested" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `title` | string | No | Request title | | `subject` | string | No | Email subject | | `message` | string | No | Email message | | `signers` | array | Yes | Signer details | | `signers[].email_address` | string | Yes | Signer email | | `signers[].name` | string | Yes | Signer name | | `signers[].order` | number | No | Signing order | | `ccEmailAddresses` | array | No | CC recipients | | `fileUrls` | array | Yes | Document URLs | | `metadata` | object | No | Custom metadata | | `signingOptions` | object | No | Signing type options | | `testMode` | boolean | No | Use test mode | | `allowDecline` | boolean | No | Allow recipients to decline | | `allowReassign` | boolean | No | Allow reassignment | | `expiresAt` | number | No | Expiration timestamp | #### sendWithTemplate Send signature request using a template. ```json { "id": "send-from-template", "type": "action", "action": "dropbox-sign.sendWithTemplate", "config": { "templateId": "{{input.templateId}}", "title": "NDA - {{input.companyName}}", "subject": "Please sign the Non-Disclosure Agreement", "message": "Please review and sign the NDA.", "signers": [ { "role": "Client", "email_address": "{{input.clientEmail}}", "name": "{{input.clientName}}" }, { "role": "Company Representative", "email_address": "{{input.repEmail}}", "name": "{{input.repName}}" } ], "ccs": [ { "role": "Legal", "email_address": "legal@company.com" } ], "customFields": [ { "name": "effective_date", "value": "{{input.effectiveDate}}" }, { "name": "contract_value", "value": "{{input.contractValue}}" } ], "testMode": false } } ``` #### sendReminder Send reminder for pending signature. ```json { "action": "dropbox-sign.sendReminder", "config": { "signatureRequestId": "{{input.signatureRequestId}}", "emailAddress": "{{input.signerEmail}}", "name": "{{input.signerName}}" } } ``` #### updateSignatureRequest Update a signature request. ```json { "action": "dropbox-sign.updateSignatureRequest", "config": { "signatureRequestId": "{{input.signatureRequestId}}", "signatureId": "{{input.signatureId}}", "emailAddress": "{{input.newEmail}}", "name": "{{input.newName}}", "expiresAt": "{{input.newExpiration}}" } } ``` #### cancelSignatureRequest Cancel a signature request. ```json { "action": "dropbox-sign.cancelSignatureRequest", "config": { "signatureRequestId": "{{input.signatureRequestId}}" } } ``` #### removeSignatureRequestAccess Remove access to a signature request (for requestor). ```json { "action": "dropbox-sign.removeSignatureRequestAccess", "config": { "signatureRequestId": "{{input.signatureRequestId}}" } } ``` #### downloadFiles Download signed documents. ```json { "action": "dropbox-sign.downloadFiles", "config": { "signatureRequestId": "{{input.signatureRequestId}}", "fileType": "pdf" } } ``` **File Types:** - `pdf` - Single PDF with all documents - `zip` - ZIP archive with separate files #### getFilesAsDataUri Get files as base64 data URI. ```json { "action": "dropbox-sign.getFilesAsDataUri", "config": { "signatureRequestId": "{{input.signatureRequestId}}" } } ``` ### Template Actions #### getTemplate Get template by ID. ```json { "action": "dropbox-sign.getTemplate", "config": { "templateId": "{{input.templateId}}" } } ``` #### listTemplates List available templates. ```json { "action": "dropbox-sign.listTemplates", "config": { "accountId": "{{input.accountId}}", "page": 1, "pageSize": 20, "query": "NDA" } } ``` #### deleteTemplate Delete a template. ```json { "action": "dropbox-sign.deleteTemplate", "config": { "templateId": "{{input.templateId}}" } } ``` #### createEmbeddedTemplateDraft Create an embedded template draft. ```json { "action": "dropbox-sign.createEmbeddedTemplateDraft", "config": { "clientId": "", "fileUrls": ["{{input.documentUrl}}"], "title": "Standard NDA Template", "subject": "Non-Disclosure Agreement", "message": "Please sign the NDA", "signerRoles": [ { "name": "Client", "order": 1 }, { "name": "Company", "order": 2 } ], "ccRoles": ["Legal", "HR"], "mergeFields": [ { "name": "effective_date", "type": "text" }, { "name": "contract_value", "type": "text" } ], "testMode": true } } ``` ### Embedded Signing Actions #### getEmbeddedSignUrl Get embedded signing URL. ```json { "id": "get-signing-url", "type": "action", "action": "dropbox-sign.getEmbeddedSignUrl", "config": { "signatureId": "{{steps.send-request.output.signatures[0].signature_id}}" } } ``` **Response:** ```json { "embedded": { "sign_url": "https://app.hellosign.com/editor/embeddedSign?...", "expires_at": 1705312800 } } ``` #### getEmbeddedEditUrl Get embedded template edit URL. ```json { "action": "dropbox-sign.getEmbeddedEditUrl", "config": { "templateId": "{{input.templateId}}", "skipSignerRoles": false, "skipSubjectMessage": false, "testMode": false } } ``` ### Team Actions #### getTeam Get current team info. ```json { "action": "dropbox-sign.getTeam", "config": {} } ``` #### addTeamMember Add member to team. ```json { "action": "dropbox-sign.addTeamMember", "config": { "emailAddress": "{{input.email}}", "accountId": "{{input.accountId}}" } } ``` #### removeTeamMember Remove member from team. ```json { "action": "dropbox-sign.removeTeamMember", "config": { "emailAddress": "{{input.email}}", "newOwnerEmailAddress": "{{input.newOwnerEmail}}" } } ``` ### Account Actions #### getAccount Get account info. ```json { "action": "dropbox-sign.getAccount", "config": { "accountId": "{{input.accountId}}" } } ``` #### updateAccount Update account settings. ```json { "action": "dropbox-sign.updateAccount", "config": { "callbackUrl": "https://yourapp.com/webhook/dropbox-sign", "locale": "en_US" } } ``` ### API App Actions #### getApiApp Get API app info. ```json { "action": "dropbox-sign.getApiApp", "config": { "clientId": "" } } ``` #### listApiApps List API apps. ```json { "action": "dropbox-sign.listApiApps", "config": { "page": 1, "pageSize": 20 } } ``` ### Unclaimed Draft Actions #### createUnclaimedDraft Create an unclaimed draft. ```json { "action": "dropbox-sign.createUnclaimedDraft", "config": { "type": "request_signature", "fileUrls": ["{{input.documentUrl}}"], "signers": [ { "email_address": "{{input.signerEmail}}", "name": "{{input.signerName}}" } ], "subject": "{{input.subject}}", "message": "{{input.message}}", "testMode": true } } ``` #### createEmbeddedUnclaimedDraft Create embedded unclaimed draft. ```json { "action": "dropbox-sign.createEmbeddedUnclaimedDraft", "config": { "clientId": "", "type": "request_signature", "fileUrls": ["{{input.documentUrl}}"], "signers": [ { "email_address": "{{input.signerEmail}}", "name": "{{input.signerName}}" } ], "requesterEmailAddress": "{{input.requesterEmail}}", "testMode": true, "isForEmbeddedSigning": true } } ``` ## Webhook Triggers Dropbox Sign webhooks trigger workflows on signature events. ```json { "trigger": { "type": "webhook", "provider": "dropbox-sign", "events": ["signature_request_all_signed", "signature_request_declined"] } } ``` **Event Types:** | Event | Description | |-------|-------------| | `signature_request_sent` | Request sent | | `signature_request_viewed` | Request viewed | | `signature_request_signed` | Individual signer completed | | `signature_request_all_signed` | All signers completed | | `signature_request_declined` | Request declined | | `signature_request_expired` | Request expired | | `signature_request_canceled` | Request canceled | | `signature_request_remind` | Reminder sent | ## Example Workflow Sales contract signing workflow: ```json { "name": "Send Contract on Deal Won", "trigger": { "type": "webhook", "provider": "salesforce", "events": ["Opportunity"] }, "steps": [ { "id": "check-won", "type": "condition", "config": { "conditions": { "left": "{{input.payload.StageName}}", "operator": "eq", "right": "Closed Won" }, "then": ["get-contact"], "else": [] } }, { "id": "get-contact", "type": "action", "action": "salesforce.query", "config": { "query": "SELECT Email, Name FROM Contact WHERE AccountId = '{{input.payload.AccountId}}' AND IsPrimary__c = true LIMIT 1" } }, { "id": "send-contract", "type": "action", "action": "dropbox-sign.sendWithTemplate", "config": { "templateId": "", "title": "Service Agreement - {{input.payload.Name}}", "signers": [ { "role": "Client", "email_address": "{{steps.get-contact.output.records[0].Email}}", "name": "{{steps.get-contact.output.records[0].Name}}" } ], "customFields": [ { "name": "deal_value", "value": "{{input.payload.Amount}}" }, { "name": "close_date", "value": "{{input.payload.CloseDate}}" } ], "metadata": { "opportunity_id": "{{input.payload.Id}}", "account_id": "{{input.payload.AccountId}}" } } }, { "id": "update-opportunity", "type": "action", "action": "salesforce.updateOpportunity", "config": { "opportunityId": "{{input.payload.Id}}", "Contract_Sent__c": true, "Contract_Sent_Date__c": "{{now | date: 'YYYY-MM-DD'}}" } }, { "id": "notify-team", "type": "action", "action": "slack.sendMessage", "config": { "channel": "#sales-wins", "text": "Contract sent for {{input.payload.Name}} (${{input.payload.Amount}})" } } ] } ``` ## Rate Limits | Plan | Limit | |------|-------| | Free | 3 signature requests/month | | Essentials | Varies by plan | | Enterprise | Custom limits | | API calls | Based on plan | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `signatureRequestId is required` | Missing request ID | Provide signatureRequestId | | `templateId is required` | Missing template ID | Provide templateId | | `emailAddress is required` | Missing signer email | Include email_address for signers | | `DOCUMENT_NOT_FOUND` | Invalid document URL | Verify fileUrls are accessible | | `TEMPLATE_NOT_FOUND` | Invalid template ID | Check template exists | ### Test Mode Use `testMode: true` during development: - No charges incurred - Documents watermarked - Signatures not legally binding # PandaDoc (/docs/integrations/documents/pandadoc) Document automation for contracts, proposals, and e-signatures ## PandaDoc Connect to PandaDoc for document creation, e-signatures, and contract automation. ## Overview PandaDoc is a document automation platform. The integration supports: - **Templates** - List and use document templates - **Documents** - Create, send, and track documents - **E-signatures** - Send for signature and track status - **Folders** - Organize documents - **Contacts** - Manage recipient contacts ## Prerequisites - PandaDoc account (Free trial, Essentials, Business, or Enterprise) - API access enabled - OAuth app or API key configured ## Authentication PandaDoc uses OAuth 2.0 for authentication via Nango. ```bash curl "https://workflow.loopfour.ai/api/v1/connections/pandadoc/auth-url" \ -H "x-api-key: YOUR_API_KEY" ``` ## Available Actions ### Template Actions #### listTemplates List available templates. ```json { "action": "pandadoc.listTemplates", "config": { "q": "Sales Contract", "tag": "sales", "folderId": "folder-uuid", "count": 50, "page": 1 } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `q` | string | Search query | | `tag` | string | Filter by tag | | `folderId` | string | Filter by folder | | `count` | number | Results per page (default: 50) | | `page` | number | Page number | | `deleted` | boolean | Include deleted | #### getTemplate Get template details. ```json { "action": "pandadoc.getTemplate", "config": { "templateId": "{{input.templateId}}" } } ``` ### Document Actions #### createDocument Create a document from a template. ```json { "id": "create-contract", "type": "action", "action": "pandadoc.createDocument", "config": { "templateId": "{{input.templateId}}", "name": "Contract - {{input.companyName}}", "recipients": [ { "email": "{{input.signerEmail}}", "first_name": "{{input.signerFirstName}}", "last_name": "{{input.signerLastName}}", "role": "signer", "signing_order": 1 }, { "email": "{{input.ccEmail}}", "first_name": "{{input.ccFirstName}}", "last_name": "{{input.ccLastName}}", "role": "cc" } ], "tokens": [ { "name": "Company.Name", "value": "{{input.companyName}}" }, { "name": "Contract.Amount", "value": "{{input.amount}}" }, { "name": "Contract.Date", "value": "{{input.contractDate}}" } ], "pricingTables": [ { "name": "Products", "sections": [ { "title": "Services", "rows": [ { "options": { "qty_editable": false }, "data": { "Name": "Professional Services", "Price": 5000, "QTY": 1 } } ] } ] } ], "folderId": "{{input.folderId}}", "tags": ["sales", "q1-2024"], "metadata": { "opportunity_id": "{{input.opportunityId}}", "source": "workflow" } } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `templateId` | string | Yes | Template UUID | | `name` | string | Yes | Document name | | `recipients` | array | No | Document recipients | | `recipients[].email` | string | Yes | Recipient email | | `recipients[].first_name` | string | No | First name | | `recipients[].last_name` | string | No | Last name | | `recipients[].role` | string | No | Role from template | | `recipients[].signing_order` | number | No | Signing order | | `tokens` | array | No | Template variable values | | `tokens[].name` | string | Yes | Token name | | `tokens[].value` | string | Yes | Token value | | `pricingTables` | array | No | Pricing table data | | `folderId` | string | No | Folder UUID | | `tags` | array | No | Document tags | | `metadata` | object | No | Custom metadata | #### getDocument Get document details. ```json { "action": "pandadoc.getDocument", "config": { "documentId": "{{input.documentId}}" } } ``` #### getDocumentStatus Get document status (lighter endpoint for status checks). ```json { "action": "pandadoc.getDocumentStatus", "config": { "documentId": "{{steps.create.output.id}}" } } ``` **Document Statuses:** | Status | Description | |--------|-------------| | `document.draft` | Draft, not yet sent | | `document.sent` | Sent for signature | | `document.completed` | All signatures collected | | `document.viewed` | Viewed by recipient | | `document.waiting_approval` | Awaiting internal approval | | `document.approved` | Internally approved | | `document.rejected` | Rejected | | `document.waiting_pay` | Awaiting payment | | `document.paid` | Payment received | | `document.voided` | Voided | | `document.declined` | Declined by recipient | | `document.external_review` | External review in progress | #### listDocuments List documents with filters. ```json { "action": "pandadoc.listDocuments", "config": { "status": "document.sent", "templateId": "{{input.templateId}}", "q": "Contract", "tag": "sales", "folderId": "{{input.folderId}}", "createdFrom": "2024-01-01", "createdTo": "2024-12-31", "orderBy": "date_created", "count": 50, "page": 1 } } ``` #### sendDocument Send a document for signature. ```json { "id": "send-for-signature", "type": "action", "action": "pandadoc.sendDocument", "config": { "documentId": "{{steps.create-contract.output.id}}", "subject": "Please sign: {{input.documentName}}", "message": "Hi {{input.recipientName}},\n\nPlease review and sign the attached document at your earliest convenience.\n\nThank you!", "silent": false, "forwarding_allowed": true } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `documentId` | string | Document UUID | | `subject` | string | Email subject | | `message` | string | Email message | | `silent` | boolean | Send without email (default: false) | | `forwarding_allowed` | boolean | Allow forwarding (default: true) | #### createDocumentLink Create a sharing/signing link. ```json { "action": "pandadoc.createDocumentLink", "config": { "documentId": "{{input.documentId}}", "recipient": "{{input.recipientEmail}}", "lifetime": 3600 } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `documentId` | string | Document UUID | | `recipient` | string | Recipient email (for specific recipient link) | | `lifetime` | number | Link lifetime in seconds (default: 3600) | **Response:** ```json { "id": "session-uuid", "expires_at": "2024-01-15T11:30:00Z", "link": "https://app.pandadoc.com/s/..." } ``` #### downloadDocument Get document download URL. ```json { "action": "pandadoc.downloadDocument", "config": { "documentId": "{{input.documentId}}", "watermark": false, "separateFiles": false } } ``` #### deleteDocument Delete a document. ```json { "action": "pandadoc.deleteDocument", "config": { "documentId": "{{input.documentId}}" } } ``` #### updateDocumentStatus Update document status (void, mark complete, etc.). ```json { "action": "pandadoc.updateDocumentStatus", "config": { "documentId": "{{input.documentId}}", "status": "document.voided", "note": "Voided per customer request", "notifySigner": true } } ``` **Valid Status Transitions:** | From | To | |------|-----| | `document.draft` | `document.sent` | | `document.sent` | `document.voided` | | `document.viewed` | `document.voided` | | `document.waiting_approval` | `document.approved`, `document.rejected` | ### Folder Actions #### listFolders List folders. ```json { "action": "pandadoc.listFolders", "config": { "parentId": "{{input.parentFolderId}}", "count": 50, "page": 1 } } ``` #### createFolder Create a new folder. ```json { "action": "pandadoc.createFolder", "config": { "name": "Q1 2024 Contracts", "parentId": "{{input.parentFolderId}}" } } ``` #### renameFolder Rename a folder. ```json { "action": "pandadoc.renameFolder", "config": { "folderId": "{{input.folderId}}", "name": "Q1 2024 Contracts - Archived" } } ``` ### Contact Actions #### listContacts List contacts. ```json { "action": "pandadoc.listContacts", "config": { "email": "{{input.email}}" } } ``` #### createContact Create a new contact. ```json { "action": "pandadoc.createContact", "config": { "email": "{{input.email}}", "firstName": "{{input.firstName}}", "lastName": "{{input.lastName}}", "company": "{{input.companyName}}", "jobTitle": "{{input.title}}", "phone": "{{input.phone}}", "streetAddress": "{{input.address.street}}", "city": "{{input.address.city}}", "state": "{{input.address.state}}", "postalCode": "{{input.address.zip}}", "country": "{{input.address.country}}" } } ``` #### updateContact Update a contact. ```json { "action": "pandadoc.updateContact", "config": { "contactId": "{{input.contactId}}", "company": "{{input.newCompanyName}}", "jobTitle": "{{input.newTitle}}" } } ``` #### deleteContact Delete a contact. ```json { "action": "pandadoc.deleteContact", "config": { "contactId": "{{input.contactId}}" } } ``` ## Webhook Triggers PandaDoc webhooks trigger workflows on document events. ```json { "trigger": { "type": "webhook", "provider": "pandadoc", "events": ["document_state_changed", "recipient_completed"] } } ``` **Event Types:** | Event | Description | |-------|-------------| | `document_state_changed` | Document status changed | | `recipient_completed` | Recipient completed their actions | | `document_completed` | All recipients completed | | `document_paid` | Payment received | | `document_viewed` | Document was viewed | | `document_deleted` | Document was deleted | ## Example Workflow Complete contract workflow: ```json { "name": "Send Contract on Deal Close", "trigger": { "type": "webhook", "provider": "hubspot", "events": ["deal.propertyChange"] }, "steps": [ { "id": "check-stage", "type": "condition", "config": { "conditions": { "left": "{{input[0].propertyName}}", "operator": "eq", "right": "dealstage" }, "then": ["check-closed"], "else": [] } }, { "id": "check-closed", "type": "condition", "config": { "conditions": { "left": "{{input[0].propertyValue}}", "operator": "eq", "right": "closedwon" }, "then": ["get-deal"], "else": [] } }, { "id": "get-deal", "type": "action", "action": "hubspot.getDeal", "config": { "dealId": "{{input[0].objectId}}", "properties": ["dealname", "amount", "closedate"] } }, { "id": "get-contact", "type": "action", "action": "hubspot.getAssociations", "config": { "objectType": "deals", "objectId": "{{input[0].objectId}}", "toObjectType": "contacts" } }, { "id": "create-contract", "type": "action", "action": "pandadoc.createDocument", "config": { "templateId": "contract-template-uuid", "name": "Service Agreement - {{steps.get-deal.output.properties.dealname}}", "recipients": [ { "email": "{{steps.get-contact.output.results[0].email}}", "role": "signer" } ], "tokens": [ { "name": "Deal.Name", "value": "{{steps.get-deal.output.properties.dealname}}" }, { "name": "Deal.Amount", "value": "{{steps.get-deal.output.properties.amount}}" } ] } }, { "id": "wait-for-processing", "type": "wait", "config": { "duration": 5000 } }, { "id": "send-contract", "type": "action", "action": "pandadoc.sendDocument", "config": { "documentId": "{{steps.create-contract.output.id}}", "subject": "Service Agreement Ready for Signature", "message": "Please review and sign the attached service agreement." } }, { "id": "notify-team", "type": "action", "action": "slack.sendMessage", "config": { "channel": "#sales", "text": "Contract sent for {{steps.get-deal.output.properties.dealname}}" } } ] } ``` ## Rate Limits | Limit | Value | |-------|-------| | API calls | 50/minute (standard) | | API calls | 300/minute (enterprise) | | Document creation | 100/hour | | Bulk operations | 10 concurrent | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `document_not_found` | Invalid document ID | Verify document exists | | `template_not_found` | Invalid template ID | Verify template ID | | `invalid_recipients` | Missing required recipient data | Check email format | | `rate_limit_exceeded` | Too many requests | Implement backoff | ### Document Processing Documents take time to process after creation: 1. Create document (status: `document.draft`) 2. Wait 3-5 seconds for processing 3. Send document (status: `document.sent`) Use a wait step between creation and sending. # Finance Integrations (/docs/integrations/finance) Finances (Plaid) ## Finance Integrations Connect bank and credit-card accounts, read balances and transactions, and keep a Data Table in step with the bank. ## Finances (Plaid) **Available Actions:** - `plaid.getInstitution` - Institution name, URL, and optional provider status - `plaid.listAccounts` - Accounts under the connection - `plaid.getBalances` - Fresh available, current, and limit balances - `plaid.listTransactions` - Historical, date-bounded transaction read - `plaid.syncTransactions` - Durable incremental sync into a Data Table [Full Finances (Plaid) Documentation →](/docs/integrations/finance/financial-accounts) # Finances (Plaid) (/docs/integrations/finance/financial-accounts) Connect bank and credit-card accounts through Plaid, read balances and transactions, and run a durable daily transaction sync into a Data Table ## Finances (Plaid) Connect a company's bank and credit-card accounts, read institution details, accounts, balances, and transactions, and keep a Data Table in step with the bank through a durable daily sync. The integration uses [Plaid](https://plaid.com/docs/). In the product it is **Finances (Plaid)**, and the bank and credit-card accounts it connects are **financial accounts**. Action IDs use the `plaid.*` prefix. ## Overview One **connection** is one institution authorization (a Plaid Item) — *Mercury — Treasury* and *Brex — Operations* are two connections. Each connection exposes one or more **accounts** (checking, savings, credit card). A workflow step is pinned to exactly one connection and selects one or more of its accounts. | Action | What it does | Provider call | | --- | --- | --- | | `plaid.getInstitution` | Institution name, URL, and optional provider status | cached, or `/institutions/get_by_id` | | `plaid.listAccounts` | Accounts under the connection, from the account cache | none | | `plaid.getBalances` | Fresh available/current/limit balances for selected accounts | `/accounts/balance/get` | | `plaid.listTransactions` | Historical, date-bounded read | `/transactions/get` | | `plaid.syncTransactions` | Durable incremental sync into a Data Table | `/transactions/sync` | Every action returns the same normalized envelope (below), so downstream Transform, Condition, Loop, and Agent blocks read one shape regardless of action. ## Prerequisites - Plaid credentials on the API and the Trigger runtime: `PLAID_CLIENT_ID`, `PLAID_SECRET`, `PLAID_ENV` (`sandbox` or `production`). The deployment picks the environment; users never choose it. - `PLAID_REDIRECT_URI` registered in the Plaid dashboard as exactly `/integrations/plaid/oauth`. Institutions that authenticate through OAuth (Chase, for example) never return to the app without it — Link simply does not come back. - The Plaid **Transactions** product enabled on the Plaid account. - Users can connect banks in the United States (`US`) and Canada (`CA`); there is no setting to turn a country off. Before deploying to production, confirm that Plaid approved Canada for the account: in the Plaid Dashboard, request Canada Production access and select the Data Transparency Messaging use case. If the account uses a Link customization, create one whose country list is exactly `CA`, and keep the existing `US` one. ## Connecting an institution 1. Open **Integrations** and choose **Finances (Plaid) → Connect institution**. 2. When more than one country is enabled, choose the country where the bank is located: **United States** or **Canada**. Plaid Link then lists only banks and credit unions in that country. To connect banks in both countries, make one connection per bank. When only one country is enabled, Link opens straight away. 3. Complete the bank's login in Plaid Link. The connection takes the institution's name once Link completes — `Mercury`, then `Mercury (2)` for a second connection to the same bank, counting disconnected ones. Rename it any time from the connection's menu. OAuth institutions redirect to the bank and back. 4. **Manage** lists each connected bank as one row with the bank logo and name. A bank with no logo from Plaid shows its first letter. Each row is collapsed and shows the country, the number of accounts, and the environment. Expand a row to see the accounts the bank shared, each as `name ••mask`, `type/subtype`, and currency. Connection states: | State | Meaning | | --- | --- | | **Active** | Selectable and runnable | | **Reconnect required** | The bank needs you to sign in again — use **Reconnect** on the Integrations row, or **Update connection** from **Manage** when the provider has more than one connection. Common causes are a changed password or MFA, revoked or lapsed bank consent, or the bank migrating its login; it does not mean a wrong password was entered. Steps pinned to this connection will not run until it is repaired. The API reports this state as `expired`. A refresh that finds no shared accounts is the exception: it reports `error` with code `NO_ACCOUNTS_SHARED`, and the card still asks you to reconnect | | **Connecting** | Link or the exchange is still in progress. Closing the bank sign-in window removes the entry straight away. If the window was never closed (the tab was closed, or the browser quit), the entry stays listed showing who started it and when, with **Remove** on that entry; the confirmation names the attempt, because removing one that a colleague is still completing makes their connection fail. Removed attempts become **Abandoned** — kept in the audit history, not deleted | | **Error** | A safe, actionable error — read the remedy on the card | | **Disconnected** | Kept for audit history; not selectable | | **Abandoned** | A connect attempt that never completed. Kept for audit history; not shown in the accounts dialog or the canvas | In **Reconnect required** and **Error**, the card also shows the stored error code in small type (for example `Error code: ITEM_LOGIN_REQUIRED`). Quote it when contacting support. **Refresh accounts** re-reads the account list from the bank; an account the bank stopped sharing is marked unavailable, never deleted. You can also add a bank from inside a block: the connection selector's **Connect new plaid** opens Link and selects the new connection when it completes (OAuth institutions finish on the Integrations page, then select the connection in the block). ## Configuring a block Drag **Finances (Plaid)** from the *Finance & Accounting* group. Fields appear in this order: **Action → Institution connection → Accounts → Data Table (sync only) → filters for the chosen action**. - **Accounts** is disabled until an active connection is selected and clears when the connection changes. It defaults to *no accounts selected*; choose the accounts or use **Select all eligible accounts**. An account without transaction data cannot be selected for `listTransactions` / `syncTransactions`. - A saved account that no longer belongs to the connection is a blocking error: the step will not run against a different account. - A Finances (Plaid) step **never falls back** to another connection. A step with no connection, or with a connection that is inactive or belongs to another company, fails before it runs. ### Step config keys | Key | Applies to | Values | | --- | --- | --- | | `operation` | all | `getInstitution` · `listAccounts` · `getBalances` · `listTransactions` · `syncTransactions` | | `connection` | all | connection id (UUID) | | `accountIds` | balances, list, sync | array of account row ids (UUIDs) | | `destinationTableId` | sync | Data Table id | | `includeStatus` | getInstitution | `true` calls the provider for status | | `accountType`, `subtype`, `currency` | listAccounts | `all` or a value | | `dateMode` | list, sync | `none` · `absolute` · `relative:` | | `startDate`, `endDate` | list, sync | `YYYY-MM-DD`, inclusive, in the workflow's timezone | | `dateField` | sync | `posted` · `authorized`; affects emitted changes only | | `status` | list, sync | `all` · `posted` · `pending` | | `direction` | list, sync | `all` · `inflow` · `outflow` | | `amountMin`, `amountMax` | list, sync | decimal strings, absolute amount | | `search` | list, sync | case-insensitive match on merchant, name, description, check number, reference | | `categories` | list, sync | array of category primaries, e.g. `INCOME` | | `limit` | list | total output limit | | `changeTypes` | sync | subset of `added`, `modified`, `removed` | There is no cursor key. The sync cursor is managed for you. `plaid.listTransactions` is always bounded by **posted date**. Plaid's `/transactions/get` endpoint applies `startDate` and `endDate` to its posted `date` and provides no authorization-date query axis, so the block does not offer one for historical reads. A saved list step with the legacy `dateField: "authorized"` fails before connection resolution or the provider request. To repair it in the editor without losing the date range or limit, change a visible filter such as **Status** and change it back; or use `syncTransactions` when authorization-date emission filtering is required. This prevents a posted-date provider window plus an authorization-date local filter from silently returning an incomplete intersection as though it were complete. ## Example: sync transactions into a Data Table ```json { "id": "sync_mercury", "type": "action", "name": "Sync Mercury transactions", "action": "plaid.syncTransactions", "config": { "operation": "syncTransactions", "connection": "", "accountIds": ["", ""], "destinationTableId": "", "changeTypes": ["added", "modified", "removed"], "dateMode": "none", "status": "all", "direction": "all" } } ``` ### The destination Data Table Create the table before the first run — the sync validates its columns by name and never adds columns. A `data_table.ensureTable` step can create it: ```json { "id": "ensure_bank_table", "type": "data_table", "name": "Ensure Bank Transactions table", "config": { "operation": "ensureTable", "name": "Bank Transactions", "folderId": null, "columns": [ { "name": "eventId", "type": "text" }, { "name": "connectionId", "type": "text" }, { "name": "accountId", "type": "text" }, { "name": "providerTransactionId", "type": "text" }, { "name": "changeType", "type": "text" }, { "name": "versionHash", "type": "text" }, { "name": "status", "type": "text" }, { "name": "postedDate", "type": "date" }, { "name": "authorizedDate", "type": "date" }, { "name": "amount", "type": "text" }, { "name": "absoluteAmount", "type": "text" }, { "name": "currency", "type": "text" }, { "name": "direction", "type": "text" }, { "name": "description", "type": "text" }, { "name": "merchant", "type": "text" }, { "name": "reference", "type": "text" }, { "name": "category", "type": "text" }, { "name": "pendingTransactionId", "type": "text" }, { "name": "accountName", "type": "text" }, { "name": "accountMask", "type": "text" }, { "name": "reconciliationStatus", "type": "text" }, { "name": "processedAt", "type": "date" }, { "name": "lastError", "type": "text" } ] } } ``` Rows are upserted by `eventId`, so a retry updates the same row instead of inserting a duplicate. New rows arrive with `reconciliationStatus: "new"`; your workflow owns `reconciliationStatus`, `processedAt`, and `lastError` from there. Amounts are stored as decimal strings. ## How the sync works - **One cursor per account per step.** Two workflows reading the same account keep independent cursors and never consume each other's updates. - **Every page is drained before the cursor moves.** Event versions, Data Table rows, and the cursor are committed in one database transaction; if the commit cannot complete, the cursor does not advance and the next run replays the same pages. - **Filters affect what is emitted, never what is stored.** Date, status, direction, amount, category, and text filters decide which committed rows appear in `transactions[]`; the Data Table always receives everything the bank sent. - **Removed transactions are tombstones.** They carry ids and provenance with `changeType: "removed"`, `status: "removed"`, `isTombstone: true`, and are emitted whenever `removed` is enabled — regardless of date filters — so downstream state can be corrected. - **Pending → posted.** The posted transaction carries `pendingTransactionId`; treat it as replacing the pending one. - **If the bank changes data mid-pagination**, the whole page loop restarts from the original cursor. Nothing partial is exposed. - **Ceilings are explicit.** A very large first sync commits what it drained and fails the run with a message saying the next run resumes; nothing is silently truncated. - **Concurrency.** An overlapping run that loses the cursor race restarts from the cursor the other run persisted. A run of `listTransactions` or `syncTransactions` against a connection in **Reconnect required** fails with the reconnect message and marks the connection; nothing downstream runs. ## Output envelope Every action returns this shape. Unused collections are empty and unused objects `null`. For `getInstitution`, `institution.countryCodes` holds the country the connection was made in (for example `["CA"]`), also when the name comes from the cache. ```json { "schemaVersion": "1.0", "provider": "plaid", "operation": "syncTransactions", "fetchedAt": "2026-08-25T08:03:11.442Z", "source": { "connectionId": "…", "connectionName": "Mercury — Treasury", "providerItemId": "item_…", "environment": "sandbox" }, "institution": null, "accounts": [], "transactions": [ { "id": "plaid:…:txn_789", "eventId": "plaid:…:txn_789:sha256:…", "providerTransactionId": "txn_789", "changeType": "added", "status": "posted", "isTombstone": false, "account": { "id": "…", "providerAccountId": "…", "name": "Operating", "mask": "4821", "type": "depository", "subtype": "checking", "currency": "USD" }, "dates": { "postedDate": "2026-08-24", "postedAt": null, "authorizedDate": "2026-08-23", "authorizedAt": null }, "amount": { "value": "1250.00", "absolute": "1250.00", "currency": "USD", "direction": "inflow", "providerValue": "-1250.00", "convention": "cash-perspective" }, "description": { "name": "ACH CREDIT ACME INV-1042", "original": null, "merchant": "Acme Productions", "reference": "INV-1042", "checkNumber": null }, "paymentChannel": "other", "category": { "primary": "INCOME", "detailed": "INCOME_OTHER_INCOME", "confidence": "HIGH" }, "counterparties": [], "location": null, "pendingTransactionId": null, "raw": null } ], "pagination": null, "sync": { "initialSync": false, "startedFromCursor": true, "nextCursor": "…", "hasMore": false, "pages": 2, "counts": { "added": 1, "modified": 0, "removed": 0, "emitted": 1, "filteredOut": 0 }, "lastSuccessfulSyncAt": "2026-08-25T08:03:11.442Z" }, "warnings": [] } ``` ### Money and direction - Money is always a **decimal string** (`"1250.00"`), never a number. - Amounts are **cash perspective**: money entering the account is positive (`inflow`), money leaving is negative (`outflow`) — for credit cards too, so a card charge is an outflow and a card payment an inflow. `providerValue` keeps Plaid's original sign for audit. - The amount filters (`amountMin`, `amountMax`) compare amounts without converting currencies. A Canadian bank often has CAD and USD accounts in one connection. When an amount filter is set and the selected accounts use more than one currency, the step adds a message to `warnings`. Select accounts in one currency, or use one step per currency. - Calendar dates are inclusive and interpreted in the workflow's timezone (the schedule trigger's timezone, else UTC). Date-only values stay date-only. ## Several banks in one workflow Use one Finances (Plaid) step per connection, then merge with a Transform: ```javascript return [...input.sync_mercury.transactions, ...input.sync_brex.transactions]; ``` Every transaction carries `source.*` and `account` metadata, so the merged list keeps its provenance. Point a Loop at the merged rows and use `eventId` as its idempotency key. ## Sandbox to production Sandbox and production are separate deployments (`PLAID_ENV`); a Sandbox connection cannot be used against production and the runtime refuses the mismatch. The Sandbox institution *Tartan-Dominion Bank of Canada* (`ins_43`) exercises the Canadian flow. Plaid's Sandbox institutions complete inside Link without leaving the page; to exercise the OAuth return, use a Sandbox OAuth institution with `PLAID_REDIRECT_URI` registered. # Payment Integrations (/docs/integrations/payments) Stripe ## Payment Integrations Connect to payment processing platforms for invoices, subscriptions, and transactions. ## Stripe **Available Actions:** - `createInvoice`, `getInvoice`, `finalizeInvoice`, `voidInvoice`, `sendInvoice` - `createSubscription`, `updateSubscription`, `cancelSubscription` - `createPaymentIntent`, `confirmPaymentIntent` - `getCustomer`, `createCustomer` **Features:** - Invoice lifecycle management - Subscription billing - Payment processing - Customer management [Full Stripe Documentation →](/docs/integrations/payments/stripe) # Stripe (/docs/integrations/payments/stripe) Payment processing for invoices, subscriptions, and payment intents ## Stripe Connect to Stripe for payment processing, subscription management, and financial operations. ## Overview Stripe is a payment infrastructure platform. The integration supports: - **Customer Management** - Create and manage customer records - **Invoicing** - Create, send, finalize, and void invoices - **Subscriptions** - Create and manage recurring billing - **Payment Intents** - Process one-time payments - **Balance** - Check account balance and transactions ## Prerequisites - Stripe account (Standard or Connect) - API keys or OAuth app credentials - Appropriate scopes for desired operations ## Authentication Stripe can be connected in two ways. Both open the same connect dialog and produce a normal Stripe connection — workflow steps, actions, and the account picker behave identically afterwards. | Method | When to use | What you provide | | --- | --- | --- | | **OAuth (Stripe Connect)** | Default. Authorize in Stripe, nothing to copy, revocable from the Stripe dashboard. | Stripe login | | **API key** | When OAuth is unavailable — for example a Stripe account that does not expose the Connect flow. | A Stripe **restricted key** (`rk_...`) | In the Studio, **Integrations → Stripe → Connect** asks which method to use. The same picker appears on a workflow block's account selector and on the chat "Connect Stripe" card. A reconnect always reuses the method the account was first connected with. The API-key method needs a *restricted* key, not a standard secret key: create one under **Developers → API keys → Restricted keys** in Stripe and grant it the permissions the workflow needs (for example write access to Customers and Invoices). A connection's method is shown next to its name under **Integrations → Stripe → Manage**. To start a connect flow over the API, pass `authVariant` (`oauth` or `api_key`): ```bash curl -X POST "https://workflow.loopfour.ai/api/v1/connect/sessions" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"provider": "stripe", "authVariant": "api_key"}' ``` ## Available Actions ### Customer Actions #### getCustomer Get a customer by ID. ```json { "action": "stripe.getCustomer", "config": { "customerId": "cus_xxx" } } ``` #### createCustomer Create a new customer. ```json { "id": "create-customer", "type": "action", "action": "stripe.createCustomer", "config": { "email": "{{input.email}}", "name": "{{input.name}}", "description": "Customer from workflow", "phone": "{{input.phone}}", "address": { "line1": "{{input.address.street}}", "city": "{{input.address.city}}", "state": "{{input.address.state}}", "postal_code": "{{input.address.zip}}", "country": "{{input.address.country}}" }, "metadata": { "source": "workflow", "company_id": "{{input.companyId}}" } } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `email` | string | No | Customer email | | `name` | string | No | Customer name | | `description` | string | No | Description | | `phone` | string | No | Phone number | | `address` | object | No | Customer address | | `invoicePrefix` | string | No | Sent as Stripe's `invoice_prefix`. Stripe numbers this customer's invoices `-0001`, `-0002`, and so on. Must be 3-12 uppercase letters or numbers; anything else fails the step before Stripe is called. Omit it and Stripe assigns a random prefix. | | `metadata` | object | No | Key-value metadata | #### updateCustomer Update an existing customer. ```json { "action": "stripe.updateCustomer", "config": { "customerId": "{{input.customerId}}", "email": "{{input.newEmail}}", "metadata": { "updated_at": "{{now}}" } } } ``` #### listCustomers List customers with filters. ```json { "action": "stripe.listCustomers", "config": { "limit": 100, "email": "{{input.email}}", "autoPaginate": true } } ``` Set `autoPaginate` to `true` to combine every page of customers matching the email before checking for an existing customer. It also works for `listTaxRates`. Leave it false to page manually with `startingAfter`. Every Stripe list operation — `listCustomers`, `listInvoices`, `listPaymentIntents`, `listSubscriptions`, `listTaxRates` and `listBalanceTransactions` — accepts `startingAfter`: the id of the last object on the previous page, sent as `starting_after`. Keep paging while the response's `has_more` is `true`. A single unpaged call finding nothing is not proof the record does not exist. ### Invoice Actions #### createInvoice Create a new invoice. ```json { "id": "create-invoice", "type": "action", "action": "stripe.createInvoice", "config": { "customerId": "{{steps.customer.output.id}}", "autoAdvance": false, "collectionMethod": "send_invoice", "daysUntilDue": 30, "description": "Invoice for services", "metadata": { "order_id": "{{input.orderId}}" } } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `customerId` | string | Yes | Customer to invoice | | `autoAdvance` | boolean | No | Auto-finalize (default: true) | | `collectionMethod` | string | No | `charge_automatically` or `send_invoice` | | `daysUntilDue` | number | No | Days until due (for send_invoice) | | `description` | string | No | Invoice description | | `metadata` | object | No | Custom metadata | #### createInvoiceItem Add a line item to an invoice. To bill an existing price, pass `priceId` and `quantity` instead of `productId` and a unit amount. The price carries its own currency, and reusing it keeps Stripe from minting a new price for every invoice. `priceId` is sent as `pricing[price]`: Stripe API version `2025-03-31.basil` removed the top-level `price` parameter from invoice items ([changelog](https://docs.stripe.com/changelog/basil/2025-03-31/invoice-pricing-configurations)). ```json { "action": "stripe.createInvoiceItem", "config": { "customerId": "{{steps.customer.output.id}}", "invoiceId": "{{steps.create-invoice.output.id}}", "amount": 9900, "currency": "usd", "description": "Professional Services - January 2024", "quantity": 1 } } ``` To bill against a catalog product instead of an ad-hoc line, pass `productId` with `unitAmount` (or `amount`). The line is then priced inline through `price_data`, so the invoice reports revenue per product: ```json { "action": "stripe.createInvoiceItem", "config": { "customerId": "{{steps.customer.output.id}}", "invoiceId": "{{steps.create-invoice.output.id}}", "productId": "{{steps.search_product.data.0.id || steps.create_product.id}}", "unitAmount": 9900, "quantity": 2, "description": "ROAR Smart Beacon" } } ``` To attach an externally computed sales tax (for example a rate Avalara returned), pass `taxRateIds` — an array of Stripe tax rate ids (`txr_...`) — and the line is sent with `tax_rates`. Create or look the rates up first with `listTaxRates` / `createTaxRate`. The value must be an array of at most 10 non-empty strings (Stripe's per-item cap); anything else fails before the request is sent. A Studio `code` field hands the value over as a JSON string, so `'["txr_1"]'` is accepted as well as a real array. ```json { "action": "stripe.createInvoiceItem", "config": { "customerId": "{{steps.customer.output.id}}", "invoiceId": "{{steps.create-invoice.output.id}}", "unitAmount": 9900, "quantity": 2, "taxRateIds": ["{{steps.create_tax_rate.id}}"] } } ``` To discount a line with a coupon that already exists, pass its id as `couponId`; it is trimmed, must contain only letters, digits, `_` and `-`, and is sent as `discounts[0][coupon]`. No coupon is created. Find or create the coupon first with `listCoupons` and `createCoupon`. `couponId` cannot be combined with `discountAmount` (the legacy path that creates a single-use `amount_off` coupon on every call); the call fails before anything is sent. A blank `couponId` sends no discount, so a loop can pass `''` for undiscounted lines. ```json { "action": "stripe.createInvoiceItem", "config": { "customerId": "{{steps.customer.output.id}}", "invoiceId": "{{steps.create-invoice.output.id}}", "priceId": "price_1MoBy5LkdIwHu7ixZhnattbh", "quantity": 3, "couponId": "lf-pct-10" } } ``` #### getInvoice Get an invoice by ID. ```json { "action": "stripe.getInvoice", "config": { "invoiceId": "in_xxx" } } ``` #### listInvoices List invoices with filters. ```json { "action": "stripe.listInvoices", "config": { "customerId": "{{input.customerId}}", "status": "open", "limit": 50 } } ``` #### finalizeInvoice Finalize a draft invoice. ```json { "action": "stripe.finalizeInvoice", "config": { "invoiceId": "{{steps.create-invoice.output.id}}" } } ``` #### sendInvoice Send an invoice to the customer. ```json { "action": "stripe.sendInvoice", "config": { "invoiceId": "{{steps.finalize.output.id}}" } } ``` #### voidInvoice Void an invoice. ```json { "action": "stripe.voidInvoice", "config": { "invoiceId": "{{input.invoiceId}}" } } ``` #### deleteInvoice Permanently delete a draft invoice (`DELETE /v1/invoices/:id`). Stripe refuses any other status; void a finalized invoice instead. ```json { "action": "stripe.deleteInvoice", "config": { "invoiceId": "{{input.invoiceId}}" } } ``` #### listInvoiceLineItems List one page of an invoice's line items (`GET /v1/invoices/{id}/lines`). The page comes back as Stripe returns it; keep paging with `startingAfter` set to the last line's `id` while `has_more` is `true`. `limit` is 1–100; blank uses Stripe's default of 10. ```json { "action": "stripe.listInvoiceLineItems", "config": { "invoiceId": "{{steps.get-run-row.output.invoiceId}}", "limit": 100 } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `invoiceId` | string | Yes | Invoice whose lines to list | | `limit` | number | No | Page size, 1–100 (Stripe default 10) | | `startingAfter` | string | No | Line item id to page after (`starting_after`) | #### previewCreditNote Preview a credit note without creating it (`GET /v1/credit_notes/preview`). It takes the same fields as `createCreditNote` and returns the credit note Stripe would issue, including per-line `amount`, `total` and `total_taxes`. Use it to read the credit before an approval, then create the note with the same fields. #### createCreditNote Issue a credit note on a finalized invoice (`POST /v1/credit_notes`). Each entry in `lines` credits one invoice line, either by `quantity` (Stripe credits that line's tax proportionally through its tax rates) or by `amount` in cents, not both. Only `invoice_line_item` lines are supported. Stripe first reduces the invoice's amount due. Whatever exceeds it must be split across `creditAmount` (customer balance, applied to the next finalized invoice), `refundAmount` and `outOfBandAmount`, and those must add up to the excess ([create a credit note](https://docs.stripe.com/api/credit_notes/create)). `idempotencyKey` is required and sent as Stripe's `Idempotency-Key` as is: 1–255 printable characters, no whitespace. Build it from stable ids (for example `cn::`) so a retry or a second run gets the first credit note back instead of crediting the invoice twice. A call without one is refused. Stripe keeps keys for 24 hours, so also guard re-runs in the workflow. ```json { "action": "stripe.createCreditNote", "config": { "invoiceId": "in_1MxvRkLkdIwHu7ixABNtI99m", "lines": [ { "type": "invoice_line_item", "invoice_line_item": "il_1MxvRlLkdIwHu7ixnkbntxUV", "quantity": 2 } ], "creditAmount": 0, "reason": "order_change", "memo": "Units not installed", "metadata": { "hubspot_deal_id": "{{trigger.data.objectId}}" }, "idempotencyKey": "cn:in_1MxvRkLkdIwHu7ixABNtI99m:quote-2" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `invoiceId` | string | Yes | Finalized invoice to credit (`invoice`) | | `lines` | array | Yes | `[{ type: "invoice_line_item", invoice_line_item, quantity }]` or with `amount` (cents) instead of `quantity` | | `creditAmount` | number | No | Cents credited to the customer balance (`credit_amount`) | | `refundAmount` | number | No | Cents refunded to the invoice's charge (`refund_amount`) | | `outOfBandAmount` | number | No | Cents credited outside Stripe (`out_of_band_amount`) | | `reason` | string | No | `duplicate`, `fraudulent`, `order_change` or `product_unsatisfactory` | | `memo` | string | No | Printed on the credit note PDF | | `metadata` | object | No | String, number or boolean values; keys without `[` or `]`; `null` values are skipped | | `idempotencyKey` | string | Yes (create) | `Idempotency-Key` sent as is, built from stable ids; required by `createCreditNote`. `previewCreditNote` never sends one | ### Payment Intent Actions #### createPaymentIntent Create a payment intent for one-time payments. ```json { "id": "create-payment", "type": "action", "action": "stripe.createPaymentIntent", "config": { "amount": 9900, "currency": "usd", "customerId": "{{input.customerId}}", "description": "Order #{{input.orderId}}", "paymentMethodTypes": ["card"], "captureMethod": "automatic", "metadata": { "order_id": "{{input.orderId}}" } } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `amount` | number | Yes | Amount in cents | | `currency` | string | Yes | Three-letter currency code | | `customerId` | string | No | Customer to charge | | `description` | string | No | Payment description | | `paymentMethodTypes` | array | No | Allowed payment methods | | `paymentMethod` | string | No | Specific payment method ID | | `captureMethod` | string | No | `automatic` or `manual` | | `confirm` | boolean | No | Confirm immediately | | `offSession` | boolean | No | Off-session payment | | `setupFutureUsage` | string | No | `on_session` or `off_session` | | `receiptEmail` | string | No | Email for receipt | | `statementDescriptor` | string | No | Statement descriptor | | `metadata` | object | No | Custom metadata | #### getPaymentIntent Get a payment intent by ID. ```json { "action": "stripe.getPaymentIntent", "config": { "paymentIntentId": "pi_xxx" } } ``` #### confirmPaymentIntent Confirm a payment intent. ```json { "action": "stripe.confirmPaymentIntent", "config": { "paymentIntentId": "{{steps.create-payment.output.id}}", "paymentMethod": "{{input.paymentMethodId}}", "returnUrl": "https://example.com/payment/complete" } } ``` #### capturePaymentIntent Capture a payment intent (for manual capture). ```json { "action": "stripe.capturePaymentIntent", "config": { "paymentIntentId": "{{input.paymentIntentId}}", "amountToCapture": 5000 } } ``` #### cancelPaymentIntent Cancel a payment intent. ```json { "action": "stripe.cancelPaymentIntent", "config": { "paymentIntentId": "{{input.paymentIntentId}}", "cancellationReason": "requested_by_customer" } } ``` #### listPaymentIntents List payment intents. ```json { "action": "stripe.listPaymentIntents", "config": { "customerId": "{{input.customerId}}", "limit": 50 } } ``` ### Subscription Actions #### createSubscription Create a new subscription. ```json { "id": "create-subscription", "type": "action", "action": "stripe.createSubscription", "config": { "customerId": "{{input.customerId}}", "items": [ { "price": "price_xxx" } ], "collectionMethod": "charge_automatically", "trialPeriodDays": 14, "metadata": { "plan": "{{input.planName}}" } } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `customerId` | string | Yes | Customer ID | | `items` | array | Yes | Subscription items. An item with `price` (a price id) and no `price_data` is sent to Stripe unchanged | | `items[].taxRateIds` | array | No | Tax rate ids (`txr_...`) sent as that item's `tax_rates`; max 10 per item | | `defaultPaymentMethod` | string | No | Default payment method | | `collectionMethod` | string | No | `charge_automatically` or `send_invoice` | | `daysUntilDue` | number | No | Days until due (for send_invoice) | | `trialPeriodDays` | number | No | Trial period in days | | `cancelAtPeriodEnd` | boolean | No | Cancel at period end | | `prorationBehavior` | string | No | `create_prorations`, `none`, `always_invoice` | | `backdateStartDate` | number | No | Past start date in unix seconds (`backdate_start_date`); a future or fractional value is refused | | `billingMode` | string | No | Only `flexible` (`billing_mode[type]`); any other value is refused before anything is created | | `metadata` | object | No | Custom metadata | Items with mixed billing periods turn on flexible billing mode and pin `Stripe-Version: 2025-06-30.basil`. When `billingMode: "flexible"` is set, the action sends it and adds no version pin, so the account's own API version applies. With `billingMode: "flexible"`, a `backdateStartDate` and no `billingCycleAnchor`, Stripe anchors the billing cycle to the backdated start and the first invoice bills one full period from it ([backdating](https://docs.stripe.com/billing/subscriptions/backdating)). A start date more than one period in the past bills every elapsed period. #### createSubscriptionSchedule Create a subscription that starts on a future date. The first phase's `start_date` becomes the schedule's start; a start already in the past is sent as `now`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `customerId` | string | Yes | Customer ID | | `phases` | array | Yes | Phases with `start_date` (unix seconds) and `items`. `duration` is supported only on a single-phase schedule and is kept as given; a schedule with two or more phases where any phase has a `duration` is refused. `iterations` is handled as before: it is replaced by the next phase's start or a fallback of `1` | | `endBehavior` | string | No | `release` (default, the subscription continues) or `cancel` | | `defaultSettings` | object | No | Laid over the defaults: `collection_method` from `collectionMethod` (default `send_invoice`) and `invoice_settings.days_until_due` from `daysUntilDue` (default 30). Accepts only `collection_method`, `days_until_due` (sent under `invoice_settings`), `invoice_settings`, `automatic_tax`, `billing_cycle_anchor` and `description`; any other key is refused. With `charge_automatically` no `days_until_due` is sent, and an explicit one (`daysUntilDue`, `days_until_due` or `invoice_settings.days_until_due`) is refused | | `metadata` | object | No | Custom metadata | Stripe removed a phase's `iterations` in API version `2025-08-27.basil`; use `duration` (`{ "interval": "month", "interval_count": 1 }`) on newer versions ([changelog](https://docs.stripe.com/changelog/clover/2025-09-30/remove-iterations)). `duration` arrived in `2025-07-30.basil`, after the `2025-06-30.basil` version that mixed billing intervals pin, so a schedule with both is refused. #### getSubscription Get a subscription by ID. ```json { "action": "stripe.getSubscription", "config": { "subscriptionId": "sub_xxx" } } ``` #### updateSubscription Update a subscription. ```json { "action": "stripe.updateSubscription", "config": { "subscriptionId": "{{input.subscriptionId}}", "items": [ { "id": "{{input.itemId}}", "price": "price_new_xxx" } ], "prorationBehavior": "create_prorations" } } ``` An upsell that adds a product, raises the quantity of an existing item, and invoices the proration immediately: ```json { "action": "stripe.updateSubscription", "config": { "subscriptionId": "{{input.subscriptionId}}", "items": [ { "price": "price_addon_xxx", "quantity": 2, "taxRateIds": ["txr_xxx"] }, { "id": "si_xxx", "quantity": 5 } ], "prorationBehavior": "always_invoice", "prorationDate": "{{input.prorationDate}}", "idempotencyKey": "upsell:{{input.subscriptionId}}:{{input.quoteId}}" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `subscriptionId` | string | Yes | Subscription to update (`sub_...`) | | `items` | array | No | Up to 20 item changes; an empty array is refused (omit `items` to leave the items unchanged). `{ "id": "si_...", "quantity": 5 }` changes an existing subscription item; `{ "price": "price_...", "quantity": 2 }` with no `id` adds one; `{ "id": "si_...", "deleted": true }` removes one. `price` and `price_data` are not accepted together. `discounts` and other Stripe item fields pass through | | `items[].taxRateIds` | array | No | Tax rate ids (`txr_...`) sent as that item's `tax_rates`; max 10 per item. An item that already spells `tax_rates` is sent unchanged; an item with both is refused | | `prorationBehavior` | string | No | `create_prorations` (Stripe's default), `always_invoice` (invoice the proration immediately; requires `idempotencyKey`) or `none`; any other value is refused | | `prorationDate` | number | No | `proration_date`, unix seconds or ISO 8601. Needs `items` and is refused with `prorationBehavior: "none"`, the same rules as `previewInvoice`. Use the value you previewed with so the update bills what was previewed | | `paymentBehavior` | string | No | `allow_incomplete`, `default_incomplete`, `error_if_incomplete` or `pending_if_incomplete` | | `idempotencyKey` | string | With `always_invoice` | Sent as the `Idempotency-Key` header: 1-255 printable characters, no whitespace. A retry with the same key returns the first result instead of updating (and invoicing) twice | | `cancelAtPeriodEnd`, `pauseCollection`, `collectionMethod`, `daysUntilDue`, `defaultPaymentMethod`, `metadata`, `billingCycleAnchor` | various | No | Sent as the matching Stripe field | `subscriptionId`, `items`, `prorationBehavior`, `prorationDate`, `paymentBehavior` and `idempotencyKey` are checked before the request, so a refused value in one of them changes nothing in Stripe. The other fields are sent as given and Stripe validates them. **Idempotency.** Stripe may prune a key once it is at least 24 hours old; a re-run after that applies the update, and bills the proration, again. Reusing a key with different parameters fails with an idempotency error ([idempotent requests](https://docs.stripe.com/api/idempotent_requests)). Build the key from ids that identify this exact change, such as the subscription id and the quote id, not just a deal id that several changes share. **Trusted data only.** Build `items` from your own records (approved quote lines, price ids you created). Every key on an item other than the ones listed above is forwarded to Stripe, so an item built from an untrusted payload could, for example, set its own `price_data.unit_amount`. See Stripe's [update a subscription](https://docs.stripe.com/api/subscriptions/update) and [prorations](https://docs.stripe.com/billing/subscriptions/prorations). #### previewInvoice Preview an invoice without creating it (`POST /v1/invoices/create_preview`). Give one target: `scheduleId` (the schedule's next invoice), `customerId` (the customer's upcoming invoice) or `subscriptionId`. With `subscriptionId`, `items`, `prorationBehavior` and `prorationDate` preview a proposed change, for example the proration an upsell would bill, before an approval step: ```json { "action": "stripe.previewInvoice", "config": { "subscriptionId": "{{input.subscriptionId}}", "items": [ { "price": "price_addon_xxx", "quantity": 2, "taxRateIds": ["txr_xxx"] }, { "id": "si_xxx", "quantity": 5 } ], "prorationBehavior": "always_invoice", "prorationDate": 1790000000 } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `subscriptionId` | string | One target | Sent as `subscription`; cannot be combined with `scheduleId` or `customerId` | | `scheduleId` | string | One target | Sent as `schedule` | | `customerId` | string | One target | Sent as `customer` | | `items` | array | No | Same shape as `updateSubscription` items (`taxRateIds` becomes `tax_rates`), sent as `subscription_details[items]`. Needs `subscriptionId` | | `prorationBehavior` | string | No | `subscription_details[proration_behavior]`: `create_prorations`, `always_invoice` or `none`. Needs `subscriptionId` | | `prorationDate` | number | No | `subscription_details[proration_date]`, unix seconds or ISO 8601. Needs `items` and cannot be combined with `prorationBehavior: "none"` | Pass the same `prorationDate` to `updateSubscription` so the update bills exactly the prorations that were previewed ([create a preview invoice](https://docs.stripe.com/api/invoices/create_preview)). The proration lines are the preview's line items whose `parent.subscription_item_details.proration` is `true` on API version `2025-03-31.basil` and later; earlier versions put `proration` at the top level of the line item ([changelog](https://docs.stripe.com/changelog/basil/2025-03-31/adds-new-parent-field-to-invoicing-objects)). Read it from wherever the connected account's API version puts it. #### cancelSubscription Cancel a subscription. ```json { "action": "stripe.cancelSubscription", "config": { "subscriptionId": "{{input.subscriptionId}}", "invoiceNow": true, "prorate": true } } ``` #### listSubscriptions List subscriptions. ```json { "action": "stripe.listSubscriptions", "config": { "customerId": "{{input.customerId}}", "status": "active", "limit": 50 } } ``` ### Product Actions #### createProduct Create a catalog product. Set `idempotencyKey` (at most 255 characters) to send it as Stripe's `Idempotency-Key` header. A second request with the same key, for example from a concurrent run, gets the first product back instead of creating another. Stripe keeps keys for at least 24 hours and rejects a reused key whose parameters differ ([idempotent requests](https://docs.stripe.com/api/idempotent_requests)). Without it, no key is sent. ```json { "action": "stripe.createProduct", "config": { "name": "ROAR Smart Beacon", "description": "Battery-powered panic button", "metadata": { "qbo_item_id": "375" }, "idempotencyKey": "hubspot-product:375" } } ``` #### searchProducts Search products with Stripe's [search query language](https://docs.stripe.com/search#search-query-language). `limit` defaults to 10 (1–100). ```json { "action": "stripe.searchProducts", "config": { "query": "active:'true' AND metadata['qbo_item_id']:'375'", "limit": 1 } } ``` #### listPrices List prices. `priceLimit` defaults to 100 (1–100); keep paging with `startingAfter` set to the last price's `id` while `has_more` is `true`. Leave `priceActive` blank for Stripe's default, which returns only active prices; set it to `false` for inactive ones. `lookupKeys` finds prices by their `lookup_key` without paging: pass up to 10 keys as an array, a JSON array, or a comma-separated list. Duplicates are dropped; more than 10 distinct keys are rejected before the call, matching Stripe's [limit](https://docs.stripe.com/api/prices/list). The currency filter is `priceCurrency`, not the shared `currency` field, so a list is never filtered to `usd` by default. ```json { "action": "stripe.listPrices", "config": { "productId": "prod_NZKdYqrwEYx6iK", "priceActive": true, "priceType": "one_time", "priceCurrency": "usd", "lookupKeys": ["lf:prod_NZKdYqrwEYx6iK:one_time:1000"] } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `productId` | string | No | Only prices of this product (`product`) | | `priceActive` | boolean | No | Only active (`true`) or inactive (`false`) prices (`active`); no default | | `priceType` | string | No | `one_time` or `recurring` (`type`) | | `priceCurrency` | string | No | Three-letter ISO currency code (`currency`); blank lists every currency | | `lookupKeys` | string[] | No | Up to 10 lookup keys (`lookup_keys[]`), de-duplicated | | `priceLimit` | number | No | Page size, 1–100 (default 100) (`limit`) | | `startingAfter` | string | No | Price id to page after (`starting_after`) | #### createPrice Create a one-time or recurring price for an existing product. The amount is sent as `unit_amount_decimal`, so fractional cents (for example `3599.1`) are kept. `0` creates a free price. Stripe accepts at most 12 decimals, so round a computed amount to 12 decimals or fewer before passing it; longer values are rejected before the call. Leave `interval` blank for a one-time price. Each call sends an `Idempotency-Key` built from the run, the loop iteration and the price fields, so a retry after a timeout returns the price Stripe already created instead of a second one. Identical prices requested twice in the same run and iteration resolve to the same price. `lookupKey` (at most 200 characters) is sent as `lookup_key`. Stripe keeps lookup keys unique, so creating a second price with a key another price holds fails; the action never sends `transfer_lookup_key`, which would move the key instead ([create a price](https://docs.stripe.com/api/prices/create)). ```json { "action": "stripe.createPrice", "config": { "productId": "prod_NZKdYqrwEYx6iK", "currency": "usd", "unitAmountDecimal": "1000", "interval": "month", "intervalCount": 1, "lookupKey": "lf:prod_NZKdYqrwEYx6iK:recurring:1000:month:1", "metadata": { "source": "loopfour-workflows" } } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `productId` | string | Yes | Product the price belongs to (`product`) | | `unitAmountDecimal` | string | Yes | Non-negative amount in the smallest currency unit, at most 12 decimals | | `currency` | string | No | Three-letter ISO currency code (default `usd`) | | `interval` | string | No | `day`, `week`, `month` or `year` (`recurring[interval]`); blank for one-time | | `intervalCount` | number | No | Positive integer (`recurring[interval_count]`); requires `interval` | | `nickname` | string | No | Internal label, hidden from customers | | `metadata` | object | No | Custom metadata | | `taxBehavior` | string | No | `inclusive`, `exclusive` or `unspecified` (`tax_behavior`) | | `lookupKey` | string | No | Unique key, at most 200 characters (`lookup_key`); a taken key fails | Stripe prices cannot change amount after creation. To reuse prices across runs, give each price a deterministic `lookupKey`, call `listPrices` with `lookupKeys`, and call `createPrice` only for keys with no match. #### Reusing products across runs Keep one Stripe product per catalog item by composing the two actions: `searchProducts` by a metadata key, a condition on whether anything came back, and `createProduct` only when nothing did. Cache the returned `id` (for example in a data table) and pass it as `productId` to `createInvoiceItem` or as `price_data.product` to `createSubscription`. Stripe's search index is eventually consistent, so the cached id, not the search, is what makes back-to-back runs reuse the same product. ```json [ { "id": "search_product", "action": "stripe.searchProducts", "config": { "query": "active:'true' AND metadata['qbo_item_id']:'375'", "limit": 1 } }, { "id": "check_product_missing", "type": "condition", "config": { "conditions": { "left": "{{steps.search_product.data.length}}", "operator": "eq", "right": "0" }, "then": ["create_product"] } }, { "id": "create_product", "action": "stripe.createProduct", "config": { "name": "ROAR Smart Beacon", "metadata": { "qbo_item_id": "375" } } } ] ``` ### Coupon Actions A coupon with a fixed id can be reused on every invoice item that needs the same discount. Stripe refuses a second coupon with an id that is already taken, so the id itself keeps runs from creating duplicates. #### listCoupons List one page of coupons. `limit` is 1–100 (Stripe's default is 10); keep paging with `startingAfter` set to the last coupon's `id` while `has_more` is `true`. Stripe leaves out a coupon's `applies_to` (the products it is restricted to) unless asked; set `expandAppliesTo` to `true` to include it (`expand[]=data.applies_to`), for example before reusing a coupon you did not create. ```json { "action": "stripe.listCoupons", "config": { "limit": 100, "expandAppliesTo": true } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `limit` | number | No | Page size, 1–100 (`limit`) | | `startingAfter` | string | No | Coupon id to page after (`starting_after`) | | `expandAppliesTo` | boolean | No | Include each coupon's `applies_to` (`expand[]=data.applies_to`); default `false` | #### createCoupon Create a percentage or amount coupon. Pass exactly one of `percentOff` or `amountOff`; `amountOff` needs a `currency`, and a currency is never sent with `percentOff`. `couponDuration` defaults to `once`, Stripe's own default; pass `forever` for a coupon that is reused across invoices. `metadata` values must be strings, numbers or booleans. The action never sends `max_redemptions` or `redeem_by`. Each call sends an `Idempotency-Key` built from the run, the loop iteration and the coupon fields. When `couponId` is already taken, Stripe fails the call with error code `resource_already_exists` ([error codes](https://docs.stripe.com/error-codes)), and the step error ends with `(resource_already_exists)`. A workflow that creates coupons by fixed id can treat that error as "reuse the existing coupon", after checking the existing coupon's terms with `listCoupons`. ```json { "action": "stripe.createCoupon", "config": { "couponId": "lf-pct-10", "percentOff": 10, "couponDuration": "forever", "couponName": "10% line discount" } } ``` **Parameters:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `couponId` | string | No | Fixed id (`id`): letters, digits, `_` and `-`; Stripe generates one when blank | | `percentOff` | number | One of | Greater than 0 and at most 100, at most 2 decimals (`percent_off`) | | `amountOff` | number | One of | Whole cents (`amount_off`); requires `currency` | | `currency` | string | With `amountOff` | Three-letter ISO currency code | | `couponDuration` | string | No | `once` (default), `forever` or `repeating` (`duration`) | | `durationInMonths` | number | With `repeating` | Positive integer (`duration_in_months`); refused with any other duration | | `couponName` | string | No | Shown on invoices; a string, trimmed, at most 40 characters (`name`) | | `metadata` | object | No | Custom metadata; string, number or boolean values | ### Tax Rate Actions Stripe tax rates carry an externally computed sales tax (for example from Avalara) onto invoice items and subscription items via `taxRateIds`. Stripe treats a tax rate as immutable once created, so create one per distinct jurisdiction/percentage pair and reuse its id. #### listTaxRates List tax rates. Returns a Stripe list response (`data[]`), combining pages when `autoPaginate` is true. `active` defaults to `true`; `inclusive` is an optional filter; `limit` defaults to 100 (1–100); `startingAfter` is the pagination cursor, sent as `starting_after`. ```json { "action": "stripe.listTaxRates", "config": { "active": true, "inclusive": false, "limit": 100, "autoPaginate": true } } ``` Stripe has no search endpoint for tax rates. Set `autoPaginate: true` when checking whether a rate already exists: the action follows `starting_after` until `has_more` is false and returns all rates in `data`. Without it, the action returns one page of at most 100 rates; finding nothing on that page does not prove the rate is absent. #### createTaxRate Create a tax rate. Returns the raw tax rate object. ```json { "action": "stripe.createTaxRate", "config": { "displayName": "Sales Tax", "percentage": 8.25, "inclusive": false, "jurisdiction": "TX", "country": "US", "state": "TX", "taxType": "sales_tax", "metadata": { "avalara_jurisdiction": "TX-AUSTIN" } } } ``` | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `displayName` | string | Yes | Name shown on invoices (`display_name`) | | `percentage` | number | Yes | 0–100, at most 4 decimal places (Stripe's precision) | | `inclusive` | boolean | No | Tax included in the price (default `false`) | | `jurisdiction` | string | No | Jurisdiction label | | `country` | string | No | 2-letter ISO country code | | `state` | string | No | State or province | | `description` | string | No | Internal description | | `taxType` | string | No | Stripe `tax_type` (`sales_tax`, `vat`, `gst`, ...) | | `metadata` | object | No | Custom metadata | `jurisdiction`, `state`, `description` and `taxType` must each be a string or a number; any other shape is rejected before the request rather than stringified. ### Balance Actions #### getBalance Get account balance. ```json { "action": "stripe.getBalance", "config": {} } ``` #### listBalanceTransactions List balance transactions, newest first. ```json { "action": "stripe.listBalanceTransactions", "config": { "limit": 100, "type": "charge", "createdFrom": "2026-09-01T00:00:00Z", "createdTo": "2026-09-30T23:59:59Z" } } ``` | Field | Sent as | Notes | |-------|---------|-------| | `type` | `type` | e.g. `charge`, `payout`, `stripe_fee` | | `payout` | `payout` | Only the transactions paid out in this automatic payout (`po_...`). Stripe returns nothing for a manual payout. | | `createdFrom` | `created[gte]` | ISO 8601 date or unix seconds, inclusive | | `createdTo` | `created[lte]` | ISO 8601 date or unix seconds, inclusive. Must not be before `createdFrom`. | To reconcile a payout against a bank deposit, list its transactions with `payout` rather than grouping transactions by date: grouping only approximates what Stripe settled in the payout. The `net` values of the non-payout rows sum to the payout amount, and processing fees, Stripe billing fees (`stripe_fee`), refunds, disputes and reserve holds or releases (`risk_reserved_funds`) each appear as their own rows. ```json { "id": "each_payout", "type": "loop", "config": { "collection": "{{steps.list_payouts.data}}", "steps": ["payout_transactions"] } }, { "id": "payout_transactions", "type": "action", "action": "stripe.listBalanceTransactions", "config": { "payout": "{{variables.loop.item.source}}", "limit": 100 } } ``` Each iteration's `output` is that payout's list. A payout with more than 100 transactions comes back with `has_more: true`; page it with `startingAfter` before relying on the totals. ## Webhook Triggers Stripe webhooks trigger workflows on payment events. ```json { "trigger": { "type": "webhook", "provider": "stripe", "events": ["invoice.paid", "customer.subscription.updated"] } } ``` **Common Events:** | Event | Description | |-------|-------------| | `invoice.paid` | Invoice was paid | | `invoice.payment_failed` | Payment failed | | `customer.subscription.created` | New subscription | | `customer.subscription.updated` | Subscription changed | | `customer.subscription.deleted` | Subscription cancelled | | `payment_intent.succeeded` | Payment completed | | `payment_intent.payment_failed` | Payment failed | | `charge.refunded` | Charge refunded | ## Example Workflow Invoice and payment workflow: ```json { "name": "Create and Send Invoice", "trigger": { "type": "api" }, "steps": [ { "id": "get-or-create-customer", "type": "action", "action": "stripe.listCustomers", "config": { "email": "{{input.email}}", "limit": 1 } }, { "id": "check-customer", "type": "condition", "config": { "conditions": { "left": "{{steps.get-or-create-customer.output.data.length}}", "operator": "gt", "right": 0 }, "then": ["create-invoice"], "else": ["create-customer"] } }, { "id": "create-customer", "type": "action", "action": "stripe.createCustomer", "config": { "email": "{{input.email}}", "name": "{{input.name}}" } }, { "id": "create-invoice", "type": "action", "action": "stripe.createInvoice", "config": { "customerId": "{{steps.get-or-create-customer.output.data[0].id || steps.create-customer.output.id}}", "autoAdvance": false, "collectionMethod": "send_invoice", "daysUntilDue": 30 } }, { "id": "add-line-item", "type": "action", "action": "stripe.createInvoiceItem", "config": { "customerId": "{{steps.get-or-create-customer.output.data[0].id || steps.create-customer.output.id}}", "invoiceId": "{{steps.create-invoice.output.id}}", "amount": "{{input.amount}}", "currency": "usd", "description": "{{input.description}}" } }, { "id": "finalize", "type": "action", "action": "stripe.finalizeInvoice", "config": { "invoiceId": "{{steps.create-invoice.output.id}}" } }, { "id": "send", "type": "action", "action": "stripe.sendInvoice", "config": { "invoiceId": "{{steps.create-invoice.output.id}}" } } ] } ``` ## Rate Limits | Limit | Value | |-------|-------| | API calls | 100/second (read) | | API calls | 100/second (write) | | Webhook delivery | Up to 5 retries | ## Troubleshooting ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `card_declined` | Card was declined | Customer should use different card | | `invalid_request_error` | Invalid parameters | Check required fields | | `rate_limit_error` | Too many requests | Implement backoff | | `authentication_error` | Invalid API key | Check connection status | # Tax & Compliance Integrations (/docs/integrations/tax) Avalara AvaTax ## Tax & Compliance Integrations Calculate sales tax as part of a workflow, before the payment step charges a customer. ## Avalara AvaTax **Available Actions:** - `createTransaction` - Calculate tax for a transaction and return transaction-level and line-level results [Full Avalara Documentation →](/docs/integrations/tax/avalara) # Avalara (/docs/integrations/tax/avalara) Sales tax calculation with Avalara AvaTax ## Avalara Connect to Avalara AvaTax to calculate sales tax inside a workflow — typically right before the step that charges the customer. ## Prerequisites - An Avalara AvaTax account - Your Account ID and a License Key (Avalara admin → Settings → License and API keys) - The company code of the AvaTax company the transactions belong to ## Authentication Avalara uses static Basic Auth — no OAuth flow. Connect it from **Integrations → Tax & Compliance → Avalara** and provide: | Field | Required | Notes | | --- | --- | --- | | Environment | yes | Production or Sandbox. Determines which Avalara host every request goes to. | | Account ID | yes | Combined with the License Key into a single encrypted credential. | | License Key | yes | Stored encrypted; never displayed again. | | Company Code | no | Default company code for steps that do not set their own. | Requests go to `https://rest.avatax.com` (production) or `https://sandbox-rest.avatax.com` (sandbox). Those two hosts are fixed — a workflow cannot point the integration at any other address. To rotate a license key, open **Manage**, then use the connection's **⋯** actions menu and choose **Update API key**. That repairs the existing connection, so workflow steps that reference it keep working. The rotate dialog comes up with **Account ID empty**, and that is deliberate: the account ID is combined with the license key into one encrypted credential (`accountId:licenseKey`) and is never stored in full on its own, so it cannot be prefilled. The Integrations list stores and shows only the account ID's last four characters alongside the Production/Sandbox ledger. Re-enter both halves. The **Environment** and **Company Code** fields ARE prefilled from the stored connection — check the environment before submitting, because it decides which ledger your documents are filed against. ## Available Actions ### createTransaction Creates an AvaTax transaction and returns the calculated tax. By default the document is created **uncommitted** (`SalesInvoice` with commit off), so it is recorded and auditable without being finalized for filing. **Inputs** | Field | Required | Description | | --- | --- | --- | | `documentCode` | yes | Stable identifier for the transaction. The runtime checks for an existing matching document before creation — see [Reusing a document code](#reusing-a-document-code). | | `customerCode` | yes | Your identifier for the customer. | | `lines` | yes | `[{ "number": "1", "amount": 250, "quantity": 2, "itemCode": "WIDGET", "taxCode": "P0000000", "description": "Widget" }]` | | `shipFrom` | yes | `{ "line1": "...", "city": "...", "region": "PA", "postalCode": "19103", "country": "US" }` | | `shipTo` | yes | Same shape as `shipFrom`. | | `companyCode` | no | Falls back to the code stored on the connection. | | `documentType` | no | `SalesInvoice` (default), `SalesOrder` for a calculation that persists nothing, or `ReturnInvoice` for a credit (see [Return invoices](#return-invoices)). | | `referenceCode` | no | AvaTax `referenceCode`. On a `ReturnInvoice`, the code of the invoice it credits. | | `documentDate` | yes | `YYYY-MM-DD`; must be a real calendar date. | | `currencyCode` | no | Omit it for `USD`. A value that is present must be a 3-letter ISO code; a blank or malformed one fails the step rather than being filed as USD. | | `discount` | no | Reserved. A non-zero document discount currently fails before AvaTax is called. | | `exemptionNo`, `entityUseCode` | no | Customer exemption details. | | `commit` | no | Defaults to `false`. Turn on only when the document is final. | | `taxOverride` | no | `{ "type": "TaxAmount", "taxAmount": 16.5, "reason": "Tax charged on invoice INV-0001" }`. Files a tax amount decided outside AvaTax — typically what the customer was already charged — instead of AvaTax's own calculation; AvaTax distributes it across the lines. `taxAmount` is in **currency units** (`16.50` means $16.50), **not cents**, with at most two decimals, and may not exceed the document's total line amount — a Stripe amount in cents bound here fails. `0` is filed as zero. Only `TaxAmount` is accepted, `reason` is required, and any other field is refused. Omit the field for no override: a value that is present but blank (a template that resolved to nothing) fails the step instead of quietly filing AvaTax's own figure. | **Outputs** `transactionId`, `code`, `status`, `documentType`, `taxDate`, `currencyCode`, `totalAmount`, `totalTaxable`, `totalExempt`, `totalDiscount`, `totalTax`, `totalTaxCalculated`, `grandTotal`, `lineCount`, `linesTruncated`, `lines[]`, `summaryCount`, `summaryTruncated`, `summary[]`, `messages[]`. `lines[]` is capped at 500 rows so a consolidated invoice cannot persist an unbounded array into every checkpoint and the run record. When the cap applies, `linesTruncated` is `true` and `lineCount` holds the number Avalara actually returned. Each line also includes jurisdiction `details[]` with `rate`, `taxableAmount`, and `tax`. Details are capped at 32 per line; check `detailsCount` and `detailsTruncated` before using them to calculate a combined rate. A missing numeric value is `null`. `summary[]` is normalized to `country`, `region`, `jurisType`, `jurisName`, `taxType`, `rate`, `taxable`, and `tax`, and capped at 100 rows. `summaryCount` and `summaryTruncated` make that cap explicit. If Avalara's response is missing `totalAmount` or `totalTax`, the step **fails** — it does not report a zero-dollar `grandTotal` that a payment step would then charge. The totals that do not feed the charge (`totalTaxable`, `totalExempt`, `totalDiscount`) and the per-line numbers come back as `null` when Avalara omits them, so "unknown" is never presented as "zero". With a `taxOverride`, `totalTax` is the amount that was filed and `totalTaxCalculated` is what AvaTax would have charged on its own; comparing the two is how you see a rate or rounding difference. The step **fails** if AvaTax reports a `totalTax` more than a cent away from the override (the override was not applied) or omits `totalTaxCalculated` while an override was filed — both after AvaTax has answered, so verify the document in Avalara and void it before filing again. Without an override, `totalTaxCalculated` is `null` when Avalara omits it. `grandTotal` is `totalAmount + totalTax` — the tax-inclusive figure to map into the payment step. Discount-bearing requests are refused until AvaTax's total semantics are verified. It is in **currency units** (`270.63` means $270.63), **not cents** — see [Using tax in a later step](#using-tax-in-a-later-step) for the conversion required before charging through Stripe. #### Return invoices A `ReturnInvoice` records a credit, for example a Stripe credit note against an earlier invoice. It is the only document type that takes a negative `taxOverride.taxAmount`; on `SalesInvoice` and `SalesOrder` the override must be zero or more. On a return: - give each line the credited amount as a negative number (`-50` credits $50), with at most two decimals. The lines must total a negative amount; - set `taxOverride.taxAmount` to the credited tax, zero or negative. It may not exceed the line total in size (compared in whole cents); - set `referenceCode` to the code of the invoice being credited, and `documentCode` to the credit's own number. ```json { "action": "avalara.createTransaction", "config": { "documentCode": "CN-0001", "documentType": "ReturnInvoice", "referenceCode": "INV-1001", "documentDate": "2026-10-02", "customerCode": "CUST-77", "lines": [{ "number": "1", "amount": -50, "quantity": 1, "itemCode": "WIDGET" }], "taxOverride": { "type": "TaxAmount", "taxAmount": -4.12, "reason": "Tax credited on credit note CN-0001" }, "commit": true } } ``` `shipFrom` and `shipTo` are required as on any other document. ### voidTransaction Voids a document AvaTax already holds. A re-billed quote voids its Stripe invoice and issues a new one under a **new** number; without this step the AvaTax document committed against the old number stays committed and over-reports tax for filing. **Inputs** | Field | Required | Description | | --- | --- | --- | | `documentCode` | yes | The document to void — the same code the `createTransaction` step used. | | `companyCode` | no | Falls back to the code stored on the connection. Must be the company the document was filed under. | | `documentType` | no | `SalesInvoice` (default) or `SalesOrder`. AvaTax needs it when more than one document shares a code. | | `voidReasonCode` | no | AvaTax `VoidReasonCode`. Defaults to `DocVoided`, which voids the document and sets its status to `Cancelled`. `DocDeleted`, `PostFailed`, `AdjustmentCancelled`, and `Unspecified` are also accepted. AvaTax refuses a void that carries no reason, so a blank value fails the step rather than being defaulted. | A company or document code containing a space, `/`, `+`, `?`, `%`, or `#` is refused before the request: AvaTax requires its own escaping for those characters, so percent-encoding them would address a different document. A code whose path segment is `.` or `..` is refused for a different reason — those segments are removed when the URL is resolved, which would point the void at another document. Dots *inside* a code are fine: `INV.2026.01` voids normally. **Outputs** `transactionId`, `code`, `status`, `documentType`. The full AvaTax `TransactionModel` is deliberately **not** returned — it restates the customer's address and line amounts, and step outputs persist for the whole run. `status` is the document's post-void status (`Cancelled` after a successful `DocVoided`). `code` and `status` are the values **AvaTax confirmed**, not the ones the step submitted. The step fails rather than reporting a void when the response carries no document code, carries a code for a different document, carries no status, or carries a status that does not mean the document was cancelled — a 2xx alone is not evidence the void happened. Transactions already reported to a tax authority by Avalara Managed Returns cannot be voided. That comes back as a provider error on the step. ## Using tax in a later step Place the Avalara step before the payment step and bind the tax-inclusive amount into the charge: ``` {{ steps..grandTotal | multiply: 100 }} ``` **Convert units — never bind `grandTotal` on its own.** Avalara returns `grandTotal` in **currency units** (`270.63` means $270.63). Stripe's `amount` field expects the **smallest currency unit (cents)** and passes whatever value it receives straight through to the Stripe API with no conversion of its own. Binding the bare `{{ steps..grandTotal }}` — without the filter — charges 270.63 cents ($2.71) instead of $270.63: a **100x undercharge**. The `| multiply: 100` filter shown above performs both the conversion and the rounding (`Math.round(grandTotal * 100)`). Always include it whenever you map an Avalara total into a Stripe (or any other cents-denominated) amount field. If Avalara cannot calculate the tax — bad credentials, an unserviceable address, a timeout — the step fails. Under the default error handling that ends the run, so the payment step never executes with an untaxed amount. That protection depends on the step keeping the default error handling (**Fail**). If you set this step to **Continue**, the run proceeds and the payment step executes with an unresolved amount. Leave tax calculation on **Fail**. ## Document date and filing periods AvaTax picks both the rate schedule and the filing period from the document date. `documentDate` is required because the workflow runtime does not carry a tenant timezone; guessing from the worker's UTC date could move an evening transaction in the Americas into the next filing period. The step returns the effective date as `taxDate`. ## Document-level discounts Document-level discounts and line items with `discounted: true` are currently refused before AvaTax is called. The integration will not expose a chargeable `grandTotal` for a discounted request until a sandbox measurement establishes whether AvaTax's `totalAmount` is gross or net of `totalDiscount`. ## Reusing a document code Before creating a transaction, the runtime looks up the same company code, document type, and document code. If a document already exists, the step refuses to submit another and tells you to inspect the existing document or use a new code. That is measured, not assumed: the same workflow was run twice against the real Avalara sandbox with a fixed `documentCode` and `commit: false`. Both runs succeeded, both returned `status: "Saved"`, and the two transaction IDs were **different**. AvaTax neither rejected the duplicate code nor updated the first document. What is **not** established: whether the first document survives alongside the second or is superseded by it (the integration exposes no lookup action, so telling them apart needs the AvaTax UI or a direct API query), and what happens with `commit: true` or with document types other than `SalesInvoice` — those were not tested, and AvaTax may well behave differently for a committed document. This guard was added because sandbox evidence showed that direct replay without the lookup can create a second document with a new transaction id. Before re-running a workflow that may have reached this step: - void the earlier document in Avalara, or - use a new `documentCode`. Derive `documentCode` from something stable and unique per invoice so you can find the earlier document when you need to void it. If AvaTax *does* reject a duplicate — which it may for a committed document — the step fails with a named error rather than an opaque provider 400: > An Avalara transaction already exists for document code "INV-1001" and type > "SalesInvoice". Use a new document code, or void/adjust the existing > transaction in Avalara. The create call itself is never automatically retried after a 429 or transport failure because AvaTax may have persisted it before the failure became visible. Verify the document code in AvaTax before a manual retry. ## Limitations - Only `createTransaction` and `voidTransaction` are available. Committing, retrieving, adjusting, unvoiding, and listing transactions are not yet exposed. - There are no Avalara webhook triggers. - Filing, remittance, nexus, and exemption-certificate management are out of scope. # Introduction (/docs/introduction) What is Loopfour Studio and what can you build with it Loopfour Studio is a visual workflow builder for deterministic, auditable B2B billing automation, and Loopfour can build and maintain workflows for you. It connects your CRM, accounting, payment, and communication systems through an intuitive drag-and-drop canvas -- eliminating manual billing processes and keeping sales and finance teams aligned. ## What You Can Build **Invoice Automation** Automatically generate invoices when deals close in Salesforce or HubSpot. Sync them to QuickBooks, Xero, or NetSuite. Send payment reminders via Slack or email. Handle partial payments, credits, and refunds without manual intervention. **Payment Reconciliation** Match incoming Stripe payments to outstanding invoices. Flag discrepancies. Update accounting records. Notify finance teams of failed payments and trigger retry workflows. **Contract-to-Cash Workflows** Process signed contracts from DocuSign or PandaDoc. Extract billing terms using AI. Create customer records, set up recurring invoices, and configure payment schedules -- all triggered by a single contract event. **Cross-System Data Sync** Keep customer data consistent across Salesforce, HubSpot, Stripe, and your accounting platform. Detect drift, resolve conflicts, and maintain a single source of truth for billing data. **Approval Workflows** Route high-value invoices, discount requests, or credit memos through multi-level approval chains. Notify approvers via Slack, pause execution until approved, then continue processing automatically. ## How It Works **Visual Canvas** Design workflows by dragging blocks onto a canvas and connecting them with edges. Each block performs a specific task -- calling an API, transforming data, making a decision, or waiting for an event. The visual layout makes complex automation logic easy to understand and maintain. **29 Block Types** Build with specialized components: integration blocks (Stripe, Salesforce, QuickBooks), AI blocks (agent, code execution), control flow blocks (conditions, loops, parallel), and utility blocks (wait, transform, approval). Each block handles a specific task in your workflow. **Multiple Trigger Types** Launch workflows through webhooks from external systems, scheduled cron jobs, platform events from Salesforce or HubSpot, Slack commands, or manual API calls. **AI Copilot** Describe what you want in natural language and let the AI Copilot build or modify your workflow. The copilot understands your canvas context and can add blocks, configure connections, and wire data references. ## Integrations Loopfour provides native integrations across the billing stack: - **Payments**: Stripe (invoices, payments, subscriptions, customers) - **CRM**: Salesforce, HubSpot, Attio (contacts, deals, records, events) - **Accounting**: QuickBooks, Xero, NetSuite, Sage Intacct, Rillet - **Communication**: Slack, Gmail, Outlook, generic email (SMTP/Resend) - **Documents**: DocuSign, PandaDoc, Dropbox Sign (e-signatures, contracts) - **Browser Automation**: Stagehand (navigate portals, download reports, fill forms) All integrations use OAuth or API key connections managed through Nango, with credentials encrypted at rest using AES-256-GCM. ## Architecture ``` Visual Canvas (React Flow) | v DAG Compiler (canvas -> execution steps) | v Trigger.dev Execution Engine | +--> Integration APIs (Stripe, Salesforce, etc.) +--> AI Models (Claude Opus 5 and GPT-5.6 Sol for agents) +--> Browser Sessions (Stagehand + Browserbase) +--> Notification Channels (Slack, Email) ``` Workflows are defined visually on a canvas, compiled into a directed acyclic graph (DAG) of steps, and executed by Trigger.dev with full retry logic, error handling, and execution logging. ## Next Steps Create your first workflow in 5 minutes Explore the 29 building blocks Connect 16+ billing and finance tools Build workflows with natural language # Triggers (/docs/triggers) Start workflows via webhooks, schedules, platform events, or API calls Triggers define when and how a workflow starts executing. Every workflow needs at least one trigger -- it is the entry point that receives data and kicks off the automation. Choose the trigger based on the event that should be authoritative for the workflow. API triggers are useful when your application or backend decides exactly when a run should start. Webhook triggers are better when an external provider owns the event, such as an invoice update, payment status change, CRM change, or signed document. Schedule triggers fit recurring operational work, and Slack events fit workflows that intentionally begin from a human request or channel interaction. For finance operations, trigger design should also cover duplicate events, missing fields, and timing. External systems may retry webhooks, send events out of order, or deliver updates before related records are fully available. A reliable workflow validates the trigger payload early, stores enough context for debugging, and routes incomplete or ambiguous cases to an explicit exception path. That keeps automation fast for normal cases while preserving control when the trigger data is not ready to trust. Before choosing a trigger, decide whether the workflow should run automatically, on a schedule, or only when a person or internal system explicitly asks it to run. That decision affects monitoring, permissions, and retry behavior. Automatic triggers need stronger guardrails because they can fire at any time. Manual or API-triggered workflows can be easier to validate during rollout because the caller controls when production data enters the workflow. ## Trigger Types Loopfour supports several trigger types, each suited to different automation patterns: Receive HTTP events from external systems like Stripe, Salesforce, or custom integrations Run on a cron schedule -- daily syncs, weekly reports, monthly reconciliation React to Slack messages, slash commands, and interactive components Execute workflows on-demand via REST API calls ## Quick Comparison | Trigger | Starts When | Best For | |---------|-------------|----------| | **Webhook** | External system sends HTTP POST | Stripe payments, custom integrations, third-party events | | **Schedule** | Cron timer fires | Daily syncs, weekly reports, periodic reconciliation | | **Event** | Platform event occurs | Salesforce record changes, HubSpot deal updates, Slack messages | | **API** | Manual API call | On-demand processing, testing, user-initiated actions | ## Trigger Payload Every trigger provides a payload that downstream blocks can access. The payload structure depends on the trigger type: - **Webhook**: The HTTP request body, headers, and query parameters - **Schedule**: Execution metadata (timestamp, run ID, schedule name) - **Event**: Platform-specific event data (e.g., Salesforce record fields, HubSpot deal properties) - **API**: The request body passed in the API call Access trigger data in downstream blocks using template variables: ``` {{input.customer.email}} {{input.opportunity.amount}} {{input.event.type}} ``` ## Supported Webhook Providers The webhook trigger includes built-in signature verification for these providers: | Provider | Events | Signature Verification | |----------|--------|----------------------| | **Stripe** | invoice.paid, payment_intent.succeeded, customer.created, etc. | Stripe-Signature header | | **Salesforce** | Platform Events, Change Data Capture | Salesforce signed events | | **HubSpot** | Contact/deal/company property changes | HubSpot signature | | **QuickBooks** | Invoice, payment, customer events | Intuit signature | | **PandaDoc** | Document completed, viewed, signed | PandaDoc webhook secret | | **Slack** | Messages, mentions, reactions, commands | Slack signing secret | | **Gmail** | New email, label changes (via Pub/Sub) | Google Cloud auth | | **Custom** | Any HTTP POST | Optional auth token | ## Event Trigger Integrations The event trigger connects to platform-native event systems: | Platform | Event Types | |----------|-------------| | **Salesforce** | Platform Events, Change Data Capture (record create/update/delete) | | **HubSpot** | Property changes on contacts, deals, companies, tickets | | **Slack** | Messages in channels, slash commands, interactive components | | **Gmail** | New email received, label applied (via Google Pub/Sub) | Event triggers require an active OAuth connection to the platform. Set up connections in the Connections panel before configuring event triggers. ## Best Practices - **Use webhooks for real-time reactions** to events in external systems. Webhook triggers execute immediately when the event occurs. - **Use schedules for batch processing** like daily invoice syncs, weekly reports, or monthly reconciliation runs. - **Use event triggers for native platform integration** when you need to react to specific record changes in Salesforce or HubSpot. - **Always validate webhook payloads.** Built-in signature verification is enabled by default for supported providers. For custom webhooks, configure an auth token. - **Design idempotent workflows.** Triggers may fire multiple times for the same event (especially webhooks). Use idempotency keys or deduplication logic to prevent duplicate processing. # API Triggers (/docs/triggers/api) Trigger workflows programmatically via the REST API ## API Triggers API triggers allow you to start workflows programmatically via the REST API. This is useful for on-demand processing, testing, or integrating with systems that don't support webhooks. ## Configuration API triggers have the simplest configuration: ```json { "trigger": { "type": "api" } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | Yes | Must be `"api"` | That's it! The workflow can now be triggered via the REST API. ## Authoring in Studio In the Studio canvas, add the **API Trigger** block from the **Triggers** section of the block palette. It is a read-only block that shows the run endpoint for the current workflow and confirms the request body is exposed downstream as `{{input}}`. No configuration is required -- the trigger is ready as soon as the workflow is active. ## Running a Workflow ### Basic Request ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{WORKFLOW_ID}/run \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": { "customerId": "cus_xxx", "amount": 9900 } }' ``` ```javascript const response = await fetch( `https://workflow.loopfour.ai/api/v1/workflows/${workflowId}/run`, { method: 'POST', headers: { 'x-api-key': process.env.API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ input: { customerId: 'cus_xxx', amount: 9900 } }) } ); const { data } = await response.json(); console.log('Run ID:', data.runId); ``` ```python response = requests.post( f'https://workflow.loopfour.ai/api/v1/workflows/{workflow_id}/run', headers={ 'x-api-key': api_key, 'Content-Type': 'application/json' }, json={ 'input': { 'customerId': 'cus_xxx', 'amount': 9900 } } ) data = response.json()['data'] print(f"Run ID: {data['runId']}") ``` ### Request Body ```json { "input": { // Your workflow input data }, "connectionId": "conn_xxx", "idempotencyKey": "unique-key-123" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `input` | object | No | Input data passed to the workflow | | `connectionId` | string | No | Specific connection to use | | `idempotencyKey` | string | No | Prevent duplicate runs | ### Response ```json { "success": true, "data": { "runId": "run_xyz789...", "status": "pending", "workflowId": "wf_abc123...", "workflowVersion": 3 } } ``` | Field | Description | |-------|-------------| | `runId` | Unique identifier for this run | | `status` | Initial status (`pending`) | | `workflowId` | The workflow being executed | | `workflowVersion` | Version of the workflow used | ## Input Data The `input` object is available throughout your workflow as `{{input}}`: **Request:** ```json { "input": { "customer": { "id": "cus_xxx", "email": "customer@example.com" }, "items": [ { "product": "Widget", "quantity": 2, "price": 1999 } ] } } ``` **In workflow steps:** ```json { "config": { "to": "{{input.customer.email}}", "subject": "Order for {{input.items.0.product}}" } } ``` ## Input Validation Define expected input with a JSON schema at the workflow level: ```json { "name": "Create Invoice", "trigger": { "type": "api" }, "inputSchema": { "type": "object", "required": ["customerId", "amount"], "properties": { "customerId": { "type": "string", "pattern": "^cus_[a-zA-Z0-9]+$" }, "amount": { "type": "number", "minimum": 100 }, "currency": { "type": "string", "enum": ["usd", "eur", "gbp"], "default": "usd" } } }, "steps": [...] } ``` If validation fails, the API returns `400 Bad Request`: ```json { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Input validation failed", "details": [ { "path": ["customerId"], "message": "Required" } ] } } ``` ## Idempotency Prevent duplicate workflow runs with idempotency keys: ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{id}/run \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": { "orderId": "order_123" }, "idempotencyKey": "process-order-123" }' ``` If the same `idempotencyKey` is used again within 24 hours: - The API returns the **existing run** instead of creating a new one - No duplicate processing occurs **Best practices:** - Use meaningful keys: `process-order-{orderId}`, `invoice-{invoiceId}` - Include relevant identifiers from your input - Keys expire after 24 hours ## Checking Run Status After triggering a workflow, poll for completion: ```bash curl https://workflow.loopfour.ai/api/v1/runs/{RUN_ID} \ -H "x-api-key: YOUR_API_KEY" ``` ```javascript async function waitForCompletion(runId, maxWait = 60000) { const start = Date.now(); while (Date.now() - start < maxWait) { const response = await fetch( `https://workflow.loopfour.ai/api/v1/runs/${runId}`, { headers: { 'x-api-key': API_KEY } } ); const { data } = await response.json(); if (data.status === 'completed') { return data.output; } if (data.status === 'failed') { throw new Error(data.error?.message || 'Workflow failed'); } // Wait 1 second before polling again await new Promise(r => setTimeout(r, 1000)); } throw new Error('Timeout waiting for workflow'); } ``` ### Run Status Response ```json { "success": true, "data": { "id": "run_xyz789...", "status": "completed", "input": { "customerId": "cus_xxx" }, "output": { "invoiceId": "inv_123" }, "startedAt": "2024-01-15T10:00:00.000Z", "completedAt": "2024-01-15T10:00:05.000Z", "steps": [ { "id": "create-invoice", "status": "completed", "output": { "id": "inv_123" } } ] } } ``` ## Specifying a Connection If your workflow uses multiple connections for the same provider, specify which one to use: ```json { "input": { ... }, "connectionId": "conn_production_stripe" } ``` This is useful when: - You have separate development and production connections - Different customers use different OAuth connections - You need to switch between sandbox and live environments ## Error Handling ### Common Error Responses **Workflow not found (404):** ```json { "success": false, "error": { "code": "NOT_FOUND", "message": "Workflow not found" } } ``` **Workflow not active (400):** ```json { "success": false, "error": { "code": "INVALID_STATUS", "message": "Workflow must be active to run. Current status: draft" } } ``` **Rate limited (429):** ```json { "success": false, "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Limit: 500 requests per minute." } } ``` **Invalid input (400):** ```json { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Input validation failed", "details": [...] } } ``` ### Rate Limits API trigger endpoints have these limits: | Category | Limit | |----------|-------| | Execute | 500 requests/minute per API key | See [Authentication](/docs/getting-started/authentication#rate-limits) for all rate limit details. ## Use Cases ### On-Demand Processing Trigger workflows from your application: ```javascript // When user clicks "Generate Report" await triggerWorkflow('wf_report_generator', { input: { reportType: 'monthly', startDate: '2024-01-01', endDate: '2024-01-31', format: 'pdf' } }); ``` ### Testing Workflows Test webhook workflows without setting up actual webhooks: ```bash # Simulate a Stripe webhook curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{id}/run \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "input": { "type": "invoice.paid", "data": { "object": { "id": "in_test", "amount_paid": 9900 } } } }' ``` ### Batch Processing Trigger multiple workflow runs for batch operations: ```javascript const customers = await getCustomersToProcess(); const runs = await Promise.all( customers.map(customer => triggerWorkflow('wf_customer_sync', { input: { customerId: customer.id }, idempotencyKey: `sync-${customer.id}-${today}` }) ) ); console.log(`Started ${runs.length} sync workflows`); ``` ### Scheduled via External Scheduler If you use an external scheduler (cron job, cloud scheduler): ```bash # In your crontab or cloud scheduler 0 9 * * * curl -X POST https://api.example.com/api/v1/workflows/wf_daily_report/run \ -H "x-api-key: $API_KEY" \ -d '{"input":{}}' ``` ## Complete Example ```json { "name": "Process Customer Order", "description": "Create invoice and send confirmation for customer order", "trigger": { "type": "api" }, "inputSchema": { "type": "object", "required": ["customerId", "items"], "properties": { "customerId": { "type": "string" }, "items": { "type": "array", "items": { "type": "object", "properties": { "productId": { "type": "string" }, "quantity": { "type": "number", "minimum": 1 }, "price": { "type": "number", "minimum": 0 } } } }, "notes": { "type": "string" } } }, "steps": [ { "id": "calculate-total", "type": "transform", "name": "Calculate Order Total", "config": { "mapping": { "subtotal": "{{input.items | map: 'quantity * price' | sum}}", "tax": "{{input.items | map: 'quantity * price' | sum | times: 0.08}}", "total": "{{input.items | map: 'quantity * price' | sum | times: 1.08}}" } } }, { "id": "create-invoice", "type": "action", "name": "Create Invoice", "action": "stripe.createInvoice", "config": { "customerId": "{{input.customerId}}", "amount": "{{steps.calculate-total.output.total}}", "metadata": { "source": "api-order" } } }, { "id": "send-confirmation", "type": "email", "name": "Send Confirmation", "config": { "action": "send", "to": "{{input.customerEmail}}", "subject": "Order Confirmed - Invoice #{{steps.create-invoice.output.number}}", "body": { "html": "

Thank you for your order!

Total: ${{steps.calculate-total.output.total}}

" } } } ] } ``` ## Troubleshooting ### Workflow Not Starting 1. **Check workflow status** - Must be `active` 2. **Verify API key** - Needs `workflows:execute` scope 3. **Check rate limits** - May be rate limited ### Input Not Available 1. **Verify input structure** - Check JSON is valid 2. **Check template syntax** - Use `{{input.field}}` 3. **Review inputSchema** - May be stripping unknown fields ### Run Stuck in Pending 1. **Check Trigger.dev** - Ensure dev mode is running 2. **Verify connections** - Required connections must be active 3. **Review logs** - Check for execution errors ## Next Steps Trigger from external events Run workflows on a schedule API key setup and security # Email Triggers (/docs/triggers/email) Trigger workflows when an email arrives at a dedicated workflow inbox address ## Email Triggers The `email_trigger` block gives each workflow its own dedicated inbound email address. When an email arrives at that address, the workflow runs automatically — with the full email (sender, subject, body, and any PDF attachments) available to every downstream step. ## How it works 1. Add an **Email Trigger** block to your workflow on the Studio canvas. 2. Save or deploy the workflow. An address of the form `wf_@inbound.loopfour.ai` is generated and stored on the workflow. The token is 80 bits of cryptographically random data, making the address unguessable. 3. Share the address with the senders you want to trigger the workflow (or configure an email client/service to forward to it). 4. When an email arrives, Resend delivers a signed webhook to the platform. The signature is verified before any run is created (fail-closed — a bad or missing signature drops the request, no run is created). 5. Optional filters determine whether the email matches the workflow's criteria. 6. A `workflow_run` is created with the normalized email payload, and `execute-workflow` is enqueued. ## Authoring in Studio In the Studio canvas, add the **Email Trigger** block from the **Triggers** section of the block palette. - **Before deploy**: the address field shows a placeholder — *"Address generated on deploy."* No address is provisioned at canvas-drop time. - **After the first save/deploy**: the full address (`wf_@inbound.loopfour.ai`) appears with a **copy** button. Re-deploying does not change the address — it is stable for the lifetime of the workflow. Only one Email Trigger block is allowed per workflow (`singleInstance: true`). ## Configuration ```json { "trigger": { "type": "email_event", "address": "wf_a1b2c3d4e5f6g7h8@inbound.loopfour.ai", "filters": { "fromAllowlist": ["invoices@vendor.com", "billing@partner.io"], "subjectContains": "invoice", "requireAttachment": true } } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | Yes | Must be `"email_event"` | | `address` | string | No | Set by the server on deploy. Do not author this field manually. | | `filters.fromAllowlist` | string[] | No | Exact sender addresses (case-insensitive). Email is dropped if the sender is not in the list. | | `filters.subjectContains` | string | No | Case-insensitive substring match on the email subject. | | `filters.requireAttachment` | boolean | No | When `true`, the email is dropped if no PDF attachment is stored successfully. | All filter fields are optional and independent. When multiple filters are set, **all** must match (AND logic). An email with no filters set always triggers the workflow. ## Filters ### Sender allowlist (`fromAllowlist`) Only emails from addresses in the list will trigger the workflow. The check is case-insensitive and matches the bare sender address. ```json { "filters": { "fromAllowlist": ["invoices@acme.com"] } } ``` When `fromAllowlist` is set, emails from senders whose SPF or DKIM record is a hard fail are dropped before filters are applied. This prevents spoofed senders from triggering workflows even if the address matches. ### Subject filter (`subjectContains`) Only emails whose subject contains the specified string (case-insensitive) will trigger the workflow. ```json { "filters": { "subjectContains": "purchase order" } } ``` ### Require attachment (`requireAttachment`) When `true`, the workflow only triggers if at least one PDF attachment was received and stored successfully. ```json { "filters": { "requireAttachment": true } } ``` ## Attachment support Inbound email attachments are supported with the following constraints in the current release: - **PDF only** — only `application/pdf` attachments are stored. Other file types (images, Word documents, spreadsheets) are skipped. - **25 MB limit per attachment** — attachments larger than 25 MB are skipped. - Stored attachments are uploaded to the workspace file store and referenced by a `fileId` UUID in the trigger payload. Skipped attachments (wrong type or oversized) are not fatal. The email still triggers the workflow unless `requireAttachment: true` is set and all attachments were skipped. ## Payload The email payload is available in your workflow steps as `{{trigger.inboundEmail}}`: ```json { "trigger": { "inboundEmail": { "source": "inbound-email", "emailId": "msg_abc123", "to": "wf_a1b2c3d4e5f6g7h8@inbound.loopfour.ai", "from": "invoices@vendor.com", "fromName": "Vendor Billing", "subject": "Invoice #1234 — July 2026", "text": "Please find the attached invoice...", "html": "

Please find the attached invoice...

", "receivedAt": "2026-07-01T09:00:00.000Z", "auth": { "spf": "pass", "dkim": "pass" }, "attachments": [ { "fileId": "550e8400-e29b-41d4-a716-446655440000", "name": "invoice-1234.pdf", "contentType": "application/pdf", "size": 204800 } ] } } } ``` ### Payload fields | Field | Type | Description | |-------|------|-------------| | `source` | `"inbound-email"` | Discriminator — always `"inbound-email"` for email triggers. | | `emailId` | string | Resend's unique email ID. Used internally for idempotency. | | `to` | string | The workflow's inbound address that received the email. | | `from` | string | Sender email address. | | `fromName` | string? | Sender display name, if present. | | `subject` | string | Email subject (empty string if absent). | | `text` | string? | Plain-text body, if provided by the sender. | | `html` | string? | HTML body, if provided by the sender. | | `receivedAt` | string (ISO 8601) | Timestamp when Resend received the email. | | `auth.spf` | string | SPF verdict: `pass`, `fail`, `softfail`, `neutral`, `none`, or `unknown`. | | `auth.dkim` | string | DKIM verdict: `pass`, `fail`, `neutral`, `none`, or `unknown`. | | `attachments` | array | Stored PDF attachments. Empty if no PDFs were attached or all were skipped. | | `attachments[].fileId` | string (UUID) | Reference to the stored file in the workspace file store. | | `attachments[].name` | string | Original filename. | | `attachments[].contentType` | string | MIME type (always `application/pdf` in the current release). | | `attachments[].size` | number | File size in bytes. | ### Using payload fields in steps ```json { "config": { "to": "{{trigger.inboundEmail.from}}", "subject": "Re: {{trigger.inboundEmail.subject}}", "body": "We received your email. Attachment count: {{trigger.inboundEmail.attachments.length}}" } } ``` Access a specific attachment's file ID: ```json { "config": { "fileId": "{{trigger.inboundEmail.attachments.0.fileId}}" } } ``` ## Rate limiting Inbound email delivery is rate-limited per address and per company to protect against abuse: - **Per address**: 10 emails per minute (default). Configurable via `INBOUND_EMAIL_RATE_PER_ADDR_MIN`. - **Per company**: 60 emails per minute (default). Configurable via `INBOUND_EMAIL_RATE_PER_COMPANY_MIN`. Emails that exceed the limit are dropped silently with a `200` response to Resend — they do not trigger runs and are not retried by Resend. If you expect high-volume inbound email, contact support to adjust the limits. ## Idempotency Every email triggers **at most one** workflow run, even if Resend delivers the webhook more than once (for example, due to retries). Idempotency is enforced at two independent layers: 1. The `process-inbound-email` task is enqueued with `idempotencyKey = email-{emailId}`, so concurrent deliveries of the same email enqueue the task at most once. 2. The `workflow_run` insert uses a `dedupeKey = email:{emailId}` backed by a partial unique index, guaranteeing exactly one run row even across concurrent task executions. ## Workflow example ```json { "name": "Process Inbound Invoice", "description": "Extract and file invoices received by email", "trigger": { "type": "email_event", "filters": { "fromAllowlist": ["invoices@vendor.com"], "subjectContains": "invoice", "requireAttachment": true } }, "steps": [ { "id": "log-receipt", "type": "action", "name": "Log Email Receipt", "action": "slack.sendMessage", "config": { "channel": "#finance-ops", "text": "New invoice email from {{trigger.inboundEmail.fromName}} ({{trigger.inboundEmail.from}}): {{trigger.inboundEmail.subject}}" } }, { "id": "file-attachment", "type": "action", "name": "File Invoice PDF", "action": "files.get", "config": { "fileId": "{{trigger.inboundEmail.attachments.0.fileId}}" } } ] } ``` ## Troubleshooting ### Address not showing in Studio The address is provisioned at **deploy/save** time, not when the block is dropped on the canvas. Save or deploy the workflow to generate the address. ### Emails not triggering the workflow 1. **Check the address** — confirm the sender is emailing the exact `wf_@inbound.loopfour.ai` address shown in Studio. 2. **Check filters** — if `fromAllowlist` is set, the sender address must match exactly (case-insensitive). If `subjectContains` is set, the subject must include the substring. 3. **Check SPF/DKIM** — if `fromAllowlist` is set, emails with hard SPF or DKIM failures are dropped before filters run. Inspect the run logs; the `auth` field in the payload shows the verdicts. 4. **Check the workflow status** — the workflow must be in `active` status. 5. **Check attachments** — if `requireAttachment: true`, at least one PDF attachment ≤ 25 MB must be present. 6. **Check rate limits** — if volume is high, the per-address or per-company limit may be dropping emails. ### Duplicate emails / no duplicate runs Email delivery is idempotent by design. If Resend retries a webhook, the same email will not create a second run. ## Next Steps Trigger from external webhook events Run workflows on a time-based schedule Trigger workflows programmatically # Event Triggers (/docs/triggers/event) React to platform events from Salesforce, HubSpot, and Slack ## Event Triggers Event triggers start workflows in response to events emitted by connected platforms -- Salesforce Change Data Capture and Platform Events, HubSpot property changes, and Slack messages. Unlike generic webhooks, event triggers are typed to a specific provider event so you only receive the changes you care about. ## Configuration Event triggers use a provider-specific `type`. The shape of the trigger depends on which platform you are subscribing to. ```json { "trigger": { "type": "salesforce_event", "eventKind": "cdc", "cdcEntity": "Opportunity", "cdcChangeTypes": ["create", "update"] } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | Yes | Provider event type (e.g. `salesforce_event`, `hubspot_event`, `slack_event`) | | `eventKind` | string | Conditional | The specific event kind for the provider | | `enabled` | boolean | No | Enable/disable the trigger (default: `true`) | Event triggers require an active OAuth connection to the source platform. Set up the connection before enabling the trigger. ## Salesforce Events Salesforce supports Platform Events and Change Data Capture (CDC). ```json { "trigger": { "type": "salesforce_event", "eventKind": "platform_event", "platformEventName": "Invoice_Created__e" } } ``` ```json { "trigger": { "type": "salesforce_event", "eventKind": "cdc", "cdcEntity": "Opportunity", "cdcChangeTypes": ["create", "update"] } } ``` **CDC Change Types:** - `create` - Record created - `update` - Record updated - `delete` - Record deleted - `undelete` - Record restored ### Salesforce CDC Input ```json { "input": { "ChangeEventHeader": { "entityName": "Opportunity", "changeType": "UPDATE", "changedFields": ["Amount", "StageName"] }, "Id": "006xxx", "Name": "Big Deal", "Amount": 50000, "StageName": "Closed Won" } } ``` ## HubSpot Events React to HubSpot property changes and object lifecycle events. ```json { "trigger": { "type": "hubspot_event", "eventKind": "deal.propertyChange", "propertyName": "dealstage" } } ``` **HubSpot Event Kinds:** - `contact.propertyChange` - Contact property changed - `company.propertyChange` - Company property changed - `deal.propertyChange` - Deal property changed - `deal.creation` - Deal created - `deal.deletion` - Deal deleted - `*` - Match all events ## Slack Events React to messages and interactions in a connected Slack workspace. Slack triggers use `eventType` (not `eventKind`) and support keyword, regex, and user filters. ```json { "trigger": { "type": "slack_event", "eventType": "message", "channelTypes": ["channel"], "keywords": ["invoice", "billing"] } } ``` **Common Slack event types:** - `message` - A message was posted to a channel or DM - `app_mention` - The app was @mentioned - `reaction_added` / `reaction_removed` - An emoji reaction changed Slack triggers require the relevant event subscriptions enabled on the Slack app. See [Slack Triggers](/docs/triggers/slack-events) for the full event/command/interactive reference and [the Slack integration](/docs/integrations/communication/slack) for scope and setup details. ## Complete Example Notify finance when a Salesforce Opportunity moves to Closed Won: ```json { "name": "Closed Won Notification", "trigger": { "type": "salesforce_event", "eventKind": "cdc", "cdcEntity": "Opportunity", "cdcChangeTypes": ["update"] }, "steps": [ { "id": "check-stage", "type": "condition", "name": "Check if Closed Won", "config": { "conditions": { "left": "{{input.StageName}}", "operator": "eq", "right": "Closed Won" }, "then": ["notify-finance"], "else": [] } }, { "id": "notify-finance", "type": "slack", "name": "Notify Finance", "config": { "action": "sendMessage", "channel": "#finance", "text": "{{input.Name}} closed for ${{input.Amount}}" } } ] } ``` ## Troubleshooting ### Trigger Not Firing 1. **Check the connection** - The source platform must have an active OAuth connection 2. **Verify event subscription** - Platform Events/CDC entities must be enabled in Salesforce; event subscriptions must be enabled on the Slack app 3. **Confirm workflow status** - The workflow must be `active` 4. **Check the event kind** - The `eventKind` must match a supported value for the provider ## Next Steps Receive events from external systems Run workflows on a time-based schedule Learn workflow fundamentals # Schedule Triggers (/docs/triggers/schedule) Trigger workflows on a time-based schedule using cron or intervals ## Schedule Triggers Schedule triggers start workflows at specified times using cron expressions or interval-based scheduling. Powered by Trigger.dev's scheduling infrastructure. ## Configuration Schedule triggers support two modes: **cron expressions** and **intervals**. ```json { "trigger": { "type": "schedule", "cron": "0 9 * * 1-5", "timezone": "America/New_York", "enabled": true } } ``` ```json { "trigger": { "type": "schedule", "interval": { "value": 30, "unit": "minutes" }, "timezone": "UTC", "enabled": true } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | Yes | Must be `"schedule"` | | `cron` | string | No* | Cron expression (e.g., `"0 9 * * 1-5"`) | | `interval` | object | No* | Interval configuration | | `interval.value` | number | Yes | Interval value (1-525600) | | `interval.unit` | string | Yes | `minutes`, `hours`, `days`, or `weeks` | | `timezone` | string | No | IANA timezone (default: `"UTC"`) | | `enabled` | boolean | No | Enable/disable schedule (default: `true`) | You must specify either `cron` or `interval`, but not both. ## Cron Syntax Cron expressions follow standard 5-field format: ``` ┌───────────── minute (0-59) │ ┌───────────── hour (0-23) │ │ ┌───────────── day of month (1-31) │ │ │ ┌───────────── month (1-12) │ │ │ │ ┌───────────── day of week (0-6, Sunday=0) │ │ │ │ │ * * * * * ``` ### Special Characters | Character | Description | Example | |-----------|-------------|---------| | `*` | Any value | `* * * * *` (every minute) | | `,` | List separator | `0,30 * * * *` (at 0 and 30 minutes) | | `-` | Range | `0 9-17 * * *` (9am to 5pm) | | `/` | Step values | `*/15 * * * *` (every 15 minutes) | ### Common Schedules | Schedule | Cron Expression | Description | |----------|-----------------|-------------| | Every minute | `* * * * *` | Runs every minute | | Every 15 minutes | `*/15 * * * *` | At 0, 15, 30, 45 past the hour | | Every hour | `0 * * * *` | At the start of every hour | | Daily at 9am | `0 9 * * *` | Every day at 9:00 AM | | Weekdays at 9am | `0 9 * * 1-5` | Monday-Friday at 9:00 AM | | Weekly on Monday | `0 9 * * 1` | Mondays at 9:00 AM | | Monthly on 1st | `0 9 1 * *` | First of month at 9:00 AM | | Quarterly | `0 9 1 1,4,7,10 *` | First of Jan, Apr, Jul, Oct | ### Workflow Examples **Daily Invoice Sync (9am EST):** ```json { "name": "Daily Invoice Sync", "trigger": { "type": "schedule", "cron": "0 9 * * *", "timezone": "America/New_York" }, "steps": [ { "id": "fetch-invoices", "type": "action", "action": "quickbooks.listInvoices", "config": { "filter": "DueDate < '{{now}}'" } } ] } ``` **Weekly Report (Fridays at 5pm):** ```json { "name": "Weekly Sales Report", "trigger": { "type": "schedule", "cron": "0 17 * * 5", "timezone": "America/Los_Angeles" }, "steps": [...] } ``` ## Interval-Based Scheduling For simpler recurring schedules, use intervals: ```json { "trigger": { "type": "schedule", "interval": { "value": 4, "unit": "hours" } } } ``` ### Interval Units | Unit | Max Value | Example | |------|-----------|---------| | `minutes` | 525600 (1 year) | Every 30 minutes | | `hours` | 8760 (1 year) | Every 4 hours | | `days` | 365 | Every day | | `weeks` | 52 | Every week | ### Interval Examples **Every 30 minutes:** ```json { "trigger": { "type": "schedule", "interval": { "value": 30, "unit": "minutes" } } } ``` **Every 6 hours:** ```json { "trigger": { "type": "schedule", "interval": { "value": 6, "unit": "hours" } } } ``` **Weekly:** ```json { "trigger": { "type": "schedule", "interval": { "value": 1, "unit": "weeks" } } } ``` ## Timezone Handling Always specify a timezone for consistent execution times: ```json { "trigger": { "type": "schedule", "cron": "0 9 * * *", "timezone": "America/New_York" } } ``` ### Common Timezones | Region | Timezone | |--------|----------| | US Eastern | `America/New_York` | | US Central | `America/Chicago` | | US Mountain | `America/Denver` | | US Pacific | `America/Los_Angeles` | | UK | `Europe/London` | | Central Europe | `Europe/Berlin` | | India | `Asia/Kolkata` | | Japan | `Asia/Tokyo` | | Australia | `Australia/Sydney` | | UTC | `UTC` | Without a timezone, schedules default to UTC. This may cause unexpected execution times. ### Daylight Saving Time Schedules automatically adjust for DST transitions: - **Spring forward**: A 2am schedule may skip when clocks move from 2am to 3am - **Fall back**: A 1am schedule may run twice when clocks move from 2am to 1am For critical schedules, consider using UTC to avoid DST edge cases. ## Enabling and Disabling Temporarily disable a schedule without deleting the workflow: ```json { "trigger": { "type": "schedule", "cron": "0 9 * * *", "enabled": false } } ``` Re-enable by setting `enabled: true` or removing the field. ## Input Data Scheduled workflows receive execution metadata as input: ```json { "input": { "scheduledTime": "2024-01-15T09:00:00.000Z", "executionTime": "2024-01-15T09:00:01.234Z", "timezone": "America/New_York", "schedule": { "type": "cron", "expression": "0 9 * * *" } } } ``` | Field | Description | |-------|-------------| | `scheduledTime` | When the run was scheduled to start | | `executionTime` | When the run actually started | | `timezone` | Configured timezone | | `schedule.type` | `cron` or `interval` | | `schedule.expression` | The cron or interval definition | Access in your steps: ```json { "config": { "message": "Running scheduled job for {{input.scheduledTime}}" } } ``` ## Execution Guarantees ### At-Least-Once Delivery Scheduled workflows are guaranteed to run at least once per scheduled time. In rare cases (infrastructure issues), a schedule may run more than once. **Design for idempotency:** ```json { "id": "process-daily", "type": "action", "config": { "idempotencyKey": "daily-sync-{{input.scheduledTime | date: '%Y-%m-%d'}}" } } ``` ### Missed Schedules If a schedule is missed (workflow was paused, system outage), the missed execution is **not** automatically recovered. The next scheduled execution runs as normal. For critical schedules, consider: 1. Monitoring workflow run history 2. Alerting on missed executions 3. Running catch-up logic on workflow resume ### Concurrency By default, a new scheduled run starts even if the previous run is still executing. To prevent overlap: ```json { "settings": { "concurrencyLimit": 1 } } ``` ## Complete Example ```json { "name": "Daily Overdue Invoice Reminder", "description": "Send Slack reminders for overdue invoices every weekday at 9am", "trigger": { "type": "schedule", "cron": "0 9 * * 1-5", "timezone": "America/New_York" }, "steps": [ { "id": "fetch-overdue", "type": "action", "name": "Get Overdue Invoices", "action": "quickbooks.listInvoices", "config": { "filter": "DueDate < '{{input.scheduledTime}}' AND Balance > 0" } }, { "id": "check-count", "type": "condition", "name": "Check if any overdue", "config": { "conditions": { "left": "{{steps.fetch-overdue.output.invoices.length}}", "operator": "gt", "right": "0" }, "then": ["send-reminder"], "else": [] } }, { "id": "send-reminder", "type": "slack", "name": "Send Slack Reminder", "config": { "action": "sendMessage", "channel": "#finance", "text": "You have {{steps.fetch-overdue.output.invoices.length}} overdue invoices totaling ${{steps.fetch-overdue.output.totalBalance}}" } } ] } ``` ## Troubleshooting ### Schedule Not Running 1. **Check workflow status** - Must be `active` 2. **Verify `enabled`** - Must be `true` (or not set) 3. **Check timezone** - Ensure correct IANA timezone 4. **Validate cron** - Use a cron validator tool ### Wrong Execution Time 1. **Check timezone** - Common issue is UTC vs local 2. **Verify DST** - Times may shift during DST transitions 3. **Review cron syntax** - Especially day-of-week (0 = Sunday) ### Missing Runs 1. **Check workflow history** - Confirm workflow was active 2. **Review system status** - Check for outages 3. **Verify concurrency** - Previous run may still be executing ## Next Steps Trigger from external events Trigger workflows programmatically Learn workflow fundamentals # Slack Triggers (/docs/triggers/slack-events) Trigger workflows from Slack events, commands, and interactions ## Slack Triggers Slack triggers start workflows in response to Slack events, slash commands, and interactive components like button clicks and modal submissions. ## Overview Three types of Slack triggers are supported: | Trigger Type | Use Case | Example | |--------------|----------|---------| | **Event Trigger** | Respond to messages, mentions, reactions | Bot @mentioned, keyword detected | | **Slash Command** | User runs `/command` | `/invoice create`, `/approve` | | **Interactive** | Button clicks, modal submissions | Approve button, form submission | ## Prerequisites ### Environment Configuration ```bash # Required for signature verification SLACK_SIGNING_SECRET=your_signing_secret ``` ### Slack App Setup 1. Create a Slack App at [api.slack.com/apps](https://api.slack.com/apps) 2. Enable Event Subscriptions and point to your endpoint 3. Add required bot scopes 4. Install app to workspace ### Endpoint URLs Configure these URLs in your Slack app settings: | Feature | URL | |---------|-----| | Event Subscriptions | `https://your-domain.com/api/v1/slack/events` | | Slash Commands | `https://your-domain.com/api/v1/slack/commands` | | Interactivity | `https://your-domain.com/api/v1/slack/interactive` | ## Event Triggers Respond to Slack Events API events like messages, mentions, and reactions. ### Configuration Schema ```json { "trigger": { "type": "slack_event", "eventType": "message", "channelTypes": ["channel", "im"], "keywords": ["invoice", "billing"], "regex": "invoice\\s+#?\\d+", "userIds": ["U123456"], "filter": { "event.channel_type": { "$eq": "channel" } } } } ``` ### Configuration Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | Yes | Must be `slack_event` | | `eventType` | string | Yes | Slack event type or `*` for all | | `channelTypes` | array | No | Filter by channel type (`channel`, `group`, `im`, `mpim`) | | `keywords` | array | No | Trigger only if message contains keyword (case-insensitive) | | `regex` | string | No | Trigger only if message matches regex | | `userIds` | array | No | Trigger only for specific user IDs | | `filter` | object | No | Advanced filter using dot notation and operators | ### Supported Event Types | Event Type | Description | Key Fields | |------------|-------------|------------| | `message` | Channel or DM message | `text`, `user`, `channel`, `ts` | | `app_mention` | Bot @mentioned in message | `text`, `user`, `channel`, `ts` | | `reaction_added` | Emoji reaction added | `reaction`, `user`, `item.channel`, `item.ts` | | `reaction_removed` | Emoji reaction removed | `reaction`, `user`, `item.channel`, `item.ts` | | `channel_created` | New channel created | `channel.id`, `channel.name`, `channel.creator` | | `channel_deleted` | Channel deleted | `channel` | | `member_joined_channel` | User joined channel | `user`, `channel`, `inviter` | | `member_left_channel` | User left channel | `user`, `channel` | | `file_shared` | File uploaded | `file_id`, `user_id`, `channel_id` | | `app_home_opened` | User opened Home tab | `user`, `tab` | ### Event Payload Structure ```json { "input": { "slackEvent": { "id": "evt_abc123", "source": "events_api", "payload": { "type": "event_callback", "team_id": "T123456", "api_app_id": "A123456", "event": { "type": "message", "user": "U123456", "channel": "C789012", "channel_type": "channel", "text": "Create invoice for Acme Corp", "ts": "1234567890.123456", "thread_ts": null }, "event_id": "Ev123456", "event_time": 1234567890 } } } } ``` ### Example: Keyword-Based Message Trigger ```json { "name": "Invoice Request Handler", "trigger": { "type": "slack_event", "eventType": "message", "channelTypes": ["channel"], "keywords": ["invoice", "billing", "payment"] }, "steps": [ { "id": "parse-request", "type": "transform", "config": { "operation": "map", "data": { "user": "{{input.slackEvent.payload.event.user}}", "channel": "{{input.slackEvent.payload.event.channel}}", "text": "{{input.slackEvent.payload.event.text}}" } } }, { "id": "notify-finance", "type": "slack", "config": { "action": "sendMessage", "channel": "#finance-requests", "text": "New invoice request from <@{{steps.parse-request.output.user}}>:\n> {{steps.parse-request.output.text}}" } } ] } ``` ### Example: Bot Mention Trigger ```json { "name": "Bot Command Handler", "trigger": { "type": "slack_event", "eventType": "app_mention" }, "steps": [ { "id": "process-mention", "type": "action", "action": "ai.analyze", "config": { "prompt": "Parse this request and determine the intent: {{input.slackEvent.payload.event.text}}" } }, { "id": "respond", "type": "slack", "config": { "action": "sendMessage", "channel": "{{input.slackEvent.payload.event.channel}}", "threadTs": "{{input.slackEvent.payload.event.ts}}", "text": "{{steps.process-mention.output.response}}" } } ] } ``` ### Example: Reaction Trigger ```json { "name": "Approval via Reaction", "trigger": { "type": "slack_event", "eventType": "reaction_added", "filter": { "event.reaction": { "$in": ["white_check_mark", "heavy_check_mark"] } } }, "steps": [ { "id": "log-approval", "type": "action", "action": "database.insert", "config": { "table": "approvals", "data": { "approved_by": "{{input.slackEvent.payload.event.user}}", "message_ts": "{{input.slackEvent.payload.event.item.ts}}", "channel": "{{input.slackEvent.payload.event.item.channel}}" } } } ] } ``` ## Slash Command Triggers Respond to custom slash commands like `/invoice`, `/approve`, etc. ### Configuration Schema ```json { "trigger": { "type": "slack_slash_command", "command": "/invoice" } } ``` ### Configuration Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | Yes | Must be `slack_slash_command` | | `command` | string | Yes | The slash command (including `/`) | ### Command Payload Structure ```json { "input": { "slackEvent": { "id": "evt_cmd123", "source": "slash_command", "payload": { "token": "xxx", "team_id": "T123456", "team_domain": "myworkspace", "channel_id": "C789012", "channel_name": "general", "user_id": "U123456", "user_name": "johndoe", "command": "/invoice", "text": "create for Acme Corp $5000", "response_url": "https://hooks.slack.com/commands/xxx", "trigger_id": "123.456.xxx", "api_app_id": "A123456" } } } } ``` ### Key Payload Fields | Field | Description | |-------|-------------| | `command` | The slash command (e.g., `/invoice`) | | `text` | Text after the command | | `user_id` | User who ran the command | | `user_name` | Username | | `channel_id` | Channel where command was run | | `trigger_id` | Used to open modals (expires in 3 seconds) | | `response_url` | URL to send delayed responses | ### Example: Invoice Creation Command ```json { "name": "Create Invoice Command", "trigger": { "type": "slack_slash_command", "command": "/invoice" }, "steps": [ { "id": "parse-command", "type": "transform", "config": { "operation": "map", "data": { "args": "{{input.slackEvent.payload.text | split: ' '}}", "user": "{{input.slackEvent.payload.user_id}}", "channel": "{{input.slackEvent.payload.channel_id}}" } } }, { "id": "open-modal", "type": "slack", "config": { "action": "openModal", "triggerId": "{{input.slackEvent.payload.trigger_id}}", "view": { "type": "modal", "callback_id": "create_invoice_modal", "title": { "type": "plain_text", "text": "Create Invoice" }, "submit": { "type": "plain_text", "text": "Create" }, "blocks": [ { "type": "input", "block_id": "customer", "element": { "type": "plain_text_input", "action_id": "customer_input", "initial_value": "{{steps.parse-command.output.args[0]}}" }, "label": { "type": "plain_text", "text": "Customer" } }, { "type": "input", "block_id": "amount", "element": { "type": "plain_text_input", "action_id": "amount_input" }, "label": { "type": "plain_text", "text": "Amount" } } ] } } } ] } ``` ### Example: Help Command ```json { "name": "Help Command", "trigger": { "type": "slack_slash_command", "command": "/workflow-help" }, "steps": [ { "id": "send-help", "type": "action", "action": "http.post", "config": { "url": "{{input.slackEvent.payload.response_url}}", "body": { "response_type": "ephemeral", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Available Commands:*\n• `/invoice create` - Create a new invoice\n• `/invoice list` - List recent invoices\n• `/approve` - View pending approvals" } } ] } } } ] } ``` ## Interactive Triggers Respond to button clicks, menu selections, modal submissions, and shortcuts. ### Configuration Schema ```json { "trigger": { "type": "slack_interactive", "interactionType": "block_actions", "actionId": "approve_button", "callbackId": "approval_modal", "filter": { "actions[0].value": { "$exists": true } } } } ``` ### Configuration Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | Yes | Must be `slack_interactive` | | `interactionType` | string | Yes | Interaction type or `*` for all | | `actionId` | string | No | Filter by action_id (for `block_actions`) | | `callbackId` | string | No | Filter by callback_id (for modals/shortcuts) | | `filter` | object | No | Advanced filter | ### Interaction Types | Type | Description | When Triggered | |------|-------------|----------------| | `block_actions` | Button click, menu selection | User clicks button or selects option | | `view_submission` | Modal form submitted | User submits modal | | `view_closed` | Modal closed | User closes modal without submitting | | `shortcut` | Global shortcut | User runs global shortcut | | `message_action` | Message shortcut | User runs message shortcut | ### Interactive Payload Structure **Block Actions (Button Click):** ```json { "input": { "slackEvent": { "id": "evt_int123", "source": "interactive", "payload": { "type": "block_actions", "team": { "id": "T123456", "domain": "myworkspace" }, "user": { "id": "U123456", "name": "johndoe" }, "channel": { "id": "C789012", "name": "general" }, "message": { "ts": "1234567890.123456" }, "trigger_id": "123.456.xxx", "response_url": "https://hooks.slack.com/actions/xxx", "actions": [ { "action_id": "approve_invoice", "block_id": "approval_block", "type": "button", "value": "inv_12345" } ] } } } } ``` **View Submission (Modal):** ```json { "input": { "slackEvent": { "id": "evt_modal123", "source": "interactive", "payload": { "type": "view_submission", "team": { "id": "T123456" }, "user": { "id": "U123456", "name": "johndoe" }, "trigger_id": "123.456.xxx", "view": { "id": "V123456", "callback_id": "create_invoice_modal", "private_metadata": "{\"channel\":\"C789012\"}", "state": { "values": { "customer": { "customer_input": { "value": "Acme Corp" } }, "amount": { "amount_input": { "value": "5000" } } } } } } } } } ``` ### Example: Button Click Handler ```json { "name": "Handle Approval Button", "trigger": { "type": "slack_interactive", "interactionType": "block_actions", "actionId": "approve_invoice" }, "steps": [ { "id": "get-invoice", "type": "action", "action": "quickbooks.getInvoice", "config": { "invoiceId": "{{input.slackEvent.payload.actions[0].value}}" } }, { "id": "approve", "type": "action", "action": "quickbooks.updateInvoice", "config": { "invoiceId": "{{input.slackEvent.payload.actions[0].value}}", "status": "approved" } }, { "id": "notify", "type": "action", "action": "http.post", "config": { "url": "{{input.slackEvent.payload.response_url}}", "body": { "replace_original": true, "text": ":white_check_mark: Invoice approved by <@{{input.slackEvent.payload.user.id}}>" } } } ] } ``` ### Example: Modal Submission Handler ```json { "name": "Process Invoice Modal", "trigger": { "type": "slack_interactive", "interactionType": "view_submission", "callbackId": "create_invoice_modal" }, "steps": [ { "id": "extract-values", "type": "transform", "config": { "operation": "map", "data": { "customer": "{{input.slackEvent.payload.view.state.values.customer.customer_input.value}}", "amount": "{{input.slackEvent.payload.view.state.values.amount.amount_input.value}}", "metadata": "{{input.slackEvent.payload.view.private_metadata | parseJson}}" } } }, { "id": "create-invoice", "type": "action", "action": "quickbooks.createInvoice", "config": { "customerName": "{{steps.extract-values.output.customer}}", "amount": "{{steps.extract-values.output.amount}}" } }, { "id": "confirm", "type": "slack", "config": { "action": "sendMessage", "channel": "{{steps.extract-values.output.metadata.channel}}", "text": ":white_check_mark: Invoice created for {{steps.extract-values.output.customer}} - ${{steps.extract-values.output.amount}}" } } ] } ``` ## Built-in Approval Handling The platform includes built-in support for human-in-the-loop approvals via Slack. When a workflow step uses the `approval` type with Slack notifications, approval buttons are automatically handled. ### Automatic Approval Button Handling Buttons with action IDs matching `approval_approve_` or `approval_reject_` are automatically processed: 1. The approval decision is recorded 2. The waiting workflow is resumed 3. The Slack message is updated with the decision No custom trigger configuration is needed for approval workflows. ## Important Behavior ### 3-Second Acknowledgment Rule Slack requires responses within 3 seconds. All triggers: - Acknowledge immediately with a brief response - Process the event asynchronously - Use `response_url` for delayed responses ```json // Immediate acknowledgment (automatic) { "ok": true } // Delayed response via response_url { "replace_original": true, "text": "Processing complete!" } ``` ### URL Verification When setting up Event Subscriptions, Slack sends a verification challenge. This is handled automatically: ```json // Slack sends: { "type": "url_verification", "challenge": "abc123" } // Endpoint responds: { "challenge": "abc123" } ``` ### Retry Deduplication Slack retries failed requests with headers: - `X-Slack-Retry-Num`: Retry attempt number - `X-Slack-Retry-Reason`: Reason for retry Retries are automatically deduplicated using Redis with a 1-hour TTL. ### Signature Verification All requests are verified using HMAC-SHA256: 1. Compute: `v0:timestamp:raw_body` 2. Sign with `SLACK_SIGNING_SECRET` 3. Compare with `X-Slack-Signature` header 4. Reject if timestamp is >5 minutes old (replay attack prevention) ## Filter Operators Use these operators in the `filter` configuration: | Operator | Description | Example | |----------|-------------|---------| | `$eq` | Equal to | `{ "event.type": { "$eq": "message" } }` | | `$ne` | Not equal to | `{ "event.channel_type": { "$ne": "im" } }` | | `$in` | In array | `{ "event.reaction": { "$in": ["thumbsup", "heart"] } }` | | `$exists` | Field exists | `{ "event.thread_ts": { "$exists": true } }` | Access nested fields with dot notation: `event.item.channel` ## Troubleshooting ### Common Issues | Issue | Cause | Solution | |-------|-------|----------| | Signature verification failed | Wrong secret or clock skew | Verify `SLACK_SIGNING_SECRET`, check server time | | Events not received | Wrong URL or app not installed | Check Event Subscriptions URL, reinstall app | | Modal won't open | Expired trigger_id | Open modal within 3 seconds of receiving event | | Duplicate events | Retries not deduplicated | Check Redis connection | | Missing scopes | Insufficient permissions | Add required scopes in app settings | ### Required Scopes by Feature | Feature | Required Scopes | |---------|-----------------| | Receive messages | `channels:history`, `groups:history`, `im:history`, `mpim:history` | | Receive mentions | `app_mentions:read` | | Slash commands | (configured per command) | | Interactive | (automatic with Interactivity URL) | | Post messages | `chat:write` | | Open modals | (automatic with bot token) | ### Debug Logging Events are logged at these points: 1. Signature verification result 2. Event deduplication check 3. Matching workflow discovery 4. Workflow trigger success/failure Check logs for `Slack event processing failed` or `Failed to trigger workflow from Slack event`. ## Related Documentation - [Slack Block](/docs/integrations/communication/slack) - Send messages and interact with Slack - [Webhook Triggers](/docs/triggers/webhook) - Other webhook-based triggers - [Approval Steps](/docs/blocks/approval) - Human-in-the-loop approvals # Webhook Triggers (/docs/triggers/webhook) Trigger workflows from external webhook events ## Webhook Triggers Webhook triggers start workflows when external systems send events to Loopfour. A webhook trigger is declared on the workflow's trigger configuration — via the API, a template, or an import — not by dragging a block onto the canvas. ## Configuration ```json { "trigger": { "type": "webhook", "provider": "stripe", "path": "stripe" } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | Yes | Must be `"webhook"` | | `path` | string | Yes | Match key for custom webhooks; required by the schema even when `provider` is set | | `provider` | string | No | Provider name to match against the inbound event's provider | | `events` | string[] | No | Accepted by the schema, but **not used for matching** (see [Event Filtering](#event-filtering)) | | `verifySignature` | boolean | No | Accepted by the schema, but **not read** — verification is decided per route | ## How Events Reach Loopfour There are three inbound paths. Which one a provider uses determines what you put in the trigger config. | Route | Providers | Verification | |-------|-----------|--------------| | `POST /webhooks/:provider` | `airwallex`, `hubspot` | Svix signature, checked by the provider's handler | | `POST /webhooks/nango` | `stripe`, `salesforce`, `quickbooks`, `pandadoc`, `netsuite`, `slack`, `gmail` | Nango HMAC signature at ingress | | `POST /webhooks/custom/:companyId/:path` | Anything else | None — the URL is the shared secret | Stripe also has a dedicated `POST /webhooks/stripe` route for Stripe Connect. See the [Webhooks API reference](/docs/api-reference/webhooks) for its request and response details. Per-provider URLs of the form `/webhooks/stripe/{companyId}`, `/webhooks/hubspot/{companyId}`, `/webhooks/salesforce/{companyId}`, `/webhooks/quickbooks/{companyId}`, `/webhooks/pandadoc/{companyId}`, `/webhooks/netsuite/{companyId}`, and `/webhooks/gmail/{companyId}` do **not** exist. Earlier versions of this page advertised them; they were never implemented. Use the routes in the table above. `POST` to `/webhooks/:provider` for a provider with no registered handler returns `404` with `{ "error": "Unknown provider: " }`. ## Matching Rules A workflow with a `webhook` trigger is selected when: 1. The event arrived as `custom` (through `/webhooks/custom/:companyId/:path`) and the trigger's `path` equals the `:path` segment. The comparison is case-insensitive and treats `.`, `-`, and `_` as equivalent, so `order.received` and `order-received` both match. A leading slash on `path` never matches. 2. Otherwise, the trigger's `provider` equals the event's provider. Only active workflows in the receiving company are considered. ## Provider Examples ### Stripe Stripe events arrive either through the Nango forwarder (after you authorize Stripe via Nango Connect) or through the dedicated Connect endpoint. ```json { "name": "Payment Received Notification", "trigger": { "type": "webhook", "provider": "stripe", "path": "stripe" }, "steps": [...] } ``` **Common Stripe Events:** - `invoice.paid` - Invoice was paid - `invoice.payment_failed` - Payment attempt failed - `payment_intent.succeeded` - Payment completed - `customer.created` - New customer created - `subscription.created` - New subscription started - `subscription.canceled` - Subscription was canceled ### Salesforce Salesforce supports three webhook types: ```json { "trigger": { "type": "salesforce_event", "eventKind": "platform_event", "platformEventName": "Invoice_Created__e" } } ``` ```json { "trigger": { "type": "salesforce_event", "eventKind": "cdc", "cdcEntity": "Opportunity", "cdcChangeTypes": ["create", "update"] } } ``` ```json { "trigger": { "type": "webhook", "provider": "salesforce", "path": "salesforce" } } ``` **Salesforce CDC Change Types:** - `create` - Record created - `update` - Record updated - `delete` - Record deleted - `undelete` - Record restored ### HubSpot HubSpot events arrive through `POST /webhooks/hubspot` and are matched by the dedicated `hubspot_event` trigger type, which supports per-event selection: ```json { "trigger": { "type": "hubspot_event", "eventKind": "deal.propertyChange", "propertyName": "dealstage" } } ``` **HubSpot Event Kinds:** - `contact.propertyChange` - Contact property changed - `company.propertyChange` - Company property changed - `deal.propertyChange` - Deal property changed - `deal.creation` - Deal created - `deal.deletion` - Deal deleted - `*` - Match all events ### QuickBooks ```json { "trigger": { "type": "webhook", "provider": "quickbooks", "path": "quickbooks" } } ``` QuickBooks event types are derived from the notification entity and operation, lowercased — for example `invoice.create`, `payment.update`, `customer.delete`. ### PandaDoc ```json { "trigger": { "type": "webhook", "provider": "pandadoc", "path": "pandadoc" } } ``` **PandaDoc Events:** - `document_state_changed` - Document status changed - `recipient_completed` - Recipient signed - `document_completed` - All signatures collected - `document_paid` - Payment received ### NetSuite ```json { "trigger": { "type": "webhook", "provider": "netsuite", "path": "netsuite" } } ``` ### Custom Webhooks For providers not listed above, use custom webhooks. `path` is the match key — no `provider` field: ```json { "trigger": { "type": "webhook", "path": "my-integration" } } ``` **Custom Webhook URL:** ``` https://your-domain.com/webhooks/custom/{COMPANY_ID}/my-integration ``` The body is parsed as JSON when possible; a non-JSON payload is wrapped as `{ "rawBody": "..." }`. There is no signature check on this route — treat the URL as a shared secret and serve it over HTTPS only. ## Event Filtering The `events` array is accepted by the trigger schema but is **not** applied when matching. Every event that reaches the matched route starts the workflow. To narrow what runs: - Subscribe to only the events you want in the provider's own webhook settings. - Put a Condition block at the top of the workflow and branch on `{{input}}`. ## Signature Verification Verification happens at the route, before workflow matching. | Route | What is checked | |-------|-----------------| | `POST /webhooks/:provider` | The provider handler's Svix signature check. A failure returns `401 Invalid signature`; a handler that throws returns `500 Verification error` | | `POST /webhooks/nango` | Nango HMAC over the raw body. Fail-closed: a missing secret returns `500`, a missing or bad signature returns `401` | | `POST /webhooks/stripe` | `stripe-signature` (HMAC-SHA256 over `${timestamp}.${rawBody}`, 5-minute window) when `STRIPE_WEBHOOK_SECRET` is set | | `POST /webhooks/custom/:companyId/:path` | Nothing | Store webhook signing secrets securely in environment variables. Never commit them to version control. ### Handling Verification Failures When signature verification fails, the webhook returns `401 Unauthorized`: ```json { "error": "Invalid signature" } ``` Check that: 1. Your signing secret matches the provider's configuration 2. The request hasn't been tampered with 3. The timestamp is within acceptable bounds (for Stripe) ## Webhook Response The Stripe and custom routes return: ```json { "received": true, "eventId": "evt_xxx", "workflowsTriggered": 2 } ``` | Field | Description | |-------|-------------| | `received` | Always `true` for successful receipt | | `eventId` | Unique identifier for this webhook event | | `workflowsTriggered` | Number of workflows triggered by this event | The `POST /webhooks/:provider` dispatcher returns `{ "received": true, "count": N }`, where `count` is the number of events in the batch that resolved to a connection and were routed. ## Input Data The full webhook payload is available in your workflow as `{{input}}`: ### Stripe Example ```json { "input": { "id": "evt_1234", "type": "invoice.paid", "data": { "object": { "id": "in_xxx", "amount_paid": 9900, "customer": "cus_xxx", "customer_email": "customer@example.com" } } } } ``` Access nested data in your steps: ```json { "config": { "to": "{{input.data.object.customer_email}}", "amount": "{{input.data.object.amount_paid / 100}}" } } ``` ### Salesforce CDC Example ```json { "input": { "ChangeEventHeader": { "entityName": "Opportunity", "changeType": "UPDATE", "changedFields": ["Amount", "StageName"] }, "Id": "006xxx", "Name": "Big Deal", "Amount": 50000, "StageName": "Closed Won" } } ``` ## Webhook Events Table All incoming webhooks are stored in the `webhook_events` table for audit and replay: | Field | Description | |-------|-------------| | `id` | Unique event ID | | `company_id` | Company that received this event | | `provider` | Provider name (stripe, salesforce, etc.) | | `event_type` | Event type (invoice.paid, etc.) | | `event_id` | Provider's event ID | | `payload` | Full event payload (JSON) | | `signature_verified` | Verification status | | `status` | Processing status | | `workflows_triggered` | Array of triggered workflow runs | ## Testing Webhooks ### Using cURL The custom route needs no signature, which makes it the simplest one to exercise end to end: ```bash curl -X POST https://workflow.loopfour.ai/webhooks/custom/{COMPANY_ID}/my-integration \ -H "Content-Type: application/json" \ -d '{ "orderId": "12345", "total": 99.99 }' ``` ### Using Stripe CLI ```bash # Forward Stripe webhooks to the Connect endpoint on localhost stripe listen --forward-to localhost:3000/webhooks/stripe ``` A Stripe event with no `account` field cannot be attributed to a company. It is acknowledged with `{ "received": true, "ignored": true }` and no workflow runs. ### Using ngrok ```bash # Expose local server ngrok http 3000 # Use the ngrok URL in provider webhook settings # https://abc123.ngrok.io/webhooks/stripe ``` ## Troubleshooting ### Webhook Not Triggering Workflow 1. **Check workflow status** - Must be `active` 2. **Check the provider match** - the trigger's `provider` must equal the provider the event arrived as. Events forwarded by Nango for an unmapped provider key arrive as `custom`, not under the provider's own name 3. **Check the path** - for custom webhooks, the trigger's `path` must equal the URL's `:path` segment, with no leading slash 4. **Check signature** - ensure signing secrets are configured 5. **Review webhook events** - check the `webhook_events` table ### Duplicate Events Loopfour does not drop repeated events — the provider's event id is stored and indexed on `webhook_events` so you can spot duplicates, but a redelivered event starts the workflow again. If you receive duplicates: 1. Ensure idempotent workflow design 2. Check provider retry settings 3. Verify webhook acknowledgement is reaching provider ### Timeout Issues Webhooks must respond within provider timeouts: - Stripe: 30 seconds - HubSpot: 5 seconds - QuickBooks: 30 seconds The API acknowledges webhooks immediately and processes asynchronously. ## Next Steps Run workflows on a time-based schedule Trigger workflows programmatically Trigger from Slack events and commands # Troubleshooting (/docs/troubleshooting) Common issues and solutions ## Troubleshooting Find solutions to common issues. Troubleshooting Loopfour usually starts by separating setup problems from execution problems. Setup issues involve authentication, missing connections, expired tokens, provider permissions, or invalid workflow configuration. Execution issues happen after a workflow starts: a step may receive unexpected input, a provider may reject a request, a condition may route down the wrong branch, or an approval may remain pending longer than expected. The guides in this section help you narrow the failure before changing workflow logic. When a run fails, collect the run ID, workflow ID, trigger payload, failed step name, provider response, and any recent connection changes. That information is usually enough to decide whether the problem belongs in the workflow definition, the integration credential, the external system, or the input data. Avoid retrying blindly in finance workflows. A retry can create duplicate invoices, messages, or records if the failed step was not idempotent. Inspect the run history first, then retry only after you understand where the previous attempt stopped. For recurring issues, compare several failed runs instead of treating each failure as isolated. If the same provider response appears every time, start with connection permissions or request shape. If failures occur only for certain customers, invoices, or contracts, inspect the input data and mapping rules. If the workflow succeeds after manual replay, look for timing assumptions in the trigger or upstream system. This pattern keeps debugging grounded in evidence rather than guesses. API errors and how to fix them OAuth and integration problems Debug workflow execution issues # Common Errors (/docs/troubleshooting/common-errors) API errors and how to fix them ## Common Errors ## Authentication Errors ### 401 Unauthorized ```json { "error": { "code": "UNAUTHORIZED", "message": "Invalid or missing API key" } } ``` **Solution:** Check that your API key is correct and included in the `x-api-key` header. ### 403 Forbidden ```json { "error": { "code": "FORBIDDEN", "message": "Access denied to this resource" } } ``` **Solution:** Verify your API key has access to the requested company/resource. ## Rate Limiting ### 429 Too Many Requests ```json { "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Limit: 100 requests per minute." } } ``` **Solution:** - Wait for the rate limit window to reset (check `X-RateLimit-Reset` header) - Reduce request frequency - Implement exponential backoff ## Validation Errors ### 400 Bad Request ```json { "error": { "code": "VALIDATION_ERROR", "message": "Invalid workflow configuration", "details": [ { "field": "steps[0].id", "message": "Step ID is required" } ] } } ``` **Solution:** Check the `details` array for specific field errors. ## Not Found ### 404 Not Found ```json { "error": { "code": "NOT_FOUND", "message": "Workflow not found" } } ``` **Solution:** Verify the resource ID exists and belongs to your company. # Connection Issues (/docs/troubleshooting/connection-issues) OAuth and integration problems ## Connection Issues ## OAuth Flow Failures ### "Access Denied" During OAuth **Possible Causes:** - User cancelled the OAuth flow - Missing required scopes - Account doesn't have required permissions **Solutions:** 1. Retry the OAuth flow 2. Check that your OAuth app has the required scopes 3. Verify the user has admin permissions in the target system ### "Token Expired" Error **Cause:** The OAuth refresh token has expired or been revoked. **Solution:** Re-authenticate by going through the OAuth flow again. ## Connection Status: Error ### Salesforce - Check that the Connected App is still active - Verify API access is enabled for the user - Confirm the user's Salesforce license includes API access ### HubSpot - Verify the HubSpot account is not deactivated - Check that required scopes are still granted - Ensure the app isn't uninstalled from HubSpot ### Stripe - Confirm the Stripe account is in good standing - Check API key permissions - Verify the account isn't in test mode (if using live mode) ## Webhook Issues ### Webhooks Not Received 1. Check the webhook URL is correctly configured in the provider 2. Verify your firewall allows incoming requests 3. Check the provider's webhook logs for delivery errors ### Signature Verification Failed 1. Verify the webhook secret is correctly configured 2. Check the request body isn't being modified by middleware 3. Ensure you're using the raw request body for verification # Workflow Debugging (/docs/troubleshooting/workflow-debugging) Debug workflow execution issues ## Workflow Debugging ## Viewing Run Details Get detailed information about a workflow run: ```bash curl https://workflow.loopfour.ai/api/v1/runs/{runId} \ -H "x-api-key: YOUR_API_KEY" ``` The response includes: - Overall run status - Each step's status, input, and output - Error messages for failed steps - Timing information ## Common Step Failures ### Template Variable Errors **Error:** `Cannot read property 'x' of undefined` **Cause:** Referencing a field that doesn't exist. **Solution:** Check the input data structure and use optional chaining: ``` {{input.data?.object?.id}} ``` ### Action Errors **Error:** `Integration error: Rate limited` **Cause:** The external API rate limited the request. **Solution:** Add retry configuration to the step: ```json { "retries": 3, "retryDelay": 5000, "retryBackoff": "exponential" } ``` ### Condition Errors **Error:** `Invalid condition expression` **Cause:** Malformed condition syntax. **Solution:** Verify operator and value types match. ## Testing Workflows ### Test with Mock Input ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{id}/run \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "input": { "test": true, "data": { "example": "value" } } }' ``` ### Dry Run Mode Execute without side effects: ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{id}/run?dryRun=true \ -H "x-api-key: YOUR_API_KEY" ``` ## Logging Enable verbose logging for debugging: ```json { "settings": { "logging": "verbose" } } ``` # Tutorials (/docs/tutorials) Step-by-step workflow guides ## Tutorials Learn to build real-world workflows with these step-by-step tutorials. Tutorials show how the primitives in the rest of the docs fit together into complete finance operations. Each guide starts with a business event, identifies the systems involved, then builds the trigger, steps, mappings, and exception paths needed to run the workflow safely. Use them as working patterns rather than copy-only recipes: the exact provider, field names, and approval thresholds should match your team's process. The most useful way to follow a tutorial is to run it first with test data, inspect the run output, and then replace the sample fields with your own operational records. Pay special attention to where the workflow reads source data, where it writes final state, and where humans review ambiguous or high-risk cases. Those boundaries are what make a workflow reliable enough for billing, revenue operations, customer onboarding, or accounting handoffs. After you complete a tutorial, review the workflow as if it were headed to production. Confirm the trigger cannot fire on the wrong event, the connected accounts point to the intended workspace or sandbox, and every external write has enough context for reconciliation. Then add the monitoring, retry, and exception rules your team expects. A tutorial should give you a working foundation, but production workflows still need your team's approval thresholds and ownership model. Create invoices when CRM deals close Sync CRM deals to contracts Automate customer onboarding flow # Customer Onboarding (/docs/tutorials/customer-onboarding) Automate new customer setup across multiple systems ## Customer Onboarding Tutorial Learn how to automate new customer onboarding across multiple systems when a Stripe subscription is created, ensuring consistent setup and immediate engagement. ## The Problem When a new customer signs up, teams typically: 1. Manually create the customer in the accounting system (QuickBooks/NetSuite) 2. Add the contact to the CRM (HubSpot/Salesforce) 3. Send a welcome email with setup instructions 4. Notify the customer success team in Slack 5. Create onboarding tasks or projects 6. Update spreadsheets or dashboards This manual process causes: - **Delayed onboarding** - Hours or days before customer is fully set up - **Inconsistent data** - Customer info varies across systems - **Missed steps** - Someone forgets to update a system - **Poor first impression** - Customer waits for manual actions - **Team bottlenecks** - One person becomes the onboarding gatekeeper ## The Solution Build a workflow that automatically: 1. Triggers when a Stripe subscription is created 2. Creates the customer in QuickBooks for invoicing 3. Creates or updates the contact in HubSpot as a customer 4. Sends a personalized welcome email 5. Notifies the customer success team in Slack 6. (Optional) Creates an onboarding project or task ## What You'll Build ``` Stripe Subscription Created │ ▼ ┌───────────────────┐ │ Get Stripe │ │ Customer Details │ └─────────┬─────────┘ │ ┌─────┴─────┐ ▼ ▼ ┌───────────┐ ┌───────────┐ │ Create │ │ Create/ │ │ QuickBooks│ │ Update │ │ Customer │ │ HubSpot │ └─────┬─────┘ └─────┬─────┘ └──────┬──────┘ │ ▼ ┌───────────────────┐ │ Send Welcome │ │ Email │ └─────────┬─────────┘ │ ▼ ┌───────────────────┐ │ Notify Team │ │ in Slack │ └───────────────────┘ ``` ## Prerequisites ### Required Connections | Provider | Connection Type | Purpose | |----------|----------------|---------| | Stripe | OAuth via Nango | Receive webhooks, get customer data | | QuickBooks | OAuth via Nango | Create accounting customers | | HubSpot | OAuth via Nango | Manage CRM contacts | | Slack | OAuth via Nango | Team notifications | | Email Provider | OAuth or API Key | Welcome emails | ### Stripe Setup 1. **Configure Webhook** in Stripe Dashboard: - Developers → Webhooks → Add endpoint - URL: `https://your-domain.com/webhooks/stripe/{companyId}` - Events: `customer.subscription.created` 2. **Note your webhook signing secret** for verification ### QuickBooks Setup Ensure you have: - An active QuickBooks Online account - OAuth connection configured in Nango ### HubSpot Setup 1. **Create Custom Properties** (recommended): - `stripe_customer_id` (Single-line text) - `stripe_subscription_id` (Single-line text) - `subscription_plan` (Dropdown) - `mrr` (Number - currency) ## Step-by-Step Implementation ### Step 1: Create the Workflow Start with the Stripe webhook trigger: ```json { "name": "Customer Onboarding", "description": "Automate new customer setup when Stripe subscription is created", "trigger": { "type": "webhook", "provider": "stripe", "events": ["customer.subscription.created"] }, "steps": [] } ``` ### Step 2: Extract Customer Information Parse the subscription data: ```json { "id": "extract-customer-data", "type": "transform", "name": "Extract Customer Data", "config": { "operation": "map", "data": { "customerId": "{{input.data.object.customer}}", "subscriptionId": "{{input.data.object.id}}", "planName": "{{input.data.object.items.data.[0].plan.nickname}}", "planAmount": "{{input.data.object.items.data.[0].plan.amount}}", "interval": "{{input.data.object.items.data.[0].plan.interval}}", "status": "{{input.data.object.status}}" } } } ``` ### Step 3: Get Full Customer Details from Stripe Retrieve the complete customer profile: ```json { "id": "get-stripe-customer", "type": "action", "name": "Get Stripe Customer Details", "config": { "connector": "stripe", "action": "getCustomer", "customerId": "{{steps.extract-customer-data.output.customerId}}" } } ``` **Output includes:** - `name` - Customer name - `email` - Email address - `phone` - Phone number (if provided) - `address` - Billing address - `metadata` - Custom fields ### Step 4: Create QuickBooks Customer Create the customer in your accounting system: ```json { "id": "create-qb-customer", "type": "action", "name": "Create QuickBooks Customer", "config": { "connector": "quickbooks", "action": "createCustomer", "DisplayName": "{{steps.get-stripe-customer.output.name}}", "CompanyName": "{{steps.get-stripe-customer.output.metadata.company_name}}", "PrimaryEmailAddr": { "Address": "{{steps.get-stripe-customer.output.email}}" }, "PrimaryPhone": { "FreeFormNumber": "{{steps.get-stripe-customer.output.phone}}" }, "BillAddr": { "Line1": "{{steps.get-stripe-customer.output.address.line1}}", "City": "{{steps.get-stripe-customer.output.address.city}}", "CountrySubDivisionCode": "{{steps.get-stripe-customer.output.address.state}}", "PostalCode": "{{steps.get-stripe-customer.output.address.postal_code}}", "Country": "{{steps.get-stripe-customer.output.address.country}}" }, "Notes": "Stripe Customer ID: {{steps.get-stripe-customer.output.id}}\nSubscription: {{steps.extract-customer-data.output.planName}}" }, "onError": { "action": "continue", "fallback": { "Id": null } } } ``` ### Step 5: Create or Update HubSpot Contact Add the customer to your CRM: ```json { "id": "upsert-hubspot-contact", "type": "action", "name": "Create/Update HubSpot Contact", "config": { "connector": "hubspot", "action": "createContact", "email": "{{steps.get-stripe-customer.output.email}}", "properties": { "firstname": "{{steps.get-stripe-customer.output.name | split: ' ' | first}}", "lastname": "{{steps.get-stripe-customer.output.name | split: ' ' | last}}", "phone": "{{steps.get-stripe-customer.output.phone}}", "company": "{{steps.get-stripe-customer.output.metadata.company_name}}", "lifecyclestage": "customer", "stripe_customer_id": "{{steps.get-stripe-customer.output.id}}", "stripe_subscription_id": "{{steps.extract-customer-data.output.subscriptionId}}", "subscription_plan": "{{steps.extract-customer-data.output.planName}}", "mrr": "{{steps.extract-customer-data.output.planAmount | divided_by: 100}}" } }, "onError": { "action": "continue", "fallback": { "id": null } } } ``` **Key properties:** - `lifecyclestage: customer` - Marks them as a paying customer - Custom Stripe fields for tracking ### Step 6: Send Welcome Email Send a personalized welcome email: ```json { "id": "send-welcome-email", "type": "email", "name": "Send Welcome Email", "config": { "action": "send", "provider": "resend", "to": "{{steps.get-stripe-customer.output.email}}", "from": "onboarding@yourcompany.com", "subject": "Welcome to {{steps.extract-customer-data.output.planName}}!", "body": { "html": "

Welcome, {{steps.get-stripe-customer.output.name}}!

Thank you for subscribing to {{steps.extract-customer-data.output.planName}}. We're excited to have you on board!

Getting Started

Here are your next steps:

  1. Complete your profile setup
  2. Read our quickstart guide
  3. Book an onboarding call with our team

If you have any questions, reply to this email or reach out to support@yourcompany.com.

Welcome aboard!

The {{steps.extract-customer-data.output.planName}} Team

" }, "template_variables": { "customer_name": "{{steps.get-stripe-customer.output.name}}", "plan_name": "{{steps.extract-customer-data.output.planName}}", "app_url": "https://app.yourcompany.com" } } } ``` ### Step 7: Notify Customer Success Team Alert the team about the new customer: ```json { "id": "notify-cs-team", "type": "slack", "name": "Notify Customer Success", "config": { "action": "sendMessage", "channel": "#new-customers", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "New Customer Signed Up!" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Customer:*\n{{steps.get-stripe-customer.output.name}}" }, { "type": "mrkdwn", "text": "*Plan:*\n{{steps.extract-customer-data.output.planName}}" }, { "type": "mrkdwn", "text": "*Email:*\n{{steps.get-stripe-customer.output.email}}" }, { "type": "mrkdwn", "text": "*MRR:*\n${{steps.extract-customer-data.output.planAmount | divided_by: 100}}/{{steps.extract-customer-data.output.interval}}" } ] }, { "type": "context", "elements": [ { "type": "mrkdwn", "text": "QuickBooks: {{#if steps.create-qb-customer.output.Id}}Created{{else}}Skipped{{/if}} | HubSpot: {{#if steps.upsert-hubspot-contact.output.id}}Created{{else}}Skipped{{/if}}" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View in Stripe" }, "url": "https://dashboard.stripe.com/customers/{{steps.get-stripe-customer.output.id}}" }, { "type": "button", "text": { "type": "plain_text", "text": "View in HubSpot" }, "url": "https://app.hubspot.com/contacts/YOUR_PORTAL_ID/contact/{{steps.upsert-hubspot-contact.output.id}}" } ] } ] } } ``` ## Complete Workflow Here's the complete workflow JSON: ```json { "name": "Customer Onboarding", "description": "Automate new customer setup when Stripe subscription is created", "trigger": { "type": "webhook", "provider": "stripe", "events": ["customer.subscription.created"] }, "steps": [ { "id": "extract-customer-data", "type": "transform", "name": "Extract Customer Data", "config": { "operation": "map", "data": { "customerId": "{{input.data.object.customer}}", "subscriptionId": "{{input.data.object.id}}", "planName": "{{input.data.object.items.data.[0].plan.nickname}}", "planAmount": "{{input.data.object.items.data.[0].plan.amount}}", "interval": "{{input.data.object.items.data.[0].plan.interval}}" } } }, { "id": "get-stripe-customer", "type": "action", "name": "Get Stripe Customer Details", "config": { "connector": "stripe", "action": "getCustomer", "customerId": "{{steps.extract-customer-data.output.customerId}}" } }, { "id": "create-qb-customer", "type": "action", "name": "Create QuickBooks Customer", "config": { "connector": "quickbooks", "action": "createCustomer", "DisplayName": "{{steps.get-stripe-customer.output.name}}", "PrimaryEmailAddr": { "Address": "{{steps.get-stripe-customer.output.email}}" }, "Notes": "Stripe ID: {{steps.get-stripe-customer.output.id}}" }, "onError": { "action": "continue", "fallback": { "Id": null } } }, { "id": "upsert-hubspot-contact", "type": "action", "name": "Create/Update HubSpot Contact", "config": { "connector": "hubspot", "action": "createContact", "email": "{{steps.get-stripe-customer.output.email}}", "properties": { "firstname": "{{steps.get-stripe-customer.output.name | split: ' ' | first}}", "lastname": "{{steps.get-stripe-customer.output.name | split: ' ' | last}}", "lifecyclestage": "customer", "stripe_customer_id": "{{steps.get-stripe-customer.output.id}}", "subscription_plan": "{{steps.extract-customer-data.output.planName}}" } }, "onError": { "action": "continue", "fallback": { "id": null } } }, { "id": "send-welcome-email", "type": "email", "name": "Send Welcome Email", "config": { "action": "send", "provider": "resend", "to": "{{steps.get-stripe-customer.output.email}}", "from": "onboarding@yourcompany.com", "subject": "Welcome to {{steps.extract-customer-data.output.planName}}!", "body": { "html": "

Welcome!

Thank you for subscribing. We're excited to have you on board!

" } } }, { "id": "notify-cs-team", "type": "slack", "name": "Notify Customer Success", "config": { "action": "sendMessage", "channel": "#new-customers", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "New Customer Signed Up!" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Customer:*\n{{steps.get-stripe-customer.output.name}}" }, { "type": "mrkdwn", "text": "*Plan:*\n{{steps.extract-customer-data.output.planName}}" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View in Stripe" }, "url": "https://dashboard.stripe.com/customers/{{steps.get-stripe-customer.output.id}}" } ] } ] } } ] } ``` ## Testing ### 1. Create Test Workflow ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d @workflow.json ``` ### 2. Activate Workflow ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{workflowId}/activate \ -H "x-api-key: YOUR_API_KEY" ``` ### 3. Test with Simulated Webhook ```bash curl -X POST https://workflow.loopfour.ai/webhooks/stripe/{companyId} \ -H "Content-Type: application/json" \ -H "stripe-signature: test" \ -d '{ "type": "customer.subscription.created", "data": { "object": { "id": "sub_1234567890", "customer": "cus_1234567890", "status": "active", "items": { "data": [{ "plan": { "nickname": "Pro Plan", "amount": 9900, "interval": "month" } }] } } } }' ``` ### 4. Verify Results | System | What to Verify | |--------|---------------| | QuickBooks | New customer created with Stripe ID in notes | | HubSpot | Contact created with lifecycle stage = customer | | Email | Welcome email received | | Slack | Notification in #new-customers | ### 5. End-to-End Test 1. Create a test product and price in Stripe 2. Use Stripe test mode to create a subscription 3. Watch the workflow execute automatically 4. Verify all systems updated ## Error Handling ### Graceful Degradation The workflow uses `onError: { action: "continue" }` for non-critical steps so the workflow continues even if one system fails: ```json { "id": "create-qb-customer", "config": { ... }, "onError": { "action": "continue", "fallback": { "Id": null } } } ``` ### Notify on Partial Failure Add a check at the end: ```json { "id": "check-all-succeeded", "type": "condition", "config": { "if": "{{steps.create-qb-customer.output.Id && steps.upsert-hubspot-contact.output.id}}", "then": "end", "else": "notify-partial-failure" } }, { "id": "notify-partial-failure", "type": "slack", "config": { "action": "sendMessage", "channel": "#ops-alerts", "text": ":warning: Customer onboarding partially failed for {{steps.get-stripe-customer.output.email}}. Check QuickBooks: {{#unless steps.create-qb-customer.output.Id}}FAILED{{/unless}}, HubSpot: {{#unless steps.upsert-hubspot-contact.output.id}}FAILED{{/unless}}" } } ``` ### Handle Duplicate Customers Check if customer already exists: ```json { "id": "check-qb-customer-exists", "type": "action", "config": { "connector": "quickbooks", "action": "findCustomerByEmail", "email": "{{steps.get-stripe-customer.output.email}}" } }, { "id": "decide-create-or-skip", "type": "condition", "config": { "if": "{{steps.check-qb-customer-exists.output.QueryResponse.Customer.length > 0}}", "then": "skip-qb-creation", "else": "create-qb-customer" } } ``` ## Variations and Extensions ### Plan-Specific Onboarding Different onboarding flows for different plans: ```json { "id": "route-by-plan", "type": "condition", "config": { "if": "{{steps.extract-customer-data.output.planName === 'Enterprise'}}", "then": "enterprise-onboarding", "else": { "if": "{{steps.extract-customer-data.output.planName === 'Pro'}}", "then": "pro-onboarding", "else": "basic-onboarding" } } } ``` ### Create NetSuite Customer Instead For enterprise companies using NetSuite: ```json { "id": "create-netsuite-customer", "type": "action", "config": { "connector": "netsuite", "action": "createCustomer", "companyName": "{{steps.get-stripe-customer.output.metadata.company_name}}", "email": "{{steps.get-stripe-customer.output.email}}", "subsidiary": { "id": "1" }, "customFields": { "custentity_stripe_id": "{{steps.get-stripe-customer.output.id}}" } } } ``` ### Scheduled Follow-up Email Send a check-in email 7 days after signup: ```json { "id": "schedule-followup", "type": "email", "config": { "action": "schedule", "provider": "resend", "to": "{{steps.get-stripe-customer.output.email}}", "subject": "How's it going with {{steps.extract-customer-data.output.planName}}?", "body": { "html": "

Hi {{steps.get-stripe-customer.output.name}},

You've been using {{steps.extract-customer-data.output.planName}} for a week now. How's it going?

If you have any questions or need help, just reply to this email!

" }, "send_at": "{{now | dateAdd: 7, 'days' | date: '%Y-%m-%dT%H:%M:%SZ'}}" } } ``` ### Create Salesforce Account and Contact For Salesforce users: ```json { "id": "create-sf-account", "type": "action", "config": { "connector": "salesforce", "action": "createOpportunity", "Name": "{{steps.get-stripe-customer.output.metadata.company_name}} - New Subscription", "StageName": "Closed Won", "CloseDate": "{{now | date: '%Y-%m-%d'}}", "Amount": "{{steps.extract-customer-data.output.planAmount | divided_by: 100}}" } }, { "id": "create-sf-contact", "type": "action", "config": { "connector": "salesforce", "action": "createContact", "FirstName": "{{steps.get-stripe-customer.output.name | split: ' ' | first}}", "LastName": "{{steps.get-stripe-customer.output.name | split: ' ' | last}}", "Email": "{{steps.get-stripe-customer.output.email}}", "Stripe_Customer_ID__c": "{{steps.get-stripe-customer.output.id}}" } } ``` ### Assign CSM Based on Plan Route high-value customers to senior CSMs: ```json { "id": "determine-csm", "type": "transform", "config": { "operation": "map", "data": { "csmEmail": "{{#if (gt steps.extract-customer-data.output.planAmount 50000)}}senior-csm@company.com{{else if (gt steps.extract-customer-data.output.planAmount 10000)}}csm@company.com{{else}}onboarding@company.com{{/if}}", "csmSlackId": "{{#if (gt steps.extract-customer-data.output.planAmount 50000)}}U12345{{else}}U67890{{/if}}" } } }, { "id": "notify-assigned-csm", "type": "slack", "config": { "action": "sendMessage", "channel": "@{{steps.determine-csm.output.csmSlackId}}", "text": "You've been assigned a new customer: {{steps.get-stripe-customer.output.name}} ({{steps.extract-customer-data.output.planName}})" } } ``` ### Trial Conversion Variant Handle trial-to-paid conversion differently: ```json { "trigger": { "type": "webhook", "provider": "stripe", "events": ["customer.subscription.updated"], "filter": { "data.object.status": { "$eq": "active" }, "data.previous_attributes.status": { "$eq": "trialing" } } } } ``` ## Common Issues | Issue | Cause | Solution | |-------|-------|----------| | Duplicate customers | Same webhook fired twice | Add idempotency check with subscription ID | | Missing customer name | Stripe customer has no name | Fall back to email prefix | | QuickBooks sync failed | Customer already exists | Check before create, update instead | | Email not sent | Invalid email format | Validate email in transform step | | Wrong plan amount | Amount is in cents | Divide by 100 for display | ## Related Resources - [Stripe Integration Guide](/docs/integrations/payments/stripe) - [QuickBooks Integration Guide](/docs/integrations/accounting/quickbooks) - [HubSpot Integration Guide](/docs/integrations/crm/hubspot) - [Email Block Reference](/docs/workflows/building-blocks/email-block) - [Customer Success Use Cases](/docs/use-cases/customer-success) # Deal Sync (/docs/tutorials/deal-sync) Automatically create contracts when CRM deals reach the right stage ## Deal Sync Tutorial Learn how to automatically create and send PandaDoc contracts when HubSpot deals reach the contract stage, eliminating manual document creation and accelerating your sales cycle. ## The Problem When a sales deal is ready for contract, sales teams typically: 1. Manually log into PandaDoc or another contract system 2. Find the right template and create a new document 3. Copy deal data from the CRM into the contract 4. Add recipients and send for signature 5. Update the CRM with the document link 6. Track signing status manually This manual process causes: - **Delayed contracts** - Hours between "ready to sign" and contract sent - **Data inconsistencies** - Typos, wrong amounts, outdated information - **Lost deals** - Momentum dies while waiting for paperwork - **No visibility** - Sales managers can't see contract status at a glance ## The Solution Build a workflow that automatically: 1. Triggers when a HubSpot deal moves to "Contract Sent" stage 2. Retrieves deal and contact information 3. Creates a contract from a PandaDoc template with deal data 4. Sends the contract for signature 5. Updates the HubSpot deal with the document link 6. Notifies the sales team in Slack 7. (Bonus) Updates the deal when the contract is signed ## What You'll Build ``` HubSpot Deal → Contract Sent Stage │ ▼ ┌───────────────────┐ │ Get Deal Details │ └─────────┬─────────┘ │ ▼ ┌───────────────────┐ │ Get Primary │ │ Contact │ └─────────┬─────────┘ │ ▼ ┌───────────────────┐ │ Get Company │ │ Details │ └─────────┬─────────┘ │ ▼ ┌───────────────────┐ │ Create PandaDoc │ │ Contract │ └─────────┬─────────┘ │ ▼ ┌───────────────────┐ │ Send for │ │ Signature │ └─────────┬─────────┘ │ ▼ ┌───────────────────┐ │ Update HubSpot │ │ Deal │ └─────────┬─────────┘ │ ▼ ┌───────────────────┐ │ Notify Sales │ │ in Slack │ └───────────────────┘ ``` ## Prerequisites ### Required Connections | Provider | Connection Type | Purpose | |----------|----------------|---------| | HubSpot | OAuth via Nango | Receive webhooks, get/update deals | | PandaDoc | OAuth via Nango | Create and send documents | | Slack | OAuth via Nango | Team notifications | ### HubSpot Setup 1. **Configure Webhook Subscriptions** in your HubSpot app: - Subscribe to `deal.propertyChange` events - Include `dealstage` property in the subscription 2. **Deal Pipeline Stages**: Note your stage IDs - Go to Settings → Objects → Deals → Pipelines - Find the internal ID for your "Contract Sent" stage (e.g., `contractsent`) 3. **Custom Properties** (recommended): - `contract_url` (Single-line text) - To store PandaDoc link - `contract_status` (Dropdown) - Draft, Sent, Viewed, Signed ### PandaDoc Setup 1. **Create Contract Template**: - Go to Templates → Create Template - Design your contract with placeholder fields - Add tokens for dynamic data (deal amount, company name, etc.) - Note the template ID from the URL 2. **Configure Tokens** in your template: - `{{deal_name}}` - Deal/opportunity name - `{{deal_amount}}` - Contract value - `{{company_name}}` - Customer company - `{{contact_name}}` - Signer name - `{{contact_email}}` - Signer email ## Step-by-Step Implementation ### Step 1: Create the Workflow Start with the trigger configuration: ```json { "name": "Deal → Contract", "description": "Create and send PandaDoc contract when deal reaches contract stage", "trigger": { "type": "webhook", "provider": "hubspot", "events": ["deal.propertyChange"], "filter": { "propertyName": { "$eq": "dealstage" }, "propertyValue": { "$eq": "contractsent" } } }, "steps": [] } ``` **Why this filter?** - HubSpot sends property change events for any deal update - We filter to only trigger when `dealstage` changes to `contractsent` - This prevents duplicate contract creation ### Step 2: Get Deal Details Retrieve the full deal information: ```json { "id": "get-deal", "type": "action", "name": "Get Deal Details", "config": { "connector": "hubspot", "action": "getDeal", "dealId": "{{input.objectId}}", "properties": [ "dealname", "amount", "closedate", "dealstage", "pipeline", "hubspot_owner_id" ], "associations": ["contacts", "companies"] } } ``` **Key properties:** - `dealname` - The deal/opportunity name - `amount` - Contract value - `closedate` - Expected close date - `associations` - Returns linked contacts and companies ### Step 3: Get Primary Contact Retrieve the contact who will sign the contract: ```json { "id": "get-contact", "type": "action", "name": "Get Primary Contact", "config": { "connector": "hubspot", "action": "getContact", "contactId": "{{steps.get-deal.output.associations.contacts.results.[0].id}}", "properties": [ "firstname", "lastname", "email", "jobtitle", "phone" ] } } ``` **Output includes:** - `firstname`, `lastname` - Contact name - `email` - For contract delivery - `jobtitle` - For contract personalization ### Step 4: Get Company Details Retrieve the associated company: ```json { "id": "get-company", "type": "action", "name": "Get Company Details", "config": { "connector": "hubspot", "action": "getCompany", "companyId": "{{steps.get-deal.output.associations.companies.results.[0].id}}", "properties": [ "name", "domain", "address", "city", "state", "zip", "country" ] } } ``` ### Step 5: Create PandaDoc Document Create the contract from your template: ```json { "id": "create-contract", "type": "action", "name": "Create Contract from Template", "config": { "connector": "pandadoc", "action": "createDocument", "templateId": "YOUR_TEMPLATE_ID", "name": "Contract - {{steps.get-deal.output.properties.dealname}}", "recipients": [ { "email": "{{steps.get-contact.output.properties.email}}", "first_name": "{{steps.get-contact.output.properties.firstname}}", "last_name": "{{steps.get-contact.output.properties.lastname}}", "role": "Signer" } ], "tokens": [ { "name": "deal_name", "value": "{{steps.get-deal.output.properties.dealname}}" }, { "name": "deal_amount", "value": "{{steps.get-deal.output.properties.amount}}" }, { "name": "company_name", "value": "{{steps.get-company.output.properties.name}}" }, { "name": "contact_name", "value": "{{steps.get-contact.output.properties.firstname}} {{steps.get-contact.output.properties.lastname}}" }, { "name": "contact_title", "value": "{{steps.get-contact.output.properties.jobtitle}}" }, { "name": "effective_date", "value": "{{now | date: '%B %d, %Y'}}" } ], "metadata": { "hubspot_deal_id": "{{input.objectId}}", "hubspot_company_id": "{{steps.get-company.output.id}}" } } } ``` **Key configuration:** - `templateId` - Your PandaDoc template ID - `recipients` - Who needs to sign - `tokens` - Dynamic values to populate in the contract - `metadata` - Track the source deal ### Step 6: Wait for Document Processing PandaDoc needs time to process the document: ```json { "id": "wait-for-document", "type": "delay", "name": "Wait for Document Processing", "config": { "duration": 5, "unit": "seconds" } } ``` ### Step 7: Send Document for Signature Send the contract to the recipient: ```json { "id": "send-contract", "type": "action", "name": "Send Contract for Signature", "config": { "connector": "pandadoc", "action": "sendDocument", "documentId": "{{steps.create-contract.output.id}}", "message": "Hi {{steps.get-contact.output.properties.firstname}},\n\nPlease find attached the contract for {{steps.get-deal.output.properties.dealname}}.\n\nPlease review and sign at your earliest convenience.\n\nThank you!", "subject": "Contract Ready: {{steps.get-deal.output.properties.dealname}}" } } ``` ### Step 8: Create Signing Link Generate a direct signing link: ```json { "id": "create-signing-link", "type": "action", "name": "Create Signing Link", "config": { "connector": "pandadoc", "action": "createDocumentLink", "documentId": "{{steps.create-contract.output.id}}", "recipientEmail": "{{steps.get-contact.output.properties.email}}", "lifetime": 604800 } } ``` **Parameters:** - `lifetime` - Link validity in seconds (604800 = 7 days) ### Step 9: Update HubSpot Deal Update the deal with contract information: ```json { "id": "update-deal", "type": "action", "name": "Update Deal with Contract Link", "config": { "connector": "hubspot", "action": "updateDeal", "dealId": "{{input.objectId}}", "properties": { "contract_url": "{{steps.create-signing-link.output.link}}", "contract_status": "Sent", "notes_last_updated": "Contract sent to {{steps.get-contact.output.properties.email}} on {{now | date: '%Y-%m-%d %H:%M'}}" } } } ``` ### Step 10: Notify Sales Team Send a Slack notification: ```json { "id": "notify-sales", "type": "slack", "name": "Notify Sales Team", "config": { "action": "sendMessage", "channel": "#sales-contracts", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Contract Sent for Signature" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Deal:*\n{{steps.get-deal.output.properties.dealname}}" }, { "type": "mrkdwn", "text": "*Value:*\n${{steps.get-deal.output.properties.amount}}" }, { "type": "mrkdwn", "text": "*Company:*\n{{steps.get-company.output.properties.name}}" }, { "type": "mrkdwn", "text": "*Signer:*\n{{steps.get-contact.output.properties.firstname}} {{steps.get-contact.output.properties.lastname}}" } ] }, { "type": "context", "elements": [ { "type": "mrkdwn", "text": "Sent to: {{steps.get-contact.output.properties.email}}" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View in PandaDoc" }, "url": "https://app.pandadoc.com/documents/{{steps.create-contract.output.id}}" }, { "type": "button", "text": { "type": "plain_text", "text": "View Deal" }, "url": "https://app.hubspot.com/contacts/YOUR_PORTAL_ID/deal/{{input.objectId}}" } ] } ] } } ``` ## Complete Workflow Here's the complete workflow JSON: ```json { "name": "Deal → Contract", "description": "Create and send PandaDoc contract when deal reaches contract stage", "trigger": { "type": "webhook", "provider": "hubspot", "events": ["deal.propertyChange"], "filter": { "propertyName": { "$eq": "dealstage" }, "propertyValue": { "$eq": "contractsent" } } }, "steps": [ { "id": "get-deal", "type": "action", "name": "Get Deal Details", "config": { "connector": "hubspot", "action": "getDeal", "dealId": "{{input.objectId}}", "properties": ["dealname", "amount", "closedate", "dealstage", "pipeline"], "associations": ["contacts", "companies"] } }, { "id": "get-contact", "type": "action", "name": "Get Primary Contact", "config": { "connector": "hubspot", "action": "getContact", "contactId": "{{steps.get-deal.output.associations.contacts.results.[0].id}}", "properties": ["firstname", "lastname", "email", "jobtitle", "phone"] } }, { "id": "get-company", "type": "action", "name": "Get Company Details", "config": { "connector": "hubspot", "action": "getCompany", "companyId": "{{steps.get-deal.output.associations.companies.results.[0].id}}", "properties": ["name", "domain", "address", "city", "state", "zip"] } }, { "id": "create-contract", "type": "action", "name": "Create Contract from Template", "config": { "connector": "pandadoc", "action": "createDocument", "templateId": "YOUR_TEMPLATE_ID", "name": "Contract - {{steps.get-deal.output.properties.dealname}}", "recipients": [ { "email": "{{steps.get-contact.output.properties.email}}", "first_name": "{{steps.get-contact.output.properties.firstname}}", "last_name": "{{steps.get-contact.output.properties.lastname}}", "role": "Signer" } ], "tokens": [ { "name": "deal_name", "value": "{{steps.get-deal.output.properties.dealname}}" }, { "name": "deal_amount", "value": "{{steps.get-deal.output.properties.amount}}" }, { "name": "company_name", "value": "{{steps.get-company.output.properties.name}}" }, { "name": "contact_name", "value": "{{steps.get-contact.output.properties.firstname}} {{steps.get-contact.output.properties.lastname}}" } ], "metadata": { "hubspot_deal_id": "{{input.objectId}}" } } }, { "id": "wait-for-document", "type": "delay", "name": "Wait for Document Processing", "config": { "duration": 5, "unit": "seconds" } }, { "id": "send-contract", "type": "action", "name": "Send Contract for Signature", "config": { "connector": "pandadoc", "action": "sendDocument", "documentId": "{{steps.create-contract.output.id}}", "message": "Please review and sign the attached contract.", "subject": "Contract Ready: {{steps.get-deal.output.properties.dealname}}" } }, { "id": "create-signing-link", "type": "action", "name": "Create Signing Link", "config": { "connector": "pandadoc", "action": "createDocumentLink", "documentId": "{{steps.create-contract.output.id}}", "recipientEmail": "{{steps.get-contact.output.properties.email}}", "lifetime": 604800 } }, { "id": "update-deal", "type": "action", "name": "Update Deal with Contract Link", "config": { "connector": "hubspot", "action": "updateDeal", "dealId": "{{input.objectId}}", "properties": { "contract_url": "{{steps.create-signing-link.output.link}}", "contract_status": "Sent" } } }, { "id": "notify-sales", "type": "slack", "name": "Notify Sales Team", "config": { "action": "sendMessage", "channel": "#sales-contracts", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Contract Sent for Signature" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Deal:*\n{{steps.get-deal.output.properties.dealname}}" }, { "type": "mrkdwn", "text": "*Value:*\n${{steps.get-deal.output.properties.amount}}" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View in PandaDoc" }, "url": "https://app.pandadoc.com/documents/{{steps.create-contract.output.id}}" } ] } ] } } ] } ``` ## Testing ### 1. Create Test Workflow ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d @workflow.json ``` ### 2. Activate Workflow ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{workflowId}/activate \ -H "x-api-key: YOUR_API_KEY" ``` ### 3. Test with Simulated Webhook ```bash curl -X POST https://workflow.loopfour.ai/webhooks/hubspot/{companyId} \ -H "Content-Type: application/json" \ -d '{ "objectId": 12345678, "propertyName": "dealstage", "propertyValue": "contractsent", "subscriptionType": "deal.propertyChange" }' ``` ### 4. Verify Results | System | What to Verify | |--------|---------------| | PandaDoc | New document created and sent | | HubSpot | Deal updated with contract_url | | Slack | Notification in #sales-contracts | | Recipient Email | Contract email received | ### 5. End-to-End Test 1. Create a test deal in HubSpot with a contact and company 2. Move the deal to "Contract Sent" stage 3. Watch the workflow execute 4. Verify contract created in PandaDoc 5. Check recipient received signing request ## Bonus: Handle Contract Signed Create a second workflow that triggers when the contract is signed: ```json { "name": "Contract Signed → Close Deal", "trigger": { "type": "webhook", "provider": "pandadoc", "events": ["document_state_changed"], "filter": { "data.status": { "$eq": "document.completed" } } }, "steps": [ { "id": "get-document", "type": "action", "config": { "connector": "pandadoc", "action": "getDocument", "documentId": "{{input.data.id}}" } }, { "id": "update-deal-closed", "type": "action", "config": { "connector": "hubspot", "action": "updateDeal", "dealId": "{{steps.get-document.output.metadata.hubspot_deal_id}}", "properties": { "dealstage": "closedwon", "contract_status": "Signed" } } }, { "id": "download-signed-contract", "type": "action", "config": { "connector": "pandadoc", "action": "downloadDocument", "documentId": "{{input.data.id}}" } }, { "id": "notify-deal-won", "type": "slack", "config": { "action": "sendMessage", "channel": "#sales-wins", "text": ":tada: Contract signed! Deal {{steps.get-document.output.name}} is now Closed Won!" } } ] } ``` ## Error Handling ### Handle Missing Contact ```json { "id": "check-contact-exists", "type": "condition", "config": { "if": "{{steps.get-deal.output.associations.contacts.results.length > 0}}", "then": "get-contact", "else": "notify-missing-contact" } } ``` ### Handle PandaDoc Errors ```json { "id": "create-contract", "type": "action", "config": { ... }, "onError": { "action": "goto", "step": "notify-contract-error" } } ``` ### Notify on Failure ```json { "id": "notify-contract-error", "type": "slack", "config": { "action": "sendMessage", "channel": "#sales-alerts", "text": ":warning: Failed to create contract for deal {{steps.get-deal.output.properties.dealname}}. Error: {{error.message}}" } } ``` ## Variations and Extensions ### Multiple Signers Add a counter-signer from your company: ```json { "recipients": [ { "email": "{{steps.get-contact.output.properties.email}}", "first_name": "{{steps.get-contact.output.properties.firstname}}", "last_name": "{{steps.get-contact.output.properties.lastname}}", "role": "Customer", "signing_order": 1 }, { "email": "sales@yourcompany.com", "first_name": "Sales", "last_name": "Team", "role": "Company", "signing_order": 2 } ] } ``` ### Different Templates by Deal Size Use conditional logic to select templates: ```json { "id": "select-template", "type": "transform", "config": { "operation": "map", "data": { "templateId": "{{#if (gt steps.get-deal.output.properties.amount 100000)}}ENTERPRISE_TEMPLATE_ID{{else if (gt steps.get-deal.output.properties.amount 25000)}}STANDARD_TEMPLATE_ID{{else}}BASIC_TEMPLATE_ID{{/if}}" } } } ``` ### Salesforce Variant Use Salesforce instead of HubSpot: ```json { "trigger": { "type": "webhook", "provider": "salesforce", "events": ["opportunity.updated"], "filter": { "StageName": { "$eq": "Contract" } } }, "steps": [ { "id": "get-opportunity", "type": "action", "config": { "connector": "salesforce", "action": "getOpportunity", "opportunityId": "{{input.payload.Id}}" } }, { "id": "get-contact", "type": "action", "config": { "connector": "salesforce", "action": "query", "query": "SELECT Id, Name, Email FROM Contact WHERE AccountId = '{{input.payload.AccountId}}' LIMIT 1" } } ] } ``` ### Add Legal Review Step For high-value deals, add a legal review step: ```json { "id": "check-legal-review", "type": "condition", "config": { "if": "{{steps.get-deal.output.properties.amount > 500000}}", "then": "request-legal-review", "else": "send-contract" } }, { "id": "request-legal-review", "type": "slack", "config": { "action": "sendMessage", "channel": "#legal-review", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Legal Review Required*\n\nDeal: {{steps.get-deal.output.properties.dealname}}\nAmount: ${{steps.get-deal.output.properties.amount}}" } }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "Approve" }, "style": "primary", "action_id": "approve_contract_{{input.objectId}}" }, { "type": "button", "text": { "type": "plain_text", "text": "Reject" }, "style": "danger", "action_id": "reject_contract_{{input.objectId}}" } ] } ] } } ``` ## Common Issues | Issue | Cause | Solution | |-------|-------|----------| | Document not created | Invalid template ID | Verify template ID in PandaDoc | | Missing recipient | No contact on deal | Add validation step, require contact | | Duplicate contracts | Webhook retries | Add idempotency check using deal ID | | Wrong data in contract | Incorrect token names | Match template tokens exactly | | Send failed | Document still processing | Increase delay or poll for status | ## Related Resources - [HubSpot Integration Guide](/docs/integrations/crm/hubspot) - [PandaDoc Integration Guide](/docs/integrations/documents/pandadoc) - [Slack Block Reference](/docs/workflows/building-blocks/slack-block) - [Conditional Steps](/docs/workflows/steps/conditions) - [Sales Ops Use Cases](/docs/use-cases/sales-ops) # Invoice Automation (/docs/tutorials/invoice-automation) Automatically create and send invoices when CRM deals close ## Invoice Automation Tutorial Learn how to automatically create invoices in Stripe when Salesforce opportunities close, eliminating manual data entry and accelerating your revenue cycle. ## The Problem When a sales deal closes, finance teams typically: 1. Wait for sales to notify them (often via email or spreadsheet) 2. Manually look up the customer in Stripe 3. Create an invoice with the correct line items 4. Send the invoice to the customer 5. Update the CRM opportunity with invoice details This manual process causes: - **Delayed invoicing** - Hours or days between deal close and invoice sent - **Data entry errors** - Wrong amounts, typos in customer details - **Missing invoices** - Deals that slip through the cracks - **No audit trail** - Hard to track what was invoiced and when ## The Solution Build a workflow that automatically: 1. Triggers instantly when a Salesforce opportunity closes as "Won" 2. Finds or creates the customer in Stripe 3. Creates an invoice with line items from the opportunity 4. Sends the invoice immediately 5. Updates the Salesforce opportunity with the invoice URL 6. Notifies the sales and finance teams ## What You'll Build ``` Salesforce Opportunity Closes (Won) │ ▼ ┌───────────────────┐ │ Get Account Email │ └─────────┬─────────┘ │ ▼ ┌───────────────────┐ │ Find/Create │ │ Stripe Customer │ └─────────┬─────────┘ │ ▼ ┌───────────────────┐ │ Create Invoice │ │ with Line Items │ └─────────┬─────────┘ │ ▼ ┌───────────────────┐ │ Finalize & Send │ │ Invoice │ └─────────┬─────────┘ │ ▼ ┌───────────────────┐ │ Update Salesforce │ │ Opportunity │ └─────────┬─────────┘ │ ▼ ┌───────────────────┐ │ Notify Team │ │ in Slack │ └───────────────────┘ ``` ## Prerequisites ### Required Connections | Provider | Connection Type | Purpose | |----------|----------------|---------| | Salesforce | OAuth via Nango | Receive webhooks, update opportunities | | Stripe | OAuth via Nango | Create customers, invoices | | Slack | OAuth via Nango | Team notifications | ### Salesforce Setup 1. **Enable Change Data Capture (CDC)** for the Opportunity object: - Setup → Integrations → Change Data Capture - Select "Opportunity" and move to Selected Entities - Save 2. **Custom Fields** (optional but recommended): - `Invoice_URL__c` (URL) - To store the Stripe invoice link - `Invoice_Status__c` (Picklist) - Draft, Sent, Paid, Overdue ### Stripe Setup Ensure your Stripe account has: - Products/prices configured for your offerings - Tax settings configured (if applicable) - Invoice customization (logo, footer, etc.) ## Step-by-Step Implementation ### Step 1: Create the Workflow Start with the basic workflow structure: ```json { "name": "Deal Close → Invoice", "description": "Automatically create and send Stripe invoice when Salesforce opportunity closes won", "trigger": { "type": "webhook", "provider": "salesforce", "events": ["opportunity.updated"], "filter": { "StageName": { "$eq": "Closed Won" }, "IsClosed": { "$eq": true }, "IsWon": { "$eq": true } } }, "steps": [] } ``` **Why this filter?** - We only want opportunities that just closed as "Won" - The CDC event fires on any update, so we filter for the specific state - `IsClosed` and `IsWon` ensure we don't process losses ### Step 2: Get Account Details Retrieve the associated account to get the billing email: ```json { "id": "get-account", "type": "action", "name": "Get Account Details", "config": { "connector": "salesforce", "action": "getAccount", "accountId": "{{input.payload.AccountId}}" } } ``` **Output includes:** - `Name` - Company name - `BillingStreet`, `BillingCity`, `BillingState`, `BillingPostalCode`, `BillingCountry` - Custom fields like `Billing_Email__c` ### Step 3: Find or Create Stripe Customer Check if the customer exists in Stripe, create if not: ```json { "id": "find-stripe-customer", "type": "action", "name": "Find Stripe Customer", "config": { "connector": "stripe", "action": "listCustomers", "email": "{{steps.get-account.output.Billing_Email__c}}" } } ``` Then conditionally create: ```json { "id": "create-stripe-customer", "type": "action", "name": "Create Stripe Customer", "condition": "{{steps.find-stripe-customer.output.data.length === 0}}", "config": { "connector": "stripe", "action": "createCustomer", "email": "{{steps.get-account.output.Billing_Email__c}}", "name": "{{steps.get-account.output.Name}}", "metadata": { "salesforce_account_id": "{{steps.get-account.output.Id}}", "salesforce_opportunity_id": "{{input.payload.Id}}" }, "address": { "line1": "{{steps.get-account.output.BillingStreet}}", "city": "{{steps.get-account.output.BillingCity}}", "state": "{{steps.get-account.output.BillingState}}", "postal_code": "{{steps.get-account.output.BillingPostalCode}}", "country": "{{steps.get-account.output.BillingCountry}}" } } } ``` ### Step 4: Resolve Customer ID Use a transform to get the customer ID regardless of which path was taken: ```json { "id": "resolve-customer", "type": "transform", "name": "Get Customer ID", "config": { "operation": "map", "data": { "customerId": "{{#if steps.create-stripe-customer.output.id}}{{steps.create-stripe-customer.output.id}}{{else}}{{steps.find-stripe-customer.output.data.[0].id}}{{/if}}" } } } ``` ### Step 5: Create the Invoice Create the invoice with line items from the opportunity: ```json { "id": "create-invoice", "type": "action", "name": "Create Stripe Invoice", "config": { "connector": "stripe", "action": "createInvoice", "customer": "{{steps.resolve-customer.output.customerId}}", "collection_method": "send_invoice", "days_until_due": 30, "metadata": { "salesforce_opportunity_id": "{{input.payload.Id}}", "salesforce_opportunity_name": "{{input.payload.Name}}" } } } ``` ### Step 6: Add Line Items Add line items based on opportunity products or the total amount: ```json { "id": "add-line-item", "type": "action", "name": "Add Invoice Line Item", "config": { "connector": "stripe", "action": "createInvoiceItem", "customer": "{{steps.resolve-customer.output.customerId}}", "invoice": "{{steps.create-invoice.output.id}}", "description": "{{input.payload.Name}}", "amount": "{{input.payload.Amount | multiply: 100}}", "currency": "usd" } } ``` **Note:** Stripe amounts are in cents, so we multiply by 100. ### Step 7: Finalize and Send Invoice Finalize the draft invoice and send it: ```json { "id": "finalize-invoice", "type": "action", "name": "Finalize Invoice", "config": { "connector": "stripe", "action": "finalizeInvoice", "invoiceId": "{{steps.create-invoice.output.id}}" } } ``` ```json { "id": "send-invoice", "type": "action", "name": "Send Invoice", "config": { "connector": "stripe", "action": "sendInvoice", "invoiceId": "{{steps.create-invoice.output.id}}" } } ``` ### Step 8: Update Salesforce Opportunity Update the opportunity with the invoice URL: ```json { "id": "update-opportunity", "type": "action", "name": "Update Opportunity with Invoice", "config": { "connector": "salesforce", "action": "updateOpportunity", "opportunityId": "{{input.payload.Id}}", "fields": { "Invoice_URL__c": "{{steps.send-invoice.output.hosted_invoice_url}}", "Invoice_Status__c": "Sent", "Description": "{{input.payload.Description}}\n\n---\nInvoice sent automatically on {{now | date: '%Y-%m-%d'}}" } } } ``` ### Step 9: Notify Team in Slack Send a notification to your finance channel: ```json { "id": "notify-slack", "type": "slack", "name": "Notify Finance Team", "config": { "action": "sendMessage", "channel": "#finance-notifications", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Invoice Sent" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Opportunity:*\n{{input.payload.Name}}" }, { "type": "mrkdwn", "text": "*Amount:*\n${{input.payload.Amount | number_format: 2}}" }, { "type": "mrkdwn", "text": "*Customer:*\n{{steps.get-account.output.Name}}" }, { "type": "mrkdwn", "text": "*Invoice #:*\n{{steps.send-invoice.output.number}}" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View Invoice" }, "url": "{{steps.send-invoice.output.hosted_invoice_url}}" }, { "type": "button", "text": { "type": "plain_text", "text": "View in Salesforce" }, "url": "https://yourinstance.salesforce.com/{{input.payload.Id}}" } ] } ] } } ``` ## Complete Workflow Here's the complete workflow JSON: ```json { "name": "Deal Close → Invoice", "description": "Automatically create and send Stripe invoice when Salesforce opportunity closes won", "trigger": { "type": "webhook", "provider": "salesforce", "events": ["opportunity.updated"], "filter": { "StageName": { "$eq": "Closed Won" }, "IsClosed": { "$eq": true }, "IsWon": { "$eq": true } } }, "steps": [ { "id": "get-account", "type": "action", "name": "Get Account Details", "config": { "connector": "salesforce", "action": "getAccount", "accountId": "{{input.payload.AccountId}}" } }, { "id": "find-stripe-customer", "type": "action", "name": "Find Stripe Customer", "config": { "connector": "stripe", "action": "listCustomers", "email": "{{steps.get-account.output.Billing_Email__c}}" } }, { "id": "create-stripe-customer", "type": "action", "name": "Create Stripe Customer", "condition": "{{steps.find-stripe-customer.output.data.length === 0}}", "config": { "connector": "stripe", "action": "createCustomer", "email": "{{steps.get-account.output.Billing_Email__c}}", "name": "{{steps.get-account.output.Name}}", "metadata": { "salesforce_account_id": "{{steps.get-account.output.Id}}", "salesforce_opportunity_id": "{{input.payload.Id}}" }, "address": { "line1": "{{steps.get-account.output.BillingStreet}}", "city": "{{steps.get-account.output.BillingCity}}", "state": "{{steps.get-account.output.BillingState}}", "postal_code": "{{steps.get-account.output.BillingPostalCode}}", "country": "{{steps.get-account.output.BillingCountry}}" } } }, { "id": "resolve-customer", "type": "transform", "name": "Get Customer ID", "config": { "operation": "map", "data": { "customerId": "{{#if steps.create-stripe-customer.output.id}}{{steps.create-stripe-customer.output.id}}{{else}}{{steps.find-stripe-customer.output.data.[0].id}}{{/if}}" } } }, { "id": "create-invoice", "type": "action", "name": "Create Stripe Invoice", "config": { "connector": "stripe", "action": "createInvoice", "customer": "{{steps.resolve-customer.output.customerId}}", "collection_method": "send_invoice", "days_until_due": 30, "metadata": { "salesforce_opportunity_id": "{{input.payload.Id}}", "salesforce_opportunity_name": "{{input.payload.Name}}" } } }, { "id": "add-line-item", "type": "action", "name": "Add Invoice Line Item", "config": { "connector": "stripe", "action": "createInvoiceItem", "customer": "{{steps.resolve-customer.output.customerId}}", "invoice": "{{steps.create-invoice.output.id}}", "description": "{{input.payload.Name}}", "amount": "{{input.payload.Amount | multiply: 100}}", "currency": "usd" } }, { "id": "finalize-invoice", "type": "action", "name": "Finalize Invoice", "config": { "connector": "stripe", "action": "finalizeInvoice", "invoiceId": "{{steps.create-invoice.output.id}}" } }, { "id": "send-invoice", "type": "action", "name": "Send Invoice", "config": { "connector": "stripe", "action": "sendInvoice", "invoiceId": "{{steps.create-invoice.output.id}}" } }, { "id": "update-opportunity", "type": "action", "name": "Update Opportunity with Invoice", "config": { "connector": "salesforce", "action": "updateOpportunity", "opportunityId": "{{input.payload.Id}}", "fields": { "Invoice_URL__c": "{{steps.send-invoice.output.hosted_invoice_url}}", "Invoice_Status__c": "Sent" } } }, { "id": "notify-slack", "type": "slack", "name": "Notify Finance Team", "config": { "action": "sendMessage", "channel": "#finance-notifications", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Invoice Sent" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Opportunity:*\n{{input.payload.Name}}" }, { "type": "mrkdwn", "text": "*Amount:*\n${{input.payload.Amount}}" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View Invoice" }, "url": "{{steps.send-invoice.output.hosted_invoice_url}}" } ] } ] } } ] } ``` ## Testing ### 1. Create Test Workflow ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d @workflow.json ``` ### 2. Activate Workflow ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{workflowId}/activate \ -H "x-api-key: YOUR_API_KEY" ``` ### 3. Test with Simulated Webhook ```bash curl -X POST https://workflow.loopfour.ai/webhooks/salesforce/{companyId} \ -H "Content-Type: application/json" \ -d '{ "payload": { "Id": "006XXXXXXXXXXXXXXX", "Name": "Test Opportunity - Enterprise Plan", "Amount": 50000, "StageName": "Closed Won", "IsClosed": true, "IsWon": true, "AccountId": "001XXXXXXXXXXXXXXX" } }' ``` ### 4. Verify Results Check each system: | System | What to Verify | |--------|---------------| | Stripe Dashboard | New customer created, invoice sent | | Salesforce | Opportunity updated with Invoice_URL__c | | Slack | Notification in #finance-notifications | | Workflow Runs | Run completed successfully | ### 5. End-to-End Test For a real test: 1. Create a test opportunity in Salesforce 2. Move it through stages to "Closed Won" 3. Watch the workflow execute in real-time 4. Verify all systems updated correctly ## Error Handling ### Handle Missing Customer Email ```json { "id": "validate-email", "type": "condition", "name": "Validate Email Exists", "config": { "if": "{{steps.get-account.output.Billing_Email__c}}", "then": "continue", "else": "notify-missing-email" } } ``` ### Handle Stripe API Errors ```json { "id": "create-invoice", "type": "action", "config": { ... }, "onError": { "action": "goto", "step": "notify-error" } } ``` ### Notify on Failure ```json { "id": "notify-error", "type": "slack", "name": "Notify Invoice Error", "config": { "action": "sendMessage", "channel": "#finance-alerts", "text": ":warning: Failed to create invoice for opportunity {{input.payload.Name}}. Error: {{error.message}}" } } ``` ## Variations and Extensions ### Multi-Currency Support Add currency detection based on account country: ```json { "id": "determine-currency", "type": "transform", "config": { "operation": "map", "data": { "currency": "{{#if (eq steps.get-account.output.BillingCountry 'United Kingdom')}}gbp{{else if (eq steps.get-account.output.BillingCountry 'Germany')}}eur{{else}}usd{{/if}}" } } } ``` ### Approval for Large Deals Add an approval step for deals over $100k: ```json { "id": "check-approval-needed", "type": "condition", "config": { "if": "{{input.payload.Amount > 100000}}", "then": "request-approval", "else": "create-invoice" } }, { "id": "request-approval", "type": "slack", "config": { "action": "sendMessage", "channel": "#finance-approvals", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Invoice Approval Required*\n\nOpportunity: {{input.payload.Name}}\nAmount: ${{input.payload.Amount}}" } }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "Approve" }, "style": "primary", "action_id": "approve_invoice_{{input.payload.Id}}" }, { "type": "button", "text": { "type": "plain_text", "text": "Reject" }, "style": "danger", "action_id": "reject_invoice_{{input.payload.Id}}" } ] } ] } } ``` ### Multiple Line Items from Opportunity Products If your opportunities have product line items: ```json { "id": "get-opportunity-products", "type": "action", "config": { "connector": "salesforce", "action": "query", "query": "SELECT Id, Name, Quantity, UnitPrice, TotalPrice FROM OpportunityLineItem WHERE OpportunityId = '{{input.payload.Id}}'" } }, { "id": "add-line-items", "type": "loop", "config": { "items": "{{steps.get-opportunity-products.output.records}}", "step": { "type": "action", "config": { "connector": "stripe", "action": "createInvoiceItem", "customer": "{{steps.resolve-customer.output.customerId}}", "invoice": "{{steps.create-invoice.output.id}}", "description": "{{item.Name}} (Qty: {{item.Quantity}})", "amount": "{{item.TotalPrice | multiply: 100}}", "currency": "usd" } } } } ``` ### Add Tax Calculation Integrate tax calculation for compliant invoicing: ```json { "id": "create-invoice", "type": "action", "config": { "connector": "stripe", "action": "createInvoice", "customer": "{{steps.resolve-customer.output.customerId}}", "automatic_tax": { "enabled": true } } } ``` ### HubSpot Variant Use HubSpot instead of Salesforce: ```json { "trigger": { "type": "webhook", "provider": "hubspot", "events": ["deal.propertyChange"], "filter": { "propertyName": "dealstage", "propertyValue": "closedwon" } } } ``` ## Common Issues | Issue | Cause | Solution | |-------|-------|----------| | Invoice not created | Missing customer in Stripe | Ensure find-or-create logic works | | Wrong amount | Currency conversion | Verify amount is in cents for Stripe | | Duplicate invoices | Webhook retries | Add idempotency key using opportunity ID | | Missing email | No billing email on account | Add validation step, notify if missing | ## Related Resources - [Stripe Integration Guide](/docs/integrations/payments/stripe) - [Salesforce Integration Guide](/docs/integrations/crm/salesforce) - [Slack Block Reference](/docs/workflows/building-blocks/slack-block) - [Transform Steps](/docs/workflows/steps/transforms) - [Finance Team Use Cases](/docs/use-cases/finance-teams) # Use Cases (/docs/use-cases) Workflow patterns for different teams ## Use Cases Discover how different teams use Workflows API. Use cases describe workflow patterns by team and operating model. They are meant to help you recognize where deterministic automation can remove repeated manual work without hiding important controls. A finance team may care most about invoice intake, reconciliation, approvals, and close evidence. Sales operations may focus on clean handoffs between CRM, contracts, billing, and customer records. Customer success may need onboarding, renewal, and account-health processes that stay synchronized with finance systems. Each use case should be read as a starting point for scoping. Before building, identify the triggering event, the source-of-truth system, the owner of each exception, and the final record that proves the work happened. Loopfour is most useful when the workflow has clear rules, repeatable inputs, and a known escalation path for ambiguous cases. If the process changes every time, document it first; if it repeats with small variations, it is a strong candidate for automation. The pages in this section also help teams decide where to begin. Pick a workflow with enough volume to matter, but narrow enough that success can be measured clearly. Good first candidates often have visible queues, repeated copy-and-paste work, frequent handoffs between systems, or exceptions that already follow documented rules. Once the first workflow is stable, expand to adjacent steps that share the same trigger, records, or approval owners. Invoice automation, payment reconciliation, reporting Deal management, contract automation, pipeline sync Onboarding, health monitoring, renewal management # Customer Success (/docs/use-cases/customer-success) Workflow patterns for customer success and retention teams ## Customer Success Workflows Automate onboarding, health monitoring, renewal management, and proactive customer engagement across your customer success stack. ## Why Automate Customer Success? Customer success teams face unique challenges: - **Manual onboarding** delays time-to-value for new customers - **Health score updates** require pulling data from multiple systems - **Renewal tracking** relies on spreadsheets and calendar reminders - **Churn signals** often detected too late to intervene - **NPS and feedback** collection is inconsistent Workflow automation solves these by: - Triggering onboarding instantly when subscriptions start - Calculating health scores automatically from usage data - Alerting CSMs proactively when accounts need attention - Standardizing customer communication touchpoints - Ensuring no renewal falls through the cracks ## Common Workflow Patterns ### Automated Onboarding **Trigger:** New subscription created in Stripe **Actions:** Create customer records, send welcome email, notify team Automatically set up new customers across all systems when they subscribe. ```json { "name": "Customer Onboarding", "trigger": { "type": "webhook", "provider": "stripe", "events": ["customer.subscription.created"] }, "steps": [ { "id": "get-customer", "type": "action", "config": { "connector": "stripe", "action": "getCustomer", "customerId": "{{input.data.object.customer}}" } }, { "id": "create-qb-customer", "type": "action", "config": { "connector": "quickbooks", "action": "createCustomer", "DisplayName": "{{steps.get-customer.output.name}}", "PrimaryEmailAddr": { "Address": "{{steps.get-customer.output.email}}" } }, "onError": { "action": "continue" } }, { "id": "create-hubspot-contact", "type": "action", "config": { "connector": "hubspot", "action": "createContact", "email": "{{steps.get-customer.output.email}}", "properties": { "firstname": "{{steps.get-customer.output.name | split: ' ' | first}}", "lastname": "{{steps.get-customer.output.name | split: ' ' | last}}", "lifecyclestage": "customer", "stripe_customer_id": "{{steps.get-customer.output.id}}", "subscription_start_date": "{{now | date: '%Y-%m-%d'}}" } } }, { "id": "send-welcome-email", "type": "email", "config": { "action": "send", "provider": "resend", "to": "{{steps.get-customer.output.email}}", "from": "success@company.com", "subject": "Welcome to Company!", "body": { "html": "

Welcome, {{steps.get-customer.output.name}}!

We're excited to have you on board.

Getting Started

  1. Complete your setup
  2. Read our documentation
  3. Book your onboarding call
" } } }, { "id": "notify-cs-team", "type": "slack", "config": { "action": "sendMessage", "channel": "#new-customers", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "New Customer!" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Name:*\n{{steps.get-customer.output.name}}" }, { "type": "mrkdwn", "text": "*Email:*\n{{steps.get-customer.output.email}}" }, { "type": "mrkdwn", "text": "*Plan:*\n{{input.data.object.items.data.[0].plan.nickname}}" }, { "type": "mrkdwn", "text": "*MRR:*\n${{input.data.object.items.data.[0].plan.amount | divided_by: 100}}" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View in Stripe" }, "url": "https://dashboard.stripe.com/customers/{{steps.get-customer.output.id}}" } ] } ] } } ] } ``` **Benefits:** - Customer set up across all systems instantly - Welcome email sent automatically - CS team notified with full context - No manual data entry required [Full Customer Onboarding Tutorial →](/docs/tutorials/customer-onboarding) --- ### Health Score Monitoring **Trigger:** Scheduled daily **Actions:** Calculate usage metrics, update health score, alert on low scores Automatically calculate and track customer health scores. ```json { "name": "Daily Health Score Update", "trigger": { "type": "schedule", "cron": "0 6 * * *" }, "steps": [ { "id": "get-active-customers", "type": "action", "config": { "connector": "hubspot", "action": "searchContacts", "filter": { "lifecyclestage": { "$eq": "customer" } } } }, { "id": "calculate-health-scores", "type": "loop", "config": { "items": "{{steps.get-active-customers.output.results}}", "step": { "type": "transform", "config": { "operation": "map", "data": { "contactId": "{{item.id}}", "email": "{{item.properties.email}}", "loginScore": "{{#if (gt item.properties.days_since_login 30)}}0{{else if (gt item.properties.days_since_login 14)}}25{{else if (gt item.properties.days_since_login 7)}}50{{else}}100{{/if}}", "usageScore": "{{#if (gt item.properties.feature_usage_pct 80)}}100{{else if (gt item.properties.feature_usage_pct 50)}}75{{else if (gt item.properties.feature_usage_pct 25)}}50{{else}}25{{/if}}", "supportScore": "{{#if (eq item.properties.open_tickets 0)}}100{{else if (lt item.properties.open_tickets 3)}}75{{else}}50{{/if}}", "healthScore": "{{(loginScore + usageScore + supportScore) / 3}}" } } } } }, { "id": "update-health-scores", "type": "loop", "config": { "items": "{{steps.calculate-health-scores.output}}", "step": { "type": "action", "config": { "connector": "hubspot", "action": "updateContact", "contactId": "{{item.contactId}}", "properties": { "health_score": "{{item.healthScore}}", "health_score_updated": "{{now | date: '%Y-%m-%d'}}" } } } } }, { "id": "find-at-risk", "type": "transform", "config": { "operation": "filter", "data": "{{steps.calculate-health-scores.output}}", "condition": "{{item.healthScore < 50}}" } }, { "id": "alert-at-risk", "type": "condition", "config": { "if": "{{steps.find-at-risk.output.length > 0}}", "then": "notify-at-risk", "else": "end" } }, { "id": "notify-at-risk", "type": "slack", "config": { "action": "sendMessage", "channel": "#cs-alerts", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "At-Risk Customers Alert" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "{{steps.find-at-risk.output.length}} customers have health scores below 50:\n\n{{#each steps.find-at-risk.output}}• {{this.email}} (Score: {{this.healthScore}})\n{{/each}}" } } ] } } ] } ``` **Benefits:** - Health scores updated consistently - At-risk accounts identified automatically - CSM team alerted proactively - Data-driven customer management --- ### Renewal Management **Trigger:** 90 days before subscription renewal **Actions:** Create renewal opportunity, notify CSM, send customer email Proactively manage renewals before they become urgent. ```json { "name": "Renewal Management", "trigger": { "type": "schedule", "cron": "0 8 * * 1" }, "steps": [ { "id": "find-upcoming-renewals", "type": "action", "config": { "connector": "stripe", "action": "listSubscriptions", "status": "active", "current_period_end": { "$gte": "{{now | dateAdd: 60, 'days' | date: '%s'}}", "$lte": "{{now | dateAdd: 90, 'days' | date: '%s'}}" } } }, { "id": "process-renewals", "type": "loop", "config": { "items": "{{steps.find-upcoming-renewals.output.data}}", "step": { "type": "action", "config": { "connector": "hubspot", "action": "createDeal", "properties": { "dealname": "{{item.customer.name}} - Renewal", "dealstage": "renewal_upcoming", "amount": "{{item.items.data.[0].price.unit_amount | divided_by: 100 | multiply: 12}}", "closedate": "{{item.current_period_end | date: '%Y-%m-%d'}}", "deal_type": "renewal", "stripe_subscription_id": "{{item.id}}" } } } } }, { "id": "notify-csms", "type": "slack", "config": { "action": "sendMessage", "channel": "#cs-renewals", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Upcoming Renewals (60-90 days)" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "{{steps.find-upcoming-renewals.output.data.length}} renewals coming up:\n\n{{#each steps.find-upcoming-renewals.output.data}}• {{this.customer.name}} - ${{this.items.data.[0].price.unit_amount | divided_by: 100}}/mo - Renews {{this.current_period_end | date: '%B %d'}}\n{{/each}}" } } ] } } ] } ``` --- ### Churn Prevention **Trigger:** Health score drops below threshold **Actions:** Create urgent task, notify manager, send check-in email Automatically intervene when customer health declines. ```json { "name": "Churn Prevention", "trigger": { "type": "webhook", "provider": "hubspot", "events": ["contact.propertyChange"], "filter": { "propertyName": { "$eq": "health_score" } } }, "steps": [ { "id": "get-contact", "type": "action", "config": { "connector": "hubspot", "action": "getContact", "contactId": "{{input.objectId}}" } }, { "id": "check-threshold", "type": "condition", "config": { "if": "{{steps.get-contact.output.properties.health_score < 40}}", "then": "trigger-intervention", "else": "end" } }, { "id": "trigger-intervention", "type": "slack", "config": { "action": "sendMessage", "channel": "#cs-urgent", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Churn Risk Alert" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Customer:*\n{{steps.get-contact.output.properties.firstname}} {{steps.get-contact.output.properties.lastname}}" }, { "type": "mrkdwn", "text": "*Company:*\n{{steps.get-contact.output.properties.company}}" }, { "type": "mrkdwn", "text": "*Health Score:*\n{{steps.get-contact.output.properties.health_score}}" }, { "type": "mrkdwn", "text": "*MRR:*\n${{steps.get-contact.output.properties.mrr}}" } ] }, { "type": "context", "elements": [ { "type": "mrkdwn", "text": "Last login: {{steps.get-contact.output.properties.last_login_date}} | Open tickets: {{steps.get-contact.output.properties.open_tickets}}" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View Customer" }, "style": "primary", "url": "https://app.hubspot.com/contacts/PORTAL/contact/{{input.objectId}}" }, { "type": "button", "text": { "type": "plain_text", "text": "Schedule Call" }, "url": "https://meetings.hubspot.com/csm-calendar" } ] } ] } }, { "id": "send-checkin-email", "type": "email", "config": { "action": "send", "provider": "gmail", "to": "{{steps.get-contact.output.properties.email}}", "from": "success@company.com", "subject": "How can we help?", "body": { "html": "

Hi {{steps.get-contact.output.properties.firstname}},

I noticed it's been a while since we connected. I wanted to check in and see how things are going with Company.

Are there any challenges I can help with? I'd love to schedule a quick call to make sure you're getting the most out of your subscription.

Book a time that works for you

Looking forward to connecting!

" } } }, { "id": "update-status", "type": "action", "config": { "connector": "hubspot", "action": "updateContact", "contactId": "{{input.objectId}}", "properties": { "customer_status": "At Risk", "churn_intervention_date": "{{now | date: '%Y-%m-%d'}}" } } } ] } ``` **Benefits:** - Immediate intervention on health drops - CS team notified with context - Automated customer outreach - Full tracking of intervention --- ### NPS Survey Automation **Trigger:** 30 days after subscription start **Actions:** Send NPS survey, collect response, update customer record Automatically collect NPS feedback at the right time. ```json { "name": "NPS Survey", "trigger": { "type": "schedule", "cron": "0 10 * * *" }, "steps": [ { "id": "find-eligible-customers", "type": "action", "config": { "connector": "hubspot", "action": "searchContacts", "filter": { "lifecyclestage": { "$eq": "customer" }, "subscription_start_date": { "$gte": "{{now | dateAdd: -31, 'days' | date: '%Y-%m-%d'}}", "$lte": "{{now | dateAdd: -29, 'days' | date: '%Y-%m-%d'}}" }, "nps_sent_date": { "$not_exists": true } } } }, { "id": "send-nps-surveys", "type": "loop", "config": { "items": "{{steps.find-eligible-customers.output.results}}", "step": { "type": "email", "config": { "action": "send", "provider": "resend", "to": "{{item.properties.email}}", "from": "feedback@company.com", "subject": "How likely are you to recommend Company?", "body": { "html": "

Hi {{item.properties.firstname}},

You've been using Company for about a month now. We'd love your feedback!

How likely are you to recommend Company to a friend or colleague?

10 - Extremely likely | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 - Not at all likely

Thank you for your feedback!

" } } } } }, { "id": "update-sent-dates", "type": "loop", "config": { "items": "{{steps.find-eligible-customers.output.results}}", "step": { "type": "action", "config": { "connector": "hubspot", "action": "updateContact", "contactId": "{{item.id}}", "properties": { "nps_sent_date": "{{now | date: '%Y-%m-%d'}}" } } } } }, { "id": "notify-cs", "type": "slack", "config": { "action": "sendMessage", "channel": "#cs-feedback", "text": "NPS surveys sent to {{steps.find-eligible-customers.output.results.length}} customers today." } } ] } ``` --- ### Expansion Opportunity Detection **Trigger:** Usage exceeds plan limits **Actions:** Create upsell opportunity, notify AE, send upgrade email Automatically identify and act on expansion opportunities. ```json { "name": "Expansion Opportunity", "trigger": { "type": "webhook", "provider": "custom", "events": ["usage.threshold_exceeded"] }, "steps": [ { "id": "get-customer", "type": "action", "config": { "connector": "hubspot", "action": "getContact", "email": "{{input.customer_email}}" } }, { "id": "create-upsell-deal", "type": "action", "config": { "connector": "hubspot", "action": "createDeal", "properties": { "dealname": "{{steps.get-customer.output.properties.company}} - Upgrade", "dealstage": "expansion_identified", "deal_type": "expansion", "amount": "{{input.upgrade_value}}", "notes": "Customer exceeded {{input.metric_name}}: {{input.current_usage}} / {{input.limit}}" }, "associations": [{ "to": "{{steps.get-customer.output.id}}", "type": "contact" }] } }, { "id": "notify-ae", "type": "slack", "config": { "action": "sendMessage", "channel": "#expansion-opps", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Expansion Opportunity" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Customer:*\n{{steps.get-customer.output.properties.company}}" }, { "type": "mrkdwn", "text": "*Current Plan:*\n{{steps.get-customer.output.properties.subscription_plan}}" }, { "type": "mrkdwn", "text": "*Metric:*\n{{input.metric_name}}" }, { "type": "mrkdwn", "text": "*Usage:*\n{{input.current_usage}} / {{input.limit}} ({{input.usage_pct}}%)" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View Deal" }, "style": "primary", "url": "https://app.hubspot.com/contacts/PORTAL/deal/{{steps.create-upsell-deal.output.id}}" } ] } ] } }, { "id": "send-upgrade-email", "type": "email", "config": { "action": "send", "provider": "gmail", "to": "{{input.customer_email}}", "from": "success@company.com", "subject": "You're outgrowing your plan!", "body": { "html": "

Hi {{steps.get-customer.output.properties.firstname}},

Great news - you've been getting a lot of value from Company! You're currently using {{input.usage_pct}}% of your {{input.metric_name}} limit.

To ensure uninterrupted service, you might want to consider upgrading to our {{input.recommended_plan}} plan.

View upgrade options

Questions? Reply to this email or book a call with your success manager.

" } } } ] } ``` ## Key Integrations | Integration | Use Cases | |-------------|-----------| | **Stripe** | Subscriptions, billing, customer data | | **HubSpot** | CRM, contacts, deals, health tracking | | **Salesforce** | CRM, accounts, opportunities | | **QuickBooks** | Customer records, invoicing | | **Slack** | Team notifications, alerts | | **Email** | Customer communication, surveys | ## Best Practices ### 1. Health Score Components Build composite health scores from multiple signals: ```json { "healthScore": "{{(loginScore * 0.3) + (usageScore * 0.4) + (supportScore * 0.3)}}" } ``` ### 2. Tiered Interventions Different actions for different severity levels: ```json { "id": "route-by-severity", "type": "condition", "config": { "if": "{{healthScore < 30}}", "then": "urgent-intervention", "else": { "if": "{{healthScore < 50}}", "then": "standard-intervention", "else": "monitor" } } } ``` ### 3. Timing Matters Send communications at optimal times: ```json { "trigger": { "type": "schedule", "cron": "0 10 * * 2" } } ``` ### 4. Track Everything Always log actions taken: ```json { "id": "log-intervention", "type": "action", "config": { "connector": "hubspot", "action": "updateContact", "properties": { "last_intervention_date": "{{now}}", "intervention_type": "churn_prevention" } } } ``` ## Getting Started 1. **[Customer Onboarding Tutorial](/docs/tutorials/customer-onboarding)** - Start with automated onboarding 2. **[Stripe Integration Guide](/docs/integrations/payments/stripe)** - Set up subscription tracking 3. **[HubSpot Integration Guide](/docs/integrations/crm/hubspot)** - Connect your CRM 4. **[Email Block Reference](/docs/workflows/building-blocks/email-block)** - Learn email automation ## Related Resources - [Invoice Automation Tutorial](/docs/tutorials/invoice-automation) - [Slack Block Reference](/docs/workflows/building-blocks/slack-block) - [Finance Team Use Cases](/docs/use-cases/finance-teams) - [Sales Ops Use Cases](/docs/use-cases/sales-ops) # Finance Teams (/docs/use-cases/finance-teams) Workflow patterns for finance and billing automation ## Finance Team Workflows Automate invoicing, payment reconciliation, revenue recognition, and financial operations across your billing and accounting systems. ## Why Automate Finance Operations? Finance teams face unique challenges: - **Manual data entry** across multiple systems (CRM, billing, accounting) - **Delayed invoicing** waiting for deal information from sales - **Reconciliation bottlenecks** matching payments to invoices - **Month-end crunches** racing to close books on time - **Audit trail gaps** when processes aren't documented Workflow automation solves these by: - Eliminating copy-paste between systems - Triggering actions instantly when events occur - Maintaining audit trails automatically - Reducing errors from manual data entry - Freeing up time for strategic analysis ## Common Workflow Patterns ### Invoice Automation **Trigger:** CRM deal closes won **Actions:** Create invoice, send to customer, update CRM Automatically generate invoices when sales deals close, eliminating the handoff delay between sales and finance. ```json { "name": "Deal Close → Invoice", "trigger": { "type": "webhook", "provider": "salesforce", "events": ["opportunity.updated"], "filter": { "StageName": { "$eq": "Closed Won" } } }, "steps": [ { "id": "get-account", "type": "action", "config": { "connector": "salesforce", "action": "getAccount", "accountId": "{{input.payload.AccountId}}" } }, { "id": "create-invoice", "type": "action", "config": { "connector": "stripe", "action": "createInvoice", "customer": "{{steps.get-account.output.Stripe_Customer_ID__c}}", "collection_method": "send_invoice", "days_until_due": 30 } }, { "id": "add-line-item", "type": "action", "config": { "connector": "stripe", "action": "createInvoiceItem", "customer": "{{steps.get-account.output.Stripe_Customer_ID__c}}", "invoice": "{{steps.create-invoice.output.id}}", "description": "{{input.payload.Name}}", "amount": "{{input.payload.Amount | multiply: 100}}", "currency": "usd" } }, { "id": "finalize-and-send", "type": "action", "config": { "connector": "stripe", "action": "sendInvoice", "invoiceId": "{{steps.create-invoice.output.id}}" } }, { "id": "update-opportunity", "type": "action", "config": { "connector": "salesforce", "action": "updateOpportunity", "opportunityId": "{{input.payload.Id}}", "fields": { "Invoice_URL__c": "{{steps.finalize-and-send.output.hosted_invoice_url}}", "Invoice_Status__c": "Sent" } } } ] } ``` **Benefits:** - Invoice sent within seconds of deal close - No manual data entry errors - Full audit trail in CRM - Customer receives invoice immediately [Full Invoice Automation Tutorial →](/docs/tutorials/invoice-automation) --- ### Payment Reconciliation **Trigger:** Payment received in Stripe **Actions:** Match to invoice, update accounting, notify team Automatically reconcile payments when they arrive, updating both accounting and CRM systems. ```json { "name": "Payment Reconciliation", "trigger": { "type": "webhook", "provider": "stripe", "events": ["invoice.payment_succeeded"] }, "steps": [ { "id": "get-invoice-details", "type": "action", "config": { "connector": "stripe", "action": "getInvoice", "invoiceId": "{{input.data.object.id}}" } }, { "id": "find-qb-invoice", "type": "action", "config": { "connector": "quickbooks", "action": "query", "query": "SELECT * FROM Invoice WHERE DocNumber = '{{steps.get-invoice-details.output.number}}'" } }, { "id": "record-payment", "type": "action", "config": { "connector": "quickbooks", "action": "createPayment", "CustomerRef": { "value": "{{steps.find-qb-invoice.output.QueryResponse.Invoice.[0].CustomerRef.value}}" }, "TotalAmt": "{{input.data.object.amount_paid | divided_by: 100}}", "Line": [{ "Amount": "{{input.data.object.amount_paid | divided_by: 100}}", "LinkedTxn": [{ "TxnId": "{{steps.find-qb-invoice.output.QueryResponse.Invoice.[0].Id}}", "TxnType": "Invoice" }] }] } }, { "id": "update-crm", "type": "action", "config": { "connector": "salesforce", "action": "query", "query": "UPDATE Opportunity SET Invoice_Status__c = 'Paid' WHERE Stripe_Invoice_ID__c = '{{input.data.object.id}}'" } }, { "id": "notify-finance", "type": "slack", "config": { "action": "sendMessage", "channel": "#finance-payments", "text": "Payment received: ${{input.data.object.amount_paid | divided_by: 100}} from {{steps.get-invoice-details.output.customer_name}} for invoice #{{steps.get-invoice-details.output.number}}" } } ] } ``` **Benefits:** - Real-time payment tracking - Automatic QuickBooks reconciliation - CRM status always current - Finance team notified instantly --- ### Revenue Recognition **Trigger:** Scheduled monthly (1st of month) **Actions:** Query subscriptions, calculate MRR/ARR, update spreadsheet Automate monthly revenue calculations for accurate financial reporting. ```json { "name": "Monthly Revenue Recognition", "trigger": { "type": "schedule", "cron": "0 6 1 * *" }, "steps": [ { "id": "get-active-subscriptions", "type": "action", "config": { "connector": "stripe", "action": "listSubscriptions", "status": "active" } }, { "id": "calculate-metrics", "type": "transform", "config": { "operation": "reduce", "data": "{{steps.get-active-subscriptions.output.data}}", "accumulator": { "mrr": 0, "customer_count": 0 }, "expression": { "mrr": "{{acc.mrr + (item.items.data.[0].price.unit_amount / 100)}}", "customer_count": "{{acc.customer_count + 1}}" } } }, { "id": "create-journal-entry", "type": "action", "config": { "connector": "netsuite", "action": "createJournalEntry", "subsidiary": { "id": "1" }, "trandate": "{{now | date: '%Y-%m-01'}}", "memo": "Monthly Revenue Recognition - {{now | date: '%B %Y'}}", "line": [ { "account": { "id": "123" }, "debit": "{{steps.calculate-metrics.output.mrr}}" }, { "account": { "id": "456" }, "credit": "{{steps.calculate-metrics.output.mrr}}" } ] } }, { "id": "notify-finance", "type": "slack", "config": { "action": "sendMessage", "channel": "#finance-metrics", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Monthly Revenue Report" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*MRR:* ${{steps.calculate-metrics.output.mrr | number_format: 2}}" }, { "type": "mrkdwn", "text": "*ARR:* ${{steps.calculate-metrics.output.mrr | multiply: 12 | number_format: 2}}" }, { "type": "mrkdwn", "text": "*Active Customers:* {{steps.calculate-metrics.output.customer_count}}" } ] } ] } } ] } ``` **Benefits:** - Consistent monthly calculations - Automatic journal entries - Team visibility into metrics - Reduces month-end workload --- ### Dunning Automation **Trigger:** Invoice overdue **Actions:** Send reminder emails, update customer status, notify sales Automatically follow up on overdue invoices without manual tracking. ```json { "name": "Invoice Dunning", "trigger": { "type": "webhook", "provider": "stripe", "events": ["invoice.payment_failed"] }, "steps": [ { "id": "get-customer", "type": "action", "config": { "connector": "stripe", "action": "getCustomer", "customerId": "{{input.data.object.customer}}" } }, { "id": "send-reminder-email", "type": "email", "config": { "action": "send", "provider": "resend", "to": "{{steps.get-customer.output.email}}", "from": "billing@company.com", "subject": "Payment Failed - Action Required", "body": { "html": "

Hi {{steps.get-customer.output.name}},

We were unable to process your payment of ${{input.data.object.amount_due | divided_by: 100}} for invoice #{{input.data.object.number}}.

Please update your payment method to avoid service interruption.

Update Payment Method

" } } }, { "id": "update-hubspot-status", "type": "action", "config": { "connector": "hubspot", "action": "updateContact", "email": "{{steps.get-customer.output.email}}", "properties": { "payment_status": "Failed", "last_payment_attempt": "{{now | date: '%Y-%m-%d'}}" } } }, { "id": "notify-account-owner", "type": "slack", "config": { "action": "sendMessage", "channel": "#sales-alerts", "text": ":warning: Payment failed for {{steps.get-customer.output.name}}. Invoice #{{input.data.object.number}} for ${{input.data.object.amount_due | divided_by: 100}}. <{{input.data.object.hosted_invoice_url}}|View Invoice>" } } ] } ``` **Benefits:** - Immediate customer notification - Sales visibility into at-risk accounts - Consistent follow-up process - Reduced churn from payment issues --- ### Subscription Billing Sync **Trigger:** Subscription changes in Stripe **Actions:** Update accounting records, sync to ERP Keep billing and accounting systems in sync when subscriptions change. ```json { "name": "Subscription → Accounting Sync", "trigger": { "type": "webhook", "provider": "stripe", "events": ["customer.subscription.updated", "customer.subscription.deleted"] }, "steps": [ { "id": "get-subscription", "type": "action", "config": { "connector": "stripe", "action": "getSubscription", "subscriptionId": "{{input.data.object.id}}" } }, { "id": "find-netsuite-customer", "type": "action", "config": { "connector": "netsuite", "action": "searchRecords", "query": "SELECT id FROM customer WHERE custentity_stripe_id = '{{input.data.object.customer}}'" } }, { "id": "update-subscription-record", "type": "action", "config": { "connector": "netsuite", "action": "updateRecord", "type": "customrecord_subscription", "id": "{{steps.find-netsuite-customer.output.id}}", "values": { "custrecord_sub_status": "{{input.data.object.status}}", "custrecord_sub_mrr": "{{steps.get-subscription.output.items.data.[0].price.unit_amount | divided_by: 100}}", "custrecord_sub_next_billing": "{{input.data.object.current_period_end | date: '%Y-%m-%d'}}" } } } ] } ``` --- ### Expense Approval Workflow **Trigger:** Expense submitted via API **Actions:** Route for approval, notify manager, process payment Automate expense approval workflows with Slack integration. ```json { "name": "Expense Approval", "trigger": { "type": "api" }, "steps": [ { "id": "determine-approver", "type": "transform", "config": { "operation": "map", "data": { "approver": "{{#if (gt input.amount 1000)}}cfo@company.com{{else}}manager@company.com{{/if}}", "approverSlackId": "{{#if (gt input.amount 1000)}}U_CFO{{else}}U_MANAGER{{/if}}" } } }, { "id": "request-approval", "type": "slack", "config": { "action": "sendMessage", "channel": "#expense-approvals", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Expense Approval Request" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Submitted By:* {{input.employee_name}}" }, { "type": "mrkdwn", "text": "*Amount:* ${{input.amount}}" }, { "type": "mrkdwn", "text": "*Category:* {{input.category}}" }, { "type": "mrkdwn", "text": "*Description:* {{input.description}}" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "Approve" }, "style": "primary", "action_id": "approve_expense_{{input.expense_id}}" }, { "type": "button", "text": { "type": "plain_text", "text": "Reject" }, "style": "danger", "action_id": "reject_expense_{{input.expense_id}}" } ] } ] } } ] } ``` ## Key Integrations | Integration | Use Cases | |-------------|-----------| | **Stripe** | Invoicing, payments, subscriptions, customer management | | **QuickBooks** | Accounting records, payments, journal entries | | **NetSuite** | ERP integration, revenue recognition, multi-entity | | **Salesforce** | CRM updates, opportunity tracking, customer data | | **HubSpot** | Customer status, lifecycle tracking | | **Slack** | Notifications, approvals, alerts | | **Email** | Customer invoices, payment reminders | ## Best Practices ### 1. Idempotency Always include unique identifiers to prevent duplicate processing: ```json { "metadata": { "salesforce_opportunity_id": "{{input.payload.Id}}", "processed_at": "{{now}}" } } ``` ### 2. Error Handling Use graceful degradation for non-critical steps: ```json { "onError": { "action": "continue", "fallback": { "id": null } } } ``` ### 3. Audit Trails Always update source systems with results: ```json { "id": "update-source", "type": "action", "config": { "connector": "salesforce", "action": "updateOpportunity", "fields": { "Invoice_Created_At__c": "{{now}}", "Invoice_ID__c": "{{steps.create-invoice.output.id}}" } } } ``` ### 4. Amount Handling Remember Stripe amounts are in cents: ```json { "amount": "{{input.Amount | multiply: 100}}", "display_amount": "{{input.data.object.amount | divided_by: 100}}" } ``` ## Getting Started 1. **[Invoice Automation Tutorial](/docs/tutorials/invoice-automation)** - Start with deal-to-invoice automation 2. **[Stripe Integration Guide](/docs/integrations/payments/stripe)** - Set up your Stripe connection 3. **[QuickBooks Integration Guide](/docs/integrations/accounting/quickbooks)** - Connect your accounting system 4. **[Transform Steps](/docs/workflows/steps/transforms)** - Learn data transformation patterns ## Related Resources - [Customer Onboarding Tutorial](/docs/tutorials/customer-onboarding) - [Email Block Reference](/docs/workflows/building-blocks/email-block) - [Slack Block Reference](/docs/workflows/building-blocks/slack-block) - [NetSuite Integration Guide](/docs/integrations/accounting/netsuite) # Sales Operations (/docs/use-cases/sales-ops) Workflow patterns for sales teams and revenue operations ## Sales Operations Workflows Automate deal progression, contract generation, lead routing, and pipeline management across your CRM and sales tools. ## Why Automate Sales Operations? Sales ops teams face unique challenges: - **Manual handoffs** between sales stages slow deal velocity - **Contract delays** while waiting for legal and document preparation - **Lead routing inefficiencies** causing uneven rep workloads - **CRM data decay** as reps forget to update records - **Pipeline visibility gaps** requiring manual report generation Workflow automation solves these by: - Automating stage transitions and handoffs - Generating contracts instantly when deals are ready - Routing leads based on rules and rep capacity - Keeping CRM data current automatically - Providing real-time pipeline visibility ## Common Workflow Patterns ### Deal-to-Contract Automation **Trigger:** Deal reaches contract stage **Actions:** Create contract from template, send for signature Automatically generate and send contracts when deals reach the right stage, eliminating manual document creation. ```json { "name": "Deal → Contract", "trigger": { "type": "webhook", "provider": "hubspot", "events": ["deal.propertyChange"], "filter": { "propertyName": { "$eq": "dealstage" }, "propertyValue": { "$eq": "contractsent" } } }, "steps": [ { "id": "get-deal", "type": "action", "config": { "connector": "hubspot", "action": "getDeal", "dealId": "{{input.objectId}}", "associations": ["contacts", "companies"] } }, { "id": "get-contact", "type": "action", "config": { "connector": "hubspot", "action": "getContact", "contactId": "{{steps.get-deal.output.associations.contacts.results.[0].id}}" } }, { "id": "get-company", "type": "action", "config": { "connector": "hubspot", "action": "getCompany", "companyId": "{{steps.get-deal.output.associations.companies.results.[0].id}}" } }, { "id": "create-contract", "type": "action", "config": { "connector": "pandadoc", "action": "createDocument", "templateId": "CONTRACT_TEMPLATE_ID", "name": "Contract - {{steps.get-deal.output.properties.dealname}}", "recipients": [{ "email": "{{steps.get-contact.output.properties.email}}", "first_name": "{{steps.get-contact.output.properties.firstname}}", "last_name": "{{steps.get-contact.output.properties.lastname}}", "role": "Signer" }], "tokens": [ { "name": "deal_name", "value": "{{steps.get-deal.output.properties.dealname}}" }, { "name": "deal_amount", "value": "{{steps.get-deal.output.properties.amount}}" }, { "name": "company_name", "value": "{{steps.get-company.output.properties.name}}" } ] } }, { "id": "send-contract", "type": "action", "config": { "connector": "pandadoc", "action": "sendDocument", "documentId": "{{steps.create-contract.output.id}}", "message": "Please review and sign your contract." } }, { "id": "update-deal", "type": "action", "config": { "connector": "hubspot", "action": "updateDeal", "dealId": "{{input.objectId}}", "properties": { "contract_url": "https://app.pandadoc.com/documents/{{steps.create-contract.output.id}}", "contract_status": "Sent" } } }, { "id": "notify-sales", "type": "slack", "config": { "action": "sendMessage", "channel": "#sales-contracts", "text": "Contract sent for {{steps.get-deal.output.properties.dealname}} (${{steps.get-deal.output.properties.amount}}) to {{steps.get-contact.output.properties.email}}" } } ] } ``` **Benefits:** - Contract sent within seconds of stage change - No manual document assembly - Deal always has contract link for tracking - Team visibility into contract status [Full Deal Sync Tutorial →](/docs/tutorials/deal-sync) --- ### Quote Generation **Trigger:** API call from CRM or form **Actions:** Calculate pricing, generate PDF, attach to deal Automatically generate quotes with correct pricing and terms. ```json { "name": "Generate Quote", "trigger": { "type": "api" }, "steps": [ { "id": "get-pricing", "type": "action", "config": { "connector": "stripe", "action": "getPrice", "priceId": "{{input.price_id}}" } }, { "id": "calculate-quote", "type": "transform", "config": { "operation": "map", "data": { "unit_price": "{{steps.get-pricing.output.unit_amount | divided_by: 100}}", "quantity": "{{input.quantity}}", "subtotal": "{{steps.get-pricing.output.unit_amount | divided_by: 100 | multiply: input.quantity}}", "discount": "{{#if (gt input.quantity 10)}}{{steps.get-pricing.output.unit_amount | divided_by: 100 | multiply: input.quantity | multiply: 0.1}}{{else}}0{{/if}}", "total": "{{#if (gt input.quantity 10)}}{{steps.get-pricing.output.unit_amount | divided_by: 100 | multiply: input.quantity | multiply: 0.9}}{{else}}{{steps.get-pricing.output.unit_amount | divided_by: 100 | multiply: input.quantity}}{{/if}}" } } }, { "id": "create-quote-doc", "type": "action", "config": { "connector": "pandadoc", "action": "createDocument", "templateId": "QUOTE_TEMPLATE_ID", "name": "Quote - {{input.company_name}} - {{now | date: '%Y-%m-%d'}}", "recipients": [{ "email": "{{input.contact_email}}", "first_name": "{{input.contact_first_name}}", "last_name": "{{input.contact_last_name}}" }], "tokens": [ { "name": "company_name", "value": "{{input.company_name}}" }, { "name": "product_name", "value": "{{steps.get-pricing.output.product.name}}" }, { "name": "unit_price", "value": "{{steps.calculate-quote.output.unit_price}}" }, { "name": "quantity", "value": "{{input.quantity}}" }, { "name": "discount", "value": "{{steps.calculate-quote.output.discount}}" }, { "name": "total", "value": "{{steps.calculate-quote.output.total}}" }, { "name": "valid_until", "value": "{{now | dateAdd: 30, 'days' | date: '%B %d, %Y'}}" } ] } }, { "id": "update-deal", "type": "action", "config": { "connector": "hubspot", "action": "updateDeal", "dealId": "{{input.deal_id}}", "properties": { "quote_url": "https://app.pandadoc.com/documents/{{steps.create-quote-doc.output.id}}", "amount": "{{steps.calculate-quote.output.total}}" } } } ] } ``` **Benefits:** - Accurate pricing every time - Volume discounts applied automatically - Quote linked to CRM deal - Consistent professional documents --- ### Lead Routing **Trigger:** New lead created in CRM **Actions:** Enrich data, score lead, assign to rep, notify Automatically route leads to the right sales rep based on territory, product interest, or round-robin. ```json { "name": "Lead Router", "trigger": { "type": "webhook", "provider": "hubspot", "events": ["contact.creation"] }, "steps": [ { "id": "get-contact", "type": "action", "config": { "connector": "hubspot", "action": "getContact", "contactId": "{{input.objectId}}", "properties": ["email", "company", "country", "jobtitle", "hs_lead_status"] } }, { "id": "determine-territory", "type": "transform", "config": { "operation": "map", "data": { "region": "{{#if (or (eq steps.get-contact.output.properties.country 'United States') (eq steps.get-contact.output.properties.country 'Canada'))}}NA{{else if (or (eq steps.get-contact.output.properties.country 'United Kingdom') (eq steps.get-contact.output.properties.country 'Germany'))}}EMEA{{else}}APAC{{/if}}" } } }, { "id": "calculate-lead-score", "type": "transform", "config": { "operation": "map", "data": { "score": "{{0 | plus: (if (contains steps.get-contact.output.properties.jobtitle 'VP') 30 else 0) | plus: (if (contains steps.get-contact.output.properties.jobtitle 'Director') 20 else 0) | plus: (if (contains steps.get-contact.output.properties.jobtitle 'Manager') 10 else 0) | plus: (if steps.get-contact.output.properties.company 15 else 0)}}" } } }, { "id": "assign-owner", "type": "transform", "config": { "operation": "map", "data": { "ownerId": "{{#if (and (eq steps.determine-territory.output.region 'NA') (gt steps.calculate-lead-score.output.score 40))}}NA_ENTERPRISE_REP_ID{{else if (eq steps.determine-territory.output.region 'NA')}}NA_SMB_REP_ID{{else if (eq steps.determine-territory.output.region 'EMEA')}}EMEA_REP_ID{{else}}APAC_REP_ID{{/if}}", "ownerEmail": "{{#if (eq steps.determine-territory.output.region 'NA')}}sales-na@company.com{{else if (eq steps.determine-territory.output.region 'EMEA')}}sales-emea@company.com{{else}}sales-apac@company.com{{/if}}" } } }, { "id": "update-contact-owner", "type": "action", "config": { "connector": "hubspot", "action": "updateContact", "contactId": "{{input.objectId}}", "properties": { "hubspot_owner_id": "{{steps.assign-owner.output.ownerId}}", "hs_lead_status": "NEW", "lead_score": "{{steps.calculate-lead-score.output.score}}", "sales_region": "{{steps.determine-territory.output.region}}" } } }, { "id": "notify-rep", "type": "slack", "config": { "action": "sendMessage", "channel": "#sales-leads", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "New Lead Assigned" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Name:*\n{{steps.get-contact.output.properties.firstname}} {{steps.get-contact.output.properties.lastname}}" }, { "type": "mrkdwn", "text": "*Company:*\n{{steps.get-contact.output.properties.company}}" }, { "type": "mrkdwn", "text": "*Title:*\n{{steps.get-contact.output.properties.jobtitle}}" }, { "type": "mrkdwn", "text": "*Score:*\n{{steps.calculate-lead-score.output.score}}" } ] }, { "type": "context", "elements": [ { "type": "mrkdwn", "text": "Region: {{steps.determine-territory.output.region}} | Assigned to: {{steps.assign-owner.output.ownerEmail}}" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View in HubSpot" }, "url": "https://app.hubspot.com/contacts/YOUR_PORTAL/contact/{{input.objectId}}" } ] } ] } } ] } ``` **Benefits:** - Instant lead assignment - Consistent territory rules - Lead scoring automation - Rep notification with context --- ### Pipeline Sync **Trigger:** Scheduled hourly **Actions:** Sync deals between CRM and other systems Keep multiple systems in sync with scheduled pipeline synchronization. ```json { "name": "Pipeline Sync", "trigger": { "type": "schedule", "cron": "0 * * * *" }, "steps": [ { "id": "get-updated-deals", "type": "action", "config": { "connector": "hubspot", "action": "searchDeals", "filter": { "lastModifiedDate": { "$gte": "{{now | dateAdd: -1, 'hours'}}" } } } }, { "id": "sync-to-salesforce", "type": "loop", "config": { "items": "{{steps.get-updated-deals.output.results}}", "step": { "type": "action", "config": { "connector": "salesforce", "action": "updateOpportunity", "opportunityId": "{{item.properties.salesforce_opportunity_id}}", "fields": { "Amount": "{{item.properties.amount}}", "StageName": "{{item.properties.dealstage}}", "CloseDate": "{{item.properties.closedate}}" } } } } }, { "id": "log-sync", "type": "slack", "config": { "action": "sendMessage", "channel": "#sales-ops", "text": "Pipeline sync complete. {{steps.get-updated-deals.output.results.length}} deals synced." } } ] } ``` --- ### Win/Loss Analysis **Trigger:** Deal closed (won or lost) **Actions:** Collect feedback, update analytics, notify team Automatically capture win/loss data for analysis and reporting. ```json { "name": "Win/Loss Capture", "trigger": { "type": "webhook", "provider": "salesforce", "events": ["opportunity.updated"], "filter": { "IsClosed": { "$eq": true } } }, "steps": [ { "id": "get-opportunity", "type": "action", "config": { "connector": "salesforce", "action": "getOpportunity", "opportunityId": "{{input.payload.Id}}" } }, { "id": "get-owner", "type": "action", "config": { "connector": "salesforce", "action": "query", "query": "SELECT Id, Name, Email FROM User WHERE Id = '{{steps.get-opportunity.output.OwnerId}}'" } }, { "id": "request-feedback", "type": "slack", "config": { "action": "sendMessage", "channel": "@{{steps.get-owner.output.records.[0].Email}}", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "{{#if steps.get-opportunity.output.IsWon}}Deal Won!{{else}}Deal Lost{{/if}}" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*{{steps.get-opportunity.output.Name}}*\nAmount: ${{steps.get-opportunity.output.Amount}}" } }, { "type": "input", "block_id": "win_loss_reason", "label": { "type": "plain_text", "text": "{{#if steps.get-opportunity.output.IsWon}}What was the key factor in winning?{{else}}What was the primary reason for loss?{{/if}}" }, "element": { "type": "static_select", "action_id": "reason_select", "options": [ { "text": { "type": "plain_text", "text": "Price/Budget" }, "value": "price" }, { "text": { "type": "plain_text", "text": "Product Fit" }, "value": "product" }, { "text": { "type": "plain_text", "text": "Competitor" }, "value": "competitor" }, { "text": { "type": "plain_text", "text": "Timing" }, "value": "timing" }, { "text": { "type": "plain_text", "text": "Relationship" }, "value": "relationship" } ] } } ] } }, { "id": "notify-leadership", "type": "slack", "config": { "action": "sendMessage", "channel": "#sales-wins-losses", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "{{#if steps.get-opportunity.output.IsWon}}Won{{else}}Lost{{/if}}: {{steps.get-opportunity.output.Name}}" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Amount:*\n${{steps.get-opportunity.output.Amount}}" }, { "type": "mrkdwn", "text": "*Owner:*\n{{steps.get-owner.output.records.[0].Name}}" }, { "type": "mrkdwn", "text": "*Stage:*\n{{steps.get-opportunity.output.StageName}}" }, { "type": "mrkdwn", "text": "*Type:*\n{{steps.get-opportunity.output.Type}}" } ] } ] } } ] } ``` --- ### Account Executive Handoff **Trigger:** Lead qualified (MQL → SQL) **Actions:** Create opportunity, assign AE, schedule meeting Automate the handoff from SDR to Account Executive. ```json { "name": "SDR → AE Handoff", "trigger": { "type": "webhook", "provider": "hubspot", "events": ["contact.propertyChange"], "filter": { "propertyName": { "$eq": "lifecyclestage" }, "propertyValue": { "$eq": "salesqualifiedlead" } } }, "steps": [ { "id": "get-contact", "type": "action", "config": { "connector": "hubspot", "action": "getContact", "contactId": "{{input.objectId}}", "associations": ["companies"] } }, { "id": "determine-ae", "type": "transform", "config": { "operation": "map", "data": { "aeId": "AE_OWNER_ID", "aeEmail": "ae@company.com", "aeSlackId": "U_AE_SLACK_ID" } } }, { "id": "create-deal", "type": "action", "config": { "connector": "hubspot", "action": "createDeal", "properties": { "dealname": "{{steps.get-contact.output.properties.company}} - New Opportunity", "dealstage": "qualifiedtobuy", "hubspot_owner_id": "{{steps.determine-ae.output.aeId}}", "pipeline": "default" }, "associations": [{ "to": "{{input.objectId}}", "type": "contact" }] } }, { "id": "notify-ae", "type": "slack", "config": { "action": "sendMessage", "channel": "@{{steps.determine-ae.output.aeSlackId}}", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "New SQL Handoff" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Contact:*\n{{steps.get-contact.output.properties.firstname}} {{steps.get-contact.output.properties.lastname}}" }, { "type": "mrkdwn", "text": "*Company:*\n{{steps.get-contact.output.properties.company}}" }, { "type": "mrkdwn", "text": "*Title:*\n{{steps.get-contact.output.properties.jobtitle}}" }, { "type": "mrkdwn", "text": "*Email:*\n{{steps.get-contact.output.properties.email}}" } ] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View Deal" }, "url": "https://app.hubspot.com/contacts/YOUR_PORTAL/deal/{{steps.create-deal.output.id}}" }, { "type": "button", "text": { "type": "plain_text", "text": "Schedule Meeting" }, "url": "https://meetings.hubspot.com/ae-calendar" } ] } ] } }, { "id": "send-intro-email", "type": "email", "config": { "action": "send", "provider": "gmail", "to": "{{steps.get-contact.output.properties.email}}", "from": "{{steps.determine-ae.output.aeEmail}}", "subject": "Let's schedule a call - {{steps.get-contact.output.properties.company}}", "body": { "html": "

Hi {{steps.get-contact.output.properties.firstname}},

I'm your dedicated account executive at Company. I'd love to learn more about your needs and show you how we can help.

Would you have 30 minutes this week for a quick call?

Schedule a time that works for you

" } } } ] } ``` ## Key Integrations | Integration | Use Cases | |-------------|-----------| | **Salesforce** | CRM, opportunities, accounts, contacts | | **HubSpot** | CRM, deals, contacts, companies | | **PandaDoc** | Contracts, quotes, proposals | | **Slack** | Notifications, approvals, team alerts | | **Stripe** | Pricing, quotes, subscriptions | | **Email** | Outreach, follow-ups, introductions | ## Best Practices ### 1. Stage-Based Triggers Use property change events instead of polling: ```json { "trigger": { "type": "webhook", "provider": "hubspot", "events": ["deal.propertyChange"], "filter": { "propertyName": { "$eq": "dealstage" } } } } ``` ### 2. Owner Notifications Always notify the record owner: ```json { "id": "notify-owner", "type": "slack", "config": { "action": "sendMessage", "channel": "@{{steps.get-owner.output.slack_id}}" } } ``` ### 3. Link Back to CRM Include CRM links in all notifications: ```json { "type": "button", "text": { "type": "plain_text", "text": "View in CRM" }, "url": "https://app.hubspot.com/contacts/PORTAL/deal/{{dealId}}" } ``` ### 4. Idempotency for Contracts Prevent duplicate contracts: ```json { "metadata": { "hubspot_deal_id": "{{input.objectId}}", "created_at": "{{now}}" } } ``` ## Getting Started 1. **[Deal Sync Tutorial](/docs/tutorials/deal-sync)** - Start with contract automation 2. **[HubSpot Integration Guide](/docs/integrations/crm/hubspot)** - Set up your CRM connection 3. **[PandaDoc Integration Guide](/docs/integrations/documents/pandadoc)** - Connect document generation 4. **[Slack Block Reference](/docs/workflows/building-blocks/slack-block)** - Learn notification patterns ## Related Resources - [Invoice Automation Tutorial](/docs/tutorials/invoice-automation) - [Salesforce Integration Guide](/docs/integrations/crm/salesforce) - [Email Block Reference](/docs/workflows/building-blocks/email-block) - [Finance Team Use Cases](/docs/use-cases/finance-teams) # Workflows (/docs/workflows) Learn how to build and manage workflows ## Workflows Workflows are the core building blocks of the Workflows API. A workflow defines a series of steps that execute in response to a trigger. A good workflow is explicit about what starts it, what data it expects, which systems it touches, and what should happen when something does not match the happy path. In Loopfour, the trigger supplies the initial context, steps transform or act on that context, and each run records the decisions and outputs produced along the way. This structure is especially important for finance processes because later review often matters as much as successful execution. When designing a workflow, start with the business outcome rather than the integration list. Decide whether the goal is to create an invoice, reconcile a payment, route an approval, update a customer record, notify an owner, or prepare data for another system. Then model the smallest reliable path to that outcome. Add branches only where the process truly differs, and name steps in terms finance operators can recognize. Clear step names make run history easier to audit and support. Before activating a workflow, review how it behaves when required data is missing, a provider is unavailable, or an approval owner does not respond. Those paths should be intentional. A workflow that only works for perfect input will create manual cleanup later; a workflow with explicit validation and exception handling can fail in a way operators understand and recover from safely. ## Workflow Structure ```json { "name": "My Workflow", "description": "Optional description", "trigger": { "type": "api" }, "steps": [ { "id": "step1", "type": "action", "..." } ] } ``` ## Sections Core concepts and workflow lifecycle How workflows are started Actions, conditions, transforms, and waits Reusable workflow components # Building Blocks (/docs/workflows/building-blocks) Reusable workflow components ## Building Blocks Building blocks are pre-configured step types that simplify common operations. Use building blocks when a workflow needs a repeatable interaction that should not be rebuilt from raw code each time. Blocks wrap common provider operations, messaging patterns, and workflow behaviors behind a typed configuration surface. That makes them easier to review, easier to validate, and safer to reuse across workflows that share the same operational pattern. In finance workflows, blocks are useful for the parts of the process that should remain consistent: sending an approval request, posting a Slack notification, formatting an email, transforming data into a provider payload, or creating a record in an external system. Choose the most specific block that matches the job. A specific block usually gives better validation, clearer run logs, and fewer hidden assumptions than a generic code step. Use custom code only when the workflow needs logic that is not already covered by a block or provider action. Blocks also make workflow review easier. A reviewer can see that one step sends a message, another writes to an accounting system, and another waits for approval without reading custom implementation code. That clarity matters when finance, operations, and engineering share responsibility for the same automation. Prefer predictable blocks for routine work, then reserve flexible steps for exceptional logic that has no reusable provider action yet. Send messages and interact with Slack Send emails with template support # Email Block (/docs/workflows/building-blocks/email-block) Send emails with Handlebars template support across multiple providers ## Email Block The Email block sends emails with full Handlebars template support, working across Gmail, Outlook, and Resend. ## Overview Use the Email block when you need to: - Send transactional emails (invoices, receipts, notifications) - Create dynamic email content with templates - Attach files to emails - Schedule emails for later delivery - Support multiple email providers with a unified interface ## Prerequisites | Provider | Connection Type | Requirements | |----------|-----------------|--------------| | Gmail | OAuth via Nango | `gmail` provider configured | | Outlook | OAuth via Nango | `microsoft-graph` provider configured | | Resend | API Key | `RESEND_API_KEY` env var or `api_key` in config | ## Configuration ### Step Definition ```json { "id": "send-invoice-email", "type": "email", "name": "Send Invoice Email", "config": { "action": "send", "provider": "gmail", "to": "{{input.customer_email}}", "subject": "Invoice #{{input.invoice_number}} Ready", "body": { "html": "

Hello {{input.customer_name}},

Your invoice is ready.

" } } } ``` ### Configuration Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `action` | string | Yes | `send`, `sendTemplate`, or `schedule` | | `provider` | string | Yes | `gmail`, `outlook`, or `resend` | | `to` | string/array | Yes | Recipient email address(es) | | `subject` | string | Yes | Email subject line | | `body` | object | Yes* | Email content (`html` and/or `text`) | | `layout` | string | No | `branded` (default) wraps the HTML body in the Loopfour template; `none` sends the HTML as written | | `from` | string | No | Sender address (provider-dependent) | | `cc` | array | No | CC recipients | | `bcc` | array | No | BCC recipients | | `reply_to` | string | No | Reply-to address | | `attachments` | array | No | File attachments | | `template` | object | No | Server-side template config (see `sendTemplate` below) | | `send_at` | string | No | Schedule time (ISO 8601) | ## Email Layout By default, HTML bodies are wrapped in the branded Loopfour email template: a 600px centered card with the Loopfour wordmark, a header, and a footer. This keeps workflow emails visually consistent with the approval, verification, and password-reset emails the product sends. The `layout` field controls this behavior: - `branded` (default) — wraps the HTML body in the Loopfour template. A body that is already a complete HTML document (it starts with `` or an `` tag) is detected and sent as-is, so a full custom document is never wrapped inside another. - `none` — sends the HTML exactly as written, with no template applied. Plain-text bodies (`body.text`) are never modified by either setting. ```json { "id": "send-plain-html", "type": "email", "config": { "action": "send", "provider": "resend", "to": "{{input.customer_email}}", "subject": "Invoice #{{input.invoice_number}}", "layout": "none", "body": { "html": "

Your invoice is ready.

" } } } ``` ## Available Actions ### send Send an email immediately. ```json { "id": "send-notification", "type": "email", "config": { "action": "send", "provider": "gmail", "to": "{{input.recipient_email}}", "cc": ["manager@company.com"], "subject": "Order Confirmation - #{{input.order_id}}", "body": { "html": "

Thank you for your order!

Order #{{input.order_id}} has been confirmed.

", "text": "Thank you for your order! Order #{{input.order_id}} has been confirmed." }, "reply_to": "support@company.com" } } ``` **Output:** ```json { "id": "msg_abc123", "threadId": "thread_xyz789", "labelIds": ["SENT"] } ``` ### sendTemplate The `sendTemplate` action is accepted by the step schema, but none of Gmail, Outlook, or Resend exposes a server-side template API — the step fails at run time on all three with `does not support server-side templates`. Use the `send` action with inline Handlebars templates instead. ### schedule Schedule an email for later delivery (Resend). ```json { "id": "schedule-reminder", "type": "email", "config": { "action": "schedule", "provider": "resend", "to": "{{input.user_email}}", "subject": "Reminder: {{input.reminder_title}}", "body": { "html": "

This is your scheduled reminder for: {{input.reminder_title}}

" }, "send_at": "{{input.reminder_time}}" } } ``` **Note:** Gmail and Outlook do not support native email scheduling. For these providers, use Trigger.dev scheduled tasks instead. ## Handlebars Templates The Email block uses Handlebars for dynamic content rendering. Templates are processed before sending. ### Basic Variables ```html

Hello {{input.customer_name}},

Your invoice #{{input.invoice_number}} for ${{input.amount}} is ready.

``` ### Conditionals ```html {{#if input.is_overdue}}

This invoice is overdue. Please pay immediately.

{{else}}

Payment is due by {{input.due_date}}.

{{/if}} ``` ### Loops ```html {{#each input.line_items}} {{/each}}
Item Quantity Price
{{this.description}} {{this.quantity}} ${{this.unit_price}}

Total: ${{input.total}}

``` ### Nested Properties ```html

Company: {{input.customer.company_name}}

Contact: {{input.customer.contact.first_name}} {{input.customer.contact.last_name}}

Address: {{input.customer.address.street}}, {{input.customer.address.city}}

``` ### Using Step Outputs ```html

Your order has been processed.

Transaction ID: {{steps.process-payment.output.transaction_id}}

Amount Charged: ${{steps.process-payment.output.amount}}

``` ## Attachments Attach files to emails using base64-encoded content. ```json { "id": "send-with-attachment", "type": "email", "config": { "action": "send", "provider": "gmail", "to": "{{input.recipient}}", "subject": "Monthly Report - {{input.month}}", "body": { "html": "

Please find the monthly report attached.

" }, "attachments": [ { "filename": "report-{{input.month}}.pdf", "content": "{{input.pdf_base64}}", "content_type": "application/pdf" }, { "filename": "data.csv", "content": "{{steps.generate-csv.output.csv_base64}}", "content_type": "text/csv" } ] } } ``` **Attachment Fields:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `filename` | string | Yes | Name of the attached file | | `content` | string | Yes | Base64-encoded file content | | `content_type` | string | No | MIME type (default: `application/octet-stream`) | ## Provider-Specific Features ### Gmail - Uses RFC 2822 message format - OAuth authentication via `gmail` provider - Supports threading with `in_reply_to` and `references` - No native scheduling support ```json { "config": { "provider": "gmail", "in_reply_to": "{{input.original_message_id}}", "references": ["{{input.thread_message_id}}"], "headers": { "X-Custom-Header": "custom-value" } } } ``` ### Outlook - Uses Microsoft Graph API - OAuth authentication via `microsoft-graph` provider - Supports priority levels - No native scheduling support ```json { "config": { "provider": "outlook", "priority": "1" } } ``` **Priority Values:** - `1` or `2` = High importance - `3` = Normal importance (default) - `4` or `5` = Low importance ### Resend - Simple, developer-friendly API - Native email scheduling - Direct API calls (no Nango) - Uses the platform Resend key by default; to send from your own Resend account, set `api_key` to a workflow secret: `{{ secrets.RESEND_API_KEY }}` ```json { "config": { "provider": "resend", "api_key": "{{ secrets.RESEND_API_KEY }}", "from": "notifications@yourdomain.com", "send_at": "2024-12-25T09:00:00Z" } } ``` **Note:** Resend does not support server-side templates. Use inline Handlebars templates instead. ## Template Variables Reference | Variable | Description | |----------|-------------| | `{{input.field}}` | Workflow trigger input data | | `{{steps.stepId.output.field}}` | Output from previous step | | `{{variables.name}}` | Loop variables (the item and index a loop binds); empty outside a loop | | `{{ secrets.NAME }}` | Workflow secrets, injected at dispatch | | `{{template_variables.field}}` | Step-specific template variables | ### Providing Additional Variables Use `template_variables` to pass extra data for template rendering: ```json { "config": { "action": "send", "provider": "gmail", "to": "{{input.email}}", "subject": "Welcome to {{company_name}}", "body": { "html": "

Welcome to {{company_name}}!

Your account: {{account_type}}

" }, "template_variables": { "company_name": "Acme Corp", "account_type": "Premium" } } } ``` ## Error Handling ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `connectionId is required` | Missing OAuth connection | Ensure provider connection exists | | `Failed to render template` | Invalid Handlebars syntax | Check template for syntax errors | | `Unsupported email provider` | Invalid provider value | Use `gmail`, `outlook`, or `resend` | | `template is required` | Missing template for sendTemplate | Provide template.id for sendTemplate action | | `send_at is required` | Missing schedule time | Provide ISO 8601 datetime for schedule action | | `Resend API error` | Invalid API key or request | Check RESEND_API_KEY and request payload | ### Provider-Specific Errors **Gmail:** ```json { "error": { "code": 403, "message": "Request had insufficient authentication scopes" } } ``` ## Rate Limits | Provider | Limit | Notes | |----------|-------|-------| | Gmail | 100 emails/day (free), 2,000/day (Workspace) | Per user quota | | Outlook | 30 messages/minute | Per mailbox | | Resend | 100/day (free), 50,000/month (Pro) | Based on plan | ## Related Blocks - [Slack Block](/docs/workflows/building-blocks/slack-block) - Send Slack notifications - [Action Steps](/docs/workflows/steps/actions) - Other integration actions - [Transform Steps](/docs/workflows/steps/transforms) - Data transformation ## Example Workflows ### Invoice Email Workflow ```json { "name": "Send Invoice on Payment Due", "trigger": { "type": "webhook", "provider": "quickbooks", "events": ["invoice.created"] }, "steps": [ { "id": "get-customer", "type": "action", "action": "quickbooks.getCustomer", "config": { "customerId": "{{input.payload.CustomerRef.value}}" } }, { "id": "send-invoice-email", "type": "email", "config": { "action": "send", "provider": "gmail", "to": "{{steps.get-customer.output.PrimaryEmailAddr.Address}}", "subject": "Invoice #{{input.payload.DocNumber}} from Acme Corp", "body": { "html": "

Invoice #{{input.payload.DocNumber}}

Dear {{steps.get-customer.output.DisplayName}},

Please find your invoice for ${{input.payload.TotalAmt}}.

Due Date: {{input.payload.DueDate}}

View Invoice

Thank you for your business!

" }, "reply_to": "billing@acme.com" } }, { "id": "notify-sales", "type": "slack", "config": { "action": "sendMessage", "channel": "#billing", "text": "Invoice #{{input.payload.DocNumber}} sent to {{steps.get-customer.output.DisplayName}}" } } ] } ``` ### Welcome Email with Attachment ```json { "name": "Send Welcome Email", "trigger": { "type": "webhook", "provider": "hubspot", "events": ["contact.creation"] }, "steps": [ { "id": "generate-welcome-pdf", "type": "action", "action": "documents.generatePdf", "config": { "template": "welcome-guide", "data": { "name": "{{input.properties.firstname}}", "company": "{{input.properties.company}}" } } }, { "id": "send-welcome", "type": "email", "config": { "action": "send", "provider": "resend", "to": "{{input.properties.email}}", "from": "welcome@company.com", "subject": "Welcome to Company, {{input.properties.firstname}}!", "body": { "html": "

Welcome, {{input.properties.firstname}}!

We're excited to have you on board.

Please find your welcome guide attached.

If you have any questions, reply to this email.

" }, "attachments": [ { "filename": "welcome-guide.pdf", "content": "{{steps.generate-welcome-pdf.output.pdf_base64}}", "content_type": "application/pdf" } ] } } ] } ``` ### Scheduled Payment Reminder ```json { "name": "Schedule Payment Reminder", "trigger": { "type": "api" }, "steps": [ { "id": "calculate-reminder-time", "type": "transform", "config": { "operation": "map", "data": { "reminder_time": "{{input.due_date | dateAdd: -3, 'days'}}" } } }, { "id": "schedule-reminder", "type": "email", "config": { "action": "schedule", "provider": "resend", "to": "{{input.customer_email}}", "from": "reminders@company.com", "subject": "Payment Reminder: Invoice #{{input.invoice_number}}", "body": { "html": "

Hello {{input.customer_name}},

This is a friendly reminder that invoice #{{input.invoice_number}} for ${{input.amount}} is due on {{input.due_date}}.

Pay Now

" }, "send_at": "{{steps.calculate-reminder-time.output.reminder_time}}" } } ] } ``` # Slack Block (/docs/workflows/building-blocks/slack-block) Send messages, manage channels, and interact with Slack ## Slack Block The Slack block provides comprehensive access to Slack messaging, channels, modals, and interactive components within your workflows. ## Overview Use the Slack block when you need to: - Send notifications to channels or users - Create approval workflows with interactive buttons - Build rich messages with Block Kit - Manage channels and users programmatically - Upload files and add reactions ## Prerequisites - **Slack Connection**: Configure OAuth via Nango with `slack` provider - **Bot Permissions**: Bot must be added to target channels - **Scopes Required**: Varies by action (see action-specific requirements) ## Configuration ### Step Definition ```json { "id": "notify-team", "type": "slack", "name": "Send Team Notification", "config": { "action": "sendMessage", "channel": "#billing", "text": "Invoice {{input.invoice_number}} has been paid!" } } ``` ### Configuration Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `action` | string | Yes | The Slack action to execute | | `channel` | string | Varies | Channel ID or name (e.g., `#general`, `C1234567890`) | | `text` | string | Varies | Message text (supports mrkdwn) | | `blocks` | array | Varies | Block Kit blocks for rich layouts | | `user` | string | Varies | User ID for user-specific actions | ## Available Actions ### Message Actions #### sendMessage Send a message to a channel or DM. **Required Scopes**: `chat:write`, `chat:write.public` (for non-member channels) ```json { "id": "send-notification", "type": "slack", "config": { "action": "sendMessage", "channel": "#sales", "text": "New deal closed: {{input.deal_name}} for ${{input.amount}}", "threadTs": "{{input.parent_message_ts}}", "unfurlLinks": false, "mrkdwn": true } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `channel` | string | Channel ID or name | | `text` | string | Message text (required if no blocks) | | `blocks` | array | Block Kit blocks | | `threadTs` | string | Reply to thread | | `replyBroadcast` | boolean | Also post to channel when replying | | `unfurlLinks` | boolean | Enable link previews | | `unfurlMedia` | boolean | Enable media previews | | `mrkdwn` | boolean | Enable mrkdwn formatting (default: true) | | `attachments` | array | Legacy attachments | | `metadata` | object | Message metadata | **Output:** ```json { "ok": true, "channel": "C1234567890", "ts": "1234567890.123456", "message": { "text": "New deal closed...", "user": "U1234567890", "ts": "1234567890.123456" } } ``` #### sendEphemeral Send an ephemeral message (only visible to one user). **Required Scopes**: `chat:write` ```json { "id": "private-hint", "type": "slack", "config": { "action": "sendEphemeral", "channel": "#general", "user": "{{input.user_id}}", "text": "Only you can see this message!" } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `channel` | string | Channel ID | | `user` | string | User ID to show message to | | `text` | string | Message text | | `blocks` | array | Block Kit blocks | | `threadTs` | string | Thread timestamp | #### scheduleMessage Schedule a message to be sent later. **Required Scopes**: `chat:write` ```json { "id": "schedule-reminder", "type": "slack", "config": { "action": "scheduleMessage", "channel": "#reminders", "text": "Don't forget: {{input.reminder_text}}", "postAt": "{{input.reminder_time}}" } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `channel` | string | Channel ID | | `postAt` | number/string | Unix timestamp or ISO 8601 datetime | | `text` | string | Message text | | `blocks` | array | Block Kit blocks | **Output:** ```json { "ok": true, "channel": "C1234567890", "scheduled_message_id": "Q1234567890", "post_at": 1234567890 } ``` #### updateMessage Update an existing message. **Required Scopes**: `chat:write` ```json { "id": "update-status", "type": "slack", "config": { "action": "updateMessage", "channel": "{{steps.send-message.output.channel}}", "ts": "{{steps.send-message.output.ts}}", "text": "Status: Completed!" } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `channel` | string | Channel ID | | `ts` | string | Message timestamp to update | | `text` | string | New message text | | `blocks` | array | New Block Kit blocks | #### deleteMessage Delete a message. **Required Scopes**: `chat:write` ```json { "id": "cleanup-message", "type": "slack", "config": { "action": "deleteMessage", "channel": "{{input.channel_id}}", "ts": "{{input.message_ts}}" } } ``` #### getPermalink Get a permanent link to a message. ```json { "id": "get-link", "type": "slack", "config": { "action": "getPermalink", "channel": "{{input.channel_id}}", "messageTs": "{{input.message_ts}}" } } ``` **Output:** ```json { "ok": true, "channel": "C1234567890", "permalink": "https://workspace.slack.com/archives/C1234567890/p1234567890123456" } ``` ### Reaction Actions #### addReaction Add an emoji reaction to a message. **Required Scopes**: `reactions:write` ```json { "id": "react-to-message", "type": "slack", "config": { "action": "addReaction", "channel": "{{input.channel_id}}", "timestamp": "{{input.message_ts}}", "name": "white_check_mark" } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `channel` | string | Channel ID | | `timestamp` | string | Message timestamp | | `name` | string | Emoji name (without colons) | #### removeReaction Remove an emoji reaction. ```json { "id": "remove-reaction", "type": "slack", "config": { "action": "removeReaction", "channel": "{{input.channel_id}}", "timestamp": "{{input.message_ts}}", "name": "eyes" } } ``` ### File Actions #### uploadFile Upload a file to Slack. **Required Scopes**: `files:write` ```json { "id": "upload-report", "type": "slack", "config": { "action": "uploadFile", "channels": ["#reports", "#finance"], "content": "{{input.csv_content}}", "filename": "report-{{input.date}}.csv", "title": "Monthly Report", "initialComment": "Here's the monthly report!" } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `channels` | string/array | Channel(s) to share file | | `content` | string | Text content for the file | | `file` | string | Base64 encoded file content | | `filename` | string | Name of the file | | `filetype` | string | File type (e.g., `csv`, `pdf`) | | `title` | string | Title of the file | | `initialComment` | string | Comment to add with file | | `threadTs` | string | Thread to upload to | #### getFile Get file information. ```json { "id": "get-file-info", "type": "slack", "config": { "action": "getFile", "fileId": "{{input.file_id}}" } } ``` #### deleteFile Delete a file. ```json { "id": "delete-file", "type": "slack", "config": { "action": "deleteFile", "fileId": "{{input.file_id}}" } } ``` ### Modal/View Actions #### openModal Open a modal view (requires trigger_id from interactive event). **Required Scopes**: None (uses bot token) ```json { "id": "open-approval-modal", "type": "slack", "config": { "action": "openModal", "triggerId": "{{input.trigger_id}}", "view": { "type": "modal", "title": { "type": "plain_text", "text": "Approve Request" }, "submit": { "type": "plain_text", "text": "Approve" }, "close": { "type": "plain_text", "text": "Cancel" }, "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "Approve invoice #{{input.invoice_number}}?" } }, { "type": "input", "block_id": "notes_block", "element": { "type": "plain_text_input", "action_id": "notes_input", "multiline": true, "placeholder": { "type": "plain_text", "text": "Add notes..." } }, "label": { "type": "plain_text", "text": "Notes" } } ] } } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `triggerId` | string | Trigger ID from interactive event | | `view` | object | Modal view definition | #### updateModal Update an existing modal. ```json { "id": "update-modal", "type": "slack", "config": { "action": "updateModal", "viewId": "{{input.view_id}}", "view": { "type": "modal", "title": { "type": "plain_text", "text": "Updated Modal" }, "blocks": [...] } } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `viewId` | string | View ID to update | | `externalId` | string | Alternative: external ID | | `view` | object | Updated view definition | | `hash` | string | Optional hash for conflict detection | #### pushModal Push a new view onto the modal stack. ```json { "id": "push-details", "type": "slack", "config": { "action": "pushModal", "triggerId": "{{input.trigger_id}}", "view": { "type": "modal", "title": { "type": "plain_text", "text": "Details" }, "blocks": [...] } } } ``` #### publishHomeTab Publish content to a user's Home tab. **Required Scopes**: None (uses bot token) ```json { "id": "update-home", "type": "slack", "config": { "action": "publishHomeTab", "userId": "{{input.user_id}}", "view": { "type": "home", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "Welcome, *{{input.user_name}}*!" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "You have {{input.pending_count}} pending approvals." } } ] } } } ``` ### User Actions #### getUser Get user information. **Required Scopes**: `users:read` ```json { "id": "get-user", "type": "slack", "config": { "action": "getUser", "userId": "{{input.user_id}}", "includeLocale": true } } ``` **Output:** ```json { "ok": true, "user": { "id": "U1234567890", "name": "johndoe", "real_name": "John Doe", "profile": { "email": "john@example.com", "display_name": "John" } } } ``` #### listUsers List workspace users. **Required Scopes**: `users:read` ```json { "id": "list-users", "type": "slack", "config": { "action": "listUsers", "limit": 100, "includeLocale": false } } ``` #### lookupUserByEmail Find a user by email address. **Required Scopes**: `users:read.email` ```json { "id": "find-user", "type": "slack", "config": { "action": "lookupUserByEmail", "email": "{{input.email}}" } } ``` ### Channel Actions #### getChannel Get channel information. **Required Scopes**: `channels:read`, `groups:read` ```json { "id": "get-channel", "type": "slack", "config": { "action": "getChannel", "channelId": "{{input.channel_id}}", "includeNumMembers": true } } ``` #### listChannels List channels. **Required Scopes**: `channels:read`, `groups:read` ```json { "id": "list-channels", "type": "slack", "config": { "action": "listChannels", "types": "public_channel,private_channel", "excludeArchived": true, "limit": 100 } } ``` **Parameters:** | Field | Type | Description | |-------|------|-------------| | `types` | string | Channel types (public_channel, private_channel, mpim, im) | | `excludeArchived` | boolean | Exclude archived channels (default: true) | | `limit` | number | Max results (default: 100) | | `cursor` | string | Pagination cursor | #### createChannel Create a new channel. **Required Scopes**: `channels:manage`, `groups:write` ```json { "id": "create-project-channel", "type": "slack", "config": { "action": "createChannel", "name": "proj-{{input.project_slug}}", "isPrivate": false } } ``` #### inviteToChannel Invite users to a channel. **Required Scopes**: `channels:manage`, `groups:write` ```json { "id": "invite-team", "type": "slack", "config": { "action": "inviteToChannel", "channelId": "{{steps.create-channel.output.channel.id}}", "users": ["U111111", "U222222", "U333333"] } } ``` #### archiveChannel Archive a channel. ```json { "id": "archive-channel", "type": "slack", "config": { "action": "archiveChannel", "channelId": "{{input.channel_id}}" } } ``` #### setChannelTopic Set channel topic. ```json { "id": "set-topic", "type": "slack", "config": { "action": "setChannelTopic", "channelId": "{{input.channel_id}}", "topic": "Project: {{input.project_name}} | Status: {{input.status}}" } } ``` #### getChannelHistory Get channel message history. **Required Scopes**: `channels:history`, `groups:history` ```json { "id": "get-history", "type": "slack", "config": { "action": "getChannelHistory", "channelId": "{{input.channel_id}}", "limit": 50, "oldest": "{{input.since_timestamp}}" } } ``` ### Bookmark Actions #### addBookmark Add a bookmark to a channel. **Required Scopes**: `bookmarks:write` ```json { "id": "add-bookmark", "type": "slack", "config": { "action": "addBookmark", "channelId": "{{input.channel_id}}", "title": "Project Dashboard", "type": "link", "link": "https://dashboard.example.com/{{input.project_id}}" } } ``` ## Block Kit Examples ### Rich Notification with Buttons ```json { "id": "approval-request", "type": "slack", "config": { "action": "sendMessage", "channel": "#approvals", "text": "New approval request", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Invoice Approval Required" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Invoice:*\n#{{input.invoice_number}}" }, { "type": "mrkdwn", "text": "*Amount:*\n${{input.amount}}" }, { "type": "mrkdwn", "text": "*Vendor:*\n{{input.vendor_name}}" }, { "type": "mrkdwn", "text": "*Due Date:*\n{{input.due_date}}" } ] }, { "type": "actions", "block_id": "approval_actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "Approve" }, "action_id": "approve_invoice", "style": "primary", "value": "{{input.invoice_id}}" }, { "type": "button", "text": { "type": "plain_text", "text": "Reject" }, "action_id": "reject_invoice", "style": "danger", "value": "{{input.invoice_id}}" }, { "type": "button", "text": { "type": "plain_text", "text": "View Details" }, "action_id": "view_invoice", "url": "https://app.example.com/invoices/{{input.invoice_id}}" } ] } ] } } ``` ### Status Update with Context ```json { "id": "pipeline-status", "type": "slack", "config": { "action": "sendMessage", "channel": "#deployments", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "{{#if input.success}}:white_check_mark:{{else}}:x:{{/if}} *Build {{input.build_number}}* {{input.status}}" } }, { "type": "context", "elements": [ { "type": "mrkdwn", "text": "Branch: `{{input.branch}}`" }, { "type": "mrkdwn", "text": "Commit: `{{input.commit_sha}}`" }, { "type": "mrkdwn", "text": "Duration: {{input.duration}}s" } ] } ] } } ``` ## Template Variables Access workflow data in your Slack configuration: | Variable | Description | |----------|-------------| | `{{input.field}}` | Workflow trigger input | | `{{steps.stepId.output.field}}` | Previous step output | | `{{variables.name}}` | Loop variables (the item and index a loop binds); empty outside a loop | | `{{ secrets.NAME }}` | Workflow secrets, injected at dispatch | ## Error Handling ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `channel_not_found` | Invalid channel ID/name | Verify channel exists and bot has access | | `not_in_channel` | Bot not in channel | Invite bot to channel or use `chat:write.public` | | `invalid_blocks` | Malformed Block Kit | Validate blocks with Block Kit Builder | | `trigger_expired` | Stale trigger_id | Trigger IDs expire in 3 seconds | | `missing_scope` | Insufficient permissions | Add required OAuth scopes | | `rate_limited` | Too many requests | Implement backoff, respect retry-after | ### Error Response ```json { "ok": false, "error": "channel_not_found", "response_metadata": { "messages": ["Channel not found or bot not a member"] } } ``` ## Rate Limits | Tier | Limit | Description | |------|-------|-------------| | Tier 1 | 1/min | Low-frequency methods | | Tier 2 | 20/min | Most methods | | Tier 3 | 50/min | High-frequency methods | | Tier 4 | 100/min | Very high-frequency | | Special | Varies | chat.postMessage: ~1/sec per channel | Slack returns `429 Too Many Requests` with `Retry-After` header when limits are exceeded. ## Related Blocks - [Email Block](/docs/workflows/building-blocks/email-block) - Send email notifications - [Action Steps](/docs/workflows/steps/actions) - Other integration actions - [Condition Steps](/docs/workflows/steps/conditions) - Conditional logic ## Example Workflow Complete approval workflow with Slack: ```json { "name": "Invoice Approval Workflow", "trigger": { "type": "webhook", "provider": "quickbooks", "events": ["invoice.created"] }, "steps": [ { "id": "check-amount", "type": "condition", "config": { "conditions": { "left": "{{input.payload.TotalAmt}}", "operator": "gt", "right": 1000 }, "then": ["request-approval"], "else": ["auto-approve"] } }, { "id": "request-approval", "type": "slack", "config": { "action": "sendMessage", "channel": "#finance-approvals", "text": "Invoice requires approval", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*New Invoice Requires Approval*\n\nAmount: ${{input.payload.TotalAmt}}\nVendor: {{input.payload.CustomerRef.name}}" } }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "Approve" }, "action_id": "approve_{{input.payload.Id}}", "style": "primary" }, { "type": "button", "text": { "type": "plain_text", "text": "Reject" }, "action_id": "reject_{{input.payload.Id}}", "style": "danger" } ] } ] } }, { "id": "auto-approve", "type": "slack", "config": { "action": "sendMessage", "channel": "#finance-log", "text": ":white_check_mark: Auto-approved invoice #{{input.payload.DocNumber}} for ${{input.payload.TotalAmt}}" } } ] } ``` # Workflow recipe templates (/docs/workflows/dashboard-templates) How the template tiles map to canonical workflow templates, and how to add a new one. ## Workflow recipe templates The tiles on the chat home (`/chat`) and the gallery (`/workflows?tab=templates`) are both backed by the same **system templates** — rows in `workflow_templates` with `company_id IS NULL` and a stable `slug` (e.g. `cash-application`). ## How the pieces fit ``` packages/db/src/templates/recipes/.ts ← canonical workflow JSON │ (compile-time registry: DASHBOARD_TEMPLATE_DEFINITIONS) ▼ bun run db:seed:template-recipes ← upserts into workflow_templates │ ▼ GET /api/v1/templates/by-slug/:slug ← chat home / chat tools resolve here GET /api/v1/templates/gallery ← gallery list ``` Slugs are unique among system templates (`UNIQUE (slug) WHERE company_id IS NULL`). They never change across environments — a UUID is generated by Postgres on insert and may differ between dev/staging/prod, but the slug is authored in code and is identical everywhere the seeder runs. ## Adding a new workflow recipe template Three small changes: ### 1. Card metadata Add the tile entry in `apps/studio/lib/templates/gallery-registry.ts`: ```ts { id: "ap-payment-batching", // kebab-case, must be unique title: "AP payment batching", description: "...", category: "ap", // ar | ap | billing | close | revrec metaItems: ["Bill.com", "Daily"], integrations: integrations("billcom"), previewSteps: ["Pull approved bills", "Group by vendor", "Queue ACH"], prompt: "Build a workflow that ...", live: true, } ``` ### 2. Canonical JSON Drop a new file in `packages/db/src/templates/recipes/.ts`: ```ts export const apPaymentBatchingTemplate = defineDashboardTemplate({ templateId: "ap-payment-batching", name: "AP payment batching", description: "...", category: "finance", tags: ["dashboard-card", "ap"], requiredConnections: ["billcom"], variables: [], trigger: { type: "schedule", cron: "0 8 * * *", timezone: "UTC", enabled: true }, steps: [ { id: "pull_bills", type: "action", name: "Pull approved bills", action: "billcom.listApprovedBills", config: {} }, // … ], }); ``` `defineDashboardTemplate` is an identity helper that pins the literal `templateId` type and throws at module-load time if a duplicate slug is registered. ### 3. Registry index Import and register in `packages/db/src/templates/recipes/index.ts`: ```ts export const DASHBOARD_TEMPLATE_DEFINITIONS = { // … "ap-payment-batching": apPaymentBatchingTemplate, } as const satisfies Record; ``` The `Record` constraint in Studio fails the build if a card id has no matching template, so missing this step is caught at compile time. ## Deploying After committing template changes: ```bash bun run --filter=@workflows/db db:seed:template-recipes ``` The seeder is idempotent — safe to re-run on every deploy. It upserts on `(slug) WHERE company_id IS NULL`, preserving each row's UUID and any stars, just patching mutable fields (name, description, steps, etc.). ## Don't - **Don't** rename a slug after it ships. Slugs are stable identifiers; renaming one breaks every reference (chat tools, deep links, audit logs). - **Don't** set `slug` from `POST /api/v1/templates`. Slugs are seeder-only; company-specific templates created via the API always have `slug = NULL`. - **Don't** use the `category` enum to express dashboard subcategories. Category stays `finance` (the API enum); dashboard subcategories live in `tags` (`ar`, `ap`, `billing`, `close`, `revrec`). # Workflow Overview (/docs/workflows/overview) Core workflow concepts, lifecycle, and execution model ## Workflow Overview Workflows are the core building blocks of the Workflows API. This guide explains the fundamental concepts you need to understand when building automated processes. ## What is a Workflow? A **workflow** is a declarative automation that defines: 1. **When** to run (the trigger) 2. **What** to do (the steps) 3. **How** to handle data (variables and templates) Workflows enable you to: - **Connect systems** - Integrate Stripe, Salesforce, HubSpot, QuickBooks, and more - **Transform data** - Map and reshape data between different formats - **Make decisions** - Branch logic based on conditions - **Wait for events** - Pause for human approval or external events - **Handle errors** - Automatic retries with configurable strategies ```mermaid graph LR A[Trigger] --> B[Step 1] B --> C[Step 2] C --> D{Condition} D -->|Yes| E[Step 3a] D -->|No| F[Step 3b] E --> G[Output] F --> G ``` ## Workflow Lifecycle Every workflow progresses through a defined set of states: ```mermaid stateDiagram-v2 [*] --> draft: Create draft --> active: Activate active --> paused: Pause paused --> active: Resume active --> archived: Archive paused --> archived: Archive draft --> archived: Archive ``` | State | Description | Can Execute? | Can Edit? | |-------|-------------|--------------|-----------| | `draft` | Initial state after creation | No | Yes | | `active` | Ready to receive triggers and execute | Yes | Yes (creates new version) | | `paused` | Temporarily disabled | No | Yes | | `archived` | Soft deleted | No | No | ### State Transitions ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{id}/activate \ -H "x-api-key: YOUR_API_KEY" ``` ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{id}/pause \ -H "x-api-key: YOUR_API_KEY" ``` ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{id}/resume \ -H "x-api-key: YOUR_API_KEY" ``` ```bash curl -X DELETE https://workflow.loopfour.ai/api/v1/workflows/{id} \ -H "x-api-key: YOUR_API_KEY" ``` ## Workflow Structure A workflow is defined as a JSON object with these key components: ```json { "name": "Invoice on Deal Close", "description": "Create a QuickBooks invoice when a Salesforce deal is won", // 1. TRIGGER - When does this workflow run? "trigger": { "type": "webhook", "provider": "salesforce", "events": ["opportunity.closed_won"] }, // 2. STEPS - What actions does it perform? "steps": [ { "id": "transform-data", "type": "transform", "name": "Map Deal to Invoice", "config": { "mapping": { "customer_email": "{{input.account.email}}", "amount": "{{input.opportunity.amount}}", "description": "{{input.opportunity.name}}" } } }, { "id": "create-invoice", "type": "action", "name": "Create QuickBooks Invoice", "config": { "connector": "quickbooks", "action": "createInvoice", "params": { "customerEmail": "{{steps.transform-data.output.customer_email}}", "amount": "{{steps.transform-data.output.amount}}", "memo": "{{steps.transform-data.output.description}}" } } } ], // 3. VARIABLES - Reusable configuration values "variables": { "taxRate": 0.0825, "defaultTerms": "Net 30" } } ``` ## Triggers Triggers define **when** a workflow executes. The Workflows API supports multiple trigger types: | Type | Description | Use Case | |------|-------------|----------| | `webhook` | External system sends an event | Stripe payment, custom webhooks | | `schedule` | Time-based execution | Daily reports, weekly syncs | | `api` | Manual execution via API | On-demand processing, testing | | `salesforce_event` | Salesforce Platform Events or CDC | Record changes, custom events | | `hubspot_event` | HubSpot property changes | Contact/deal updates | | `slack_event` | Slack Events API | Messages, mentions, reactions | | `slack_slash_command` | Slack slash commands | /invoice, /approve | | `slack_interactive` | Slack interactive components | Button clicks, modal submissions | | `gmail_event` | Gmail Pub/Sub events | New emails, label changes | ### Webhook Triggers Webhook triggers listen for events from external providers: ```json { "trigger": { "type": "webhook", "provider": "stripe", "events": ["invoice.paid", "invoice.payment_failed"] } } ``` Supported providers: `stripe`, `salesforce`, `hubspot`, `quickbooks`, `pandadoc`, `netsuite`, `slack` See [Webhook Triggers](/docs/triggers/webhook) for provider-specific configuration. ### Schedule Triggers Schedule triggers use cron expressions for time-based execution: ```json { "trigger": { "type": "schedule", "cron": "0 9 * * MON", "timezone": "America/New_York" } } ``` See [Schedule Triggers](/docs/triggers/schedule) for cron syntax and examples. ### API Triggers API triggers allow manual workflow execution: ```json { "trigger": { "type": "api" } } ``` ```bash curl -X POST https://workflow.loopfour.ai/api/v1/workflows/{id}/run \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": {"customer_id": "cus_123"}}' ``` ## Steps Steps define **what** a workflow does. Each step executes sequentially (unless using parallel execution). ### Step Types | Type | Description | Example | |------|-------------|---------| | `action` | Call an integration | Create Stripe invoice, update Salesforce record | | `transform` | Map and reshape data | Convert webhook payload to invoice format | | `condition` | Branch based on logic | If amount > $1000, require approval | | `wait` | Pause execution | Wait 24 hours, delay before retry | | `parallel` | Execute steps concurrently | Notify Slack AND send email | | `loop` | Iterate over a collection | Process each line item | | `approval` | Human-in-the-loop | Wait for manager approval | | `agent` | AI-powered processing | Extract invoice data, classify expense | | `code` | Custom JavaScript | Run sandboxed code for complex logic | | `slack` | Slack building block | Send message, open modal | | `email` | Email building block | Send templated email | | `gmail` | Gmail building block | Read/send Gmail messages | ### Step Structure Every step has this base structure: ```json { "id": "unique-step-id", "type": "action", "name": "Human-readable name", "action": "stripe.createInvoice", "config": { // Step-specific configuration }, "retryConfig": { "maxAttempts": 3, "initialDelayMs": 1000, "maxDelayMs": 30000, "backoffMultiplier": 2 }, "errorHandling": { "strategy": "fail", "fallback": {} }, "timeoutMs": 30000 } ``` See [Steps Documentation](/docs/workflows/steps) for detailed configuration of each step type. ## Template Variables Template variables allow dynamic data access throughout your workflow using `{{expression}}` syntax. ### Available Contexts | Context | Syntax | Description | |---------|--------|-------------| | Input | `{{input.field}}` | Trigger payload data | | Steps | `{{steps.stepId.output.field}}` | Previous step outputs | | Loop variables | `{{variables.name}}` | The item and index a loop binds; empty outside a loop | | Secrets | `{{ secrets.NAME }}` | Workflow secrets, injected at dispatch | | System | `{{now}}`, `{{runId}}` | System-provided values | ### Examples ```json { "steps": [ { "id": "send-email", "type": "action", "config": { "connector": "email", "action": "send", "params": { // Access trigger input "to": "{{input.customer.email}}", // Access previous step output "subject": "Invoice #{{steps.create-invoice.output.invoice_number}}", // A fixed per-workflow value goes in literally "cc": "finance@example.com", // Use expressions "body": "Amount due: ${{input.amount / 100}}" } } } ] } ``` ### Nested Access Access nested properties using dot notation: ``` {{input.customer.billing.address.city}} {{steps.fetch-deal.output.opportunity.account.owner.email}} ``` ### Conditional Expressions Use Handlebars-style conditionals in templates: ``` {{#if input.is_priority}}URGENT: {{/if}}New invoice created ``` ## Workflow Execution When a workflow executes, it creates a **run** that tracks the execution state. ```mermaid sequenceDiagram participant T as Trigger participant W as Workflow Engine participant S1 as Step 1 participant S2 as Step 2 participant DB as Database T->>W: Event received W->>DB: Create run (pending) W->>S1: Execute step S1-->>W: Output W->>DB: Update step status W->>S2: Execute step S2-->>W: Output W->>DB: Run completed ``` ### Run States | State | Description | |-------|-------------| | `pending` | Run created, waiting to start | | `running` | Currently executing steps | | `suspended` | Paused for human approval or external event | | `completed` | All steps finished successfully | | `failed` | A step failed after all retries | | `cancelled` | Manually cancelled | ### Viewing Run History ```bash # List runs for a workflow curl "https://workflow.loopfour.ai/api/v1/workflows/{id}/runs" \ -H "x-api-key: YOUR_API_KEY" # Get run details curl "https://workflow.loopfour.ai/api/v1/runs/{runId}" \ -H "x-api-key: YOUR_API_KEY" ``` ## Error Handling The Workflows API provides robust error handling at multiple levels. ### Step-Level Retries Configure automatic retries for transient failures using `retryConfig`: ```json { "id": "call-api", "type": "action", "config": { ... }, "retryConfig": { "maxAttempts": 3, "initialDelayMs": 1000, "maxDelayMs": 30000, "backoffMultiplier": 2 } } ``` | Option | Type | Description | |--------|------|-------------| | `maxAttempts` | number | Maximum retry attempts (1-10) | | `initialDelayMs` | number | Initial delay in milliseconds (min: 100) | | `maxDelayMs` | number | Maximum delay between retries (min: 1000) | | `backoffMultiplier` | number | Multiplier for exponential backoff (1-10) | ### Error Strategies Define how to handle step failures using `errorHandling`: ```json { "id": "optional-step", "type": "action", "config": { ... }, "errorHandling": { "strategy": "continue", "fallback": { "defaultValue": "N/A" } } } ``` | Strategy | Behavior | |----------|----------| | `fail` | Stop workflow, mark run as failed (default) | | `continue` | Log error, continue to next step | | `retry` | Retry with configured `retryConfig` settings | The optional `fallback` object provides default output values when a step fails with `continue` strategy. ### Timeout Configuration Prevent steps from hanging indefinitely with `timeoutMs`: ```json { "id": "slow-api", "type": "action", "config": { ... }, "timeoutMs": 60000 } ``` ## Versioning Workflows support version control to track changes over time. ### How Versioning Works 1. **Create** - Initial workflow is version 1 2. **Update** - Each update to an active workflow creates a new version 3. **Execution** - Running workflows complete with their started version 4. **Rollback** - Revert to any previous version ### Version API ```bash # List versions curl "https://workflow.loopfour.ai/api/v1/workflows/{id}/versions" \ -H "x-api-key: YOUR_API_KEY" # Get specific version curl "https://workflow.loopfour.ai/api/v1/workflows/{id}/versions/3" \ -H "x-api-key: YOUR_API_KEY" # Rollback to version curl -X POST "https://workflow.loopfour.ai/api/v1/workflows/{id}/versions/3/rollback" \ -H "x-api-key: YOUR_API_KEY" ``` ### Version States | State | Description | |-------|-------------| | `current` | Active version used for new runs | | `archived` | Previous version, available for rollback | | `rollback` | Version restored from rollback operation | ## Best Practices ### Naming Conventions - Use descriptive workflow names: `Invoice Customer on Deal Close` - Use verb-noun step IDs: `transform-payment`, `send-notification` - Include context in descriptions ### Idempotency Design workflows to be safely re-run: ```json { "id": "create-or-update", "type": "action", "config": { "connector": "stripe", "action": "createCustomer", "params": { "idempotencyKey": "{{input.deal_id}}" } } } ``` ### Error Recovery - Use appropriate retry counts for each integration - Set reasonable timeouts - Use `onError: continue` for non-critical steps ### Testing - Start with `draft` status for development - Use API trigger for manual testing - Check run history for debugging ## Next Steps Configure webhook, schedule, and API triggers Learn about actions, conditions, transforms, and more Connect to Stripe, Salesforce, HubSpot, and more Build a complete workflow step by step # Steps (/docs/workflows/steps) Workflow step types and configuration ## Workflow Steps Steps are the building blocks of workflows. Each step performs an operation and passes data to subsequent steps. Think of a step as one reviewable unit of work. It should have a clear input, a clear output, and a name that explains its business purpose. A workflow becomes easier to maintain when each step does one thing: fetch a record, transform fields, test a condition, send a notification, wait for approval, or write the final update. If a step performs several unrelated operations, split it so run logs can show exactly where a workflow succeeded or failed. Step design matters most when workflows touch money, contracts, invoices, or customer data. A failed provider call should not obscure whether validation already passed. A condition should make its threshold obvious. A transform should preserve the fields needed for later review. When naming and configuring steps, assume that someone outside the implementation team may inspect the run weeks later to answer why a record changed or why an exception was routed to a human. ## Step Types Execute integration actions Branch based on conditions Transform and map data Pause execution ## Common Properties All steps share these properties: ```json { "id": "unique-step-id", "type": "action", "name": "Human-readable name", "config": { } } ``` | Property | Required | Description | |----------|----------|-------------| | `id` | Yes | Unique identifier for referencing in templates | | `type` | Yes | Step type (action, condition, transform, wait) | | `name` | No | Display name for the step | | `config` | Yes | Type-specific configuration | # Action Steps (/docs/workflows/steps/actions) Execute integration actions in workflows ## Action Steps Action steps execute operations on external services through connectors. They are the primary way to interact with integrated systems like Stripe, Salesforce, HubSpot, QuickBooks, and more. ## Configuration ```json { "id": "create-invoice", "type": "action", "name": "Create Stripe Invoice", "action": "stripe.createInvoice", "config": { "customerId": "{{input.customer_id}}", "items": [ { "price": "price_xxx", "quantity": 1 } ] } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | Yes | Unique step identifier | | `type` | string | Yes | Must be `"action"` | | `name` | string | Yes | Human-readable name | | `action` | string | Yes | Action identifier (e.g., `stripe.createInvoice`) | | `config` | object | Yes | Action-specific configuration | | `retryConfig` | object | No | Retry configuration | | `errorHandling` | object | No | Error handling strategy | | `timeoutMs` | number | No | Timeout in milliseconds | ## Action Format Actions follow the pattern `{connector}.{actionName}`: ``` stripe.createInvoice salesforce.updateOpportunity hubspot.createContact quickbooks.listCustomers slack.sendMessage ``` ## Available Connectors | Connector | Description | Example Actions | |-----------|-------------|-----------------| | `stripe` | Payment processing | `createInvoice`, `getCustomer`, `createSubscription` | | `salesforce` | CRM operations | `getAccount`, `updateOpportunity`, `query` | | `hubspot` | Marketing/CRM | `createContact`, `getDeal`, `searchContacts` | | `quickbooks` | Accounting | `createInvoice`, `getCustomer`, `createPayment` | | `pandadoc` | Document signing | `createDocument`, `sendDocument`, `getStatus` | | `netsuite` | ERP operations | `createCustomer`, `createInvoice`, `suiteql` | | `slack` | Messaging | `sendMessage`, `openModal`, `addReaction` | | `email` | Email sending | `send`, `send_template`, `schedule` | | `api` | Call any HTTP endpoint | `request` | See [Integrations](/docs/integrations) for complete action documentation per connector. ## API Request Block When no connector covers the service you need, the core `api` block calls any HTTP endpoint directly. ```json { "id": "fetch_report", "type": "action", "action": "api.request", "config": { "url": "https://api.example.com/v1/reports", "method": "POST", "headers": [{ "Key": "Authorization", "Value": "Bearer {{secrets.EXAMPLE_API_KEY}}" }], "params": [{ "Key": "format", "Value": "detailed" }], "body": { "startDate": "{{input.startDate}}", "endDate": "{{input.endDate}}" }, "responseFormat": "auto", "retries": 3, "retryDelayMs": 500 } } ``` | Field | Type | Description | |-------|------|-------------| | `url` | string | Absolute https URL. Templates are resolved before the call. | | `method` | string | `GET`, `POST`, `PUT`, `PATCH`, or `DELETE` (default `GET`) | | `params` | json | Query parameters, as an object or a `[{Key, Value}]` table | | `headers` | json | Request headers, same shapes. Use `{{secrets.NAME}}` for credentials. | | `body` | json | Object (sent as JSON, sets `Content-Type`) or a raw string | | `responseFormat` | string | `auto` (default), `json`, `csv`, `text`, `pdf`, or `binary` | | `csvDelimiter` | string | Delimiter for `csv` parsing (default `,`) | | `timeout` | number | Milliseconds; default 300000, capped at 600000 | | `retries` | number | Retry attempts for timeouts, 408, 429 and 5xx (default 0) | | `retryDelayMs` | number | Initial backoff, doubled each attempt (default 500) | | `retryMaxDelayMs` | number | Backoff ceiling (default 30000) | | `retryNonIdempotent` | boolean | Allow retries for `POST`/`PATCH` (default false) | | `maxResponseBytes` | number | Refuse a larger response (default 20 MB) | | `failOnError` | boolean | Throw on non-2xx (default true) | Plain `http://` targets are rejected. The request is pinned to an address that was resolved and validated before the connection is opened, and an unencrypted hop cannot be trusted to reach that address — so the API block, like every outbound path on the platform, only speaks `https://`. Existing steps that name an `http://` URL fail at run time with `url must use https:// to a public destination.` until the URL is updated. **Outputs:** `data` (parsed body), `status`, `headers`, `ok`, `contentType`, `sizeBytes`, plus `text` for textual formats, `rows`/`columns` for CSV, and base64 `content` for `pdf`/`binary` — so a downloaded file can flow straight into a `workspace_files`, `data_table`, `transform`, or `agent` step. `POST` and `PATCH` are **not** retried unless `retryNonIdempotent` is set, because a retry can duplicate a side effect. Requests to loopback, private-network addresses and cloud metadata endpoints are refused, and every redirect hop is re-checked before it is followed. ## Template Variables Use template syntax to reference input and previous step outputs: ```json { "id": "update-contact", "type": "action", "action": "hubspot.updateContact", "config": { "contactId": "{{steps.lookup.output.id}}", "properties": { "company": "{{input.company_name}}", "lifecyclestage": "customer", "last_order_date": "{{now}}" } } } ``` ### Available Template Contexts | Context | Example | Description | |---------|---------|-------------| | `input` | `{{input.customer_id}}` | Workflow trigger input | | `steps` | `{{steps.step-id.output.field}}` | Previous step output | | `variables` | `{{variables.item}}` | Loop variables (the item and index a loop binds); empty outside a loop | | `secrets` | `{{ secrets.API_TOKEN }}` | Workflow secrets, injected at dispatch | | `now` | `{{now}}` | Current ISO timestamp | ### Nested Access ```json { "config": { "email": "{{input.customer.contact.email}}", "amount": "{{steps.calculate.output.totals.grandTotal}}" } } ``` ## Error Handling ### Retry Configuration Configure automatic retries for transient failures: ```json { "id": "api-call", "type": "action", "action": "stripe.createInvoice", "config": { ... }, "retryConfig": { "maxAttempts": 3, "initialDelayMs": 1000, "maxDelayMs": 30000, "backoffMultiplier": 2 } } ``` | Field | Type | Description | |-------|------|-------------| | `maxAttempts` | number | Maximum retry attempts (1-10) | | `initialDelayMs` | number | Initial delay in ms (min: 100) | | `maxDelayMs` | number | Maximum delay in ms (min: 1000) | | `backoffMultiplier` | number | Exponential backoff multiplier (1-10) | `retryConfig` is accepted by the step schema but is **not yet honoured by the execution engine**, and `errorHandling.strategy: "retry"` re-raises to the task runner rather than retrying the step. For real per-step retries today, use the `api` block's own `retries` fields (above). ### Error Handling Strategy ```json { "id": "optional-sync", "type": "action", "action": "hubspot.updateContact", "config": { ... }, "errorHandling": { "strategy": "continue", "fallback": { "synced": false, "error": "Sync failed" } } } ``` | Strategy | Behavior | |----------|----------| | `fail` | Stop workflow, mark run as failed (default) | | `continue` | Log error, continue with fallback output | | `retry` | Retry with `retryConfig` settings | ### Timeout ```json { "id": "slow-api", "type": "action", "action": "netsuite.suiteql", "config": { ... }, "timeoutMs": 60000 } ``` ## Output Action outputs are stored and available to subsequent steps: ```json // Access in later steps {{steps.create-invoice.output.id}} {{steps.create-invoice.output.number}} {{steps.create-invoice.output.status}} {{steps.create-invoice.output.amount_due}} ``` ### Checking Output Existence ```json { "id": "check-result", "type": "condition", "config": { "conditions": { "left": "{{steps.create-invoice.output.id}}", "operator": "exists" }, "then": ["send-notification"], "else": ["handle-error"] } } ``` ## Common Patterns ### Create Then Update ```json { "steps": [ { "id": "create-customer", "type": "action", "action": "stripe.createCustomer", "config": { "email": "{{input.email}}", "name": "{{input.name}}" } }, { "id": "create-invoice", "type": "action", "action": "stripe.createInvoice", "config": { "customer": "{{steps.create-customer.output.id}}", "auto_advance": true } } ] } ``` ### Conditional Action ```json { "steps": [ { "id": "check-exists", "type": "action", "action": "hubspot.searchContacts", "config": { "filterGroups": [{ "filters": [{ "propertyName": "email", "operator": "EQ", "value": "{{input.email}}" }] }] } }, { "id": "route-action", "type": "condition", "config": { "conditions": { "left": "{{steps.check-exists.output.total}}", "operator": "gt", "right": "0" }, "then": ["update-contact"], "else": ["create-contact"] } } ] } ``` ### With Fallback ```json { "id": "sync-to-crm", "type": "action", "action": "salesforce.updateAccount", "config": { "id": "{{input.sf_account_id}}", "fields": { "Website": "{{input.website}}" } }, "errorHandling": { "strategy": "continue", "fallback": { "synced": false, "syncError": "Failed to update Salesforce" } } } ``` ## Connector-Specific Examples ### Stripe ```json { "id": "create-payment", "type": "action", "action": "stripe.createPaymentIntent", "config": { "amount": "{{input.amount}}", "currency": "usd", "customer": "{{input.stripe_customer_id}}", "metadata": { "order_id": "{{input.order_id}}" } } } ``` ### Salesforce ```json { "id": "query-accounts", "type": "action", "action": "salesforce.query", "config": { "soql": "SELECT Id, Name, Website FROM Account WHERE Type = 'Customer' LIMIT 100" } } ``` ### Slack ```json { "id": "notify-team", "type": "action", "action": "slack.sendMessage", "config": { "channel": "#sales-notifications", "text": "New deal closed: {{input.deal_name}} for ${{input.amount}}" } } ``` ## Troubleshooting ### Action Not Found ```json { "error": { "code": "ACTION_NOT_FOUND", "message": "Action 'stripe.unknownAction' not found" } } ``` Verify the action name matches the connector's available actions. ### Connection Required ```json { "error": { "code": "CONNECTION_REQUIRED", "message": "No active connection found for provider 'salesforce'" } } ``` Ensure you have an active OAuth connection for the connector. ### Rate Limited ```json { "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded for provider" } } ``` Add retry configuration with exponential backoff. ## Next Steps Add branching logic Map and transform data All connector actions # Approval Steps (/docs/workflows/steps/approval) Pause workflows for human approval or input ## Approval Steps Approval steps enable human-in-the-loop (HITL) workflows by pausing execution until a designated approver takes action. Use them for scenarios requiring human judgment, data entry, or authorization before proceeding. ## Configuration ```json { "id": "manager-approval", "type": "approval", "name": "Request Manager Approval", "config": { "approvers": ["user@example.com"], "reason": "Invoice #{{input.invoice_id}} for ${{input.amount}} requires approval", "timeoutMs": 86400000, "notifyChannels": [ { "type": "slack", "channel": "C0123456789" }, { "type": "email", "instructions": "Write a short email asking the approver to review the invoice." } ] } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | Yes | Unique step identifier | | `type` | string | Yes | Must be `"approval"` | | `name` | string | Yes | Human-readable name | | `config.approvers` | string[] | Yes | List of approver identifiers (email, user ID, or template). An entry that is one whole template resolving to a list, such as `["{{steps.build_approvers.result.approvers}}"]`, is flattened into the list. Entries are trimmed, blanks are dropped and duplicates are removed ignoring case, so at least one non-blank approver must remain. Any other entry (a number, an object, a list nested deeper) fails validation. `escalateTo` is normalized the same way. | | `config.reason` | string | Yes | Reason shown to approvers explaining what needs approval | | `config.timeoutMs` | number | No | Timeout in milliseconds (default: 86400000 / 24 hours) | | `config.notifyChannels` | array | No | Where approval requests are sent: a list of Email and Slack destinations | ## Notification Channels Configure where approval requests are sent. `notifyChannels` is a list; each entry is an Email or a Slack destination: ```json { "config": { "notifyChannels": [ { "type": "slack", "channel": "C0123456789" }, { "type": "email", "instructions": "Write a short, polite email to a finance approver." } ] } } ``` | Entry | Field | Type | Description | |-------|-------|------|-------------| | Slack | `channel` | string | Slack channel ID (e.g. `C0123456789`). The approval request, with its Approve and Reject buttons, is posted here (see [Slack Requests](#slack-requests)). | | Slack | `threadTs` | string | Optional. Thread timestamp to post into | | Email | `instructions` | string | What the AI-written approval email should say. Required, but ignored when `bodyHtml` is set | | Email | `sender` | string | Optional. From address | | Email | `subject` | string | Optional. Fixed subject line, replacing the AI-written one (see [Fixed Email Body](#fixed-email-body)) | | Email | `bodyHtml` | string | Optional. Email body built by the workflow, sent instead of an AI-written email (see [Fixed Email Body](#fixed-email-body)) | ### Fixed Email Body By default the email body and subject are written by an AI model from the step's `reason`, `context` and `instructions`. To send the same email every time, build the HTML in an earlier step and pass it as `bodyHtml`. `subject` and `bodyHtml` are templates, resolved when the approval step runs: ```json { "config": { "approveLabel": "Approve billing", "rejectLabel": "Send back", "notifyChannels": [ { "type": "email", "instructions": "Not used when bodyHtml is set.", "subject": "Approve billing: {{steps.load_deal.name}}", "bodyHtml": "{{steps.build_email.html}}" } ] } } ``` - When `bodyHtml` resolves to text that is not blank, no AI model is called. `instructions` and `model` are ignored, but `instructions` must still be present. - When `bodyHtml` is missing or blank, the email is written by the AI model as usual. `subject`, if set, still replaces the AI-written subject. - Without `subject`, a fixed body is sent with the subject `Approval Required: `. - When the request goes to the `escalateTo` approvers, a subject set by `subject` or by a fixed body starts with `Escalated: `. An AI-written subject is not changed. - Line breaks and other control characters in `subject` are replaced with spaces, and it is cut to 200 characters. - **Template values are inserted into `bodyHtml` as they are; they are not HTML-escaped.** If a value comes from outside your team (a CRM field, a customer name, an inbound email), escape `&`, `<` and `>` in it before it reaches `bodyHtml`, or it can change the email's markup. Tags outside the allowlist below are removed, but allowed tags in a value, including `https:` links, are kept and shown. - The body is placed in the standard approval email, after the greeting and before the buttons and deadline, and goes through the same HTML allowlist as AI-written bodies. Allowed tags: `p`, `ul`, `ol`, `li`, `strong`, `em`, `a` (`https:` and `mailto:` links only), `br`, `table`, `thead`, `tbody`, `tfoot`, `tr`, `th`, `td`. Every other tag is removed and its text kept. - On `th` and `td`, only `colspan` and `rowspan` (1 to 99) and `align` (`left`, `right`, `center`) are kept. All other attributes, including `style` and `class`, are removed; tables get a fixed border and padding. - Tags are balanced before sending: a closing tag with nothing to close is removed, table rows and cells outside a table are removed (their text is kept), and anything left open is closed at the end of the body. - A body longer than 200,000 characters is cut, any table the cut leaves open is closed, and a note after it says the full details are in the workflow run. - Reminder, timeout and decision emails keep their standard text and do not use `bodyHtml` or `subject`. Reminder emails do show the step's `approveLabel` and `rejectLabel` on their buttons. ### Slack Requests Each approver's request is posted in the Slack channel with its own Approve and Reject buttons. - **Anyone in the channel can press them**, so manage who can decide through the channel's membership. Slack records who clicked. - The post carries no **View approval** link, because that link works outside Slack. - Broadcast and user-group mentions in the reason (``, ``, ``, ``) are shown as text. - When the decision is made, the request is updated and its buttons are removed. Reminder replies in its thread keep theirs. - If Slack refuses the post (for example, a private channel the Loopfour app has not been invited to), the request is sent to the approver as a Slack direct message, with a **View approval** link, and the channel gets a notice ("Approval requested for … Details sent privately to the approver."). Requests go through the **first** Slack destination only. Every other Slack destination gets a short notice saying where the requests went. ### Slack Threading Use `threadTs` to post the request into a thread. Reminders reply in the same thread: ```json { "id": "ask-for-input", "type": "approval", "config": { "approvers": ["{{input.slackEvent.payload.event.user}}"], "reason": "Please provide additional details", "notifyChannels": [ { "type": "slack", "channel": "{{input.slackEvent.payload.event.channel}}", "threadTs": "{{input.slackEvent.payload.event.ts}}" } ] } } ``` Posting needs `chat:write`; posting to a public channel the Loopfour app has not joined also needs `chat:write.public`. Private channels and Slack Connect channels need the app invited. The direct-message fallback looks up the approver by email, so it also needs `users:read.email` and `im:write`. If the approver is not in the workspace or Slack rejects the DM, the worker sends the private approval link by email when an email provider is configured. Set `API_URL` on the Trigger.dev worker so the private **View approval** button opens the full reason and decision page. ## Slack Message After A Decision When someone approves or rejects a request in Slack, the request message is updated: in the channel, or in the approver's DM when the request fell back to one. Two optional fields control what it becomes: ```json { "config": { "decisionMessage": "keep_request", "decisionMessageText": "Invoice {{steps.extract.invoice_number}} from {{steps.extract.vendor}}" } } ``` | Field | Type | Default | Description | |-------|------|---------|-------------| | `decisionMessage` | string | `"replace"` | `"replace"` swaps the request for a short note: the decision, the workflow, and who decided. `"keep_request"` keeps the request as it was posted, removes the Approve and Reject buttons, and adds Approved or Rejected and who decided at the end. | | `decisionMessageText` | string | - | Text added at the end of the updated message, in either mode. A fixed string or a template expression, resolved when the approval step runs, so it can show values from earlier steps. It must resolve to text; an expression that resolves to an object fails the step. Text over 3,000 characters is cut. | Use `keep_request` when the request details should stay visible after the decision. These settings apply when someone approves or rejects. A request that times out is still replaced with the timeout note. Slack formatting in `decisionMessageText` is not escaped. Keep values that come from outside your company, such as an inbound email's sender or subject, out of it: text like `` or `` renders as a mention or a link. ## Input Types Control how approvers interact with the approval request: ```json { "config": { "inputType": "text", "inputLabel": "Amount", "inputPlaceholder": "Enter the invoice amount", "approveLabel": "Submit", "rejectLabel": "Cancel" } } ``` | Field | Type | Default | Description | |-------|------|---------|-------------| | `inputType` | string | `"buttons"` | Input type: `"buttons"`, `"text"`, or `"both"` | | `inputLabel` | string | - | Label for text input field | | `inputPlaceholder` | string | - | Placeholder text for text input | | `approveLabel` | string | `"Approve"` | Custom label for approve/submit button. Also used on the email and Slack request and reminder buttons, cut to 75 characters | | `rejectLabel` | string | `"Reject"` | Custom label for reject/cancel button. Also used on the email and Slack request and reminder buttons, cut to 75 characters | ### Button Mode (Default) Standard approve/reject buttons: ```json { "config": { "inputType": "buttons", "approveLabel": "Approve Request", "rejectLabel": "Deny Request" } } ``` ### Text Input Mode Collect text input from approvers via a modal: ```json { "config": { "inputType": "text", "inputLabel": "Invoice Amount", "inputPlaceholder": "Enter amount (e.g., 5000)", "approveLabel": "Enter Input" } } ``` When `inputType` is `"text"`, clicking the approve button opens a Slack modal for text entry. The entered value is available in the step output. ### Both Mode Show both approve/reject buttons and a text input option: ```json { "config": { "inputType": "both", "inputLabel": "Notes", "inputPlaceholder": "Add optional notes", "approveLabel": "Approve", "rejectLabel": "Reject" } } ``` ## Output Approval steps return information about the decision: ```json { "decision": { "approved": true, "approvedBy": "user@example.com", "approvedAt": "2024-01-15T10:30:00Z", "reason": "Looks good, approved." } } ``` | Field | Description | |-------|-------------| | `decision.approved` | Whether the request was approved (`true`) or rejected (`false`) | | `decision.approvedBy` | Identifier of the user who made the decision | | `decision.approvedAt` | ISO 8601 timestamp of the decision | | `decision.reason` | Text input provided by the approver (if `inputType` includes text) | ## Common Patterns ### Amount-Based Approval Routing Route to different approvers based on amount: ```json { "steps": [ { "id": "check-amount", "type": "condition", "config": { "conditions": { "left": "{{input.amount}}", "operator": "gt", "right": "10000" }, "then": ["executive-approval"], "else": ["manager-approval"] } }, { "id": "executive-approval", "type": "approval", "config": { "approvers": ["cfo@company.com"], "reason": "High-value transaction: ${{input.amount}} requires executive approval", "notifyChannels": { "slack": "#executive-approvals", "email": true } } }, { "id": "manager-approval", "type": "approval", "config": { "approvers": ["{{input.manager_email}}"], "reason": "Transaction of ${{input.amount}} requires manager approval", "notifyChannels": { "slack": "#manager-approvals", "email": true } } } ] } ``` ### Sequential Multi-Level Approval Require approval from multiple levels: ```json { "steps": [ { "id": "manager-approval", "type": "approval", "config": { "approvers": ["{{input.manager}}"], "reason": "Step 1/2: Manager approval for {{input.request_type}}", "notifyChannels": { "slack": "#approvals" } } }, { "id": "check-manager-decision", "type": "condition", "config": { "conditions": { "left": "{{steps.manager-approval.decision.approved}}", "operator": "eq", "right": "true" }, "then": ["director-approval"], "else": ["notify-rejection"] } }, { "id": "director-approval", "type": "approval", "config": { "approvers": ["{{input.director}}"], "reason": "Step 2/2: Director approval for {{input.request_type}}", "notifyChannels": { "slack": "#approvals" } } } ] } ``` ### Data Collection in Loops Collect user input across multiple iterations: ```json { "steps": [ { "id": "setup", "type": "transform", "config": { "operation": "map", "data": { "iterations": [1, 2, 3] } } }, { "id": "collection-loop", "type": "loop", "config": { "collection": "{{steps.setup.iterations}}", "itemVariable": "iteration", "steps": ["collect-value", "process-value"] } }, { "id": "collect-value", "type": "approval", "config": { "approvers": ["{{input.slackEvent.payload.event.user}}"], "reason": "Iteration {{loop.iteration}}: Enter a value to process", "inputType": "text", "inputLabel": "Value", "inputPlaceholder": "Enter a number", "approveLabel": "Submit", "notifyChannels": { "slack": "{{input.slackEvent.payload.event.channel}}", "slackThreadTs": "{{input.slackEvent.payload.event.ts}}" } } }, { "id": "process-value", "type": "code", "config": { "action": "runCode", "code": "return { value: parseFloat(input.userInput) || 0 };", "input": { "userInput": "{{steps.collect-value.decision.reason}}" } } } ] } ``` ### Slack-Triggered Approval with Threading Handle approvals from Slack conversations: ```json { "name": "Slack Approval Workflow", "triggerConfig": { "type": "slack_event", "eventType": "message", "channelTypes": ["channel"], "keywords": ["request approval"] }, "steps": [ { "id": "acknowledge", "type": "slack", "config": { "action": "send_message", "channel": "{{input.slackEvent.payload.event.channel}}", "text": "Processing your request...", "thread_ts": "{{input.slackEvent.payload.event.ts}}" } }, { "id": "get-approval", "type": "approval", "config": { "approvers": ["manager@company.com"], "reason": "Approval requested by <@{{input.slackEvent.payload.event.user}}>: {{input.slackEvent.payload.event.text}}", "notifyChannels": { "slack": "{{input.slackEvent.payload.event.channel}}", "slackThreadTs": "{{input.slackEvent.payload.event.ts}}" } } }, { "id": "respond", "type": "slack", "config": { "action": "send_message", "channel": "{{input.slackEvent.payload.event.channel}}", "text": "Your request was {{#if steps.get-approval.decision.approved}}approved{{else}}rejected{{/if}} by {{steps.get-approval.decision.approvedBy}}", "thread_ts": "{{input.slackEvent.payload.event.ts}}" } } ] } ``` ## Timeout Handling When an approval times out: 1. The step completes with a timeout status 2. The workflow can handle this in subsequent condition steps ```json { "steps": [ { "id": "request-approval", "type": "approval", "config": { "approvers": ["approver@company.com"], "reason": "Urgent: Requires response within 1 hour", "timeoutMs": 3600000 } }, { "id": "check-response", "type": "condition", "config": { "conditions": { "left": "{{steps.request-approval.decision.approved}}", "operator": "exists" }, "then": ["process-decision"], "else": ["handle-timeout"] } } ] } ``` ## Best Practices ### Clear Approval Reasons Provide context so approvers can make informed decisions: ```json { "reason": "Invoice #{{input.invoice_id}} from {{input.vendor_name}} for ${{input.amount}} (due: {{input.due_date}})" } ``` ### Appropriate Timeouts Set timeouts based on urgency: | Scenario | Timeout | Milliseconds | |----------|---------|--------------| | Urgent approval | 1 hour | `3600000` | | Same-day approval | 8 hours | `28800000` | | Standard approval | 24 hours | `86400000` | | Extended review | 72 hours | `259200000` | ### Thread Continuity For Slack-triggered workflows, maintain thread context: ```json { "notifyChannels": { "slack": "{{input.slackEvent.payload.event.channel}}", "slackThreadTs": "{{input.slackEvent.payload.event.ts}}" } } ``` ## Troubleshooting ### Approval Not Received 1. **Check approvers list** - Ensure at least one valid approver is specified 2. **Verify channel access** - Bot must have access to notification channels 3. **Check timeout** - Approval may have expired ### Slack Modal Not Opening 1. **Verify inputType** - Must be `"text"` or `"both"` for modal 2. **Check connection** - Slack connection must be valid 3. **Ensure bot permissions** - Bot needs appropriate scopes ### Loop Iterations Not Waiting Each iteration within a loop creates its own approval waitpoint. Ensure: - Loop `concurrency` is set to `1` for sequential processing - Each iteration waits for user response before continuing ## Next Steps Timed delays and pauses Branching based on approval decisions Trigger workflows from Slack events # Condition Steps (/docs/workflows/steps/conditions) Branch workflows based on conditional logic ## Condition Steps Condition steps enable branching logic in workflows, allowing you to execute different paths based on data values, comparisons, and complex boolean expressions. ## Configuration ```json { "id": "check-amount", "type": "condition", "name": "Check Invoice Amount", "config": { "conditions": { "left": "{{input.amount}}", "operator": "gt", "right": "10000" }, "then": ["high-value-flow"], "else": ["standard-flow"] } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | Yes | Unique step identifier | | `type` | string | Yes | Must be `"condition"` | | `name` | string | Yes | Human-readable name | | `config.conditions` | object | Yes | Condition expression to evaluate | | `config.then` | string[] | Yes | Step IDs to execute if true | | `config.else` | string[] | No | Step IDs to execute if false | ## Simple Conditions A simple condition compares two values using an operator: ```json { "config": { "conditions": { "left": "{{input.status}}", "operator": "eq", "right": "approved" }, "then": ["process-order"], "else": ["send-rejection"] } } ``` | Field | Description | |-------|-------------| | `left` | Left operand (template expression or literal) | | `operator` | Comparison operator | | `right` | Right operand (optional for some operators) | ## Comparison Operators | Operator | Description | Example | |----------|-------------|---------| | `eq` | Equal to | `"status" eq "active"` | | `ne` | Not equal to | `"type" ne "draft"` | | `gt` | Greater than | `"amount" gt "1000"` | | `gte` | Greater than or equal | `"count" gte "10"` | | `lt` | Less than | `"priority" lt "5"` | | `lte` | Less than or equal | `"score" lte "100"` | | `contains` | String contains substring | `"email" contains "@company.com"` | | `startsWith` | String starts with | `"id" startsWith "cus_"` | | `endsWith` | String ends with | `"filename" endsWith ".pdf"` | | `exists` | Value exists (not null/undefined) | `"customer_id" exists` | | `isEmpty` | Value is empty | `"notes" isEmpty` | | `matches` | Regex pattern match | `"phone" matches "^\+1"` | ### Operator Examples **Numeric Comparison:** ```json { "conditions": { "left": "{{input.order_total}}", "operator": "gte", "right": "100" } } ``` **String Comparison:** ```json { "conditions": { "left": "{{input.email}}", "operator": "endsWith", "right": "@enterprise.com" } } ``` **Existence Check:** ```json { "conditions": { "left": "{{input.coupon_code}}", "operator": "exists" } } ``` **Regex Match:** ```json { "conditions": { "left": "{{input.phone}}", "operator": "matches", "right": "^\\+1[0-9]{10}$" } } ``` ## Compound Conditions ### AND Conditions All conditions must be true: ```json { "config": { "conditions": { "operator": "and", "conditions": [ { "left": "{{input.status}}", "operator": "eq", "right": "active" }, { "left": "{{input.amount}}", "operator": "gte", "right": "1000" } ] }, "then": ["premium-processing"], "else": ["standard-processing"] } } ``` ### OR Conditions At least one condition must be true: ```json { "config": { "conditions": { "operator": "or", "conditions": [ { "left": "{{input.priority}}", "operator": "eq", "right": "urgent" }, { "left": "{{input.vip}}", "operator": "eq", "right": "true" } ] }, "then": ["fast-track"], "else": ["normal-queue"] } } ``` ### NOT Conditions Negate a condition: ```json { "config": { "conditions": { "not": { "left": "{{input.status}}", "operator": "eq", "right": "cancelled" } }, "then": ["process"], "else": ["skip"] } } ``` ### Nested Conditions Combine AND, OR, and NOT for complex logic: ```json { "config": { "conditions": { "operator": "and", "conditions": [ { "left": "{{input.status}}", "operator": "eq", "right": "active" }, { "operator": "or", "conditions": [ { "left": "{{input.amount}}", "operator": "gt", "right": "10000" }, { "left": "{{input.customer_tier}}", "operator": "eq", "right": "enterprise" } ] } ] }, "then": ["priority-handling"], "else": ["standard-handling"] } } ``` ## Branching Patterns ### If/Else Basic two-way branching: ```json { "id": "check-approval", "type": "condition", "config": { "conditions": { "left": "{{input.amount}}", "operator": "gt", "right": "5000" }, "then": ["require-approval"], "else": ["auto-approve"] } } ``` ### Multiple Then Steps Execute multiple steps when condition is true: ```json { "config": { "conditions": { "left": "{{input.new_customer}}", "operator": "eq", "right": "true" }, "then": ["send-welcome-email", "create-onboarding-task", "notify-sales"], "else": ["update-existing-record"] } } ``` ### Chained Conditions (If/Else If/Else) Use multiple condition steps for switch-like logic: ```json { "steps": [ { "id": "check-tier-enterprise", "type": "condition", "config": { "conditions": { "left": "{{input.tier}}", "operator": "eq", "right": "enterprise" }, "then": ["enterprise-flow"], "else": ["check-tier-pro"] } }, { "id": "check-tier-pro", "type": "condition", "config": { "conditions": { "left": "{{input.tier}}", "operator": "eq", "right": "pro" }, "then": ["pro-flow"], "else": ["free-flow"] } } ] } ``` ### Guard Conditions Exit early if a condition isn't met: ```json { "steps": [ { "id": "validate-input", "type": "condition", "config": { "conditions": { "left": "{{input.required_field}}", "operator": "exists" }, "then": ["continue-processing"], "else": [] } } ] } ``` An empty `else: []` means the workflow ends without error if the condition is false. ## Using Step Outputs Reference previous step outputs in conditions: ```json { "steps": [ { "id": "fetch-customer", "type": "action", "action": "stripe.getCustomer", "config": { "customerId": "{{input.customer_id}}" } }, { "id": "check-balance", "type": "condition", "config": { "conditions": { "left": "{{steps.fetch-customer.output.balance}}", "operator": "gt", "right": "0" }, "then": ["send-balance-reminder"], "else": ["send-thank-you"] } } ] } ``` ## Common Patterns ### Approval Routing Route based on amount thresholds: ```json { "id": "route-approval", "type": "condition", "name": "Route by Amount", "config": { "conditions": { "operator": "or", "conditions": [ { "left": "{{input.amount}}", "operator": "gt", "right": "50000" }, { "left": "{{input.risk_score}}", "operator": "gt", "right": "80" } ] }, "then": ["executive-approval"], "else": ["manager-approval"] } } ``` ### Feature Flags Check for optional features: ```json { "id": "check-feature", "type": "condition", "config": { "conditions": { "left": "{{input.features.advanced_analytics}}", "operator": "eq", "right": "true" }, "then": ["run-advanced-analytics"], "else": ["run-basic-report"] } } ``` ### Error Recovery Check if a previous step succeeded: ```json { "id": "check-api-result", "type": "condition", "config": { "conditions": { "left": "{{steps.api-call.output.success}}", "operator": "eq", "right": "true" }, "then": ["process-result"], "else": ["handle-error", "notify-admin"] } } ``` ### Data Validation Validate required fields before processing: ```json { "id": "validate-order", "type": "condition", "name": "Validate Order Data", "config": { "conditions": { "operator": "and", "conditions": [ { "left": "{{input.customer_id}}", "operator": "exists" }, { "left": "{{input.items}}", "operator": "exists" }, { "left": "{{input.items.length}}", "operator": "gt", "right": "0" } ] }, "then": ["process-order"], "else": ["reject-invalid-order"] } } ``` ## Troubleshooting ### Condition Always False 1. **Check template syntax** - Ensure `{{}}` wrapping is correct 2. **Verify data types** - Numbers should be compared as numbers 3. **Check for null values** - Use `exists` operator first 4. **Debug with transform** - Add a transform step to log values ### Type Coercion Values are compared as strings by default. For numeric comparisons, ensure both operands are numeric: ```json // May not work as expected (string comparison) { "left": "{{input.count}}", "operator": "gt", "right": "9" // "10" < "9" as strings! } // Works correctly (treated as numbers) { "left": "{{input.count}}", "operator": "gt", "right": "9" // Template engine handles numeric comparison } ``` ### Missing Branches If neither `then` nor `else` matches, the workflow continues to the next sequential step. ## Next Steps Map and transform data Iterate over collections Execute steps concurrently # Transform Steps (/docs/workflows/steps/transforms) Map, normalize, and validate data in workflows ## Transform Steps Transform steps manipulate and reshape data as it flows through your workflow. They support field mapping, data normalization, validation, and enrichment operations. ## Configuration ```json { "id": "transform-data", "type": "transform", "name": "Transform Customer Data", "config": { "action": "transform", "sourceData": "{{input}}", "targetSchema": { "type": "object", "properties": { "fullName": { "type": "string" }, "email": { "type": "string" } } }, "mappingRules": [ { "source": "first_name", "target": "fullName", "transform": "capitalize" } ] } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | Yes | Unique step identifier | | `type` | string | Yes | Must be `"transform"` | | `name` | string | Yes | Human-readable name | | `config.action` | string | No | Transform action (default: `transform`) | | `config.sourceData` | string | Yes | Template expression for source data | | `config.targetSchema` | object | No | JSON Schema for target structure | | `config.mappingRules` | array | No | Field mapping rules | | `config.normalizations` | array | No | Data normalization rules | | `config.validationRules` | array | No | Validation rules | | `config.strict` | boolean | No | Fail on any transformation error | ## Transform Actions | Action | Description | |--------|-------------| | `transform` | Full transformation with mapping and normalization | | `map` | Simple field-to-field mapping | | `normalize` | Standardize data formats | | `validate` | Validate data against schema | | `enrich` | Add computed or external data | ## Transform Types Transforms can be categorized by type: | Type | Description | |------|-------------| | `mapping` | Field-to-field transformations | | `normalization` | Data standardization | | `enrichment` | Adding external or computed data | | `aggregation` | Combining multiple values | | `custom` | Custom transformation logic | ## Mapping Rules Mapping rules define how fields are transformed from source to target: ```json { "config": { "action": "map", "sourceData": "{{input}}", "mappingRules": [ { "source": "customer.firstName", "target": "name.first", "transform": "capitalize" }, { "source": "customer.lastName", "target": "name.last", "transform": "uppercase" }, { "source": "customer.emailAddress", "target": "email", "transform": "lowercase", "required": true } ] } } ``` ### Mapping Rule Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | No | Unique rule identifier | | `source` | string | Yes | JSONPath or dot notation to source field | | `target` | string | Yes | JSONPath or dot notation to target field | | `transform` | string | No | Transformation operation (default: `direct`) | | `customTransform` | string | No | Natural language transformation description | | `default` | any | No | Default value if source is missing | | `required` | boolean | No | Whether source field is required | | `condition` | string | No | Condition for applying this rule | ### Field Transforms | Transform | Description | Example | |-----------|-------------|---------| | `direct` | Copy value as-is | `"John"` → `"John"` | | `uppercase` | Convert to uppercase | `"John"` → `"JOHN"` | | `lowercase` | Convert to lowercase | `"John"` → `"john"` | | `trim` | Remove whitespace | `" John "` → `"John"` | | `capitalize` | Capitalize first letter | `"john"` → `"John"` | | `titlecase` | Title Case Each Word | `"john doe"` → `"John Doe"` | | `snake_case` | Convert to snake_case | `"firstName"` → `"first_name"` | | `camelCase` | Convert to camelCase | `"first_name"` → `"firstName"` | | `number` | Parse as number | `"123"` → `123` | | `string` | Convert to string | `123` → `"123"` | | `boolean` | Parse as boolean | `"true"` → `true` | | `array` | Wrap in array | `"value"` → `["value"]` | | `flatten` | Flatten nested array | `[[1], [2]]` → `[1, 2]` | | `custom` | Custom transformation | Use with `customTransform` | ### Conditional Mapping Apply mappings conditionally: ```json { "mappingRules": [ { "source": "amount", "target": "total", "transform": "number", "condition": "{{input.currency}} === 'USD'" }, { "source": "amount_eur", "target": "total", "transform": "number", "condition": "{{input.currency}} === 'EUR'" } ] } ``` ### Default Values Provide fallback values for missing fields: ```json { "mappingRules": [ { "source": "customer.phone", "target": "contactPhone", "default": "Not provided" }, { "source": "preferences.language", "target": "locale", "default": "en-US" } ] } ``` ## Normalization Rules Normalization rules standardize data formats: ```json { "config": { "action": "normalize", "sourceData": "{{input}}", "normalizations": [ { "field": "created_date", "type": "date", "format": "YYYY-MM-DD" }, { "field": "amount", "type": "currency", "locale": "en-US" }, { "field": "phone_number", "type": "phone", "format": "E.164" } ] } } ``` ### Normalization Types | Type | Description | Example | |------|-------------|---------| | `date` | Standardize date format | `"01/15/2024"` → `"2024-01-15"` | | `currency` | Format currency values | `1234.5` → `"$1,234.50"` | | `phone` | Standardize phone numbers | `"(555) 123-4567"` → `"+15551234567"` | | `address` | Normalize address format | Standardize street, city, zip | | `name` | Standardize name format | `"JOHN DOE"` → `"John Doe"` | | `email` | Normalize email addresses | `"John@EXAMPLE.com"` → `"john@example.com"` | | `url` | Standardize URLs | Add protocol, normalize case | | `percentage` | Format percentages | `0.156` → `"15.6%"` | ### Normalization Rule Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | No | Unique rule identifier | | `field` | string | Yes | Field path to normalize | | `type` | string | Yes | Type of normalization | | `format` | string | No | Target format (e.g., `YYYY-MM-DD` for dates) | | `locale` | string | No | Locale for formatting (e.g., `en-US`) | | `options` | object | No | Additional normalization options | ### Date Normalization Examples ```json { "normalizations": [ { "field": "order_date", "type": "date", "format": "YYYY-MM-DD" }, { "field": "timestamp", "type": "date", "format": "ISO8601" }, { "field": "birth_date", "type": "date", "format": "MM/DD/YYYY", "locale": "en-US" } ] } ``` ### Currency Normalization Examples ```json { "normalizations": [ { "field": "price", "type": "currency", "locale": "en-US", "options": { "currency": "USD", "minimumFractionDigits": 2 } }, { "field": "total_eur", "type": "currency", "locale": "de-DE", "options": { "currency": "EUR" } } ] } ``` ## Validation Rules Validate transformed data against constraints: ```json { "config": { "action": "validate", "sourceData": "{{steps.transform.output}}", "validationRules": [ { "field": "email", "rule": "required", "message": "Email is required" }, { "field": "email", "rule": "pattern", "value": "^[^@]+@[^@]+\\.[^@]+$", "message": "Invalid email format" }, { "field": "age", "rule": "range", "value": { "min": 18, "max": 120 }, "message": "Age must be between 18 and 120" } ] } } ``` ### Validation Rule Types | Rule | Description | Value Example | |------|-------------|---------------| | `required` | Field must exist and not be null | N/A | | `type` | Field must be specific type | `"string"`, `"number"`, `"boolean"` | | `pattern` | Field must match regex | `"^[A-Z]{2}$"` | | `range` | Numeric range | `{ "min": 0, "max": 100 }` | | `enum` | Must be one of listed values | `["active", "inactive", "pending"]` | | `custom` | Custom validation expression | `"{{value}} > 0"` | ### Validation Rule Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | No | Unique rule identifier | | `field` | string | Yes | Field path to validate | | `rule` | string | Yes | Validation type | | `value` | any | Depends | Validation parameter | | `message` | string | No | Custom error message | ## Target Schema Define the expected output structure: ```json { "config": { "action": "transform", "sourceData": "{{input}}", "targetSchema": { "type": "object", "required": ["customerId", "email"], "properties": { "customerId": { "type": "string", "pattern": "^cus_[a-zA-Z0-9]+$" }, "email": { "type": "string", "format": "email" }, "name": { "type": "object", "properties": { "first": { "type": "string" }, "last": { "type": "string" } } }, "tags": { "type": "array", "items": { "type": "string" } } } }, "mappingRules": [...] } } ``` ## Using Templates Use pre-built transform templates: ```json { "config": { "action": "transform", "templateId": "stripe-to-quickbooks-customer", "sourceData": "{{steps.get-customer.output}}" } } ``` ## Transform Output Transform steps output detailed reports: ```json { "transformed": { "customerId": "cus_abc123", "email": "customer@example.com", "name": { "first": "John", "last": "Doe" } }, "mappingReport": { "successful": ["customerId", "email", "name.first", "name.last"], "failed": [], "defaultsUsed": ["phone"], "skipped": [] }, "normalizationReport": { "normalized": ["email"], "issues": [] }, "validationErrors": [], "isValid": true, "metadata": { "sourceFieldCount": 5, "targetFieldCount": 4, "transformDurationMs": 12 } } ``` ### Output Fields | Field | Description | |-------|-------------| | `transformed` | The transformed data matching target schema | | `mappingReport` | Report of field mapping operations | | `normalizationReport` | Report of normalization operations | | `validationErrors` | List of validation errors | | `isValid` | Whether the transformation is valid | | `metadata` | Transformation statistics | ## Common Patterns ### Data Mapping Between Systems Map Stripe customer to QuickBooks format: ```json { "id": "map-to-qb", "type": "transform", "name": "Map to QuickBooks", "config": { "action": "map", "sourceData": "{{steps.stripe-customer.output}}", "mappingRules": [ { "source": "name", "target": "DisplayName", "transform": "titlecase" }, { "source": "email", "target": "PrimaryEmailAddr.Address", "transform": "lowercase" }, { "source": "phone", "target": "PrimaryPhone.FreeFormNumber" }, { "source": "address.line1", "target": "BillAddr.Line1" }, { "source": "address.city", "target": "BillAddr.City" }, { "source": "address.state", "target": "BillAddr.CountrySubDivisionCode", "transform": "uppercase" }, { "source": "address.postal_code", "target": "BillAddr.PostalCode" } ] } } ``` ### Data Cleanup and Standardization ```json { "id": "cleanup-data", "type": "transform", "name": "Clean Customer Data", "config": { "action": "normalize", "sourceData": "{{input}}", "normalizations": [ { "field": "email", "type": "email" }, { "field": "phone", "type": "phone" }, { "field": "name", "type": "name" }, { "field": "created_at", "type": "date", "format": "ISO8601" } ] } } ``` ### Validation Before API Call ```json { "id": "validate-invoice", "type": "transform", "name": "Validate Invoice Data", "config": { "action": "validate", "sourceData": "{{input}}", "validationRules": [ { "field": "customer_id", "rule": "required", "message": "Customer ID is required" }, { "field": "customer_id", "rule": "pattern", "value": "^cus_", "message": "Invalid customer ID format" }, { "field": "line_items", "rule": "required", "message": "At least one line item is required" }, { "field": "amount", "rule": "range", "value": { "min": 100 }, "message": "Amount must be at least $1.00" } ], "strict": true } } ``` ### Enriching Data Add computed fields: ```json { "id": "enrich-order", "type": "transform", "name": "Enrich Order Data", "config": { "action": "enrich", "sourceData": "{{input}}", "targetSchema": { "type": "object", "properties": { "orderId": { "type": "string" }, "subtotal": { "type": "number" }, "tax": { "type": "number" }, "total": { "type": "number" }, "processedAt": { "type": "string" } } }, "mappingRules": [ { "source": "id", "target": "orderId" }, { "source": "amount", "target": "subtotal", "transform": "number" } ], "customInstructions": "Calculate tax at 8% and total. Add processedAt with current timestamp." } } ``` ### Flattening Nested Data ```json { "id": "flatten-contacts", "type": "transform", "name": "Flatten Contact List", "config": { "action": "transform", "sourceData": "{{steps.fetch-accounts.output.accounts}}", "mappingRules": [ { "source": "contacts", "target": "allContacts", "transform": "flatten" } ] } } ``` ## Strict Mode Enable strict mode to fail on any transformation error: ```json { "config": { "action": "transform", "sourceData": "{{input}}", "mappingRules": [...], "strict": true } } ``` In strict mode: - Missing required fields cause failure - Transformation errors stop execution - Validation errors cause failure Without strict mode (default): - Missing fields use defaults or are skipped - Transformation errors are logged but continue - Validation errors are reported but don't stop execution ## Troubleshooting ### Mapping Not Working 1. **Check field paths** - Use dot notation for nested fields 2. **Verify source data** - Add a debug transform to inspect data 3. **Check transform type** - Ensure correct transform for the data type ### Data Type Issues 1. **Use explicit transforms** - Add `number`, `string`, `boolean` transforms 2. **Check for null values** - Use `default` for missing fields 3. **Validate input** - Add validation step before transform ### Performance 1. **Minimize rules** - Combine mappings where possible 2. **Use templates** - Pre-built templates are optimized 3. **Skip unnecessary fields** - Only map fields you need ## Next Steps Execute integration actions Add branching logic Pause workflow execution # Wait Steps (/docs/workflows/steps/wait) Pause workflow execution for timed delays ## Wait Steps Wait steps pause workflow execution for a specified duration. They're useful for rate limiting, scheduling delays, and allowing external processes to complete. ## Configuration ```json { "id": "wait-before-retry", "type": "wait", "name": "Wait Before Retry", "config": { "duration": 5000 } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | Yes | Unique step identifier | | `type` | string | Yes | Must be `"wait"` | | `name` | string | Yes | Human-readable name | | `config.duration` | number | Yes | Wait time in milliseconds | ## Duration The `duration` field specifies how long to pause in milliseconds: ```json { "config": { "duration": 30000 } } ``` ### Common Durations | Duration | Milliseconds | Use Case | |----------|--------------|----------| | 1 second | `1000` | Quick pause | | 5 seconds | `5000` | Brief delay | | 30 seconds | `30000` | Rate limiting | | 1 minute | `60000` | Processing time | | 5 minutes | `300000` | External sync | | 1 hour | `3600000` | Scheduled delay | ## Output Wait steps return information about the wait: ```json { "waited": 5000 } ``` | Field | Description | |-------|-------------| | `waited` | The actual duration waited (in ms) | ## Common Patterns ### Rate Limiting Add delays between API calls to avoid rate limits: ```json { "steps": [ { "id": "first-api-call", "type": "action", "action": "hubspot.getContact", "config": { "contactId": "{{input.contactId}}" } }, { "id": "rate-limit-delay", "type": "wait", "name": "Rate Limit Delay", "config": { "duration": 1000 } }, { "id": "second-api-call", "type": "action", "action": "hubspot.updateContact", "config": { "contactId": "{{input.contactId}}", "properties": { ... } } } ] } ``` ### Retry with Delay Wait before retrying a failed operation: ```json { "steps": [ { "id": "attempt-sync", "type": "action", "action": "salesforce.updateAccount", "config": { ... }, "errorHandling": { "strategy": "continue", "fallback": { "success": false } } }, { "id": "check-success", "type": "condition", "config": { "conditions": { "left": "{{steps.attempt-sync.output.success}}", "operator": "eq", "right": "false" }, "then": ["wait-and-retry"], "else": ["continue-flow"] } }, { "id": "wait-and-retry", "type": "wait", "name": "Wait Before Retry", "config": { "duration": 5000 } }, { "id": "retry-sync", "type": "action", "action": "salesforce.updateAccount", "config": { ... } } ] } ``` ### Processing Delay Wait for external system to process: ```json { "steps": [ { "id": "submit-document", "type": "action", "action": "pandadoc.sendDocument", "config": { "documentId": "{{input.documentId}}" } }, { "id": "wait-for-processing", "type": "wait", "name": "Wait for Document Processing", "config": { "duration": 10000 } }, { "id": "check-status", "type": "action", "action": "pandadoc.getDocumentStatus", "config": { "documentId": "{{input.documentId}}" } } ] } ``` ### Scheduled Notification Delay Delay a notification: ```json { "steps": [ { "id": "create-invoice", "type": "action", "action": "stripe.createInvoice", "config": { ... } }, { "id": "wait-before-reminder", "type": "wait", "name": "Wait 24 Hours", "config": { "duration": 86400000 } }, { "id": "send-reminder", "type": "action", "action": "slack.sendMessage", "config": { "channel": "#finance", "text": "Invoice {{steps.create-invoice.output.id}} is still unpaid" } } ] } ``` ### Polling Pattern Poll for status with delays: ```json { "steps": [ { "id": "start-export", "type": "action", "action": "netsuite.createExport", "config": { "type": "invoices" } }, { "id": "poll-status", "type": "loop", "config": { "collection": [1, 2, 3, 4, 5], "itemVariable": "attempt", "steps": ["check-export", "wait-if-pending"] } }, { "id": "check-export", "type": "action", "action": "netsuite.getExportStatus", "config": { "exportId": "{{steps.start-export.output.id}}" } }, { "id": "wait-if-pending", "type": "condition", "config": { "conditions": { "left": "{{steps.check-export.output.status}}", "operator": "eq", "right": "pending" }, "then": ["wait-poll"], "else": [] } }, { "id": "wait-poll", "type": "wait", "name": "Poll Delay", "config": { "duration": 5000 } } ] } ``` ## Dynamic Duration Use template expressions for dynamic wait times: ```json { "id": "dynamic-wait", "type": "wait", "name": "Dynamic Delay", "config": { "duration": "{{input.delayMs}}" } } ``` Or calculate based on previous step output: ```json { "steps": [ { "id": "get-retry-delay", "type": "transform", "config": { "action": "map", "sourceData": "{{input}}", "mappingRules": [ { "source": "retryCount", "target": "delayMs", "customTransform": "Multiply by 1000 for exponential backoff" } ] } }, { "id": "backoff-wait", "type": "wait", "name": "Exponential Backoff", "config": { "duration": "{{steps.get-retry-delay.output.delayMs}}" } } ] } ``` ## Best Practices ### Reasonable Timeouts Keep wait durations reasonable for your use case: - **API rate limiting**: 100-1000ms - **Processing delays**: 5-30 seconds - **Polling intervals**: 5-60 seconds - **Scheduled delays**: Use schedule triggers instead for hours/days ### Avoid Long Waits For delays longer than a few minutes, consider: 1. **Schedule triggers** - Use cron or interval triggers instead 2. **Subworkflows** - Split workflow at natural pause points 3. **External scheduling** - Let external systems handle timing ### Idempotency Design steps after waits to be idempotent in case of restarts: ```json { "steps": [ { "id": "wait-for-sync", "type": "wait", "config": { "duration": 60000 } }, { "id": "check-already-processed", "type": "condition", "config": { "conditions": { "left": "{{steps.lookup.output.processed}}", "operator": "eq", "right": "true" }, "then": [], "else": ["do-processing"] } } ] } ``` ## Troubleshooting ### Wait Not Working 1. **Check duration type** - Must be a number in milliseconds 2. **Verify template resolution** - Dynamic values must resolve to numbers 3. **Check workflow status** - Workflow must be active ### Workflow Timeout If your total wait time exceeds the workflow timeout: 1. **Split into subworkflows** - Break at natural boundaries 2. **Use schedule triggers** - For long delays 3. **Reduce polling frequency** - Increase wait duration between checks ### Resource Usage Long-running waits consume resources: 1. **Use appropriate intervals** - Don't poll too frequently 2. **Consider schedules** - For regular intervals 3. **Monitor run duration** - Track total workflow time ## Next Steps Execute integration actions Add branching logic Map and transform data