API reference
Manage clients, onboardings, templates, and responses through 7 scoped REST API endpoints with offset and page-based pagination and per-plan rate limits.
Pro plan and above.
The Portico API lets you create clients, launch onboardings, and read responses programmatically. All endpoints use JSON and follow REST conventions.
Authentication
Every request must include your API key in the Authorization header:
Authorization: Bearer pk_your-api-key
Generate keys in Settings > Integrations > API keys. Keys are shown once on creation — store them securely. You can have up to 5 active keys per team.
Scopes
Each key can be restricted to specific scopes. If no scopes are set, the key has full access.
| Scope | Grants access to |
|---|---|
clients:read | List and get clients |
clients:write | Create clients |
onboardings:read | List and get onboardings |
onboardings:write | Create and update onboardings |
templates:read | List and get templates |
templates:write | Create templates |
responses:read | List responses for an onboarding |
Base URL
https://app.portico.run/api/v1
Account info
Get current team
GET /api/v1/me
Returns team details and plan limits for the authenticated API key. Useful for verifying credentials and checking usage caps.
Scope: Any valid API key.
Response (200):
{
"team": {
"id": "uuid",
"name": "Smith and Co",
"tier": "pro"
},
"limits": {
"onboardings_per_month": 100,
"api_rate_limit": "200 requests/minute"
}
}
onboardings_per_month is a number for capped plans or "unlimited" for Business.
Rate limits
| Plan | Per key | Per key and IP |
|---|---|---|
| Pro | 200 requests/min | 100 requests/min |
| Business | 1,000 requests/min | 500 requests/min |
When you exceed the limit, the API returns 429 with a Retry-After: 60 header.
Error format
All errors return a JSON object with an error field:
{
"error": "Description of what went wrong"
}
Common status codes:
| Code | Meaning |
|---|---|
| 400 | Invalid or missing request parameters |
| 401 | Missing or invalid API key |
| 403 | Key lacks the required scope, or your plan does not include API access |
| 404 | Resource not found or does not belong to your team |
| 409 | Conflict (for example, duplicate email or invalid status transition) |
| 429 | Rate limit exceeded |
| 500 | Server error — retry the request |
Pagination
List endpoints return a total count and accept limit (1 to 100, default 50). Clients and onboardings use offset-based pagination (offset, default 0). Templates and responses use page-based pagination (page, starts at 1).
Offset-based example:
{
"clients": [],
"total": 142
}
Page-based example:
{
"templates": [],
"total": 5,
"page": 1,
"limit": 50
}
Clients
List clients
GET /api/v1/clients
Query parameters: limit, offset, email
Filter by email for an exact (case-insensitive) match.
Scope: clients:read
Response (200):
{
"clients": [
{
"id": "uuid",
"name": "Jane Smith",
"email": "jane@example.com",
"company": "Smith and Co",
"phone": null,
"notes": null,
"created_at": "2026-03-15T10:30:00Z"
}
],
"total": 42
}
Get a client
GET /api/v1/clients/:id
Scope: clients:read
Response (200):
{
"client": {
"id": "uuid",
"name": "Jane Smith",
"email": "jane@example.com",
"company": "Smith and Co",
"phone": null,
"notes": null,
"created_at": "2026-03-15T10:30:00Z"
}
}
Create a client
POST /api/v1/clients
Scope: clients:write
Request body:
{
"name": "Jane Smith",
"email": "jane@example.com",
"company": "Smith and Co",
"phone": "+1-555-0100",
"notes": "Referred by Alex",
"locale": "en-US",
"date_format": "MM/DD/YYYY",
"timezone": "America/New_York"
}
name and email are required. All other fields are optional and default to your team's settings.
Field limits: name 200 chars, company 200 chars, phone 50 chars, notes 2,000 chars.
Supported locales: en-US, en-GB, de, fr, it, es, pt, ja, zh
Response (201):
{
"client": {
"id": "uuid",
"name": "Jane Smith",
"email": "jane@example.com",
"company": "Smith and Co",
"phone": "+1-555-0100",
"created_at": "2026-03-15T10:30:00Z"
}
}
Errors:
| Code | Error |
|---|---|
| 400 | name and email are required |
| 400 | Invalid email address |
| 409 | Client with this email already exists |
Onboardings
List onboardings
GET /api/v1/onboardings
Query parameters: limit, offset, status, client_id
status accepts: draft, sent, in_progress, completed, cancelled.
Scope: onboardings:read
Response (200):
{
"onboardings": [
{
"id": "uuid",
"status": "in_progress",
"progress_pct": 60,
"created_at": "2026-03-15T10:30:00Z",
"due_date": "2026-04-01T00:00:00Z",
"assigned_to": "uuid",
"clients": {
"id": "uuid",
"name": "Jane Smith",
"email": "jane@example.com"
},
"templates": {
"id": "uuid",
"name": "Agency Onboarding"
}
}
],
"total": 18
}
Get an onboarding
GET /api/v1/onboardings/:id
Scope: onboardings:read
Returns the full onboarding including the template snapshot (the form structure captured when the onboarding was created).
Response (200):
{
"onboarding": {
"id": "uuid",
"status": "in_progress",
"progress_pct": 60,
"created_at": "2026-03-15T10:30:00Z",
"due_date": "2026-04-01T00:00:00Z",
"assigned_to": null,
"template_snapshot": {
"name": "Agency Onboarding",
"steps": [
{
"id": "uuid",
"title": "Business Details",
"sort_order": 0,
"is_optional": false,
"fields": [
{
"id": "uuid",
"type": "short_text",
"label": "Company Name",
"is_required": true,
"sort_order": 0,
"config": {}
}
]
}
]
},
"clients": {
"id": "uuid",
"name": "Jane Smith",
"email": "jane@example.com"
},
"templates": {
"id": "uuid",
"name": "Agency Onboarding"
}
}
}
Create an onboarding
POST /api/v1/onboardings
Scope: onboardings:write
Request body:
{
"template_id": "uuid",
"client_id": "uuid",
"due_date": "2026-04-01T00:00:00Z"
}
template_id and client_id are required. due_date is optional.
Creating an onboarding counts toward your monthly limit. If the limit is reached, the request returns 403.
Response (201):
{
"onboarding": {
"id": "uuid",
"status": "draft",
"progress_pct": 0,
"created_at": "2026-03-15T10:30:00Z",
"due_date": "2026-04-01T00:00:00Z",
"token": "hex-string"
}
}
Errors:
| Code | Error |
|---|---|
| 400 | template_id and client_id are required |
| 403 | Monthly onboarding limit reached |
| 404 | Template not found |
| 404 | Client not found |
Update an onboarding
PATCH /api/v1/onboardings/:id
Scope: onboardings:write
Request body (all fields optional):
{
"status": "cancelled",
"due_date": "2026-04-15T00:00:00Z",
"assigned_to": "uuid"
}
status only accepts cancelled or in_progress. Valid transitions:
| From | To |
|---|---|
| draft | cancelled |
| sent | cancelled |
| sent | in_progress |
| in_progress | cancelled |
Setting status to cancelled deactivates the client link and cancels pending reminders.
Response (200):
{
"onboarding": {
"id": "uuid",
"status": "cancelled",
"progress_pct": 60,
"due_date": "2026-04-15T00:00:00Z",
"assigned_to": null
}
}
Errors:
| Code | Error |
|---|---|
| 400 | No valid fields to update |
| 404 | Onboarding not found |
| 409 | Could not transition from "completed" to "cancelled" |
Send an onboarding
POST /api/v1/onboardings/:id/send
Scope: onboardings:write
Sends (or resends) the onboarding invite email to the client with a fresh magic-link token. The onboarding must be in draft, sent, or in_progress status.
Request body (optional):
{
"force": false,
"pin": "482901"
}
| Field | Type | Description |
|---|---|---|
force | boolean | Bypass the active-client safety check (default false). Required when the client has responded within the last 5 minutes. |
pin | string | Optional 6-digit PIN the client must enter before opening the form. |
If the onboarding is in_progress and the client has submitted a response in the last 5 minutes, the request returns 409 unless force: true is set.
Sending a draft onboarding transitions its status to sent. Prior access tokens are deactivated so only the newest link works.
Response (200):
{
"success": true
}
Errors:
| Code | Error |
|---|---|
| 400 | No client associated with this onboarding |
| 400 | PIN must be exactly 6 digits |
| 404 | Onboarding not found |
| 409 | Client is actively working on this onboarding. Set force: true to resend. |
| 409 | Completed, cancelled, or expired onboardings must be reopened before sending |
| 502 | Could not send the email. Please try again. |
Templates
List templates
GET /api/v1/templates
Query parameters: page, limit
page— starts at 1, default 1.limit— 1 to 100, default 50.
Scope: templates:read
Response (200):
{
"templates": [
{
"id": "uuid",
"name": "Agency Onboarding",
"industry": "agency",
"created_at": "2026-02-10T08:00:00Z",
"updated_at": "2026-03-01T12:00:00Z"
}
],
"total": 5,
"page": 1,
"limit": 50
}
Get a template
GET /api/v1/templates/:id
Scope: templates:read
Returns the template with all steps and fields.
Response (200):
{
"template": {
"id": "uuid",
"name": "Agency Onboarding",
"industry": "agency",
"created_at": "2026-02-10T08:00:00Z",
"updated_at": "2026-03-01T12:00:00Z",
"template_steps": [
{
"id": "uuid",
"title": "Business Details",
"sort_order": 0,
"is_optional": false,
"conditional_rules": null,
"template_fields": [
{
"id": "uuid",
"type": "short_text",
"label": "Company Name",
"is_required": true,
"sort_order": 0,
"config": {},
"conditional_rules": null
}
]
}
]
}
}
Create a template
POST /api/v1/templates
Scope: templates:write
Request body:
{
"name": "New Client Intake",
"industry": "agency"
}
name is required. industry is optional.
This creates an empty template with no steps or fields. Use the template builder in the UI to add form content, or build steps and fields through subsequent API calls.
Response (201):
{
"template": {
"id": "uuid",
"name": "New Client Intake",
"industry": "agency",
"created_at": "2026-03-15T10:30:00Z",
"updated_at": "2026-03-15T10:30:00Z"
}
}
Errors:
| Code | Error |
|---|---|
| 400 | name is required |
Responses
List responses
GET /api/v1/responses?onboarding_id=uuid
Query parameters: onboarding_id (required), page, limit
page— starts at 1, default 1.limit— 1 to 100, default 50.
Scope: responses:read
Response (200):
{
"responses": [
{
"id": "uuid",
"field_id": "uuid",
"value": "Acme Corp",
"status": "approved",
"rejection_reason": null,
"created_at": "2026-03-20T14:00:00Z"
}
],
"total": 12,
"page": 1,
"limit": 50
}
Response status is one of: draft, submitted, approved, rejected, changes_requested.
Events
List events
GET /api/v1/events
Returns recent audit events for your team in reverse chronological order. Use this to poll for activity since your last sync.
Query parameters:
| Param | Description |
|---|---|
limit | 1 to 100, default 25. |
type | Filter by event type (see table below). |
Scope: Any valid API key.
Event types:
| Type | Description |
|---|---|
onboarding.completed | Client finished all required steps. |
onboarding.sent | Invite email delivered to client. |
onboarding.started | Client opened the onboarding link. |
onboarding.overdue | Overdue workflow triggered. |
response.submitted | Client submitted a field response. |
signature.signed | Client signed a signature field. |
payment.succeeded | Client payment processed. |
file.uploaded | Client uploaded a file. |
webhook.delivered | Outgoing webhook delivered to your endpoint. |
Response (200):
{
"events": [
{
"id": "uuid",
"type": "onboarding.completed",
"timestamp": "2026-03-20T14:00:00Z",
"onboarding_id": "uuid",
"data": {}
}
]
}
Errors:
| Code | Error |
|---|---|
| 400 | Unknown event type. Allowed: ... |
Use the Events endpoint as a fallback alongside webhooks. If a webhook delivery fails, you can still poll events to catch what you missed.
Zapier subscriptions
Manage webhook subscriptions for Zapier triggers. See Webhooks for event types and payload details.
Subscribe
POST /api/v1/zapier/subscribe
Request body:
{
"event": "onboarding.completed",
"target_url": "https://hooks.zapier.com/hooks/catch/123/abc"
}
target_url must be a public HTTPS URL.
Allowed events: onboarding.completed, onboarding.sent, onboarding.started, onboarding.overdue, response.submitted, signature.signed, payment.succeeded, file.uploaded
Response (201):
{
"id": "uuid"
}
Unsubscribe
DELETE /api/v1/zapier/subscribe
Request body:
{
"id": "uuid"
}
Response (200):
{
"success": true
}
Frequently asked questions
- Which plan includes API access?
- The REST API is available on the Pro plan ($79/month) and above.
- How is the API authenticated?
- API requests are authenticated with a scoped API key generated in Settings. Keys use Bearer token authentication.
- Are there rate limits?
- Yes. Rate limits vary by plan. Check the rate limits section for your plan's per-minute and per-day limits.