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.

  1. Go to Settings > Integrations and find the REST API section.
  2. Enter an API key name (optional). An empty name saves as "Default".
  3. Choose one or more Permissions.
  4. Optionally set Expires at end of date (UTC).
  5. 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.

ScopePermission labelGrants access to
clients:readRead clientsList and get clients
clients:writeCreate clientsCreate clients
onboardings:readRead formsList and get onboardings
onboardings:writeCreate and update formsCreate, update, and send onboardings
templates:readRead templatesList and get templates
responses:readRead responsesList responses for an onboarding
events:readRead eventsList events
webhooks:writeManage webhook subscriptionsCreate 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

PlanPer API keyPer API key and IP address
Pro and other plans with API access200 requests/min100 requests/min
Business1,000 requests/min500 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 429 rate-limit error returns error and code, with no request_id and no X-Request-Id header.
  • The 409 requires_force error from the send endpoint returns error, code, requires_force, and retryable.
  • A 422 short-link error from the clients endpoint returns error and code.
  • Most validation and not-found errors from the clients, templates, responses, events, /me, and Zapier endpoints return only error. Their 500 errors can omit code too.

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:

CodeMeaning
400Invalid 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.
401Missing, invalid, revoked, or expired API key (api_key_required, api_key_inactive)
403API 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)
404Resource not found or does not belong to your team
409Conflict: 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)
410The client was erased or is being erased (client_erasure_pending)
413Request body too large
422A short link in client-facing text (link_blocked)
429Rate limit exceeded (rate_limited), or an unpaid team invited its maximum of new clients in 24 hours (recipient_cap_reached)
500, 503Server 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:

CodeError
400name and email are required
400Invalid email address
400company must be a string or null
400name must be 200 characters or fewer (the same pattern applies to company and phone)
422Short 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_id can 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 returns 409 with code agreement_text_required. Copy the template into your workspace, add your agreement text in the template builder, and create the onboarding from your copy.
  • due_date takes 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_at sets 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):

CodeError
400invalid_body (a missing or malformed field)
400invalid_deadline
404template_missing
404client_scope_missing (the client does not exist or belongs to another team)
410client_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:

FromTo
draftcancelled
sentcancelled
sentin_progress
in_progresscancelled

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):

CodeError
400invalid_body (no supported field, or a malformed value)
400invalid_assignee
400invalid_deadline
404target_unavailable (not found, or archived)
409invalid_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"
}
FieldTypeDescription
forcebooleanBypass the active-client safety check (default false). Required when the client saved an answer within the last 5 minutes.
pinstringOptional 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):

CodeError
400invalid_body (a malformed field, such as a PIN that is not exactly 6 digits)
400invalid_payment (a required payment field has an amount Stripe cannot charge)
400payment_not_connected (a required payment field needs a connected Stripe account)
403limit_reached
403sending_paused (sending is paused for your workspace)
403payment_plan_required (a required payment field needs a plan that can collect payments)
404target_unavailable (the onboarding does not exist, or it is archived)
404client_scope_missing (the onboarding's client no longer exists)
409requires_force (the client saved an answer in the last 5 minutes; set force: true to resend)
409invalid_state (the onboarding is completed, cancelled, or expired)
409agreement_text_required (a signature field still has the sample agreement text; details.field_id and details.step_id name the field)
429recipient_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

  • page starts at 1, default 1.
  • limit takes 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

  • page starts at 1, default 1.
  • limit takes 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:

CodeError
400onboarding_id is required (missing, or not a valid ID)
404Onboarding 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:

ParamDescription
limit1 to 100, default 25.
typeFilter by event type (see table below).

Scope: events:read

Event types:

TypeDescription
onboarding.completedClient finished all required steps.
onboarding.sentYou sent or resent the onboarding, and Portico queued the invite email.
onboarding.startedClient opened the link. Recorded on the first open after each send or reopen.
onboarding.overdueOverdue workflow triggered.
response.submittedClient submitted answers. One event covers one submission.
signature.signedClient signed a signature field.
payment.succeededA one-time client payment succeeded.
file.uploadedClient 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:

CodeError
400Unknown 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:

CodeError
400event and target_url are required
400Invalid event. Allowed: ...
400target_url must be a public HTTPS URL

Unsubscribe

DELETE /api/v1/zapier/subscribe

Request body:

{
  "id": "uuid"
}

Response (200):

{
  "success": true
}

Errors:

CodeError
400id is required
404Subscription 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.