API reference
Create clients, create and send onboardings, and read templates, responses, and events through the Portico REST API with scoped API keys and rate limits.
Pro, Business, the Pro trial, and the lifetime and AppSumo Practice and Firm plans.
Free, Starter, Lifetime Solo, and AppSumo Solo do not include API access.
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
Create API keys
Only owners and admins can see the Integrations tab and manage API keys.
- Go to Settings > Integrations and find the REST API section.
- Enter an API key name (optional). An empty name saves as "Default".
- Choose one or more Permissions.
- Optionally set Expires at end of date (UTC).
- Click Create API key.
Portico shows the new API key once, under New API key. Copy it, store it securely, then click Dismiss. If Portico cannot confirm that the key was created, click Check creation result. Portico repeats the same request and never creates a second key. If the key was already created, Portico cannot show it again. Revoke that key, then create a new one. Portico may ask you to authenticate again before it creates or revokes an API key.
A team can have up to 5 active API keys. To rotate an API key, click Create replacement next to it. The form fills in with the key's name and permissions. Click Create API key. Both keys stay active until you revoke the old API key, so you can update your integration first. A revoked or expired API key stops working on the next request.
Scopes
Each API key carries one or more scopes. A request outside the API key's scopes returns 403 with code api_key_scope_denied. Onboarding endpoints check the request body and query first. A malformed request returns 400 with code invalid_body before the API key's scopes are checked.
| Scope | Permission label | Grants access to |
|---|---|---|
clients:read | Read clients | List and get clients |
clients:write | Create clients | Create clients |
onboardings:read | Read forms | List and get onboardings |
onboardings:write | Create and update forms | Create, update, and send onboardings |
templates:read | Read templates | List and get templates |
responses:read | Read responses | List responses for an onboarding |
events:read | Read events | List events |
webhooks:write | Manage webhook subscriptions | Create and delete Zapier webhook subscriptions |
GET /api/v1/me works with any scope. The API also defines a templates:write scope, but no endpoint uses it and the settings page does not offer it.
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. The team's plan must include API access.
Response (200):
{
"team": {
"id": "uuid",
"name": "Smith and Co",
"tier": "pro"
},
"limits": {
"onboardings_per_month": 100,
"api_rate_limit": "200 requests/minute"
}
}
tier is the team's current plan, such as pro, pro_trial, or business. The onboardings_per_month field is your plan's monthly limit: a number for capped plans, or "unlimited" for Business. An onboarding counts toward it when you first send it to a real client. Resends do not count again, but reopening a closed onboarding counts once more.
Rate limits
| Plan | Per API key | Per API key and IP address |
|---|---|---|
| Pro and other plans with API access | 200 requests/min | 100 requests/min |
| Business | 1,000 requests/min | 500 requests/min |
Before Portico checks the API key, every request passes a limit of 100 requests a minute per IP address. That limit covers all API keys and plans. So one server can send at most 100 requests a minute on any plan. The higher limits in the table need requests from several IP addresses. All windows last 60 seconds. There is no daily limit.
When you exceed a limit, the API returns 429 with code rate_limited and a Retry-After header. The header gives the number of seconds until the window resets, up to 60.
Error format
Every error returns a JSON object with an error field. Most errors from API key checks and from the onboarding endpoints also return code, message, retryable, and request_id, plus an X-Request-Id header:
{
"error": "Use an API key with the clients:read scope.",
"code": "api_key_scope_denied",
"message": "Use an API key with the clients:read scope.",
"retryable": false,
"request_id": "uuid"
}
Other errors return fewer fields:
- A
429rate-limit error returnserrorandcode, with norequest_idand noX-Request-Idheader. - The
409requires_forceerror from the send endpoint returnserror,code,requires_force, andretryable. - A
422short-link error from the clients endpoint returnserrorandcode. - Most validation and not-found errors from the clients, templates, responses, events,
/me, and Zapier endpoints return onlyerror. Their500errors can omitcodetoo.
The onboarding endpoints mostly return a generic sentence in error, so check code to tell refusals apart. The agreement_text_required error names the signature field in error, and its details object holds field_id and step_id. Where an endpoint returns only error, match on the status code and the error text.
Common status codes:
| Code | Meaning |
|---|---|
| 400 | Invalid or missing request parameters. Malformed onboarding requests return invalid_body. Sending a required payment field with an amount Stripe cannot charge returns invalid_payment, and one with no connected Stripe account returns payment_not_connected. |
| 401 | Missing, invalid, revoked, or expired API key (api_key_required, api_key_inactive) |
| 403 | API key lacks the scope (api_key_scope_denied), your plan does not include API access (api_key_plan_required), the monthly onboarding limit is reached (limit_reached), a required payment field needs a plan that can collect payments (payment_plan_required), or sending is paused (sending_paused) |
| 404 | Resource not found or does not belong to your team |
| 409 | Conflict: an invalid status transition (invalid_state), a resend while the client is active (requires_force), a signature field that still has the sample agreement text (agreement_text_required), or a reused Idempotency-Key with a different body or onboarding (external_command_conflict) |
| 410 | The client was erased or is being erased (client_erasure_pending) |
| 413 | Request body too large |
| 422 | A short link in client-facing text (link_blocked) |
| 429 | Rate limit exceeded (rate_limited), or an unpaid team invited its maximum of new clients in 24 hours (recipient_cap_reached) |
| 500, 503 | Server or dependency error. Retry the request. Responses with retryable: true are safe to retry. |
Idempotent retries
POST /api/v1/onboardings, PATCH /api/v1/onboardings/:id, and POST /api/v1/onboardings/:id/send accept an optional Idempotency-Key header. Use 1 to 128 visible ASCII characters, with no spaces. Any other value returns 400 with code invalid_idempotency_key.
Repeat a request with the same header value and the same body within 24 hours to get the original result without running it twice. Each value belongs to one API key and one operation: create, update, or send. One value covers every onboarding ID for that operation. Reusing it with a different body or a different onboarding returns 409 with code external_command_conflict. Without the header, every request runs as a new command.
Portico keeps each header value for 24 hours from its first use. After 24 hours, a request with the same value runs as a new command, and Portico deletes the old result shortly after. There is no limit on the total number of commands an API key can run.
Pagination
List endpoints accept limit (1 to 100, default 50) and return a total count. Clients and onboardings use offset-based pagination (offset, default 0). Templates and responses use page-based pagination (page, starts at 1). The events endpoint has no pagination: its limit defaults to 25, and it returns no total.
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
Results are sorted newest first. 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,
"custom_fields": {},
"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,
"custom_fields": {},
"created_at": "2026-03-15T10:30:00Z"
}
}
An unknown ID, or a client from another team, returns 404 with Client not found.
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",
"locale": "en-US",
"date_format": "MM/DD/YYYY",
"timezone": "America/New_York",
"custom_fields": {}
}
name and email are required. Portico trims and lowercases the email. locale, date_format, and timezone default to your team's settings. custom_fields takes a JSON object. The notes field takes up to 2,000 characters and is stored trimmed. A locale, date_format, timezone, or custom_fields value of the wrong type returns 400. Unknown keys are ignored.
Field limits: name 200 characters, company 200 characters, phone 50 characters.
Supported locales: en-US, en-GB, es-ES, fr-FR, de-DE, pt-BR, it-IT, nl-NL, ja-JP. The API does not check locale. An unsupported value shows the client form in English (US).
Date formats: MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, DD.MM.YYYY.
If your team already has a client with that email, the API returns 200 with the existing client and creates nothing.
Response (201):
{
"client": {
"id": "uuid",
"name": "Jane Smith",
"email": "jane@example.com",
"company": "Smith and Co",
"phone": "+1-555-0100",
"notes": null,
"custom_fields": {},
"created_at": "2026-03-15T10:30:00Z"
}
}
Errors:
| Code | Error |
|---|---|
| 400 | name and email are required |
| 400 | Invalid email address |
| 400 | company must be a string or null |
| 400 | name must be 200 characters or fewer (the same pattern applies to company and phone) |
| 422 | Short links cannot be sent to clients. Paste the full web address instead. |
Onboardings
List onboardings
GET /api/v1/onboardings
Query parameters: limit, offset, status, client_id, archived
status accepts: draft, sent, in_progress, completed, cancelled, expired. Any other value returns 400 with code invalid_body.
archived accepts false (the default, which leaves out archived onboardings), true, only, or all. Onboardings of erased clients are never listed.
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-02T03:59:59.999+00:00",
"due_on": "2026-04-01",
"due_timezone": "America/New_York",
"assigned_to": "uuid",
"archived_at": null,
"clients": {
"id": "uuid",
"name": "Jane Smith",
"email": "jane@example.com"
},
"templates": {
"id": "uuid",
"name": "Agency Onboarding"
}
}
],
"total": 18
}
The assigned_to value is a team member ID, not a user ID. The due_on and due_timezone fields are set when the due date was given as a calendar date.
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). An unknown ID returns 404 with code target_unavailable. An ID that is not a UUID returns 400 with code invalid_body.
Response (200):
{
"onboarding": {
"id": "uuid",
"status": "in_progress",
"progress_pct": 60,
"created_at": "2026-03-15T10:30:00Z",
"due_date": "2026-04-02T03:59:59.999+00:00",
"due_on": "2026-04-01",
"due_timezone": "America/New_York",
"assigned_to": null,
"archived_at": 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"
}
}
}
The snapshot also carries description, vertical, settings, and translations. Steps and fields also carry conditional_rules and translations.
Create an onboarding
POST /api/v1/onboardings
Scope: onboardings:write
Request body:
{
"template_id": "uuid",
"client_id": "uuid",
"due_date": "2026-04-01",
"expires_at": null
}
template_idcan be one of your templates or a gallery template. Some gallery templates have sample agreement text in a signature field. An onboarding made from one is created as a draft, but sending it returns409with codeagreement_text_required. Copy the template into your workspace, add your agreement text in the template builder, and create the onboarding from your copy.due_datetakes a calendar date (YYYY-MM-DD, meaning the end of that day in your team's timezone) or a timestamp with a timezone offset.expires_atsets the optional link expiry, in the same formats. After it passes, the link stops working and the form accepts no more answers.
template_id and client_id are required. due_date is optional.
Creating a draft onboarding through the API does not count toward your monthly limit. Sending it counts once. If the limit is reached, the send request returns 403 with code limit_reached and the onboarding stays a draft.
Response (201):
{
"onboarding": {
"id": "uuid",
"status": "draft",
"progress_pct": 0,
"created_at": "2026-03-15T10:30:00Z",
"due_date": "2026-04-02T03:59:59.999+00:00",
"due_on": "2026-04-01",
"due_timezone": "America/New_York",
"expires_at": null,
"assigned_to": null,
"token": "64-character-hex-string"
}
}
The new onboarding is a draft that uses your team's default reminder schedule. token is the client access token issued at creation. Sending the onboarding deactivates it and emails the client a new link.
Errors (the 400, 404, and 410 rows show the code value):
| Code | Error |
|---|---|
| 400 | invalid_body (a missing or malformed field) |
| 400 | invalid_deadline |
| 404 | template_missing |
| 404 | client_scope_missing (the client does not exist or belongs to another team) |
| 410 | client_erasure_pending |
Update an onboarding
PATCH /api/v1/onboardings/:id
Scope: onboardings:write
Request body (all fields optional):
{
"status": "cancelled",
"due_date": "2026-04-15",
"assigned_to": "uuid"
}
Send at least one field. The due_date field takes the same formats as on create, and null clears it. The assigned_to field takes a team member ID, and null or an empty string unassigns.
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-16T03:59:59.999+00:00",
"due_on": "2026-04-15",
"due_timezone": "America/New_York",
"assigned_to": null
}
}
Errors (the code value is shown):
| Code | Error |
|---|---|
| 400 | invalid_body (no supported field, or a malformed value) |
| 400 | invalid_assignee |
| 400 | invalid_deadline |
| 404 | target_unavailable (not found, or archived) |
| 409 | invalid_state (the transition is not allowed) |
Send an onboarding
POST /api/v1/onboardings/:id/send
Scope: onboardings:write
Sends (or resends) the onboarding to the client. Portico issues a new client link and queues the invite email. The onboarding must be in draft, sent, or in_progress status, and it cannot be archived. Reopening a closed onboarding is not available through the API.
Request body (optional):
{
"force": false,
"pin": "482901"
}
| Field | Type | Description |
|---|---|---|
force | boolean | Bypass the active-client safety check (default false). Required when the client saved an answer within the last 5 minutes. |
pin | string | Optional 6-digit PIN the client must enter before opening the form. If you omit it, the PIN of the previous link carries over. |
The active-client check applies when the onboarding is in_progress and the client saved any answer in the last 5 minutes. Autosaved drafts count. The request then returns 409 with code requires_force unless force: true is set.
A send also schedules reminders from the onboarding's reminder schedule and fires the onboarding.sent webhook.
Sending a draft onboarding transitions its status to sent. Prior access tokens are deactivated so only the newest link works.
Sending counts once toward your monthly onboarding limit. If the limit is reached, the request returns 403 with code limit_reached and the onboarding stays a draft.
A required payment field is also checked, in this order: an invalid amount, then a plan that cannot collect payments, then a disconnected Stripe account. Portico then checks every signature field for sample agreement text. Next it checks whether sending is paused or the new-client limit is reached. The monthly onboarding limit comes last. The first check that fails decides the error. An optional payment field does not block the send, and the API response does not report it. See Stripe payments.
Response (200):
{
"success": true,
"queued": true,
"command_id": "uuid"
}
queued means Portico queued the invite email. A delivery problem later does not change this response. command_id identifies this send.
Errors (the code value is shown):
| Code | Error |
|---|---|
| 400 | invalid_body (a malformed field, such as a PIN that is not exactly 6 digits) |
| 400 | invalid_payment (a required payment field has an amount Stripe cannot charge) |
| 400 | payment_not_connected (a required payment field needs a connected Stripe account) |
| 403 | limit_reached |
| 403 | sending_paused (sending is paused for your workspace) |
| 403 | payment_plan_required (a required payment field needs a plan that can collect payments) |
| 404 | target_unavailable (the onboarding does not exist, or it is archived) |
| 404 | client_scope_missing (the onboarding's client no longer exists) |
| 409 | requires_force (the client saved an answer in the last 5 minutes; set force: true to resend) |
| 409 | invalid_state (the onboarding is completed, cancelled, or expired) |
| 409 | agreement_text_required (a signature field still has the sample agreement text; details.field_id and details.step_id name the field) |
| 429 | recipient_cap_reached (an unpaid team invited its maximum of new clients in 24 hours) |
Templates
The templates endpoints return only your team's own templates. Gallery templates are not listed.
List templates
GET /api/v1/templates
Query parameters: page, limit
pagestarts at 1, default 1.limittakes 1 to 100, default 50.
Results are sorted newest first.
Scope: templates:read
Response (200):
{
"templates": [
{
"id": "uuid",
"name": "Agency Onboarding",
"vertical": "agency",
"created_at": "2026-02-10T08:00:00Z"
}
],
"total": 5,
"page": 1,
"limit": 50
}
vertical is the template's industry.
Get a template
GET /api/v1/templates/:id
Scope: templates:read
Returns the template with all steps and fields. The API does not sort steps or fields, so order them by sort_order yourself. An unknown ID returns 404 with Template not found.
Response (200):
{
"template": {
"id": "uuid",
"name": "Agency Onboarding",
"vertical": "agency",
"created_at": "2026-02-10T08: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
}
]
}
]
}
}
The API cannot create or edit templates. Build them in the template builder.
Responses
List responses
GET /api/v1/responses?onboarding_id=uuid
Query parameters: onboarding_id (required), page, limit
pagestarts at 1, default 1.limittakes 1 to 100, default 50.
Results are sorted oldest first.
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
}
value can be any JSON type, depending on the field. Response status is one of: draft, pending, submitted, approved, rejected, changes_requested.
Errors:
| Code | Error |
|---|---|
| 400 | onboarding_id is required (missing, or not a valid ID) |
| 404 | Onboarding not found |
Events
List events
GET /api/v1/events
Returns your team's most recent audit events, newest first. There is no cursor or since parameter, so poll often enough that new events fit within one request of up to 100 events.
Query parameters:
| Param | Description |
|---|---|
limit | 1 to 100, default 25. |
type | Filter by event type (see table below). |
Scope: events:read
Event types:
| Type | Description |
|---|---|
onboarding.completed | Client finished all required steps. |
onboarding.sent | You sent or resent the onboarding, and Portico queued the invite email. |
onboarding.started | Client opened the link. Recorded on the first open after each send or reopen. |
onboarding.overdue | Overdue workflow triggered. |
response.submitted | Client submitted answers. One event covers one submission. |
signature.signed | Client signed a signature field. |
payment.succeeded | A one-time client payment succeeded. |
file.uploaded | Client uploaded a file. |
Without type, the response includes every audit event for your team. Events without a public name keep their internal name, such as onboarding_created or subscription_started.
Response (200):
{
"events": [
{
"id": "uuid",
"type": "onboarding.completed",
"timestamp": "2026-03-20T14:00:00Z",
"onboarding_id": "uuid",
"data": {}
}
]
}
data holds the event's audit details, not the webhook payload.
Errors:
| Code | Error |
|---|---|
| 400 | Unknown event type. Allowed: ... |
Use the Events endpoint as a fallback alongside webhooks. If your endpoint misses a webhook, you can still poll events to catch up.
Zapier subscriptions
These endpoints manage the REST hook subscriptions that Zapier triggers use. Any API client can call them. See Webhooks for event types and payload details.
Scope: webhooks:write
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 of up to 500 characters, without a username or password. Private and internal hosts are refused.
Allowed events: onboarding.completed, onboarding.sent, onboarding.started, onboarding.overdue, response.submitted, signature.signed, payment.succeeded, file.uploaded
Response (201):
{
"id": "uuid"
}
Portico does not merge duplicate subscriptions. Each request creates a new active subscription and returns 201 with a new id, even for an event and URL already subscribed. Each active subscription receives its own copy of every event. To replace a subscription, delete the old one first.
Errors:
| Code | Error |
|---|---|
| 400 | event and target_url are required |
| 400 | Invalid event. Allowed: ... |
| 400 | target_url must be a public HTTPS URL |
Unsubscribe
DELETE /api/v1/zapier/subscribe
Request body:
{
"id": "uuid"
}
Response (200):
{
"success": true
}
Errors:
| Code | Error |
|---|---|
| 400 | id is required |
| 404 | Subscription not found |
Frequently asked questions
- Which plan includes API access?
- The REST API is available on Pro, Business, the Pro trial, and the lifetime and AppSumo Practice and Firm plans. Pro costs $79 a month, or $59 a month billed annually.
- How is the API authenticated?
- Each request sends a scoped API key as a Bearer token. Owners and admins create API keys in Settings > Integrations, in the REST API section.
- Are there rate limits?
- Yes. All limits are per minute. Each IP address can send at most 100 requests a minute on any plan. An API key can send 200 requests a minute on Pro and 1,000 on Business, summed across all its IP addresses. There is no daily limit.