Developer Quickstart
This guide will help you get started with the Zapa Client Portals REST API and webhooks in under 10 minutes.
API access requires an Enterprise plan with API access enabled for your organization.
Base URL: https://api.zapaportal.com
For the complete endpoint reference, see the API Reference. The OpenAPI 3.0 spec is also published as openapi.json for Postman, SDK generators, and other tooling.
Prerequisites
- A Zapa Client Portals organization account (admin access required, Enterprise plan)
- Basic knowledge of REST APIs and OAuth 2.0
- A tool for making HTTP requests (Postman, curl, or similar)
Step 1: Create an OAuth Client
- Log into your Zapa Client Portals account
- Navigate to Settings → API Settings (see API & Webhook Settings)
- Enable API Access if it is not already enabled
- Click Create OAuth Client:
- Enter a name (e.g., "My Integration")
- Add your redirect URI (e.g.,
https://your-app.com/callback) - Select the scopes you need (start with
portal:read,portal:write)
- IMPORTANT: Copy your
client_idandclient_secretimmediately — the secret is shown only once!
Step 2: Get an Access Token
The API uses the OAuth 2.0 Authorization Code flow.
1. Direct the user to the authorization URL (on the web app domain):
https://app.zapaportal.com/oauth/authorize?
client_id=YOUR_CLIENT_ID&
response_type=code&
redirect_uri=YOUR_REDIRECT_URI&
scope=portal:read portal:write file:list&
state=RANDOM_STRING
2. The user approves and is redirected back with a code:
https://your-redirect-uri?code=AUTHORIZATION_CODE&state=RANDOM_STRING
3. Exchange the code for an access token (on the API domain):
curl -X POST https://api.zapaportal.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTHORIZATION_CODE" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "redirect_uri=YOUR_REDIRECT_URI"
Response:
{
"access_token": "eyJhbGc...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "refresh_token_here",
"scope": "portal:read portal:write file:list"
}
The token endpoint accepts both application/x-www-form-urlencoded and application/json bodies.
Step 3: Make Your First API Call
# Verify authentication
curl https://api.zapaportal.com/api/v1/me \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
# List all portals
curl https://api.zapaportal.com/api/v1/portals \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
# Create a new portal
curl -X POST https://api.zapaportal.com/api/v1/portals \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Client Onboarding - ABC Corp",
"tags": ["client", "onboarding"]
}'
Working with External System IDs
If your integration already has its own record for a client (a CRM customer, a tax-software client file), store that ID on the portal and use it for lookups instead of keeping your own mapping table.
# Link a portal to your system (merge-patch: other systems' IDs are left alone;
# send null to remove a key)
curl -X PATCH https://api.zapaportal.com/api/v1/portals/PORTAL_ID \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "externalSystemIds": { "QuickBooks": "cust_12345" } }'
# Find the portal by your ID later (returns [] when none matches)
curl "https://api.zapaportal.com/api/v1/portals?externalSystem=QuickBooks&externalId=cust_12345" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Each (system, id) pair is unique within your organization — assigning one that already belongs to
another portal returns 409 Conflict. Every webhook payload includes the portal's current map as
external_system_ids, so your receiver can route events straight to the right record.
Requesting Documents With Tasks
Create a File Request task assigned to the client. When they upload a document to it (in the
web app, or via the API with task_id), the task completes automatically and you receive a
task.completed event carrying the file_id.
# Create a File Request with a reminder email (send_reminder requires due_date)
curl -X POST https://api.zapaportal.com/api/v1/portals/PORTAL_ID/tasks \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Upload your 2025 W-2",
"action_type": "File Request",
"due_date": "2026-10-15T00:00:00Z",
"assigned_guest_ids": ["GUEST_USER_ID"],
"send_reminder": true
}'
# Attach an upload to that task (both upload endpoints accept task_id)
curl -X POST https://api.zapaportal.com/api/v1/portals/PORTAL_ID/files \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"filename": "W2_2025.pdf",
"contentType": "application/pdf",
"file_url": "https://your-app.com/exports/W2_2025.pdf",
"task_id": "TASK_ID"
}'
Uploads made by team members are attached to the task but leave it open; only a guest's upload to a File Request completes it.
Step 4: Set Up Webhooks (Optional)
Subscribe to real-time events with the webhooks API:
curl -X POST https://api.zapaportal.com/api/v1/webhooks \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/zapa",
"events": ["portal.created", "file.uploaded", "task.completed"]
}'
The response includes a secret for signature verification. You can also manage webhooks in the app under Settings → Webhooks — see API & Webhook Settings.
Verify Webhook Signatures
The signature is an HMAC-SHA256 of the exact request body bytes, so verify against the raw body rather than a re-serialized object, and compare with a constant-time function.
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signature, secret) {
if (!signature) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(String(signature), 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// In your webhook endpoint — keep the raw body (express.raw / bodyParser.raw)
app.post('/webhooks/zapa', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-webhook-signature'];
if (!verifyWebhookSignature(req.body, signature, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body.toString('utf8'));
// Reject stale deliveries (replay protection); payload.timestamp is ISO-8601
const ageMs = Date.now() - new Date(payload.timestamp).getTime();
if (isNaN(ageMs) || ageMs > 5 * 60 * 1000) {
return res.status(400).send('Stale delivery');
}
// Event fields are flat on the payload (no nested `data` object)
console.log('Webhook verified:', payload.event, payload.portal_id);
res.status(200).send('OK');
});
Each delivery also carries an X-Webhook-Delivery-Id header; store recent ids and ignore
duplicates so retries are idempotent.
Example file.uploaded payload:
{
"event": "file.uploaded",
"org_id": "org-uuid",
"portal_id": "portal-uuid",
"portal_name": "Smith Family Trust",
"file_id": "file-uuid",
"name": "W2_2025.pdf",
"extension": "pdf",
"size": 48213,
"content_type": "application/pdf",
"uploaded_by": "user-uuid",
"uploaded_by_email": "client@example.com",
"uploaded_by_name": "Jane Smith",
"task_id": "task-uuid",
"external_system_ids": { "QuickBooks": "cust_12345" },
"timestamp": "2026-09-12T20:15:00.000Z"
}
external_system_ids is present on every portal event ({} when the portal has none). task_id is
set when the upload was attached to a task. task.completed events additionally carry action_type,
status, attached_files, and — for File Requests fulfilled by an upload — the completing file_id.
Step 5: Refresh Your Access Token
Access tokens expire after 1 hour. Use the refresh token to get a new one:
curl -X POST https://api.zapaportal.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=YOUR_REFRESH_TOKEN" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"
Every refresh response includes a new refresh_token. Store it immediately and discard the
old one — the old token is only accepted again for about 60 seconds (in case you lost the
response) and presenting it after that revokes the whole token chain, requiring the user to
re-authorize.
Each rotation also restarts the refresh token's 30-day lifetime, so an integration that refreshes
at least once every 30 days stays connected indefinitely. client_secret is required on refresh
for clients created in Settings → API Settings.
Available Scopes
| Scope | Description |
|---|---|
portal:read | List and view portals |
portal:write | Create and update portals |
file:list | List file names and metadata |
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 |
Available Webhook Events
| Event | Description |
|---|---|
portal.created | New portal created |
portal.workflow_changed | Portal workflow state changed |
file.uploaded | File uploaded to portal |
file.signed | PDF signature completed |
task.created | New task created |
task.completed | Task marked as done (web app, API, or a File Request fulfilled by an upload) |
guest.invited | Guest invited to portal |
Error Handling
All API errors return standard HTTP status codes with JSON error details:
{
"error": "invalid_request",
"error_description": "The request is missing a required parameter"
}
Common error codes:
400- Bad Request (invalid parameters)401- Unauthorized (invalid or missing token)403- Forbidden (insufficient scopes)404- Not Found409- Conflict (e.g. an external system ID already assigned to another portal)429- Too Many Requests (rate limit exceeded)500- Internal Server Error
Rate Limits
- API Calls: 10,000 requests per day per client
- Webhook Delivery: 3 retry attempts with exponential backoff
Webhook Testing
Use webhook.site to test webhook delivery:
- Go to webhook.site and copy your unique URL
- Register it as a webhook (Step 4 above)
- Trigger an event (create a portal, upload a file, etc.)
- See the webhook payload in real-time
Security Best Practices
- Never share your client secret — treat it like a password
- Use HTTPS only — never send tokens over unencrypted connections
- Store tokens securely — use environment variables or secure vaults
- Verify webhook signatures — always check HMAC signatures
- Use minimum required scopes — only request what you need
- Handle token expiration — implement refresh token logic
What the API Can't Do (Yet)
- File downloads: the API returns file metadata only, never file contents. This is a deliberate privacy
guarantee — the OAuth consent screen tells users that connected apps cannot download their documents. To
point someone at a file, link them into the web app instead:
https://app.zapaportal.com/org/{org_id}/vault/{portal_id}/folder/main?file={file_id}. The one exception is the first-party Taxi integration, where Zapa itself pushes files to Taxi — no API client ever reads them. - Guest permission management: invited guests receive default permissions; adjust them in the web app.
Need one of these, or something else the API doesn't cover? Email support@zapaportal.com — API additions are prioritized based on customer requests.
Next Steps
- Browse the full API Reference
- See what changed in each version in the release notes
- Connect without code using our Zapier integration — triggers for every webhook event, actions for portals, files, tasks and guests, and searches by portal name or external system ID