{
  "openapi": "3.0.3",
  "info": {
    "title": "Zapa Client Portals API",
    "version": "1.0.2",
    "description": "REST API for Zapa Client Portals management. See the\n[release notes](/docs/developers/releases/v1-0-2) for what changed in each version.\n\n## Authentication\n\nAll API endpoints require OAuth 2.0 authentication. Include your access token in the Authorization header:\n```\nAuthorization: Bearer YOUR_ACCESS_TOKEN\n```\n\nAccess tokens expire after 1 hour. Refresh tokens **rotate**: every refresh response returns a new\n`refresh_token`, and the previous one stops working about 60 seconds later. Clients created in\nSettings → API Settings must send `client_secret` when refreshing.\n\n## External System IDs\n\nA portal can carry one identifier per external system (`externalSystemIds`, e.g.\n`{ \"QuickBooks\": \"cust_12345\" }`). Set them with `PATCH /api/v1/portals/{portalId}` (merge-patch;\n`null` removes a key), look portals up with `GET /api/v1/portals?externalSystem=…&externalId=…`,\nand receive them on every webhook payload as `external_system_ids`. Pairs are unique per organization.\n\nThe `taxi` system is special: when the organization has connected the Taxi integration, setting\n`externalSystemIds.taxi` to a 6-digit Taxi customer id links the portal and Zapa pushes uploads and\nsigned documents to that customer. The verification outcome is exposed read-only as `taxi_link`.\n\n## Rate Limiting\n\n- 10,000 requests per day per client\n- Retry with exponential backoff on 429 responses\n\n## Webhooks\n\nSubscribe to real-time events by registering webhooks. All webhook payloads are signed with HMAC-SHA256\nover the raw request body (`X-Webhook-Signature`). Payloads are flat JSON: `event`, `org_id`, `portal_id`,\n`portal_name`, `external_system_ids`, `timestamp`, plus event-specific fields such as `file_id`,\n`task_id`, `guest_email` or `workflow_state_name`. See the\n[Developer Quickstart](/docs/developers/quickstart#verify-webhook-signatures) for verification code and\nan example payload.\n\n## Linking Users Into the App\n\nAPI resources can be turned into web links that open the app directly:\n\n- **Portal**: `https://app.zapaportal.com/org/{org_id}/vault/{portal_id}/folder/main`\n- **File preview**: append `?file={file_id}` to a portal link to open that file's preview automatically\n\nThe `org_id`, portal `id`, and file `id` values all come from the API responses below.\n\n## File Content Access\n\nThe API intentionally provides file **metadata** only — there is no endpoint that returns file contents\nor a download URL. Portals typically hold sensitive client documents (tax records, contracts, signed\nagreements), so downloads are restricted to signed-in users in the web app, where access is permission-checked\nand recorded in the portal's audit history. Every OAuth consent screen makes this promise to the end user:\n*\"This app will not have access to download file contents.\"*\n\nTo send someone to a file, use a file preview link (above) rather than trying to fetch the bytes.\n\n## Requesting Additional Functionality\n\nIf your integration needs a capability this API doesn't offer yet, contact\n[support@zapaportal.com](mailto:support@zapaportal.com) — we prioritize API additions based on\ncustomer requests.\n",
    "contact": {
      "name": "Zapa Client Portals Support",
      "email": "support@zapaportal.com"
    },
    "license": {
      "name": "Proprietary"
    }
  },
  "servers": [
    {
      "url": "https://api.zapaportal.com",
      "description": "Production server"
    },
    {
      "url": "https://api.zapatest.com",
      "description": "Development (sprint) server"
    }
  ],
  "tags": [
    {
      "name": "OAuth",
      "description": "OAuth 2.0 authentication endpoints"
    },
    {
      "name": "User",
      "description": "Authenticated user information"
    },
    {
      "name": "Portals",
      "description": "Client portal management"
    },
    {
      "name": "Files",
      "description": "File upload and management"
    },
    {
      "name": "Tasks",
      "description": "Task management"
    },
    {
      "name": "Guests",
      "description": "Guest invitation"
    },
    {
      "name": "Webhooks",
      "description": "Webhook management"
    }
  ],
  "components": {
    "securitySchemes": {
      "OAuth2": {
        "type": "oauth2",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "/oauth/authorize",
            "tokenUrl": "/oauth/token",
            "refreshUrl": "/oauth/token",
            "scopes": {
              "portal:read": "List and view portals",
              "portal:write": "Create and update portals",
              "file:list": "List and download files",
              "file:upload": "Upload files",
              "task:read": "List tasks",
              "task:write": "Create, update, and complete tasks",
              "guest:invite": "Invite guests to portals",
              "webhook:manage": "Manage webhooks"
            }
          }
        }
      }
    },
    "schemas": {
      "Portal": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "org_id": {
            "type": "string",
            "format": "uuid"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "workflowStateId": {
            "type": "string"
          },
          "workflowStateName": {
            "type": "string"
          },
          "archived": {
            "type": "boolean"
          },
          "externalSystemIds": {
            "type": "object",
            "description": "One identifier per external system, e.g. `{ \"taxi\": \"000123\" }`. Keys are matched case-insensitively (`taxi` and `Taxi` are the same system). Set via `PATCH`.",
            "additionalProperties": {
              "type": "string"
            }
          },
          "taxi_link": {
            "type": "object",
            "readOnly": true,
            "nullable": true,
            "description": "Present when the organization uses the Taxi integration and this portal has (or had) a\n`taxi` external system id. Written by Zapa after verifying the id against Taxi; cannot be\nset through the API. `status` is one of `verifying`, `linked`, `not_found`,\n`credential_rejected`, `error`, `unlinked`.\n",
            "properties": {
              "status": {
                "type": "string",
                "example": "linked"
              },
              "tenant_id": {
                "type": "string",
                "description": "Taxi tenant the portal is linked into (only when `linked`)."
              },
              "customer_id": {
                "type": "string",
                "description": "The 6-digit Taxi customer id that was verified.",
                "example": "000123"
              },
              "display_name": {
                "type": "string",
                "description": "Customer name as shown in Taxi (only when `linked`)."
              },
              "verified_at": {
                "type": "string",
                "format": "date-time"
              },
              "error": {
                "type": "string",
                "description": "Reason when `status` is not `linked`."
              }
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_by": {
            "type": "string"
          }
        }
      },
      "File": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "vault_id": {
            "type": "string",
            "format": "uuid"
          },
          "org_id": {
            "type": "string",
            "format": "uuid"
          },
          "size": {
            "type": "integer"
          },
          "extension": {
            "type": "string"
          },
          "parent_folder_file_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "The containing folder's file ID, or null if in the portal root"
          },
          "uploaded_at": {
            "type": "string",
            "format": "date-time"
          },
          "uploaded_by": {
            "type": "string"
          }
        }
      },
      "Task": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "vault_id": {
            "type": "string",
            "format": "uuid"
          },
          "org_id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "action_type": {
            "type": "string",
            "example": "General"
          },
          "status": {
            "type": "string",
            "description": "Stored and returned as `Open` or `Done`. On input, `open` is treated as open and `done`, `completed`, or `closed` as finished (case-insensitive).",
            "example": "Open"
          },
          "due_date": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WorkflowState": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          }
        }
      },
      "Me": {
        "type": "object",
        "properties": {
          "user_id": {
            "type": "string"
          },
          "user_name": {
            "type": "string",
            "nullable": true
          },
          "user_email": {
            "type": "string",
            "nullable": true
          },
          "org_id": {
            "type": "string"
          },
          "org_name": {
            "type": "string",
            "nullable": true
          },
          "client_id": {
            "type": "string"
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "Webhook": {
        "type": "object",
        "properties": {
          "webhook_id": {
            "type": "string",
            "format": "uuid"
          },
          "org_id": {
            "type": "string",
            "format": "uuid"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "portal.created",
                "portal.workflow_changed",
                "file.uploaded",
                "file.signed",
                "task.created",
                "task.completed",
                "guest.invited"
              ]
            }
          },
          "secret": {
            "type": "string"
          },
          "enabled": {
            "type": "boolean"
          },
          "success_count": {
            "type": "integer"
          },
          "failure_count": {
            "type": "integer"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "error_description": {
            "type": "string"
          }
        }
      }
    }
  },
  "paths": {
    "/oauth/authorize": {
      "get": {
        "tags": [
          "OAuth"
        ],
        "summary": "Authorization endpoint",
        "description": "Starts the OAuth 2.0 Authorization Code flow. Direct the user's browser here (on the **web app**\ndomain, `https://app.zapaportal.com`) — they'll see a consent screen listing the scopes you requested\nand can approve or deny each one. After approval, the user is redirected to your `redirect_uri` with a\nshort-lived `code` to exchange at the token endpoint.\n\nAlways pass a random `state` value and verify it on the redirect to protect against CSRF.\n",
        "operationId": "authorize",
        "parameters": [
          {
            "name": "client_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "response_type",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "code"
              ]
            }
          },
          {
            "name": "redirect_uri",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uri"
            }
          },
          {
            "name": "scope",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to consent screen or callback URL"
          }
        }
      }
    },
    "/oauth/token": {
      "post": {
        "tags": [
          "OAuth"
        ],
        "summary": "Token endpoint",
        "description": "Exchanges an authorization code for an access token, or refreshes an expired access token.\nThis endpoint lives on the **API** domain (`https://api.zapaportal.com`), unlike the authorize\nendpoint which is on the web app domain.\n\nAccess tokens expire after 1 hour (`expires_in: 3600`); store the `refresh_token` and use the\n`refresh_token` grant to get a new one without user interaction. The endpoint accepts both\n`application/x-www-form-urlencoded` and `application/json` bodies.\n",
        "operationId": "token",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "type": "object",
                    "required": [
                      "grant_type",
                      "code",
                      "client_id",
                      "client_secret",
                      "redirect_uri"
                    ],
                    "properties": {
                      "grant_type": {
                        "type": "string",
                        "enum": [
                          "authorization_code"
                        ]
                      },
                      "code": {
                        "type": "string"
                      },
                      "client_id": {
                        "type": "string"
                      },
                      "client_secret": {
                        "type": "string"
                      },
                      "redirect_uri": {
                        "type": "string"
                      }
                    }
                  },
                  {
                    "type": "object",
                    "required": [
                      "grant_type",
                      "refresh_token",
                      "client_id",
                      "client_secret"
                    ],
                    "properties": {
                      "grant_type": {
                        "type": "string",
                        "enum": [
                          "refresh_token"
                        ]
                      },
                      "refresh_token": {
                        "type": "string"
                      },
                      "client_id": {
                        "type": "string"
                      },
                      "client_secret": {
                        "type": "string"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Successful token response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "access_token": {
                      "type": "string"
                    },
                    "token_type": {
                      "type": "string",
                      "example": "Bearer"
                    },
                    "expires_in": {
                      "type": "integer",
                      "example": 3600
                    },
                    "refresh_token": {
                      "type": "string"
                    },
                    "scope": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid request",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me": {
      "get": {
        "tags": [
          "User"
        ],
        "summary": "Get authenticated user",
        "description": "Returns the user and organization associated with the access token — user and org IDs and names,\nthe OAuth client ID, and the scopes that were actually granted.\n\nCall this first after obtaining a token: it verifies authentication works, tells you which scopes\nthe user approved on the consent screen (they may have granted fewer than you requested), and gives\nyou the `org_id` needed to build web links to portals and files. No specific scope is required —\nany valid token works.\n",
        "operationId": "getMe",
        "security": [
          {
            "OAuth2": []
          }
        ],
        "responses": {
          "200": {
            "description": "Authenticated user information",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Me"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired token",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/workflow-states": {
      "get": {
        "tags": [
          "Portals"
        ],
        "summary": "List workflow states",
        "description": "Returns the organization's configured workflow states as `{id, name}` pairs. Call this before\nsetting a portal's workflow state — the workflow endpoint requires both values, and they must match\nan existing state. Also handy for populating dropdowns in your integration's UI.\n",
        "operationId": "listWorkflowStates",
        "security": [
          {
            "OAuth2": [
              "portal:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "List of workflow states",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/WorkflowState"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/portals": {
      "get": {
        "tags": [
          "Portals"
        ],
        "summary": "List portals",
        "description": "Returns all active (non-deleted) portals in your organization, including tags, workflow state,\ntask counters, and recent-activity timestamps — useful for syncing portal lists into a CRM or\ndashboard.\n\n**Templates**: pass `templatesOnly=true` to list your organization's portal templates instead of\nregular portals. Use a template's `id` as the `templateId` when creating a new portal to copy its\nfolder structure and settings.\n\n**Linking**: build a web link to any portal with\n`https://app.zapaportal.com/org/{org_id}/vault/{id}/folder/main`.\n\n**External-id lookup**: pass both `externalSystem` and `externalId` to return only the portal(s)\nwhose `externalSystemIds[externalSystem]` equals `externalId` — e.g.\n`?externalSystem=taxi&externalId=cust_12345`. Returns an empty array when nothing matches.\n",
        "operationId": "listPortals",
        "security": [
          {
            "OAuth2": [
              "portal:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "templatesOnly",
            "in": "query",
            "description": "When true, returns portal templates instead of regular portals",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "externalSystem",
            "in": "query",
            "description": "External system name (as defined in Settings → External Systems). Must be paired with `externalId`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "externalId",
            "in": "query",
            "description": "Identifier in the external system. Must be paired with `externalSystem`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of portals",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Portal"
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Portals"
        ],
        "summary": "Create portal",
        "description": "Creates a new client portal — typically one per client or engagement.\n\n**Creating from a template** (Business & Enterprise): first call `GET /api/v1/portals?templatesOnly=true`\nto find your template's `id`, then pass it as `templateId`. The new portal is created with the\ntemplate's folder structure and default settings:\n\n```json\n{ \"name\": \"ABC Corp - 2026 Tax Return\", \"templateId\": \"TEMPLATE_UUID\", \"tags\": [\"tax-2026\"] }\n```\n\nSet `isTemplate: true` instead to create a new template rather than a regular portal.\n\n**Next steps**: invite the client with `POST /api/v1/portals/{portalId}/guests`, then upload files\nwith `POST /api/v1/portals/{portalId}/files`. Subscribing a webhook to `portal.created` lets other\nsystems react to portals created here or in the web app.\n",
        "operationId": "createPortal",
        "security": [
          {
            "OAuth2": [
              "portal:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Display name, e.g. client name or project"
                  },
                  "tags": {
                    "type": "array",
                    "description": "Tags for filtering and grouping on the dashboard",
                    "items": {
                      "type": "string"
                    }
                  },
                  "isTemplate": {
                    "type": "boolean",
                    "description": "Create a reusable template instead of a regular portal"
                  },
                  "templateId": {
                    "type": "string",
                    "format": "uuid",
                    "description": "ID of a portal template to copy structure and settings from"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Portal created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Portal"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/portals/{portalId}": {
      "get": {
        "tags": [
          "Portals"
        ],
        "summary": "Get portal by ID",
        "description": "Returns a single portal's details, including tags, workflow state, and activity timestamps.\nUse this to refresh one portal's state instead of re-listing everything, or to resolve a portal ID\nreceived in a webhook payload.\n",
        "operationId": "getPortal",
        "security": [
          {
            "OAuth2": [
              "portal:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "portalId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Portal details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Portal"
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "Portals"
        ],
        "summary": "Update portal",
        "description": "Updates a portal's name, tags, workflow state, or archive status. Only include the fields you\nwant to change.\n\nSet `archived: true` to archive a portal when an engagement ends — nothing is deleted, and it can\nbe restored later with `archived: false`. There is no delete endpoint; portals can only be\npermanently deleted from the web app.\n\n**External system ids**: `externalSystemIds` is a merge-patch — keys you send are set, a `null`\nvalue deletes that key, and keys you omit are left unchanged. Each `(system, id)` pair must be\nunique within your organization; a duplicate returns `409 conflict`. The response echoes the\nmerged map.\n",
        "operationId": "updatePortal",
        "security": [
          {
            "OAuth2": [
              "portal:write"
            ]
          }
        ],
        "parameters": [
          {
            "name": "portalId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "tags": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "workflowStateId": {
                    "type": "string"
                  },
                  "workflowStateName": {
                    "type": "string"
                  },
                  "archived": {
                    "type": "boolean"
                  },
                  "externalSystemIds": {
                    "type": "object",
                    "description": "Merge-patch of external system ids. Values are strings (max 256 chars) or `null` to delete.",
                    "additionalProperties": {
                      "type": "string",
                      "nullable": true
                    },
                    "example": {
                      "taxi": "cust_12345"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Portal updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Portal"
                }
              }
            }
          },
          "409": {
            "description": "An externalSystemIds pair is already assigned to another portal in the organization",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/portals/{portalId}/workflow": {
      "post": {
        "tags": [
          "Portals"
        ],
        "summary": "Set portal workflow state",
        "description": "Moves a portal to a different workflow state (e.g. \"In Progress\" → \"Review\"). Fetch valid\n`workflowStateId`/`workflowStateName` pairs from `GET /api/v1/workflow-states` first — both values\nare required and should come from that list.\n\nState changes fire the `portal.workflow_changed` webhook event, making this a good integration\npoint for driving external processes (e.g. notify your practice-management system when a portal\nreaches \"Complete\").\n",
        "operationId": "setWorkflowState",
        "security": [
          {
            "OAuth2": [
              "portal:write"
            ]
          }
        ],
        "parameters": [
          {
            "name": "portalId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "workflowStateId",
                  "workflowStateName"
                ],
                "properties": {
                  "workflowStateId": {
                    "type": "string"
                  },
                  "workflowStateName": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Workflow state updated"
          }
        }
      }
    },
    "/api/v1/portals/{portalId}/files": {
      "get": {
        "tags": [
          "Files"
        ],
        "summary": "List files in portal",
        "description": "Returns metadata (name, size, extension, upload date/user) for every active file and folder in a\nportal. Folders appear as entries too — a file's `parent_folder_file_id` links it to its folder.\n\n**Linking to a file**: to send someone to a specific file, build a web preview link:\n`https://app.zapaportal.com/org/{org_id}/vault/{portalId}/folder/main?file={file_id}` — opening it\nsigns the user in (if needed) and opens that file's preview automatically.\n\n**Why no download endpoint?** The API deliberately never returns file contents. Portal documents are\noften sensitive (tax records, contracts, signed agreements), and the OAuth consent screen promises\nusers that connected apps cannot download their file contents. Downloads happen in the web app, where\nthey're permission-checked per user and recorded in the portal's audit history. If your integration\nhas a use case that requires content access, contact\n[support@zapaportal.com](mailto:support@zapaportal.com) to discuss it.\n",
        "operationId": "listFiles",
        "security": [
          {
            "OAuth2": [
              "file:list"
            ]
          }
        ],
        "parameters": [
          {
            "name": "portalId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of files",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/File"
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Files"
        ],
        "summary": "Upload a file",
        "description": "Uploads a file to a portal. Two modes are supported:\n\n**One-step (recommended for automation)**: include a public HTTPS `file_url` and the server fetches\nthe file (up to 100 MB) and stores it immediately — the response is the completed file record.\nThis is how the Zapier integration uploads files.\n\n**Two-step (for uploading local bytes)**: omit `file_url` and the response contains a presigned\n`upload_url` valid for 1 hour. Then:\n\n1. `PUT` the raw file bytes to `upload_url`, sending the same `Content-Type` you specified here\n   (the response echoes the exact method and headers to use)\n2. Call `POST /api/v1/portals/{portalId}/files/complete` with the returned `file_id` to register\n   the file — until you do, the file will not appear in the portal\n\nSuccessful uploads fire the `file.uploaded` webhook event and notify portal users per their\nnotification settings. Pass `parent_folder_file_id` (a folder's file ID from the list endpoint) to\nplace the file inside a folder.\n",
        "operationId": "uploadFile",
        "security": [
          {
            "OAuth2": [
              "file:upload"
            ]
          }
        ],
        "parameters": [
          {
            "name": "portalId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "filename"
                ],
                "properties": {
                  "filename": {
                    "type": "string",
                    "description": "File name including extension, e.g. `engagement-letter.pdf`"
                  },
                  "contentType": {
                    "type": "string",
                    "description": "MIME type (defaults to `application/octet-stream`)"
                  },
                  "size": {
                    "type": "integer",
                    "description": "File size in bytes (used for the two-step flow's file record)"
                  },
                  "file_url": {
                    "type": "string",
                    "format": "uri",
                    "description": "Public HTTPS URL to fetch the file from (one-step mode, max 100 MB)"
                  },
                  "parent_folder_file_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Folder to place the file in (omit for the portal root)"
                  },
                  "task_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Attach the uploaded file to this task in the same portal. Uploads made with an API\ntoken are attributed to a member, so the task is linked but not auto-completed;\nguest uploads in the web app against an open File Request are marked Done.\n"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Two-step mode — presigned upload URL (PUT your file bytes here, then call the complete endpoint)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "file_id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "upload_url": {
                      "type": "string",
                      "format": "uri"
                    },
                    "expires_in": {
                      "type": "integer",
                      "example": 3600
                    },
                    "method": {
                      "type": "string",
                      "example": "PUT"
                    },
                    "headers": {
                      "type": "object",
                      "description": "Headers to send with the PUT request"
                    }
                  }
                }
              }
            }
          },
          "201": {
            "description": "One-step mode (`file_url` provided) — file uploaded and registered",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "name": {
                      "type": "string"
                    },
                    "extension": {
                      "type": "string"
                    },
                    "size": {
                      "type": "integer"
                    },
                    "created_date": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing filename or the file could not be fetched from file_url",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/portals/{portalId}/files/complete": {
      "post": {
        "tags": [
          "Files"
        ],
        "summary": "Complete a two-step upload",
        "description": "Registers a file after you've PUT its bytes to a presigned `upload_url`. This creates the file\nrecord so it appears in the portal, notifies users, and fires the `file.uploaded` webhook event.\n\nOnly needed for the two-step upload flow — one-step (`file_url`) uploads are registered\nautomatically.\n",
        "operationId": "completeFileUpload",
        "security": [
          {
            "OAuth2": [
              "file:upload"
            ]
          }
        ],
        "parameters": [
          {
            "name": "portalId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "file_id",
                  "filename"
                ],
                "properties": {
                  "file_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "The `file_id` returned when you requested the upload URL"
                  },
                  "filename": {
                    "type": "string"
                  },
                  "extension": {
                    "type": "string",
                    "description": "Defaults to the filename's extension"
                  },
                  "size": {
                    "type": "integer",
                    "description": "File size in bytes"
                  },
                  "parent_folder_file_id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "task_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Attach the file to this task in the same portal (see upload endpoint for semantics)"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "File registered and visible in the portal"
          },
          "400": {
            "description": "Missing file_id or filename",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/portals/{portalId}/guests": {
      "post": {
        "tags": [
          "Guests"
        ],
        "summary": "Invite guests to a portal",
        "description": "Invites one or more guests (clients) to a portal by email. Each guest receives an invitation email;\nnew users are prompted to create an account, existing users just accept the invitation. Accepts\neither singular (`email`, `name`) or plural (`emails`, `names`) forms.\n\nThis is the typical second step after creating a portal: create the portal, invite the client,\nupload their documents. Invitations fire the `guest.invited` webhook event.\n\nGuest permissions can't be set through the API yet — new guests get your organization's default\nportal permissions, adjustable in the web app's Members panel. Contact\n[support@zapaportal.com](mailto:support@zapaportal.com) if your integration needs permission control.\n",
        "operationId": "inviteGuests",
        "security": [
          {
            "OAuth2": [
              "guest:invite"
            ]
          }
        ],
        "parameters": [
          {
            "name": "portalId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "description": "Single guest email (alternative to `emails`)"
                  },
                  "name": {
                    "type": "string",
                    "description": "Single guest name (alternative to `names`)"
                  },
                  "emails": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "email"
                    }
                  },
                  "names": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Guests invited",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "invited_count": {
                      "type": "integer"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing or invalid email address",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/portals/{portalId}/tasks": {
      "get": {
        "tags": [
          "Tasks"
        ],
        "summary": "List tasks in a portal",
        "description": "Returns all active tasks in a portal with their status, due date, assignees, and type. Useful for\nsyncing outstanding client action items into an external task tracker, or checking whether a client\nhas completed their requested items before advancing the portal's workflow state.\n",
        "operationId": "listTasks",
        "security": [
          {
            "OAuth2": [
              "task:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "portalId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of tasks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Task"
                  }
                }
              }
            }
          },
          "404": {
            "description": "Portal not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Tasks"
        ],
        "summary": "Create task",
        "description": "Creates a task in a portal — a request for the client (or your team) to do something, like\n\"Upload your W-2\" or \"Review the engagement letter\". Tasks are created with status `Open`.\n\n`action_type` categorizes the request (`Question?`, `File Request`, `Sign Document`, `Fill Form`,\nor `General`). Creating a task fires the `task.created` webhook event.\n\n**Assignees**: `assigned_guest_ids` must be user ids of guests already invited to the portal;\n`assigned_member_ids` must be user ids of members of your organization. Display names are\nresolved server-side. Unknown ids return `400`.\n\n**Reminders**: set `send_reminder: true` (requires `due_date`) to have Zapa email the portal's\nguests on its standard reminder cadence (14, 7, 3 and 0 days before due, and 7 days after).\n",
        "operationId": "createTask",
        "security": [
          {
            "OAuth2": [
              "task:write"
            ]
          }
        ],
        "parameters": [
          {
            "name": "portalId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "title"
                ],
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "description": {
                    "type": "string"
                  },
                  "due_date": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "action_type": {
                    "type": "string",
                    "default": "General"
                  },
                  "assigned_guest_ids": {
                    "type": "array",
                    "description": "User ids of portal guests to assign. Names are resolved server-side.",
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  },
                  "assigned_member_ids": {
                    "type": "array",
                    "description": "User ids of organization members to assign. Names are resolved server-side.",
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  },
                  "send_reminder": {
                    "type": "boolean",
                    "default": false,
                    "description": "Email reminders to portal guests as the due date approaches. Requires `due_date`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Task created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "task_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing title or creation failed",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/tasks/{taskId}": {
      "patch": {
        "tags": [
          "Tasks"
        ],
        "summary": "Update task",
        "description": "Updates one or more fields on a task; omitted fields keep their current values. Task IDs come from\nthe portal's task list endpoint.\n\nYou can change status here (e.g. reopen a task by setting a non-done status), but if you just want\nto finish a task, prefer `POST /api/v1/tasks/{taskId}/complete` — it also fires the\n`task.completed` webhook event.\n",
        "operationId": "updateTask",
        "security": [
          {
            "OAuth2": [
              "task:write"
            ]
          }
        ],
        "parameters": [
          {
            "name": "taskId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "description": {
                    "type": "string"
                  },
                  "status": {
                    "type": "string",
                    "description": "Use `done`, `completed`, or `closed` to finish a task; anything else counts as open."
                  },
                  "due_date": {
                    "type": "string",
                    "format": "date-time",
                    "nullable": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Task updated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "task_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Task not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/tasks/{taskId}/complete": {
      "post": {
        "tags": [
          "Tasks"
        ],
        "summary": "Mark task complete",
        "description": "Marks a task as completed and updates the portal's open/closed task counters. This is the\npreferred way to finish a task from an integration — for example, auto-completing a \"File Request\"\ntask after your system uploads the requested document. Fires the `task.completed` webhook event.\n",
        "operationId": "completeTask",
        "security": [
          {
            "OAuth2": [
              "task:write"
            ]
          }
        ],
        "parameters": [
          {
            "name": "taskId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Task marked complete",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "task_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Task not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/webhooks": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "List webhooks",
        "description": "Returns all webhook subscriptions for your organization, including each webhook's subscribed\nevents, enabled status, signing `secret`, and delivery success/failure counters. Check the\ncounters to spot endpoints that are failing deliveries.\n",
        "operationId": "listWebhooks",
        "security": [
          {
            "OAuth2": [
              "webhook:manage"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "List of webhooks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Webhook"
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Create webhook",
        "description": "Subscribes an HTTPS endpoint to real-time events. Deliveries are signed with HMAC-SHA256 in the\n`X-Webhook-Signature` header — verify it with the returned `secret` before trusting a payload\n(see the [Developer Quickstart](/docs/developers/quickstart) for example code). Failed deliveries\nare retried 3 times with exponential backoff.\n\nAfter creating, use `POST /api/v1/webhooks/{webhookId}/test` to send a test event and confirm your\nendpoint receives and verifies it correctly.\n",
        "operationId": "createWebhook",
        "security": [
          {
            "OAuth2": [
              "webhook:manage"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url",
                  "events"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri"
                  },
                  "events": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "portal.created",
                        "portal.workflow_changed",
                        "file.uploaded",
                        "file.signed",
                        "task.created",
                        "task.completed",
                        "guest.invited"
                      ]
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Webhook created. The response includes the `secret` used to sign deliveries — store it, it is not shown again.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Webhook"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/webhooks/{webhookId}": {
      "patch": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Update webhook",
        "description": "Updates the URL, subscribed events, or enabled flag; at least one field is required. Setting\n`enabled: false` pauses deliveries without losing the configuration or signing secret — useful\nduring maintenance windows on your receiving endpoint.\n",
        "operationId": "updateWebhook",
        "security": [
          {
            "OAuth2": [
              "webhook:manage"
            ]
          }
        ],
        "parameters": [
          {
            "name": "webhookId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri"
                  },
                  "events": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "portal.created",
                        "portal.workflow_changed",
                        "file.uploaded",
                        "file.signed",
                        "task.created",
                        "task.completed",
                        "guest.invited"
                      ]
                    }
                  },
                  "enabled": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Webhook updated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "webhook_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Webhook not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Delete webhook",
        "description": "Permanently removes a webhook subscription — deliveries stop immediately and the signing secret is\ninvalidated. If you only need a temporary stop, disable it with the update endpoint instead.\n",
        "operationId": "deleteWebhook",
        "security": [
          {
            "OAuth2": [
              "webhook:manage"
            ]
          }
        ],
        "parameters": [
          {
            "name": "webhookId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Webhook deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "webhook_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Webhook not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/webhooks/{webhookId}/test": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Send test delivery",
        "description": "Sends a signed `webhook.test` event to the webhook's URL and reports the delivery result — use it\nto verify your endpoint is reachable and your signature verification works before relying on real\nevents. The response's `status_code` is what your endpoint returned; a `success: false` with an\n`error` message usually means a network or TLS problem reaching your URL.\n",
        "operationId": "testWebhook",
        "security": [
          {
            "OAuth2": [
              "webhook:manage"
            ]
          }
        ],
        "parameters": [
          {
            "name": "webhookId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Delivery attempted (check `success` and `status_code` for the result)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "status_code": {
                      "type": "integer"
                    },
                    "delivery_id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "error": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Webhook not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  }
}
