# Get credential info Source: https://docs.getaptly.com/api-reference/app/get-credential-info /openapi.yaml get /api/app/me Returns identity information for the credential used in the request. The response shape depends on the auth method: - **Delegate token** (`Authorization: DelegateToken `): returns user identity and, if the token has an `appClientId`, embedded-app context. - **API key** (`x-token` header or query param): returns company identity. - **Partner token** (`Authorization: Bearer `): returns the partner's permission list. # Verify a delegate token (keyless) Source: https://docs.getaptly.com/api-reference/app/verify-a-delegate-token-keyless /openapi.yaml post /api/app/verify Validates a short-lived delegate token without requiring an API key. The token must include an `appClientId` (i.e. it was issued for an embedded app via `POST /api/platform/user-token`). Returns the user's identity, company name, and the app's title. # Add a tab view Source: https://docs.getaptly.com/api-reference/board/add-a-tab-view /openapi.yaml post /api/board/{boardId}/configuration/tabViews Adds an embedded tab view to the board's tab list. # Add a tab view (legacy) Source: https://docs.getaptly.com/api-reference/board/add-a-tab-view-legacy /openapi.yaml post /api/board/{boardId}/tabView Deprecated alias for `POST /api/board/{boardId}/configuration/tabViews`. Adds an embedded tab view to the board's tab list. Use the `/configuration/tabViews` path for new integrations. # Get board access settings Source: https://docs.getaptly.com/api-reference/board/get-board-access-settings /openapi.yaml get /api/board/{boardId}/configuration/shares Returns the board's access type and ACL (shares array). # Get board identity fields Source: https://docs.getaptly.com/api-reference/board/get-board-identity-fields /openapi.yaml get /api/board/{boardId}/configuration/theme Returns the board's display name, color, icon, description, and short code. # Get board options Source: https://docs.getaptly.com/api-reference/board/get-board-options /openapi.yaml get /api/board/{boardId}/configuration/options Returns the current configuration options for the board. # Get full board configuration Source: https://docs.getaptly.com/api-reference/board/get-full-board-configuration /openapi.yaml get /api/board/{boardId}/configuration Returns all configuration sections for the board in a single response: fields, automations, options, tabViews, workflows, groups, shares, theme, and filters. # List board automations Source: https://docs.getaptly.com/api-reference/board/list-board-automations /openapi.yaml get /api/board/{boardId}/configuration/automations Returns the automations configured on a board. # List board field groups Source: https://docs.getaptly.com/api-reference/board/list-board-field-groups /openapi.yaml get /api/board/{boardId}/configuration/groups Returns the field groups (sections) configured on the board. # List board fields Source: https://docs.getaptly.com/api-reference/board/list-board-fields /openapi.yaml get /api/board/{boardId}/configuration/fields Returns all fields defined on the board, including archived ones. # List board filters Source: https://docs.getaptly.com/api-reference/board/list-board-filters /openapi.yaml get /api/board/{boardId}/configuration/filters Returns saved filters for this board visible to the caller. Company-scoped filters are always included; user-scoped (private) filters are included only when the credential carries a `userId` (API keys and partner tokens are company-scoped and generally do not). Quick-view filters are excluded. # List board workflows Source: https://docs.getaptly.com/api-reference/board/list-board-workflows /openapi.yaml get /api/board/{boardId}/configuration/workflows Returns the workflows (sequences) configured on a board. # Verify a delegate token Source: https://docs.getaptly.com/api-reference/board/verify-a-delegate-token /openapi.yaml post /api/board/verify-user Validates a short-lived delegate token issued by `POST /api/platform/user-token`. Checks the JWT signature, expiry, and confirms the token was issued for the same company as the API key. Use this to confirm the identity of an authenticated Aptly user inside an embedded plugin or iframe. # List boards Source: https://docs.getaptly.com/api-reference/boards/list-boards /openapi.yaml get /api/boards Returns all boards in your company that have API access enabled. Each board includes its UUID, display name, and a list of pre-built endpoint URLs you can use to interact with cards on that board. Accepts an API key (`x-token`) or a delegate token (`Authorization: DelegateToken `). # Add or update a comment Source: https://docs.getaptly.com/api-reference/cards/add-or-update-a-comment /openapi.yaml post /api/board/{boardId}/{cardId}/comment Adds a new comment to a card. To update an existing comment, include its `id` in the body — the `userId` must match the original comment's author. # Create or update a card Source: https://docs.getaptly.com/api-reference/cards/create-or-update-a-card /openapi.yaml post /api/board/{boardId} Creates a new card on the board. Use field UUIDs (from the schema endpoint) as keys in the request body. To update an existing card, include its `_id` in the request body. The card must belong to this board. Fields not provided in the body are left unchanged on update. # Get a card Source: https://docs.getaptly.com/api-reference/cards/get-a-card /openapi.yaml get /api/board/{boardId}/{cardId} Returns a single card by its ID. # List cards Source: https://docs.getaptly.com/api-reference/cards/list-cards /openapi.yaml get /api/board/{boardId} Returns a paginated list of cards on the board. Field values are keyed by field UUID — use the schema endpoint to map keys to labels. Money fields are returned as `{ amount, currency }` where `amount` is a decimal. All filter params are optional and ANDed together. # List comments on a card Source: https://docs.getaptly.com/api-reference/cards/list-comments-on-a-card /openapi.yaml get /api/board/{boardId}/{cardId}/comments Returns all comments on the specified card in chronological order. # List contacts linked to a card Source: https://docs.getaptly.com/api-reference/cards/list-contacts-linked-to-a-card /openapi.yaml get /api/board/{boardId}/{cardId}/contacts Returns all person contacts linked to the card via its person/persons fields. Returns an empty array if the board has no person fields or none are populated. # Upload a file to a card Source: https://docs.getaptly.com/api-reference/cards/upload-a-file-to-a-card /openapi.yaml post /api/board/{boardId}/{cardId}/file Uploads a file and attaches it to a card. Send as `multipart/form-data` with the file in the `file` field. **Max file size:** 50 MB **Accepted types:** JPEG, PNG, GIF, WebP, SVG, PDF, Word, Excel, plain text, CSV, MP4, MOV, MP3, WAV # Get company info Source: https://docs.getaptly.com/api-reference/company/get-company-info /openapi.yaml get /api/company/info Returns the name, address, contact details, and logo URL for the company associated with the API key. # Confirm contact email verification Source: https://docs.getaptly.com/api-reference/contacts/confirm-contact-email-verification /openapi.yaml post /api/contacts/verify-email/{requestId}/confirm Submits the 6-digit code received by email. Returns the matching contact records if the code is valid, not expired, and has not already been used. After 5 consecutive failed attempts the verification is permanently invalidated. The caller must re-initiate a new verification to try again. # Create or update a contact Source: https://docs.getaptly.com/api-reference/contacts/create-or-update-a-contact /openapi.yaml post /api/contacts Creates a new contact or updates an existing one (upsert). **Lookup order:** 1. If `_id` is provided, finds by ID. 2. Otherwise finds by first email address. 3. If no match is found, creates a new contact. **Body formats** — either native or legacy (capitalized keys) are accepted: *Native:* `firstname`, `lastname`, `email` (string or array), `phone` (array of `{number, type}`), `typeId`, `contactType`, `isCompany`, `title`, `company`, `imageUrl`, `customFields` *Legacy:* `"First Name"`, `"Last Name"`, `Email`, `"Mobile Phone"`, `"Work Phone"`, `"Home Phone"`, `"Contact Type"`, `Title`, `Company` Returns the enriched contact with custom fields populated by their type definitions. # Get a contact Source: https://docs.getaptly.com/api-reference/contacts/get-a-contact /openapi.yaml get /api/contacts/{contactId} Returns a single contact by its ID, with custom fields enriched by their type definitions. # Initiate contact email verification Source: https://docs.getaptly.com/api-reference/contacts/initiate-contact-email-verification /openapi.yaml post /api/contacts/verify-email Looks up an email address against your org's contact database. If a match is found, generates a cryptographically strong 6-digit code, sends it to the address, and returns a `requestId` and `verifyUrl` to complete the verification. The code expires after 10 minutes and can only be used once. # List contacts Source: https://docs.getaptly.com/api-reference/contacts/list-contacts /openapi.yaml get /api/contacts Returns a paginated list of contacts scoped to your company. All filter params are optional and ANDed together. # Look up contacts by email Source: https://docs.getaptly.com/api-reference/contacts/look-up-contacts-by-email /openapi.yaml post /api/contacts/by-email Returns contacts whose email address matches one or more of the provided values. Matching is case-insensitive and exact. Results are scoped to your company. # Update a contact Source: https://docs.getaptly.com/api-reference/contacts/update-a-contact /openapi.yaml post /api/contacts/{contactId} Updates an existing contact by ID using the same upsert logic as `POST /api/contacts`. The `_id` is taken from the URL — any `_id` in the body is ignored. Accepts the same **native** or **legacy** body formats as `POST /api/contacts`. Returns the updated, enriched contact. # Get nearby schools for a property Source: https://docs.getaptly.com/api-reference/context-&-locations/get-nearby-schools-for-a-property /portal/openapi.yaml get /schools/{locationId} # Get single listing details Source: https://docs.getaptly.com/api-reference/context-&-locations/get-single-listing-details /portal/openapi.yaml get /listing/{locationId} # List all locations for an organization Source: https://docs.getaptly.com/api-reference/context-&-locations/list-all-locations-for-an-organization /portal/openapi.yaml get /locations/{orgId} # List public property listings Source: https://docs.getaptly.com/api-reference/context-&-locations/list-public-property-listings /portal/openapi.yaml get /listings/{orgId}/{segmentId} # Load company-level config Source: https://docs.getaptly.com/api-reference/context-&-locations/load-company-level-config /portal/openapi.yaml get /company/{contextId} # Load property/location context Source: https://docs.getaptly.com/api-reference/context-&-locations/load-propertylocation-context /portal/openapi.yaml get /context/{contextId} # Fetch this OpenAPI spec Source: https://docs.getaptly.com/api-reference/docs/fetch-this-openapi-spec /openapi.yaml get /api/docs/openapi Returns the raw contents of this OpenAPI spec (docs/openapi.yaml) as `text/yaml`. No authentication required. # Create an email draft Source: https://docs.getaptly.com/api-reference/email/create-an-email-draft /openapi.yaml post /api/email/create-draft Creates a new outbound email discussion (stream) with a single draft entry, scoped to your company. Returns the `streamId` and `draftUuid` needed to send via `POST /api/email/send`. Recipient fields (`to`, `cc`, `bcc`) accept an array of `{ value, label? }` objects where `value` is the email address. ### Adding attachments Attachments must be uploaded to storage **before** you reference them here — you cannot post file bytes to this endpoint. Upload each file with the three-step direct upload flow, then attach it: 1. **Get an upload URL** — `POST /api/files/upload-url` with `attachEntityType: "channel"`, `attachEntityId` set to the same `channelId` you're emailing from, and the file's `name`, `extension`, `size`, and `contentType`. It returns `{ fileId, url, fields }`. 2. **Upload to S3** — send a `multipart/form-data` `POST` to `url`, appending every entry from `fields` first and the file bytes last as a `file` field. S3 returns `204`. 3. **Mark complete** — `POST /api/files/upload-complete` with the `fileId` from step 1. Then, on this request: - **Regular attachment** — add the `fileId`(s) to `attachmentIds`. - **Inline image** — embed an `` in the HTML `body` whose `src` is the file's download URL (`.../cdn/storage/AptlyFiles//original/...`, returned by step 3). It is auto-detected and registered; do **not** also list it in `attachmentIds`. Repeat steps 1–3 per file. Each `fileId` must reference a finished upload or the request is rejected. # Send an email Source: https://docs.getaptly.com/api-reference/email/send-an-email /openapi.yaml post /api/email/send Sends an outbound email scoped to your company. Two usage patterns: **From an existing draft** — provide `discussionId` and `uuid` returned by `POST /api/email/create-draft`. The draft's stored recipients, subject, and body are used. **On-the-fly** — omit `uuid` and provide `userId`, `channelId`, recipients, subject, and body. A draft is created automatically before sending. Supplying `discussionId` (without `uuid`) sends the message as a reply into that existing thread — the thread's subject is kept; omitting both `discussionId` and `uuid` starts a new thread. ### Adding attachments Attachments apply to the **on-the-fly** pattern (for an existing draft, attach files when you call `POST /api/email/create-draft`). Upload each file with the three-step direct upload flow first — you cannot post file bytes to this endpoint: 1. **Get an upload URL** — `POST /api/files/upload-url` with `attachEntityType: "channel"`, `attachEntityId` set to the same `channelId` you're sending from, and the file's `name`, `extension`, `size`, and `contentType`. Returns `{ fileId, url, fields }`. 2. **Upload to S3** — `multipart/form-data` `POST` to `url`, all `fields` first then the file bytes last as a `file` field. S3 returns `204`. 3. **Mark complete** — `POST /api/files/upload-complete` with the `fileId`. Then, on this request: - **Regular attachment** — add the `fileId`(s) to `attachmentIds`. - **Inline image** — embed an `` in the HTML `body` whose `src` is the file's download URL (`.../cdn/storage/AptlyFiles//original/...`). Auto-detected and registered; do **not** also list it in `attachmentIds`. Each `fileId` must reference a finished upload. # Finalize a direct file upload Source: https://docs.getaptly.com/api-reference/files/finalize-a-direct-file-upload /openapi.yaml post /api/files/upload-complete Final (third) step of the direct file-upload flow. Confirms the file was uploaded to S3 and records it. Call this with the `fileId` from `/api/files/upload-url` after the `multipart/form-data` POST to the presigned `url` succeeded. Returns the file's download URL. # Get a presigned URL for a direct file upload Source: https://docs.getaptly.com/api-reference/files/get-a-presigned-url-for-a-direct-file-upload /openapi.yaml post /api/files/upload-url First step of the direct file-upload flow. Issues a presigned S3 POST policy and records a pending upload. **Uploading a file takes three steps:** 1. **Get a URL** — call this endpoint. It returns `{ fileId, url, fields }`. 2. **Upload directly to S3** — send a `multipart/form-data` `POST` to `url`. Include **every** key/value from `fields` as form fields first, then the file itself as the last field named `file`. The upload goes straight to S3; it does not pass through this API. A successful upload returns HTTP `204` from S3. 3. **Mark the upload complete** — call `POST /api/files/upload-complete` with the `fileId` from step 1. This records the file and returns its download URL. The presigned policy enforces the content type and a 1 byte–50 MB size range. Both `extension` and `contentType` must be on the accepted lists. `attachEntityType` is `channel`, `aptlet`, or `knowledge`, and `attachEntityId` is the channel id, aptlet uuid, or knowledge doc id respectively. The target must exist (scoped to your company) or the request is rejected. The file is stored under that entity's folder. # Get embeddable form definition Source: https://docs.getaptly.com/api-reference/forms/get-embeddable-form-definition /portal/openapi.yaml get /forms/{formId}/{cardId}/{boardId} # Search locations for form location picker Source: https://docs.getaptly.com/api-reference/forms/search-locations-for-form-location-picker /portal/openapi.yaml post /forms/searchLocations # Submit a completed form Source: https://docs.getaptly.com/api-reference/forms/submit-a-completed-form /portal/openapi.yaml post /forms/submit # Conversation analytics for an inbox Source: https://docs.getaptly.com/api-reference/inboxes/conversation-analytics-for-an-inbox /openapi.yaml get /api/inboxes/{channelId}/analytics Aggregated conversation metrics for one inbox over a date range. Response times are **business-hours aware** — they use the company's configured business hours and holidays, the same basis as in-app reports, so figures reconcile. `responseCount` is the number of replies the averages were computed from. Averages and rates are `null` rather than `0` when the window contains nothing to measure. Automated/junk threads are excluded unless `includeJunk=true`. `sentiment` carries a numeric average plus the `coverage` it was computed from, and `enabled` reports whether scoring is currently switched on for the inbox. Scores exist only for threads the pipeline actually scored, so a coverage of 40% means the average describes 40% of the window's threads. `topics` lists the categories applied to threads in the window with their thread counts. Qualitative tone summaries (prose descriptions of tone) are not part of this endpoint yet and are absent from the response rather than returned as null. # List inboxes you can query Source: https://docs.getaptly.com/api-reference/inboxes/list-inboxes-you-can-query /openapi.yaml get /api/inboxes Returns the email and phone/SMS inboxes available to the credential, for use as the `channelId` on the messages and analytics endpoints. With delegate-token auth (`inboxes:*` scope) results are limited to inboxes the authenticated user can reach, and each entry carries `access`: `owned` when the user is a member of the inbox (directly or through a team), `monitored` when it is another user's private inbox that the caller is permitted to monitor. `scope` filters on that value. Monitoring requires the `conversations.monitor_admin` permission on the caller and `conversations.monitor_access` on the inbox owner's role, so a caller without those permissions sees only `owned` inboxes. API keys and partner tokens are company-scoped and carry no user identity, so they return every inbox in the company, omit `access`, and reject `scope` with a `400`. # List threads and messages on an inbox Source: https://docs.getaptly.com/api-reference/inboxes/list-threads-and-messages-on-an-inbox /openapi.yaml get /api/inboxes/{channelId}/messages Returns threads on the inbox, newest activity first, each with its messages and stored per-thread metrics. Paging is over **threads**, not individual messages. The window filters on the thread's last message time and `dateTo` is exclusive. Automated/junk threads are excluded unless `includeJunk=true`. `sentVia` indicates whether an outbound was composed in Aptly (`aptly`) or sent from the user's own mail client and synced in (`native`). Inbound messages are always `null`. # List unsent drafts on an inbox Source: https://docs.getaptly.com/api-reference/inboxes/list-unsent-drafts-on-an-inbox /openapi.yaml get /api/inboxes/{channelId}/drafts Returns the unsent drafts on the given email inbox. The inbox is identified by its `channelId` — the same value returned as `channelId` from `GET /api/users/{userId}/inboxes`. Each item in the response is a draft entry with the parent discussion's `streamId` attached. Drafts currently in the process of sending (after `POST /api/email/send` was called and before the upstream provider accepts the message) are excluded. With API-key auth the only scope check is that the inbox belongs to your company. With delegate-token auth (`inboxes:*` scope) the inbox must also be one the authenticated user can reach personally or via a team membership. # Volume and response-time trends for an inbox Source: https://docs.getaptly.com/api-reference/inboxes/volume-and-response-time-trends-for-an-inbox /openapi.yaml get /api/inboxes/{channelId}/trends A gap-free time series for one inbox, rolled up server-side by day, week, month or quarter. Both `dateFrom` and `dateTo` are required (max 400 days); `dateTo` is exclusive. Periods with no activity are returned with zero volume rather than omitted, so the series can be charted directly. All bucketing is UTC. Weeks start Monday (ISO) and are keyed by that Monday's date, so week keys sort alongside day keys. **Two bucketing bases, deliberately:** `volume` counts messages sent or received *during* the period, while `threadCount` and the response/rate figures describe threads whose *last activity* fell in the period. Response times carry no per-message timestamps, so they can't be attributed to a single day of a long-running thread. Do not divide `volume` by `threadCount`. Averages are combined from underlying sums and counts, so a quiet day never carries the same weight as a busy one. `responseCount` is the number of replies behind `avgResponseHrs`. # Volume health for an inbox against its own baseline Source: https://docs.getaptly.com/api-reference/inboxes/volume-health-for-an-inbox-against-its-own-baseline /openapi.yaml get /api/inboxes/{channelId}/health Compares the requested window's message volume against this inbox's own recent norm and scores the deviation, for health/risk surfaces. The baseline is a trailing average of the `baselinePeriods` windows of the same length immediately before `dateFrom` (default 3). No seasonal adjustment is applied. `dateFrom` and `dateTo` are both required; the window plus its baseline must stay within a 400-day scan, so long ranges need a lower `baselinePeriods`. `severityScore` (0–100) tracks the size of a **decline** — growth scores 0 with label `none`. Bands: `critical` 75+, `high` 50–74, `moderate` 25–49, `low` 1–24. `trend` compares the two halves of the requested window, which is what separates "down and still falling" from "down but recovering". **Insufficient history is reported explicitly.** With no baseline volume, `volumeVsBaselinePct` and `severityScore` are `null` and `severityLabel` is `insufficient_history` — a silent `-100%` would be indistinguishable from a real collapse and would drive alerts the data can't justify. # Get knowledge base document Source: https://docs.getaptly.com/api-reference/knowledge-base/get-knowledge-base-document /portal/openapi.yaml get /knowledge/{knowledgeId} # Create a knowledge document Source: https://docs.getaptly.com/api-reference/knowledge/create-a-knowledge-document /openapi.yaml post /api/knowledge/create Creates a new knowledge document scoped to your company. Optionally associates the document with a board (`aptletUuid`), a card (`aptletInstanceId`), or a parent document (`parentId`). HTML or Markdown content is accepted and stored internally as a structured document format. Provide either `html` or `markdown`, not both. # Get a knowledge document Source: https://docs.getaptly.com/api-reference/knowledge/get-a-knowledge-document /openapi.yaml get /api/knowledge/{id} Returns a knowledge document's content rendered as HTML. # Update a knowledge document Source: https://docs.getaptly.com/api-reference/knowledge/update-a-knowledge-document /openapi.yaml put /api/knowledge/{id} Updates a knowledge document's content and/or metadata. Only fields provided in the request body are updated — omitted fields are left unchanged. Provide either `html` or `markdown`, not both. # Archive a routing group Source: https://docs.getaptly.com/api-reference/routinggroups/archive-a-routing-group /openapi.yaml post /api/routing-groups/{id}/archive Soft-deletes a routing group. Archived groups are excluded from list results and cannot be updated. # Create a routing group Source: https://docs.getaptly.com/api-reference/routinggroups/create-a-routing-group /openapi.yaml post /api/routing-groups/create Creates a new routing group for the authenticated company. Returns the new group's ID. # List routing groups Source: https://docs.getaptly.com/api-reference/routinggroups/list-routing-groups /openapi.yaml get /api/routing-groups Returns all active routing groups for the authenticated company. # Update a routing group Source: https://docs.getaptly.com/api-reference/routinggroups/update-a-routing-group /openapi.yaml put /api/routing-groups/{id} Updates an existing routing group. All fields are optional — only provided fields are changed. Pass `null` for a field to clear it. # Get board schema Source: https://docs.getaptly.com/api-reference/schema/get-board-schema /openapi.yaml get /api/schema/{boardId} Returns the list of fields defined on the board. Always fetch the schema first so you know which field keys to use when reading or writing card data. # Create a task Source: https://docs.getaptly.com/api-reference/tasks/create-a-task /openapi.yaml post /api/tasks Creates a task. When `aptletInstanceId` is set, the task is also mirrored as a checklist entry on that card. The task is attributed to an acting user who must belong to the company. With a delegate token the user is taken from the token. With an API key (no associated user), supply `userId` in the body — it must belong to the company or the request is rejected. # Get a task by ID Source: https://docs.getaptly.com/api-reference/tasks/get-a-task-by-id /openapi.yaml get /api/tasks/{taskId} Fetches a single task with related card/board context and resolved attachment metadata. When `includeMetadata=true`, also returns display labels (`priorityLabel`, `statusLabel`) and the resolved `assignee`. # Search tasks Source: https://docs.getaptly.com/api-reference/tasks/search-tasks /openapi.yaml post /api/tasks/search Query tasks for the authenticated company. All body fields are optional filters. Date-range filters (`dueAt`, `checkedAt`, `updatedAt`) take an object of `{ startDate, endDate }` — either bound may be supplied independently. Set `useCount: true` to return `{ count }` instead of `{ tasks }`. # Update a task Source: https://docs.getaptly.com/api-reference/tasks/update-a-task /openapi.yaml put /api/tasks/{taskId} Updates a task and keeps its card-checklist mirror entry in sync. The update is attributed to a user who must belong to the company. With a delegate token the user is taken from the token. With an API key (no associated user), supply `userId` in the body — it must belong to the company or the request is rejected. # Get a template Source: https://docs.getaptly.com/api-reference/templates/get-a-template /openapi.yaml get /api/templates/{id} Returns a single template by its ID. The template must belong to the authenticated company. # List templates Source: https://docs.getaptly.com/api-reference/templates/list-templates /openapi.yaml get /api/templates Returns communication templates for the company. All filter parameters are optional. Accepts an API key (`x-token`), delegate token (`Authorization: DelegateToken `), or partner token (`Authorization: Bearer ` with `templates` permission). When using a partner token, pass `companyId` as a query parameter. # List a user's email inboxes Source: https://docs.getaptly.com/api-reference/users/list-a-users-email-inboxes /openapi.yaml get /api/users/{userId}/inboxes Returns the email inboxes (Hermes/Nylas channels) accessible to the given user within your company. Includes personal inboxes (where the user is a direct member) and shared inboxes the user can access via team membership. Each inbox is tagged `kind: "personal"` (`isShared` is false and the user is in the channel's `userIds`) or `kind: "shared"` (channel marked `isShared`, or accessible only through a team membership). The user must belong to the company associated with your API key. Returns `401` if the user does not belong to your company. # List users Source: https://docs.getaptly.com/api-reference/users/list-users /openapi.yaml get /api/users Returns all non-archived users for the API key's company. # Submit a web form Source: https://docs.getaptly.com/api-reference/webforms/submit-a-web-form /openapi.yaml post /api/web-forms/{formId} Submits data to an Aptly Web Form. The request must use the API key issued for this exact form and that key must have insert access to the form's board. Submitted board-field values are keyed by field UUID. Depending on the form's configuration, the submission creates a card or updates a card whose configured match fields have the same values. Unknown properties are ignored. Contact shortcut properties can link the card to an existing contact or create a new contact when the form and board allow it. # Aptly SDK Reference Source: https://docs.getaptly.com/aptly-sdk-reference Complete reference for the window.aptly object — properties, methods, config scoping, and dev testing URL parameters. The Aptly SDK is loaded via a single script tag and populates `window.aptly`. New to the SDK? Start with the [Quickstart](/quickstart). Related documentation: * [Building Embedded Apps & Dashboards](/building-embedded-apps-&-dashboards) — full guide for embedded apps and dashboards * [Embed App Actions](/embed-actions) — per-action payload reference and permission model * [Delegate Tokens](/delegate-tokens) — raw token exchange API for server-side integrations * [Field Types Reference](/field-types) — value formats for reading and writing card data * [Rate Limits](/rate-limits) — request limits and 429 handling ## Canonical pattern Always call `requestToken` with `{ version: 2 }`. It returns a result object — token on success, structured error on failure — so you can handle rate limits and other failures without a separate side channel. ```js theme={null} async function init() { const { token, error } = await aptly.requestToken({ version: 2 }); if (error?.code === 'RATE_LIMITED') { setTimeout(init, error.retryAfterMs); return; } if (!token) return; const res = await aptly.fetch('/board/' + aptly.config.BOARD_ID + '?page=0'); const { records } = await res.json(); } ``` > **Note:** `requestToken()` called without arguments returns a bare `string | null` for backward compatibility. Prefer `{ version: 2 }` in all new code. *** ## API reference | Property / method | Type | Description | | ------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `aptly.token` | `string \| null` | Current delegate token. `null` if not yet received or if the last token request failed. | | `aptly.org` | `{ id, name }` | Logged-in user's organization | | `aptly.user` | `{ id, email, firstName, lastName, phone, role, teams }` | Logged-in user. `role` is an array of role names assigned to the user in this org. `teams` is an array of team IDs the user belongs to. | | `aptly.config` | `object` | Admin-declared config variables, fully merged across all active scopes. See [Config scoping](#config-scoping). | | `aptly.board` | `{ id, schema } \| null` | Board context — only present when the app is embedded as a board tab. `schema` is the board's field schema. | | `aptly.boards` | `{ leases, properties, units, deals, tickets, workOrders, screenings } \| null` | Pre-fetched board data (`{ id, name, schema, records, total }`) for boards the app declared. Undeclared keys and missing board types are `null`. See [Standard boards](#standard-boards). | | `aptly.fetch(path, options?)` | `Promise` | Auth-aware fetch. Prepends the Aptly API base URL (`https://core-api.getaptly.com`). Automatically retries once on `401` with a fresh token. Returns a standard `Response`. Omit `/api` from paths. See [Fetching board data](#fetching-board-data) for pagination details. | | `aptly.requestToken(opts?)` | `Promise` or `Promise<{ token, error }>` | Refresh the delegate token. Pass `{ version: 2 }` to get a result object with structured error handling. See [Rate limiting](#rate-limiting). Normally not needed — `aptly.fetch` handles token refresh automatically. | | `aptly.startEmulation(token, opts?)` | `Promise` | Verify a dev token against the Aptly API, resolve real user/org identity, and populate `window.aptly` as if Aptly had delivered context. `opts.config` overrides config values; `opts.org` overrides org fields; `opts.boards` simulates pre-fetched board data. Returns `window.aptly`. | | `aptly.actions` | `string[]` | Action IDs the app is permitted to trigger (set by the admin in the app definition). Empty array = no actions allowed. | | `aptly.action(actionId, payload?)` | `Promise<{ success, error? }>` | Trigger a named Aptly action. Resolves `{ success: false, error: 'Not permitted' }` if the action isn't in `aptly.actions`, or `{ success: false, error: 'timeout' }` after 3 s with no response. See [Embed App Actions](/embed-actions). | | `aptly.openEmailComposer(opts?)` | `Promise<{ success, error? }>` | Open email/SMS composer. `opts`: `{ to?, subject?, content?, composeMode? }`. `to` takes an address, an array of addresses, or an array of `{ value, label }` objects — email addresses when `composeMode` is `'email'` (the default), phone numbers when it is `'sms'`. Only fires if `'open-email-composer'` is in `aptly.actions`. | | `aptly.createCard(opts?)` | `Promise<{ success, error? }>` | Open new-card form. `opts`: `{ boardId, fields? }`. Requires `'create-card'` in `aptly.actions`. | | `aptly.createTask(opts?)` | `Promise<{ success, error? }>` | Open task creation form. `opts`: `{ title?, dueAt?, priority?, assigneeId?, aptletInstanceId? }`. Requires `'create-task'` in `aptly.actions`. | | `aptly.createContact(opts?)` | `Promise<{ success, error? }>` | Open contact creation modal. `opts`: `{ firstname?, lastname?, phone?, email? }`. Requires `'create-contact'` in `aptly.actions`. | | `aptly.navigateToContact(opts)` | `Promise<{ success, error? }>` | Navigate to a contact record. `opts`: `{ contactId }`. Requires `'navigate-to-contact'` in `aptly.actions`. | | `aptly.openCardPane(opts)` | `Promise<{ success, error? }>` | Open card in side panel. `opts`: `{ cardId }`. | | `aptly.openCardView(opts)` | `Promise<{ success, error? }>` | Open card in fullscreen modal. `opts`: `{ cardId }`. | | `aptly.startDialer(opts)` | `Promise<{ success, error? }>` | Open phone dialer. `opts`: `{ number, name? }` — `number` must be E.164 format. Requires `'start-dialer'` in `aptly.actions`. | | `aptly.createEvent(opts?)` | `Promise<{ success, error? }>` | Open the calendar event editor. `opts`: `{ date?, comments?, recipients? }`. Requires `'create-event'` in `aptly.actions`. | *** ## Fetching board data `aptly.fetch` prepends the Aptly API base URL and handles auth automatically. Pass the path starting from `/board/` — omit `/api`. ```js theme={null} const res = await aptly.fetch('/board/MY_BOARD_UUID?page=0'); const { data, count, page, pageSize } = await res.json(); // data — array of card objects for this page // count — total matching cards across all pages // page — the page number you requested // pageSize — number of cards per page (default 20, max 1000) ``` ### Pagination Pages are **zero-based**. The first page is `page=0`, the second is `page=1`, and so on. The `page` parameter is required — omitting it returns a 400 error. ```js theme={null} // ✅ Correct — first page const res = await aptly.fetch('/board/lease?page=0&pageSize=100'); // ❌ Wrong — skips the first 100 records, returns empty if fewer than 100 exist const res = await aptly.fetch('/board/lease?page=1&pageSize=100'); ``` To page through all records: ```js theme={null} async function fetchAllCards(boardId) { const pageSize = 100; let page = 0; let allCards = []; while (true) { const res = await aptly.fetch(`/board/${boardId}?page=${page}&pageSize=${pageSize}`); const { data, count } = await res.json(); allCards = allCards.concat(data); if (allCards.length >= count) break; page++; } return allCards; } ``` ### Query parameters | Param | Required | Description | | ----------------- | -------- | -------------------------------------------------------------- | | `page` | Yes | Zero-based page number (0–9999) | | `pageSize` | No | Cards per page. Default `20`, max `1000` | | `updatedAtMin` | No | ISO timestamp — only return cards updated after this time | | `includeArchived` | No | Set to `true` to include archived cards. Default excludes them | | `assignee` | No | Filter by assignee user ID | | `contactEmail` | No | Filter cards linked to this contact email | | `keyTerm` | No | Full-text search on card title | ### Schema endpoint To get the field definitions for a board (key → label → type mapping): ```js theme={null} const fields = await aptly.fetch('/schema/MY_BOARD_UUID').then(r => r.json()); // [ { key: 'stage', label: 'Stage', type: 'stage' }, ... ] ``` The schema endpoint requires no additional parameters and returns an array directly (not wrapped in `{ data }`). *** ## Config scoping `aptly.config` always contains the **fully merged** configuration for the current context — app devs just read `aptly.config.YOUR_KEY` without knowing which scope a value came from. Admins set a `contextScope` on each app that controls how config values are stored: | Scope | Where values are stored | Use case | | ------------------- | ------------------------------- | ------------------------------------------------------------------------------------- | | `company` (default) | One record per org per app | All users and boards share the same config | | `board` | One record per board per app | Different config per board — e.g. each board uses its own data source ID | | `user` | One record per user per app | Personal settings that don't affect teammates | | `all` | All three levels simultaneously | Maximum flexibility — user settings override board settings override company settings | **Merge priority:** `user` > `board` > `company`. If the same key is set at multiple levels, the narrowest scope wins. ```js theme={null} // Same code regardless of what scope the admin chose const boardId = aptly.config.LEASES_BOARD_ID; ``` **Configuring board-scoped apps:** Users configure board-scoped apps directly in the board view settings panel, where the board context is known. Company and user-scoped apps are configured in the app store. *** ## Rate limiting Token requests are rate-limited **per browser connection** (not per user account), so opening the same app in multiple tabs does not share a quota. The default limit is **60 token requests per 60 seconds** per connection. When the limit is exceeded, `requestToken({ version: 2 })` returns `{ token: null, error }` where `error` is: ```js theme={null} { code: 'RATE_LIMITED', message: 'Rate limited. Retry in 42 seconds.', retryAfterMs: 42000 // milliseconds until the bucket resets } ``` ### Handling rate limit errors Use `{ version: 2 }` and check the `error` field: ```js theme={null} async function loadData() { const { token, error } = await aptly.requestToken({ version: 2 }); if (error?.code === 'RATE_LIMITED') { console.warn(error.message); setTimeout(loadData, error.retryAfterMs ?? 60000); return; } if (!token) { console.error('Could not obtain token'); return; } const res = await aptly.fetch('/boards/leases/records'); const data = await res.json(); // ... } ``` `aptly.fetch` does not automatically retry on rate limit errors — it only retries once on `401`. Your app is responsible for scheduling retries when `error.code === 'RATE_LIMITED'`. *** ## URL params (Option C dev testing) Append any of these to your app's URL to simulate Aptly context with no code changes: | Param | Sets | | ----------------------- | --------------------------------- | | `aptly_token` | `aptly.token` | | `aptly_config_KEY` | `aptly.config.KEY` (any key name) | | `aptly_org_id` | `aptly.org.id` | | `aptly_org_name` | `aptly.org.name` | | `aptly_user_id` | `aptly.user.id` | | `aptly_user_email` | `aptly.user.email` | | `aptly_user_first_name` | `aptly.user.firstName` | | `aptly_user_last_name` | `aptly.user.lastName` | Example: ``` https://your-app.repl.co?aptly_token=MY_TOKEN&aptly_config_BOARD_ID=lease&aptly_org_name=Acme ``` When running inside Aptly, URL params are ignored — the real postMessage context takes priority. *** ## Standard boards `aptly.boards` delivers pre-fetched schema and records for standard Aptly board types — no fetches required. The app admin selects which boards to pre-load in the marketplace admin panel. Only declared boards are populated; undeclared keys are `null`. ### Board data shape Each non-null entry is a `BoardData` object: ```ts theme={null} type BoardData = { id: string; // board UUID ('lease', 'location', etc.) name: string; // display name ('Leases') schema: { key: string; label: string; type: string }[]; // field definitions records: object[]; // first 50 records, keyed by schema field keys total: number; // total record count (for pagination) } ``` ### Usage ```js theme={null} // Schema and records are ready immediately — no fetch needed const leases = aptly.boards.leases; if (leases) { console.log(leases.name); // 'Leases' console.log(leases.schema); // [{ key: 'name', label: 'Name', type: 'text' }, ...] console.log(leases.records); // [{ _id: '...', name: 'Unit 1A', stage: 'Active' }, ...] console.log(leases.total); // 847 — fetch more pages if total > 50 } ``` ### Board keys | Key | Board type | | ------------------------- | ---------------------- | | `aptly.boards.leases` | Leases | | `aptly.boards.properties` | Properties / Buildings | | `aptly.boards.units` | Units | | `aptly.boards.deals` | Deals | | `aptly.boards.tickets` | Tickets / Work Orders | | `aptly.boards.workOrders` | Work Orders | | `aptly.boards.screenings` | Screenings | A key is `null` if the app didn't declare that board, or if the company doesn't have it. Always guard: ```js theme={null} if (aptly.boards.leases) { /* safe to use */ } ``` ### Fetching more records The first 50 records are pre-loaded. If `total > 50`, use `aptly.fetch` to page through the rest: ```js theme={null} const { id, records, total } = aptly.boards.leases; // records already has page 0 — fetch additional pages if needed if (total > records.length) { const more = await aptly.fetch('/board/' + id + '?page=1').then(r => r.json()); } ``` ### Dev mode `aptly.boards` is `null` by default in `startEmulation` and `APTLY_DEV` (no live Aptly session to deliver data). Pass a `boards` override to simulate it locally. You can use either the full rich object or a plain UUID string (the SDK normalizes it): ```html theme={null} ``` Or via `startEmulation`: ```js theme={null} aptly.startEmulation('MY_DEV_TOKEN', { boards: { leases: 'lease' } }); ``` # Authentication Source: https://docs.getaptly.com/authentication How to authenticate requests to the Aptly API. Most Aptly API endpoints accept an API key. Some endpoints also accept delegate tokens or partner bearer tokens, and explicitly public endpoints require no credential. Check each operation in the API Reference for its supported methods. ## API keys API keys are created per company and work across all boards. A key must belong to a company that has the API enabled on the board being accessed. To create a key: 1. Open the board in Aptly 2. Go to **Card Sources → API** 3. Toggle the API **on** 4. Click **Create New Key**, enter a name, and optionally set an expiration date 5. Copy the key — it won't be shown again ## Passing the key ```bash theme={null} curl https://core-api.getaptly.com/api/board/{boardId}?page=0 \ -H "x-token: YOUR_API_KEY" ``` Always pass API keys in the `x-token` header. Query-string credentials can be captured in server logs, browser history, and referrer headers. **Core API and Portal API use different authentication schemes.** The Core API uses a static API key passed as an `x-token` header or query parameter. Everything on this page applies to the Core API only. The Portal API (`https://app.getaptly.com/api/portal`) uses JWT-based authentication scoped to a contact session. If you are building against the Portal API, refer to the [Contact Verification & Lightweight SSO](/contact-verification-sso) guide for how to obtain and pass a token. ## Error responses | Status | Meaning | | ------------------ | ------------------------------------------------ | | `401 Unauthorized` | API key is missing, invalid, or expired | | `403 Forbidden` | API access is not enabled for this board | | `404 Not Found` | Board or card does not exist within your company | ## Key expiration Keys can be created with or without an expiration date. Keys without an expiration remain active until archived. Expired or archived keys return `401`. # Building Embedded Apps & Dashboards Source: https://docs.getaptly.com/building-embedded-apps-&-dashboards Add the Aptly SDK to a custom dashboard or embedded app to get delegate auth, user identity, and config variables — then read and write Aptly data directly from the page. > Add the Aptly SDK to a custom dashboard, internal tool, or any iframe to get delegate > auth, user identity, and config variables -- then read and write Aptly data directly > from the page. This guide is written for developers and for AI agents generating > apps on a user's behalf. Related documentation: * [SDK Reference](/aptly-sdk-reference) -- full `window.aptly` API, config scoping, URL params * [Delegate Tokens](/delegate-tokens) -- raw token exchange API for server-side integrations * [Embed App Actions](/embed-actions) -- trigger Aptly UI interactions from an embedded app * [Field Types Reference](/field-types) -- value formats for reading and writing card data * [Rate Limits](/rate-limits) -- request limits and 429 handling * [Documentation index](/llms.txt) -- machine-readable index of all pages *** ## How embedded apps work When your page runs inside Aptly (as a board tab or dashboard panel), Aptly delivers a short-lived delegate token and context -- the logged-in user, their organization, and admin-declared config -- to your page via postMessage. The SDK handles that handshake and exposes everything on `window.aptly`. Your page then calls the Aptly REST API directly with the token. Three architectural rules apply to every embedded app: 1. **The page calls the Aptly API at runtime.** Do not call AI model APIs (Anthropic, OpenAI, or any other) from the page. Browser-side model calls require credentials that are only injected inside the model vendor's own products; on a hosted page they fail with authentication and CORS errors. If an AI agent generates the page, the agent's involvement ends at generation time. 2. **Fetch data fresh on every load.** Never embed a snapshot of queried data in the page source. A snapshot renders correctly but is frozen at generation time, and the discrepancy is invisible to users until the numbers drift. 3. **Compute deterministically in the page.** Filtering, aggregation, and report logic belong in plain JavaScript, not in a runtime model call. Client-side compute is instant, free, and produces the same result every time. *** ## Quick start Add one script tag to your HTML ``: ```html theme={null} ``` The SDK is served from `app.getaptly.com` only. After it loads, `window.aptly` is available: ```js theme={null} // Auth-aware fetch: adds the token, prepends the API base URL // (https://core-api.getaptly.com), and retries once on 401 with a fresh token. const res = await aptly.fetch("/board/MY_BOARD_ID?page=0&pageSize=1000"); const body = await res.json(); // Context delivered by Aptly aptly.org.id; // companyId of the logged-in user's org aptly.org.name; // org display name aptly.user.email; // logged-in user's email aptly.user.firstName; aptly.config; // admin-declared config variables aptly.token; // current delegate token (managed by the SDK) ``` Detect SDK readiness by polling for `window.aptly` with a bounded attempt count. Do not attach an `onerror` handler to the script tag for this purpose: some sandboxed environments fire `onerror` even when the script subsequently loads, which produces false-positive failures. ```js theme={null} function whenSdkReady(timeoutMs) { return new Promise(function (resolve, reject) { var maxAttempts = Math.ceil(timeoutMs / 100); var attempts = 0; (function check() { if (window.aptly) return resolve(window.aptly); attempts += 1; if (attempts >= maxAttempts) { return reject( new Error( "The Aptly SDK did not load after " + attempts + " attempts." ) ); } setTimeout(check, 100); })(); }); } ``` *** ## Enable the SDK for your app Open the board view settings for the tab that contains your app (or the dashboard panel) and toggle on **Needs Aptly Context**. This tells Aptly to deliver a delegate token and context to your app on each load. Without it, the postMessage handshake never completes and the page receives no token. *** ## Requesting the delegate token On boot, follow this order: 1. If `aptly.token` is already populated, initialize immediately. 2. Otherwise call `aptly.requestToken({ version: 2 })` and handle the result. 3. Fall back to a bounded poll for `aptly.token` only when running against an SDK build that does not expose `requestToken`. `requestToken` **never rejects**. The default (v1) form resolves the token string, or `null` on failure, with no further detail. The version-2 form resolves a result object: ```js theme={null} const { token, error } = await aptly.requestToken({ version: 2 }); // success: { token: '...', error: null } // failure: { token: null, error: { code, message, retryAfterMs } } ``` Handle failures immediately rather than waiting for a timeout: * `RATE_LIMITED` -- retry once after `error.retryAfterMs` milliseconds. * Any other code -- display `error.message` and `error.code` to the user with instructions (typically: enable **Needs Aptly Context** and confirm the app is authorized). * A `null` token from a v1-only SDK build -- treat as a failure with the same guidance. Wrap the request in a hard timeout (8 seconds is a reasonable default) so an unanswered postMessage cannot stall the page indefinitely. The complete worked example below shows the full boot sequence. *** ## Reading board data ### Resolve fields from the schema -- never hardcode field IDs Fetch `GET /api/schema/{boardId}` before reading or writing card data, and resolve the fields your app needs at runtime. Match candidates against both the field `label` and the field `key`, case-insensitively, with a list of accepted variants per field: ```js theme={null} var F = resolveFields(schema, { stage: ["Stage"], marketRent: ["Market Rent", "marketRent"] }); ``` Built-in fields use semantic keys (`name`, `stage`, `dueAt`); custom fields use generated IDs; some boards expose camelCase keys with different display labels. Matching both label and key handles every case and makes the app portable across boards and companies. When a required field is missing, surface an error that lists the fields the board actually has -- this turns a configuration mismatch into something the user can fix without developer help. ### Paginate with the response envelope `GET /api/board/{boardId}?page=N&pageSize=M` returns: ```json theme={null} { "data": [ ...cards ], "count": 412, "page": 0, "pageSize": 1000 } ``` `count` is the board total. Use it for an exact stop condition and progress display. Request `pageSize=1000` (the maximum) so most boards load in one or two requests. ### Respect the rate limit The API allows **60 requests per minute per connection**. For multi-page loads: * pause about 1 second between page requests, * on a `429` response, retry once after the `Retry-After` header (or 5 seconds if the header is absent), and show a "retrying" message in the loading state, * cap every pagination loop at a hard maximum page count. ### Normalize field value formats Value formats differ between reading and writing, and across boards: * **Money** fields are returned as `{ amount, currency }` from list endpoints but are written as plain numbers. Accept both when reading. * **Checkbox** values may arrive as `true`, `"true"`, `"Yes"`, or `1`. * **Dates** may be ISO (`2026-05-15`) or `MM-DD-YYYY`. Parse both. * Treat unrecognized field types as plain strings. The [Field Types Reference](/field-types) lists the write formats. ### API error reference All API calls use the `Authorization: DelegateToken ` scheme -- not `Bearer` -- against `https://core-api.getaptly.com` (`aptly.fetch` sets both automatically). | Situation | Response | | ----------------------------------------------- | ------------------------------------------------------- | | Board exists but API access is disabled | `400` with `{ "error": { "code": "API_DISABLED" } }` | | Board does not exist | `404` with `{ "error": { "code": "BOARD_NOT_FOUND" } }` | | `Bearer` scheme used instead of `DelegateToken` | `401` `"Invalid or missing API key"` | | Token lacks the required scope | `403 Forbidden` | | Token expired or invalid | `401` | | Rate limit exceeded | `429`, may include a `Retry-After` header | Notes: API access is enabled per board in the board's settings, and `GET /api/boards` returns only boards that have it enabled. *** ## Building a reliable UI Embedded apps are used by people who will not open browser developer tools. Build accordingly: **Every error renders on screen.** Each failure path -- a missing field, an expired token, a rate limit, an unexpected exception -- must appear in a visible error element with instructions for resolving it. Register global `error` and `unhandledrejection` handlers so unanticipated exceptions also land in the UI. Do not rely on `console.*` output for anything a user might need to see. **Every loop is bounded.** Polls and pagination loops need a maximum attempt count, and the timeout message should state it. Unbounded loops hang the page or hammer the API when something unexpected happens. **Follow the hosted-page source requirements.** Pages uploaded to Aptly's page hosting pass through an HTML transform. The following constraints keep source intact through that transform (and are good practice everywhere): * Attach all event handlers with `addEventListener` in script blocks. Inline handler attributes (`onclick=`, `onchange=`, and similar) are not preserved. * Build DOM with `createElement` and `textContent`, not concatenated HTML strings assigned to `innerHTML`. This also avoids HTML-injection issues. * Do not place a `<` character directly followed by a letter anywhere outside a quoted string -- including inside comments (for example, write `returns the total` rather than `returns `). Such sequences can be interpreted as markup. * Keep source pure ASCII. Replace em dashes, ellipses, smart quotes, and arrows with ASCII equivalents, or use `\uXXXX` escapes inside string literals. * Keep lines under 200 characters. **Skip the refresh button by default.** Pages fetch live data on every load and embedded tabs reload when opened. Add a manual refresh control only when users keep the tab open for extended periods. *** ## Config variables Config variables let users configure your app with friendly pickers -- board selectors, text fields, toggles -- rather than pasting raw IDs. Admins declare them in the app store admin panel; users fill them in during install. Read them from `aptly.config` with a sensible fallback: ```js theme={null} const boardId = aptly.config.BOARD_ID ?? "my-default-board"; const threshold = aptly.config.OVERDUE_THRESHOLD ?? 30; ``` `aptly.config` is the fully merged configuration for the current context. If the app supports board-level or user-level config, Aptly merges scopes before delivering context (priority: user over board over company) -- your code always reads one flat object. See [Config scoping](/aptly-sdk-reference#config-scoping). *** ## Testing your app Embedded context only exists inside Aptly, so local development uses one of three emulation options. ### Option A -- `startEmulation` with a dev token (recommended) An Aptly admin generates a long-lived developer token in **Global Admin -> \[Company] -> Dev Tokens**. Call `startEmulation` after the SDK loads: ```js theme={null} await aptly.startEmulation("DEV_TOKEN", { config: { BOARD_ID: "my-board" } }); ``` `startEmulation` verifies the token against the Aptly API to resolve real user and org identity, then populates `window.aptly` exactly as if Aptly had delivered the context. Two behaviors to account for: * `startEmulation` **resolves even when verification fails**; a failed verify leaves `aptly.user` unpopulated. After it resolves, check `aptly.user && aptly.user.id` and treat an unpopulated identity as an invalid token. * When the page runs genuinely embedded in Aptly, real postMessage context takes priority over emulation. **Do not hardcode dev tokens in page source.** Dev tokens are live credentials. The recommended pattern is a "Developer mode" toggle that expands into a paste field: the operator pastes a token at runtime, the app verifies it via `startEmulation`, and on success stores it in `localStorage` so dev mode survives reloads (cleared when the toggle is turned off). Persist this state in `localStorage`, not the URL -- when embedded, the iframe URL belongs to Aptly's tab configuration. A page built this way ships zero secrets and the toggle can remain in any build. ### Option B -- `window.APTLY_DEV` block Set values directly before the SDK tag -- useful for mocking config without a token. Remove before deploy: ```html theme={null} ``` ### Option C -- URL params No code changes needed; nothing to remove before deploy: ``` https://your-app.example.com?aptly_token=MY_TOKEN&aptly_config_BOARD_ID=my-board ``` See the [SDK Reference](/aptly-sdk-reference#url-params) for the full parameter list. ### Where testing works -- and where it does not Test in a regular browser tab (with one of the options above) or embedded in Aptly with **Needs Aptly Context** enabled. AI assistant sandboxes (for example, the Claude.ai artifact preview) enforce a Content Security Policy that blocks the SDK script from loading; iframes inside those sandboxes inherit the same policy. Pages that depend on the Aptly SDK cannot be previewed there -- open the file in a normal browser tab instead. *** ## Triggering Aptly UI from your app The SDK exposes named methods that open dialogs in the parent Aptly window -- the phone dialer, email composer, card creation, contact navigation, and more. Each returns a `Promise` of `{ success, error? }` and is a no-op outside Aptly. Actions must be enabled per embed by an admin: ```js theme={null} await aptly.startDialer({ number: "+15551234567", name: "Jane Smith" }); await aptly.openEmailComposer({ to: ["jane@example.com"], subject: "Lease renewal" }); await aptly.createCard({ boardId: "my-board-uuid", fields: { name: "Follow-up call" } }); console.log(aptly.actions); // actions granted to this embed ``` See [Embed App Actions](/embed-actions) for the full list, payloads, and setup. *** ## Complete worked example: board overview The page below runs on any board with no custom-field requirements -- it uses only the built-in `stage` field and card basics. The top section is a wrapping grid of boxes with the card count per stage; selecting a box filters the table below to that stage (selecting it again, or "All cards", clears the filter). Each table row demonstrates embed actions: clicking the row opens the card in the board side pane, the expand icon opens the card in a fullscreen view, the email icon looks up the card's linked contacts via the REST API and opens the composer with their email addresses as recipients (`to`) and the card name as subject, and the phone icon opens the dialer with the first linked phone number found. Action failures (not embedded, not granted, timeout) render as on-screen messages. Every pattern in this guide is applied: the boot sequence with version-2 token requests, schema-first field resolution, rate-limited pagination, on-screen errors, transform-safe source, and admin-granted UI actions. Use it as a starting template and extend it with board-specific fields via `resolveFields`. ```html theme={null} Board Overview

Board Overview

Connecting to Aptly...
``` *** ## Using delegate tokens without the SDK Server-side scripts, custom auth flows, and AI agents can use the raw token exchange directly: 1. `POST https://core-api.getaptly.com/api/platform/user-token` with the logged-in user's session JWT in the `access_token` header and a body of `{ "readScopes": ["boards:*"], "writeScopes": [] }` -- or `{ "appClientId": "..." }` for registered marketplace apps. Optional `expirationSeconds` up to 604800 (7 days); the default expiry is 5 minutes. 2. Verify identity when needed: `POST /api/app/verify` with `{ "token": "" }`. No API key is required. 3. Call any supported route with `Authorization: DelegateToken `. Scopes follow `resource:qualifier` (`boards:*`, `boards:`, `contacts:*`, `knowledge:*`, `email:*`). Omitted scopes are denied; a missing scope returns `403`. Request the narrowest scope the app needs -- a read-only dashboard needs only `readScopes: ["boards:"]`. Full details: [Delegate Tokens](/delegate-tokens). *** ## Pre-deployment checklist 1. No calls to AI model APIs anywhere in the page. 2. No snapshot data embedded in the source; all data fetched at load. 3. SDK script tag points at `app.getaptly.com`. 4. Boot order: SDK readiness poll (bounded) -> existing token, else `requestToken({ version: 2 })` with fail-fast error handling and one `RATE_LIMITED` retry -> bounded token poll only as a v1 fallback. 5. Schema fetched before card data; fields resolved by label and key with variants; missing-field errors list the board's actual fields. 6. Pagination uses the `{ data, count }` envelope, caps pages, pauses between pages, and retries once on `429`. 7. Every error path renders in the UI; global `error` and `unhandledrejection` handlers installed. 8. No inline event handler attributes; all listeners attached with `addEventListener`. 9. DOM built with `createElement` and `textContent`; no HTML-string `innerHTML`. 10. Source is pure ASCII, has no `<` + letter sequences outside quoted strings, and keeps lines under 200 characters. 11. No tokens or other credentials in the source; developer mode (if present) accepts a pasted token at runtime. 12. The target board has API access enabled, and the tab has **Needs Aptly Context** turned on. # Changelog Source: https://docs.getaptly.com/changelog A record of notable changes to the Aptly API, grouped by date. ## August 24, 2026 * **Enhancement:** `POST /api/web-forms/{formId}` now accepts the reserved `contactFirstName`, `contactLastName`, `contactFullName`, `contactEmail`, and `contactPhone` properties for automatic contact linking. When a board has exactly one contact field, these values can find or create the linked contact without an explicit form contact mapping; the submitted card name is used as a fallback contact name. ## August 20, 2026 * **Enhancement:** `POST /api/web-forms/{formId}` now supports linking a submission to a contact — when the form has contact mapping configured, it looks up an existing person by normalized email/phone (or creates one) and writes the resulting contact reference onto the board's configured field. ## August 18, 2026 * **Enhancement:** `GET /api/inboxes/{channelId}/messages` threads now include a `labels` array (`{ id, name }`) listing the folder labels applied to the thread. Only real folder labels are returned — the encoded scoping ids also stored on a thread (channel, assignee, mention, person type, category) are omitted. ## August 16, 2026 * **New Endpoint:** `POST /api/web-forms/{formId}` — Public submission endpoint for Aptly Web Forms. Maps the submitted data onto the form's board, creating a new card or updating a matching existing one (depending on the form's configured match mode), and records the submission. The API key must be the exact token minted for the form — a board-scoped insert key can only submit to the one form it was created for. ## August 13, 2026 * **Enhancement:** Board API keys (`x-token`) can now be scoped to specific boards and to a subset of read/insert/update permissions, configurable from Setup → Developer → Board API Tokens. Requests outside a key's allowed boards or permissions now receive a `403 FORBIDDEN`. Existing keys are unaffected — an absent `boardIds`/`permissions` on a key means full access to all boards, as before. ## July 31, 2026 * **New Endpoint:** `GET /api/inboxes` — Lists the email and phone/SMS inboxes your credential can query. With a delegate token (`inboxes:*` scope) results are limited to inboxes the authenticated user can reach and each carries `access: owned` or `monitored`; `?scope=` filters on that. API keys and partner tokens are company-scoped, so they return every inbox in the company and reject `?scope=`. * **New Endpoint:** `GET /api/inboxes/{channelId}/messages` — Threads on an inbox, newest activity first, each with its messages and per-thread metrics (first/average response time, response count, reopen flag, touch counts). Filter by `dateFrom`/`dateTo` (exclusive, max 400 days) and `direction`; page over threads with `page`/`pageSize` (max 200). Automated/junk threads are excluded unless `includeJunk=true`. * **Enhancement:** `GET /api/inboxes/{channelId}/analytics` now returns `sentiment` and `topics`. `sentiment` gives the average AI sentiment score for the window plus the `coverage` it was computed from (`scoredThreads` / `threadCount` / `pct`) and an `enabled` flag reporting whether scoring is currently on for that inbox — scores exist only for threads the pipeline actually scored, so the coverage tells you how much of the window the average describes. `topics` lists the categories applied to threads in the window with their thread counts. Both are also broken out per group when `groupBy` is used (without the inbox-level `enabled` flag). Qualitative tone summaries are still not included. * **New Endpoint:** `GET /api/inboxes/{channelId}/health` — Scores an inbox's message volume for the requested window against its own recent baseline, for health and risk surfaces. The baseline is a trailing average of the `baselinePeriods` same-length windows before `dateFrom` (default 3, max 12). Returns `volumeVsBaselinePct`, a `trend` comparing the two halves of the window, a `severityScore` (0–100, tracking decline magnitude — growth scores 0), a `severityLabel` (`critical` 75+, `high` 50–74, `moderate` 25–49, `low` 1–24, `none`) and tags such as `Usage down`, `No activity` and `Still falling`. When there is no baseline volume to compare against, `volumeVsBaselinePct` and `severityScore` are `null` and `severityLabel` is `insufficient_history` rather than reporting a misleading −100%. * **New Endpoint:** `GET /api/inboxes/{channelId}/trends` — A gap-free volume and response-time time series for an inbox, rolled up server-side by `day`, `week`, `month` or `quarter`. `dateFrom` and `dateTo` are both required (`dateTo` exclusive, max 400 days), and periods with no activity are returned with zero volume rather than omitted so the series can be charted directly. Bucketing is UTC and weeks start Monday. Note the two bucketing bases: `volume` counts messages during the period, while `threadCount` and the response/rate figures describe threads whose last activity fell in the period — don't divide one by the other. * **New Endpoint:** `GET /api/inboxes/{channelId}/analytics` — Aggregated conversation metrics for one inbox: average first and overall response time with the `responseCount` behind them, one-touch and reopen rates, inbound/outbound volume, distinct contact count, plus first-party `noCustomerResponseCount` (outbound that never got a reply) and `awaitingOurReplyCount` (open threads where the contact spoke last). `groupBy=assignee` or `groupBy=contactType` breaks the same metrics out per group. Response times are business-hours aware, using your configured business hours and holidays, so they reconcile with in-app reports. Averages and rates are `null` rather than `0` when a window has nothing to measure. ## July 29, 2026 * **Fix:** `GET /api/board/{boardId}` and `GET /api/board/{boardId}/{cardId}` now return Aptly's standard card fields (`name`, `stage`, `dueAt`, `assignee`, and the rest of the built-in field set) alongside your board's custom fields. Previously only fields explicitly defined on the board were returned, so standard fields came back missing. Where a custom field shares a `uuid` with a standard one, your board's definition wins. * **Enhancement:** Card responses now include a `lastActivity` object describing the most recent conversation on the card — `content` (message preview), `type` (`email`, `sms`, `voice`), `direction` (`in` or `out`), `publishedAt`, and `conversationUrl`, a deep link to the conversation thread in Aptly. It is `null` when the card has no activity, and `conversationUrl` is `null` when the activity isn't attached to a thread. * **Fix:** `GET /api/board/{boardId}/{cardId}` now returns `assignee` as the assigned user's display name, matching the list endpoint. It previously returned the raw user id. ## July 20, 2026 * **Enhancement:** `POST /api/knowledge/create` and `PUT /api/knowledge/{id}` now accept an optional `markdown` field as an alternative to `html` for the document body — provide one or the other, not both (a `400` is returned if both are set). Knowledge doc HTML retrieval (`GET /api/knowledge/{id}`) also now renders through an upgraded conversion engine with table support, with an automatic fallback for documents created before this change. ## July 17, 2026 * **New Endpoint:** `GET /api/docs/openapi` — Returns the raw contents of this OpenAPI spec (`docs/openapi.yaml`) as `text/yaml`. No authentication required. ## July 9, 2026 * **Enhancement:** `POST /api/email/create-draft` and `POST /api/email/send` now accept an optional `discussionId` to reply into an existing thread instead of starting a new one. On `send`, supplying `discussionId` without `uuid` appends the new draft as a reply to that thread; the thread's own subject is kept. The thread must belong to the same company and channel. ## June 26, 2026 * **New Endpoint:** `POST /api/files/upload-url` — Request a presigned upload for a file. Returns a `fileId` plus the `url` and form `fields` for uploading the file directly to storage. The file is attached to a `channel`, `aptlet`, or `knowledge` doc (which must exist in your company), with content-type and 1 byte–50 MB size validation. * **New Endpoint:** `POST /api/files/upload-complete` — Finalize a direct upload after the file has been sent to storage. Records the file and returns its `fileId` and download `url`. Both endpoints accept an API key (`x-token`) or a delegate token with the `files:*` scope. * **Enhancement:** `POST /api/email/create-draft` and `POST /api/email/send` now accept `attachmentIds` to include uploaded files as attachments, and auto-detect inline images embedded in the HTML `body` via their file download URL (tagged and registered automatically — don't also list them in `attachmentIds`). Upload each file first with the direct upload flow (`POST /api/files/upload-url` → S3 → `POST /api/files/upload-complete`), then pass the returned `fileId`s. ## June 15, 2026 * **New Endpoint:** `GET /api/routing-groups` — List all active routing groups for the authenticated company. Accepts an API key (`x-token`) or a delegate token with `routing-groups` read scope. * **New Endpoint:** `POST /api/routing-groups/create` — Create a routing group with a name, ring type (`simultaneous` or `sequential`), destination configuration, and optional caller experience and overflow settings. Returns the new group's `_id`. * **New Endpoint:** `PUT /api/routing-groups/{id}` — Update an existing routing group. All fields are optional — only provided fields are changed. Pass `null` to clear a field. * **New Endpoint:** `POST /api/routing-groups/{id}/archive` — Archive (soft-delete) a routing group. Archived groups are excluded from list results and cannot be updated. ## June 9, 2026 * **New Endpoint:** `POST /api/tasks/search` — Query tasks for your company with optional filters (assignee, completion/pinned state, priority, board/card, stream/channel, and `dueAt`/`checkedAt`/`updatedAt` date ranges). Returns the matching tasks, or a count when `useCount` is set. * **New Endpoint:** `POST /api/tasks` — Create a task. When `aptletInstanceId` is provided, the task is also mirrored as a checklist entry on that card. * **New Endpoint:** `GET /api/tasks/{taskId}` — Fetch a single task with related card/board context and resolved attachments; pass `includeMetadata=true` for display labels and the resolved assignee. * **New Endpoint:** `PUT /api/tasks/{taskId}` — Update a task and keep its card-checklist mirror entry in sync. All task endpoints accept an API key (`x-token`) or a delegate token with the `tasks:*` scope. ## June 3, 2026 * **New:** Embedded iframe apps can now trigger Aptly UI interactions via a postMessage action system or the Aptly SDK. Nine actions are available: open a card pane, open a card in fullscreen, start the dialer, open the email/SMS composer, create a calendar event, create a card, create a task, create a contact, and navigate to a contact. Actions must be explicitly enabled per embed by an admin. See [Embed App Actions](/embed-actions). * **New SDK methods:** `aptly.openCardPane()`, `aptly.openCardView()`, `aptly.startDialer()`, `aptly.openEmailComposer()`, `aptly.createEvent()`, `aptly.createCard()`, `aptly.createTask()`, `aptly.createContact()`, `aptly.navigateToContact()`, and `aptly.action()` — each returns `Promise<{ success, error? }>` and times out after 3 seconds. * **New SDK property:** `aptly.actions` — array of action IDs the current embed has been granted, for runtime capability checks. * **Enhancement:** `POST /api/email/create-draft` and `POST /api/email/send` now accept an optional `aptletInstanceId` in the request body. When set, the outbound is linked to that card — the email is logged as an activity on the card and the discussion is tagged with it. Works whether the draft is pre-created or created on-the-fly. ## May 26, 2026 * **New Endpoint:** `GET /api/app/me` — Returns identity information for the credential used in the request. Delegate tokens return user identity (and embedded-app context when an `appClientId` is present); API keys return company identity; partner tokens return their permission list. * **New Endpoint:** `GET /api/board/{boardId}/configuration` — Returns all board configuration sections in one call: fields, automations, options, tabViews, workflows, groups, shares, theme, and filters. Accepts an API key or a partner token with `board-admin` permission (delegate tokens are not supported for board configuration endpoints). * **New Endpoint:** `GET /api/board/{boardId}/configuration/automations` — List the automations configured on a board. * **New Endpoint:** `GET /api/board/{boardId}/configuration/options` — Get the current board-level option flags. * **New Endpoint:** `GET /api/board/{boardId}/configuration/fields` — List all fields defined on a board, including archived ones. * **New Endpoint:** `GET /api/board/{boardId}/configuration/workflows` — List the workflows (sequences) configured on a board. * **New Endpoint:** `GET /api/board/{boardId}/configuration/groups` — List the field groups (sections) configured on a board. * **New Endpoint:** `GET /api/board/{boardId}/configuration/shares` — Get a board's access type (`public`/`private`) and its ACL entries. * **New Endpoint:** `GET /api/board/{boardId}/configuration/theme` — Get a board's display name, card name, color, icon, description, and short code. * **New Endpoint:** `GET /api/board/{boardId}/configuration/filters` — List saved filters for a board. Company-scoped filters are always included; private (user-scoped) filters are included when authenticating with a delegate token that carries a `userId`. * **New Endpoint:** `GET /api/templates` — List communication templates for your company, with optional filters for `templateType`, `aptletUuid`, and `archived`. Accepts an API key, a delegate token with `templates` scope, or a partner token with `templates` permission (pass `companyId` as a query param with partner auth). * **New Endpoint:** `GET /api/templates/{id}` — Get a single communication template by ID. * **Enhancement:** `POST /api/board/{boardId}/tabView` is now also available at `POST /api/board/{boardId}/configuration/tabViews`. The original path remains active as a legacy alias. * **Enhancement:** `GET /api/boards` now accepts delegate tokens with `boards` read scope in addition to API keys. The `endpoints` array in each board entry now includes all board configuration endpoints. ## May 25, 2026 * **New Endpoint:** `GET /api/inboxes/{channelId}/drafts` — List the unsent drafts on a given email inbox. Each item is the raw draft entry with the parent `streamId` attached; drafts currently being sent are excluded. Accepts the same dual-auth as the rest of the email endpoints (API key, delegate token with `email:*` read scope, or partner bearer token with `inboxes`/`internal-admin`). ## May 21, 2026 * **New Endpoint:** `POST /api/email/create-draft` — Create a new outbound email draft discussion from recipient and content fields (`to`, `cc`, `bcc`, `subject`, `body`, `channelId`). Returns `streamId` and `draftUuid` for use with `POST /api/email/send`. * **New Endpoint:** `POST /api/email/send` — Send an outbound email, either from an existing draft (`discussionId` + `uuid`) or on-the-fly from bare email fields. Auto-assignment is always applied when the draft is finalized. * **New Endpoint:** `GET /api/users/{userId}/inboxes` — List the email inboxes (Hermes/Nylas channels) accessible to a user, including both personal channels and team-shared channels. Each inbox is tagged `kind: "personal"` or `kind: "shared"`. * **Enhancement:** Delegate tokens now support an `email:*` scope. When granted (read or write), the token can call `POST /api/email/create-draft`, `POST /api/email/send`, and `GET /api/users/{userId}/inboxes` on behalf of the user. ## May 4, 2026 * **Fix:** Card objects in board responses now consistently include both `_id` and `cardId` fields. ## April 24, 2026 — Delegate Token Authentication * **New Endpoint:** `POST /api/app/verify` — Verify a short-lived delegate token and retrieve user identity without an API key. For use by embedded apps and plugins that receive a token from the Aptly platform. * **New Endpoint:** `POST /api/board/verify-user` — Verify a delegate token scoped to board access and retrieve the associated user context. * **New Endpoint:** `GET /api/board/{boardId}/{cardId}/comments` — List all comments on a card in chronological order. * **New Endpoint:** `GET /api/board/{boardId}/{cardId}/contacts` — List person contacts linked to a card via its person fields. * **New Endpoint:** `POST /api/board/{boardId}/{cardId}/comment` — Add or update a comment on a card. * **New Endpoint:** `POST /api/board/{boardId}/{cardId}/file` — Upload a file attachment to a card (multipart/form-data, max 50 MB). * **New Endpoint:** `POST /api/board/{boardId}/tabView` — Embed a tab view on a board. * **Enhancement:** Board, Contacts, and Knowledge endpoints now accept delegate token authentication (`Authorization: DelegateToken `) in addition to API keys. Tokens carry explicit read and write scopes per resource. * **Enhancement:** `GET /api/board/{boardId}` now supports an `assignee` query parameter to filter cards by assigned user. * **Enhancement:** Cards created or updated via a delegate token now record the authenticated user as `createdBy` / `updatedBy` rather than attributing changes to "aptly". ## April 20, 2026 * **New Endpoint:** `GET /api/company/info` — Retrieve your company's profile including name, address, contact details, and logo URL. ## April 16, 2026 * **New Endpoint:** `GET /api/users` — List all non-archived users in your company. ## April 14, 2026 * **New Endpoint:** `GET /api/boards` — Discover all API-enabled boards for your account, including their UUIDs and ready-to-use endpoint URLs for card operations. ## April 7, 2026 * **Enhancement:** `GET /api/contacts` now supports `updated_after` and `updated_before` query parameters to filter contacts by last-updated timestamp. # Contact Verification & Lightweight SSO Source: https://docs.getaptly.com/contact-verification-sso Use the email verification flow to confirm a contact's identity, then load their data — no passwords required. ## Before you build — choose your auth context Aptly supports several distinct auth patterns depending on who is using the app you're building. Pick the one that matches your use case before writing any code. Getting this wrong is the most common source of integration problems. ### Context 1 — Customer portal (contact verification SSO) ✅ Recommended starting point **Who this is for:** You're an Aptly customer building an app for *your* contacts — a client portal, a booking page, a status tracker. Your end users are people stored as contacts in your Aptly account. **How auth works:** 1. Aptly handles the initial identity check (email + 6-digit code) 2. Your app creates and manages its own session after verification succeeds 3. All Aptly API calls happen server-side, invisibly to the end user **Architecture requirement — server component is mandatory.** Your Aptly API key grants access to your entire organization's data. If it appears anywhere in client-side HTML, JavaScript, or network requests visible in the browser, any user can extract it and query your full contact database. You must build a server-side component (Node.js, Python, a serverless function, etc.) that holds the API key and proxies all Aptly calls. A front-end-only app using an API key puts your entire organization at risk. ``` Browser ──POST /auth/start──► Your Server ──POST /api/contacts/verify-email──► Aptly API (API key stays here, never sent to browser) Browser ◄── { requestId } ──────────────────────────────────────────────────────────────── [user enters code] Browser ──POST /auth/confirm──► Your Server ──POST /api/contacts/verify-email/:id/confirm──► Aptly ◄── { verified: true, contacts: [...] } ────────── issues signed session cookie Browser ◄── Set-Cookie: session= ────────────────────────────────────────────────────────── [subsequent requests] Browser ──GET /portal/cards──► Your Server (validates cookie, extracts contactId) ──► Aptly API ``` This document covers this context end-to-end. *** ### Context 2 — Aptly user session (plugin or embedded app) **Who this is for:** You're building a plugin, iframe embed, or extension that runs *inside* the Aptly UI for logged-in Aptly users. **How auth works:** Aptly passes a signed user session token to your app at load time (via `postMessage`, a URL parameter on redirect, or another handoff mechanism). Your app uses that token to call Aptly APIs on behalf of the logged-in user — no separate login flow required. **Architecture:** Because the token is scoped to the session and carries no persistent API key, these apps can be front-end-only. There is no long-lived credential to protect. See the [Embedded App Authentication](delegate-auth) guide for the full implementation. *** ### Context 3 — Public / unauthenticated app **Who this is for:** You're building a public-facing surface — a company website, a listings page, a booking widget — where the visitor is anonymous and no login is required. **How auth works:** Either no auth at all (public endpoints only), or an Aptly API key for read-only public data. **Architecture:** If the app uses an API key, you still need a server component for the same reasons as Context 1. If the app uses only fully public endpoints, it can be front-end-only. *** ## Contact verification SSO — how it works This is the full implementation guide for **Context 1**. The flow has two steps: 1. Your app sends an email address to Aptly → Aptly looks up the contact and sends a 6-digit code 2. Your app sends the code back → Aptly confirms it and returns the contact record Your server then issues a signed session cookie. All subsequent requests use that cookie to identify the contact — Aptly is not involved again until the session expires and the user needs to re-verify. Codes expire after **10 minutes**. After **5 failed attempts**, the verification is permanently invalidated and the user must restart from step 1. *** ## Step 1 — Initiate verification Call this from your server, not the browser. Your API key must not appear in client-side code. ```bash theme={null} curl -X POST https://core-api.getaptly.com/api/contacts/verify-email \ -H "x-token: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "jane@example.com" }' ``` **Response:** ```json theme={null} { "requestId": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "verifyUrl": "/api/contacts/verify-email/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/confirm" } ``` Return the `requestId` to the browser. It is not a secret — the code sent to the user's email is the secret. Store `requestId` in your UI state (a hidden field, React state, etc.) so you can submit it in step 2. ### Custom email content ```json theme={null} { "email": "jane@example.com", "emailSubject": "Your login code for Acme Portal", "replyTo": "support@acme.com", "emailHtml": "

Hi! Your code is: {{ verificationCode }}

It expires in {{ expirationTime }}.

" } ``` `{{ verificationCode }}` and `{{ expirationTime }}` are replaced automatically. Omit `emailHtml` to use the Aptly default. ### Error cases | Status | Meaning | | ------ | ------------------------------------------------------- | | `400` | `email` field is missing or invalid | | `401` | API key is missing or invalid | | `404` | No contact found with that email address | | `500` | Contact found but the verification email failed to send | A `404` means the email isn't in your Aptly contact database. Show a "not found" message or a registration prompt — do not fall back to creating a session anyway. *** ## Step 2 — Confirm the code The browser submits the code + `requestId` to your server. Your server calls Aptly and creates the session. ```bash theme={null} curl -X POST https://core-api.getaptly.com/api/contacts/verify-email/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/confirm \ -H "x-token: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "code": "042815" }' ``` **On success:** ```json theme={null} { "verified": true, "contacts": [ { "_id": "64a1f2b3c4d5e6f7a8b9c0d1", "firstname": "Jane", "lastname": "Doe", "fullName": "Jane Doe", "email": "jane@example.com", "phone": "555-867-5309", "isCompany": false, "company": null } ] } ``` ### Error cases | Status | Meaning | | ------ | -------------------------------------------------------------- | | `400` | `code` field is missing | | `401` | Code is wrong, expired, already used, or `requestId` not found | After 5 wrong codes the verification is permanently invalidated. *** ## Step 3 — Issue a session cookie **Do not trust a client-supplied `contactId` on subsequent requests.** MongoDB ObjectIds are not secrets — they are time-ordered and partially predictable. If you store `_id` raw in `localStorage` or an unsigned cookie, any user can swap it for another contact's ID. Always lock the `contactId` inside a signed token that your server verifies on every request. After the confirm step succeeds, mint a JWT, set it as an `HttpOnly` cookie, and return only safe display fields to the browser. ```javascript theme={null} // server-side (Node.js + jsonwebtoken) import jwt from "jsonwebtoken"; const SESSION_SECRET = process.env.SESSION_SECRET; // long random string, kept server-side only const SESSION_TTL = "4h"; // adjust to your app's needs function issueSessionToken(contact) { return jwt.sign( { sub: contact._id, // contactId — locked inside the signature email: contact.email, name: contact.fullName, }, SESSION_SECRET, { expiresIn: SESSION_TTL, algorithm: "HS256" }, ); } function verifySessionToken(token) { // throws if expired, tampered, or signed with the wrong secret return jwt.verify(token, SESSION_SECRET, { algorithms: ["HS256"] }); } ``` ```javascript theme={null} // POST /auth/start — browser hits this; your server calls Aptly app.post("/auth/start", async (req, res) => { const { email } = req.body; const aptlyRes = await fetch(`${APTLY_BASE}/api/contacts/verify-email`, { method: "POST", headers: { "x-token": APTLY_API_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ email }), }); if (aptlyRes.status === 404) { return res.status(404).json({ error: "No account found for that email." }); } if (!aptlyRes.ok) { return res .status(500) .json({ error: "Failed to send verification email." }); } const { requestId } = await aptlyRes.json(); res.json({ requestId }); // safe to return — not a secret }); // POST /auth/confirm — browser submits code + requestId; your server confirms and issues cookie app.post("/auth/confirm", async (req, res) => { const { requestId, code } = req.body; const aptlyRes = await fetch( `${APTLY_BASE}/api/contacts/verify-email/${requestId}/confirm`, { method: "POST", headers: { "x-token": APTLY_API_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ code }), }, ); if (!aptlyRes.ok) { return res.status(401).json({ error: "Invalid or expired code." }); } const { contacts } = await aptlyRes.json(); const contact = contacts[0]; const sessionToken = issueSessionToken(contact); // HttpOnly prevents client JS from reading or modifying the cookie res.cookie("session", sessionToken, { httpOnly: true, secure: true, // requires HTTPS in production sameSite: "lax", maxAge: 4 * 60 * 60 * 1000, // 4 hours in ms — match SESSION_TTL }); // Return only display-safe fields — never the raw contactId res.json({ name: contact.fullName, email: contact.email }); }); ``` ### Validate the session on every protected request ```javascript theme={null} // Middleware — runs before any route that needs an authenticated contact function requireContact(req, res, next) { const token = req.cookies.session; if (!token) return res.status(401).json({ error: "Not authenticated." }); try { const payload = verifySessionToken(token); // throws if expired or tampered req.contactId = payload.sub; // extracted from signed token — never from req.body or req.query next(); } catch { res.status(401).json({ error: "Session expired or invalid." }); } } // Protected route — contactId comes from the verified token, not the client app.get("/portal/cards", requireContact, async (req, res) => { const { contactId } = req; // safe — cryptographically bound to the verified contact const aptlyRes = await fetch( `${APTLY_BASE}/api/board/${BOARD_ID}?page=0&relatedId=${contactId}`, { headers: { "x-token": APTLY_API_KEY } }, ); res.json(await aptlyRes.json()); }); ``` **Never accept `contactId` as a query param or body field from the browser on protected endpoints.** The only valid source is the value extracted from the verified session cookie. ### Session persistence on revisit On page load, your browser-side code should call a lightweight `/auth/me` endpoint to check whether a valid session cookie already exists. If it does, the user is still logged in and does not need to re-verify. ```javascript theme={null} // GET /auth/me — check for an active session app.get("/auth/me", requireContact, async (req, res) => { // requireContact already validated the cookie — if we're here, the session is valid // You can re-fetch the contact from Aptly if you need fresh data, // or return what's already in the token for a fast response const token = req.cookies.session; const payload = verifySessionToken(token); res.json({ name: payload.name, email: payload.email, loggedIn: true }); }); // GET /auth/logout — clear the session app.post("/auth/logout", (req, res) => { res.clearCookie("session"); res.json({ loggedIn: false }); }); ``` On the client: ```javascript theme={null} // On app load — check for existing session before showing the login form async function checkSession() { const res = await fetch("/auth/me"); if (res.ok) { const { name, email } = await res.json(); showPortal(name, email); // user is still logged in } else { showLoginForm(); // session expired or never existed — prompt for email } } ``` *** ## Contact object reference The contact returned on successful verification: | Field | Description | | ----------- | ------------------------------------------------------------- | | `_id` | MongoDB document ID — use as your primary identifier | | `fullName` | Display name (first + last, or company name) | | `email` | Verified email address | | `phone` | Contact's phone number | | `title` | Job title | | `isCompany` | `true` if this is an organization record rather than a person | ### Display a contact card ```javascript theme={null} function renderContactCard(contact) { return `
${ contact.imageUrl ? `${contact.fullName}` : `
${contact.duogram}
` }

${contact.fullName}

${contact.title ? `

${contact.title}

` : ""}

${contact.email}

${contact.phone ? `

${contact.phone}

` : ""}
`; } ``` *** ## Loading related board cards for a verified contact Once you have the contact's `_id`, pass it as `relatedId` on the board endpoint to fetch all cards linked to that contact: ```bash theme={null} curl "https://core-api.getaptly.com/api/board/{boardId}?page=0&relatedId=64a1f2b3c4d5e6f7a8b9c0d1" \ -H "x-token: YOUR_API_KEY" ``` Cards are linked to a contact when the contact's `_id` appears in the card's `references` field. ```javascript theme={null} // Call this from your server — contactId comes from the verified session, not the browser async function getContactCards(boardId, contactId) { const url = new URL(`https://core-api.getaptly.com/api/board/${boardId}`); url.searchParams.set("page", "0"); url.searchParams.set("relatedId", contactId); const res = await fetch(url, { headers: { "x-token": APTLY_API_KEY }, }); if (!res.ok) throw new Error("Failed to load board cards."); return res.json(); // { cards: [...], total: n, page: 0 } } ``` *** ## Full end-to-end example A minimal but complete Node.js + Express backend that handles all three auth endpoints, plus a corresponding browser-side flow: ```javascript theme={null} // server.js — Node.js + Express import express from "express"; import cookieParser from "cookie-parser"; import jwt from "jsonwebtoken"; const app = express(); app.use(express.json()); app.use(cookieParser()); const APTLY_BASE = "https://core-api.getaptly.com"; const APTLY_API_KEY = process.env.APTLY_API_KEY; // never expose this to the browser const APTLY_BOARD_ID = process.env.APTLY_BOARD_ID; const SESSION_SECRET = process.env.SESSION_SECRET; // long random string, server-only const SESSION_TTL = "4h"; function issueSessionToken(contact) { return jwt.sign( { sub: contact._id, email: contact.email, name: contact.fullName }, SESSION_SECRET, { expiresIn: SESSION_TTL, algorithm: "HS256" }, ); } function verifySessionToken(token) { return jwt.verify(token, SESSION_SECRET, { algorithms: ["HS256"] }); } function requireContact(req, res, next) { const token = req.cookies.session; if (!token) return res.status(401).json({ error: "Not authenticated." }); try { req.contactId = verifySessionToken(token).sub; next(); } catch { res.status(401).json({ error: "Session expired or invalid." }); } } // Step 1 — initiate verification app.post("/auth/start", async (req, res) => { const { email } = req.body; const aptlyRes = await fetch(`${APTLY_BASE}/api/contacts/verify-email`, { method: "POST", headers: { "x-token": APTLY_API_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ email }), }); if (aptlyRes.status === 404) return res.status(404).json({ error: "No account found." }); if (!aptlyRes.ok) return res.status(500).json({ error: "Could not send code." }); const { requestId } = await aptlyRes.json(); res.json({ requestId }); }); // Step 2 — confirm code and issue session cookie app.post("/auth/confirm", async (req, res) => { const { requestId, code } = req.body; const aptlyRes = await fetch( `${APTLY_BASE}/api/contacts/verify-email/${requestId}/confirm`, { method: "POST", headers: { "x-token": APTLY_API_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ code }), }, ); if (!aptlyRes.ok) return res.status(401).json({ error: "Invalid or expired code." }); const { contacts } = await aptlyRes.json(); const contact = contacts[0]; res.cookie("session", issueSessionToken(contact), { httpOnly: true, secure: true, sameSite: "lax", maxAge: 4 * 60 * 60 * 1000, }); res.json({ name: contact.fullName, email: contact.email }); }); // Check existing session (called on page load) app.get("/auth/me", requireContact, (req, res) => { const payload = verifySessionToken(req.cookies.session); res.json({ name: payload.name, email: payload.email, loggedIn: true }); }); // Logout app.post("/auth/logout", (req, res) => { res.clearCookie("session"); res.json({ loggedIn: false }); }); // Protected route — load cards for the verified contact app.get("/portal/cards", requireContact, async (req, res) => { const aptlyRes = await fetch( `${APTLY_BASE}/api/board/${APTLY_BOARD_ID}?page=0&relatedId=${req.contactId}`, { headers: { "x-token": APTLY_API_KEY } }, ); if (!aptlyRes.ok) return res.status(502).json({ error: "Failed to load cards." }); res.json(await aptlyRes.json()); }); app.listen(3000); ``` ```javascript theme={null} // client.js — runs in the browser, no API key here async function init() { const res = await fetch("/auth/me"); if (res.ok) { const { name } = await res.json(); showPortal(name); // already logged in } else { showLoginForm(); } } async function startVerification(email) { const res = await fetch("/auth/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }); if (res.status === 404) throw new Error("No account found for that email."); if (!res.ok) throw new Error("Could not send verification code."); const { requestId } = await res.json(); return requestId; // store in UI state for the next step } async function confirmVerification(requestId, code) { const res = await fetch("/auth/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requestId, code }), }); if (!res.ok) throw new Error("Invalid or expired code."); return res.json(); // { name, email } — safe display data only } ``` # Delegate Tokens Source: https://docs.getaptly.com/delegate-tokens Issue short-lived JWTs so an embedded app, server-side script, or AI agent can call the Aptly API on behalf of a logged-in user — without exposing a raw API key. Delegate tokens are short-lived JWTs (default 5-minute expiry) that represent a specific logged-in Aptly user. An embedded app or agent receives the token and uses it directly for API calls. Tokens are scoped — they only grant access to the resources you declare at issue time. If you're building a Replit app or iframe widget, the [Aptly SDK](/aptly-sdk-reference) handles all of this automatically. Come here if you need the raw token exchange API for a server-side integration, a custom auth flow, or an AI agent. *** ## The flow ``` Your server Aptly API ────────────────────────────────────────────────────────────── 1. User is logged in to Aptly. Their session JWT is available server-side. POST /api/platform/user-token ───────────────────────► access_token: body: { readScopes, writeScopes } ◄─────────────────────── { token, expiration } 2. Deliver token to embedded app / agent (postMessage, response body, env var, etc). 3. Embedded app / agent uses the token: A. Verify identity (no API key needed): POST /api/app/verify ─────────────────────────────► body: { token } ◄──────────────────────────── { userId, email, firstName, lastName, companyId, appClientId, appTitle } B. Call the API directly: GET /api/board/:boardId ───────────────────────────► Authorization: DelegateToken ◄───────────────────── board data ``` *** ## Issue a delegate token **`POST /api/platform/user-token`** Call this from your server. Requires the logged-in user's session JWT in the `access_token` header. **Option A — explicit scopes** (no marketplace app registration needed): ```bash theme={null} curl -X POST https://api.getaptly.com/api/platform/user-token \ -H "access_token: " \ -H "Content-Type: application/json" \ -d '{ "readScopes": ["boards:*", "contacts:*"], "writeScopes": ["boards:*"] }' ``` **Option B — registered marketplace app** (scopes come from the marketplace item): ```bash theme={null} curl -X POST https://api.getaptly.com/api/platform/user-token \ -H "access_token: " \ -H "Content-Type: application/json" \ -d '{ "appClientId": "your-app-client-id" }' ``` **Optional: custom expiry** Both options accept `expirationSeconds` to override the default 5-minute TTL. Maximum is 7 days (604800). ```json theme={null} { "readScopes": ["boards:*"], "expirationSeconds": 86400 } ``` **Response:** ```json theme={null} { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expiration": "2025-01-01T12:05:00.000Z" } ``` | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | Token issued | | 401 | `access_token` header missing, expired, or invalid | | 401 | User not found or not associated with a company | | 400 | Invalid scope format | | 401 | `appClientId` not found for this company | *** ## Verify the token Use this to confirm who the token belongs to before trusting an incoming request. ### Option A — keyless verify **`POST /api/app/verify`** No API key needed. Works for two token types: * **Marketplace tokens** — issued with `appClientId`. Returns user identity plus `appClientId` and `appTitle`. * **Scoped dev tokens** — issued by a GA admin via the Dev Tokens panel, without an `appClientId`. Returns user identity with `appClientId: null`. Revoked tokens return `401`. ```bash theme={null} curl -X POST https://api.getaptly.com/api/app/verify \ -H "Content-Type: application/json" \ -d '{ "token": "" }' ``` Response: ```json theme={null} { "userId": "abc123", "email": "user@example.com", "firstName": "Jane", "lastName": "Smith", "companyId": "company456", "appClientId": "your-app-client-id", "appTitle": "My Plugin" } ``` ### Option B — verify with board API key **`POST /api/board/verify-user`** Works for any delegate token. Requires a board API key from the same company. ```bash theme={null} curl -X POST https://api.getaptly.com/api/board/verify-user \ -H "x-token: " \ -H "Content-Type: application/json" \ -d '{ "token": "" }' ``` Response: ```json theme={null} { "userId": "abc123", "email": "user@example.com", "firstName": "Jane", "lastName": "Smith", "companyId": "company456" } ``` | Status | Meaning | | ------ | ---------------------------------------------------------------------------- | | 200 | Token valid, identity returned | | 400 | `token` field missing from body | | 401 | Token expired, signature invalid, revoked, or issued for a different company | *** ## Call the API with a delegate token Pass the token as `Authorization: DelegateToken ` on any supported route. The token's scopes must cover the resource. ```bash theme={null} # Read a board curl https://api.getaptly.com/api/board/ \ -H "Authorization: DelegateToken " # Create a card curl -X POST https://api.getaptly.com/api/board//card \ -H "Authorization: DelegateToken " \ -H "Content-Type: application/json" \ -d '{ "name": "New card" }' # Look up a contact curl -X POST https://api.getaptly.com/api/contacts/by-email \ -H "Authorization: DelegateToken " \ -H "Content-Type: application/json" \ -d '{ "email": "contact@example.com" }' ``` **Always use `DelegateToken` as the Authorization scheme — not `Bearer`.** *** ## Scopes Scopes follow the format `resource:qualifier`. | Scope | Grants access to | | ------------------ | --------------------------------------- | | `boards:*` | All board routes for the user's company | | `boards:` | A single specific board | | `contacts:*` | Contact lookup and verification routes | | `knowledge:*` | Knowledge base routes | | `email:*` | Email draft and send routes | **`readScopes`** — GET requests and read operations. **`writeScopes`** — POST/PUT/DELETE requests and write operations. A missing scope returns `403 Forbidden`. No scopes are granted by default — omitted scopes are denied. ### Marketplace vs explicit scopes | Method | How scopes are set | Good for | | --------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | Explicit (`readScopes` / `writeScopes`) | Embedded in the JWT at issue time | One-off scripts, agentic workflows | | Marketplace (`appClientId`) | Stored on the registered marketplace item, looked up at verify time | Registered plugins where you want to update granted scopes without redeploying | *** ## Server-side example ```javascript theme={null} // server.js import express from "express"; const app = express(); app.use(express.json()); const APTLY_API = "https://api.getaptly.com"; app.post("/delegate-token", async (req, res) => { const { accessToken } = req.body; if (!accessToken) return res.status(400).json({ error: "accessToken required" }); const response = await fetch(`${APTLY_API}/api/platform/user-token`, { method: "POST", headers: { "access_token": accessToken, "Content-Type": "application/json" }, body: JSON.stringify({ readScopes: ["boards:*", "contacts:*"], writeScopes: ["boards:*"] }), }); if (!response.ok) return res.status(401).json({ error: "Could not issue delegate token" }); const { token, expiration } = await response.json(); res.json({ delegateToken: token, expiration }); }); app.listen(3000); ``` ```javascript theme={null} // client.js const APTLY_API = "https://api.getaptly.com"; const { delegateToken } = await fetch("/delegate-token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accessToken: userSessionJwt }), }).then(r => r.json()); const board = await fetch(`${APTLY_API}/api/board/${boardId}`, { headers: { Authorization: `DelegateToken ${delegateToken}` }, }).then(r => r.json()); ``` *** ## Iframe embed pattern (without the SDK) If you can't use the Aptly SDK script tag, you can implement the postMessage handshake manually. The parent Aptly window responds to `{ type: 'aptly-token-request' }` with a token and context object. ```javascript theme={null} const APTLY_API = "https://api.getaptly.com"; let aptlyToken = null; function requestToken() { return new Promise((resolve) => { function handler(e) { if (e.data?.type === "aptly-delegate-token") { window.removeEventListener("message", handler); aptlyToken = e.data.token; resolve(aptlyToken); } } window.addEventListener("message", handler); window.parent.postMessage({ type: "aptly-token-request" }, "*"); }); } requestToken().then(init); async function apiFetch(path, options = {}, retry = true) { const res = await fetch(`${APTLY_API}${path}`, { ...options, headers: { Authorization: `DelegateToken ${aptlyToken}`, ...options.headers }, }); if (res.status === 401 && retry) { await requestToken(); return apiFetch(path, options, false); } if (!res.ok) throw new Error(`API ${res.status}`); return res.json(); } ``` **Register the listener before calling `postMessage`** — the parent may respond synchronously. The listener removes itself after the first matching message to avoid leaks. **Retry once, not in a loop** — a second `401` after a fresh token means the session is actually expired or the scope is wrong. One retry distinguishes stale tokens from real auth failures. *** ## For AI agents When Aptly embeds a generated app or AI agent into an iframe context, the system prompt will include a block declaring what scopes are available: ``` A delegate token is available in this embedding context. Granted scopes: - `contacts:*` read - `knowledge:*` read - `boards:*` read ``` If you see this block, use `requestToken()` from the iframe embed pattern above to receive the token, then use `apiFetch` for all API calls. The scope list is authoritative — calling a route outside those scopes returns `403 Forbidden`. Check whether a scope appears under read or write access before attempting mutations. *** ## Error reference | Status | Code | Meaning | | ------ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | | 400 | — | Required field missing from request body | | 400 | `INVALID_DATA` | Scope has invalid format or unknown namespace | | 401 | `INVALID_ACCESS_TOKEN` | Token expired, signature mismatch, or wrong company | | 401 | `UNAUTHORIZED` | User or app not found, company mismatch, or token revoked | | 403 | `FORBIDDEN` | Token valid but lacks required scope for this resource | | 429 | `RATE_LIMITED` | Too many token requests from this connection. Response includes `retryAfterMs` (milliseconds until the bucket resets). | When you use the Aptly SDK with `{ version: 2 }`, rate limit errors are returned in the result object — `requestToken({ version: 2 })` resolves `{ token: null, error: { code: 'RATE_LIMITED', retryAfterMs } }`. See [Rate Limits](/rate-limits) for the retry pattern. # Embed App Actions Source: https://docs.getaptly.com/embed-actions Trigger Aptly UI interactions (open a card, start a call, compose an email, and more) from an embedded iframe app using the SDK or raw postMessage. Embedded apps running inside Aptly can trigger UI interactions in the parent window — opening a card, starting a phone call, launching the email composer, creating an event, and more — by posting a browser message. Aptly executes the action if your app has been granted that permission. This works alongside [delegate token authentication](/delegate-tokens). You can use one, the other, or both. *** ## Using the Aptly SDK (recommended) If your app includes the [Aptly SDK](/aptly-sdk-reference), use the named wrapper methods. They return a `Promise<{ success: boolean, error?: string }>`, handle the response listener for you, and time out after 3 seconds. ```js theme={null} await aptly.openCardPane({ cardId: "abc123" }); await aptly.openCardView({ cardId: "abc123" }); await aptly.startDialer({ number: "+15551234567", name: "Jane Smith" }); await aptly.openEmailComposer({ subject: "Hello", content: "

Hi

", composeMode: "email" }); await aptly.createEvent({ date: "2026-06-15T10:00:00.000Z", comments: "Walkthrough" }); await aptly.createCard({ boardId: "board-uuid", fields: { name: "New Lead" } }); await aptly.createTask({ title: "Follow up", dueAt: "2026-06-15T09:00:00.000Z", priority: "high" }); await aptly.createContact({ firstname: "Jane", lastname: "Smith", email: "jane@example.com" }); await aptly.navigateToContact({ contactId: "person123" }); // Generic escape hatch — useful when the action ID is dynamic await aptly.action("start-dialer", { number: "+15551234567" }); // Check which actions are available at runtime before calling console.log(aptly.actions); // e.g. ['start-dialer', 'create-card'] ``` *** ## Raw postMessage If you are not using the SDK, post a message directly to the parent window. Aptly validates the permission and sends a response. ```js theme={null} const APTLY_ORIGIN = "https://app.getaptly.com"; // use https://preview.getaptly.com for preview window.parent.postMessage( { type: "aptly-action", action: "start-dialer", payload: { number: "+15551234567", name: "Jane Smith" } }, APTLY_ORIGIN ); ``` Listen for the response: ```js theme={null} const APTLY_ORIGINS = [ "https://app.getaptly.com", "https://preview.getaptly.com" ]; window.addEventListener("message", (event) => { if (!APTLY_ORIGINS.includes(event.origin) || event.source !== window.parent) return; if (event.data?.type === "aptly-action-result") { const { action, success, error } = event.data; if (!success) console.warn(`Action ${action} failed: ${error}`); } }); ``` Aptly responds with `{ type: 'aptly-action-result', action, success: true }` or `{ ..., success: false, error: 'Not permitted' }`. *** ## Enabling actions for your app Actions must be explicitly granted — nothing runs by default. An admin configures this in the settings for the specific embed. **Marketplace app:** In Global Admin → App Marketplace, open your app's edit modal → Permissions tab → Embed Actions. When a user installs the app on a board, these permissions are copied to that board tab and can be adjusted in board settings. **Custom board tab:** In Board Settings → your tab → edit → Allowed Actions. **Dashboard widget:** In Dashboard → add or edit app → Embed Actions. *** ## Available actions ### `open-card-pane` Navigates to a specific card in the board's side panel. ```js theme={null} await aptly.openCardPane({ cardId: "abc123" }); ``` | Field | Type | Required | Description | | -------- | ------ | -------- | ------------------------------ | | `cardId` | string | Yes | The `_id` of the card to open. | *** ### `open-card-view` Opens a card in a fullscreen detail view (floating modal). ```js theme={null} await aptly.openCardView({ cardId: "abc123" }); ``` | Field | Type | Required | Description | | -------- | ------ | -------- | ------------------------------ | | `cardId` | string | Yes | The `_id` of the card to open. | *** ### `start-dialer` Opens the Aptly phone dialer with a number pre-filled. ```js theme={null} await aptly.startDialer({ number: "+15551234567", name: "Jane Smith" }); ``` | Field | Type | Required | Description | | -------- | ------ | -------- | ---------------------------------------------- | | `number` | string | Yes | Phone number in E.164 format (`+15551234567`). | | `name` | string | No | Contact name to display in the dialer. | *** ### `open-email-composer` Opens a new email compose window, optionally pre-filled. ```js theme={null} await aptly.openEmailComposer({ to: [{ value: "jane@example.com", label: "Jane Smith" }], subject: "Follow-up on your application", content: "

Hi there,

Just following up...

", composeMode: "email" }); ``` | Field | Type | Required | Description | | ------------- | ------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `to` | string \| string\[] \| `{ value, label }[]` | No | Pre-filled recipients — a single address, an array of addresses, or an array of `{ value, label }` objects. Email addresses when `composeMode` is `'email'`, phone numbers when it is `'sms'`. | | `subject` | string | No | Pre-filled subject line. | | `content` | string | No | Pre-filled body content (HTML supported). | | `composeMode` | `'email'` \| `'sms'` | No | Defaults to `'email'`. | *** ### `create-event` Opens the calendar event editor, optionally pre-filled. ```js theme={null} await aptly.createEvent({ date: "2026-06-15T10:00:00.000Z", comments: "Property walkthrough", recipients: [{ email: "tenant@example.com", name: "Alex Tenant" }] }); ``` | Field | Type | Required | Description | | ------------ | --------------- | -------- | ------------------------------------------------------------------------------- | | `date` | ISO 8601 string | No | Pre-filled event start date/time. | | `comments` | string | No | Pre-filled event description. | | `recipients` | array | No | Pre-filled attendees. Each object should include `email` and optionally `name`. | *** ### `create-card` Opens the new card form on a specific board, optionally pre-filled. ```js theme={null} await aptly.createCard({ boardId: "board-uuid-here", fields: { name: "New Applicant", email: "applicant@example.com" } }); ``` | Field | Type | Required | Description | | --------- | ------ | -------- | -------------------------------------------------------------------------------------------- | | `boardId` | string | Yes | UUID of the board to create the card on. | | `fields` | object | No | Key-value pairs of field values to pre-fill. Keys are field UUIDs or well-known field names. | *** ### `create-task` Opens the task creation form, optionally pre-filled. Common use case: a "remind me later" button that pre-fills a future due date. ```js theme={null} await aptly.createTask({ title: "Follow up with tenant", dueAt: "2026-06-15T09:00:00.000Z", priority: "high", note: "Check in about lease renewal" }); ``` | Field | Type | Required | Description | | ------------------ | --------------------------------------------- | -------- | ---------------------------------- | | `title` | string | No | Pre-filled task title. | | `dueAt` | ISO 8601 string | No | Pre-filled due date/time. | | `activityLogType` | `'todo'` \| `'email'` \| `'sms'` \| `'voice'` | No | Task type. Defaults to `'todo'`. | | `priority` | `'asap'` \| `'high'` \| `'medium'` \| `'low'` | No | Pre-filled priority. | | `note` | string | No | Pre-filled task description. | | `assigneeId` | string | No | User ID to pre-assign the task to. | | `aptletInstanceId` | string | No | Card `_id` to link the task to. | | `aptletUuid` | string | No | Board UUID for context. | *** ### `create-contact` Opens the contact creation modal, optionally pre-filled. ```js theme={null} await aptly.createContact({ firstname: "Jane", lastname: "Smith", phone: "+14155551234", email: "jane@example.com" }); ``` | Field | Type | Required | Description | | ----------- | ------ | -------- | ------------------------- | | `firstname` | string | No | Pre-filled first name. | | `lastname` | string | No | Pre-filled last name. | | `phone` | string | No | Pre-filled phone number. | | `email` | string | No | Pre-filled email address. | | `company` | string | No | Pre-filled company name. | | `title` | string | No | Pre-filled job title. | *** ### `navigate-to-contact` Navigates to a contact's record page. ```js theme={null} await aptly.navigateToContact({ contactId: "person123" }); ``` | Field | Type | Required | Description | | ----------- | ------ | -------- | ---------------------------------------------- | | `contactId` | string | Yes | The `_id` of the person record to navigate to. | *** ## Troubleshooting **Action fires but nothing happens.** Check that the action is listed under Allowed Actions for the specific embed where your app is running. Permission is configured per embed, not globally. **Response says `success: false, error: 'Not permitted'`.** The action is not in the embed's allowed list. An admin needs to enable it in the app or board settings. **No response message at all.** Ensure your app is loaded inside an Aptly embed (not a standalone tab) and that the message is sent via `window.parent`. The SDK wrappers handle this correctly. **`open-email-composer` opens but the To field is empty.** Pre-filling recipients is not yet supported for this action — the user can type recipients manually after the composer opens. # Field Types Reference Source: https://docs.getaptly.com/field-types All field types returned by the board schema endpoint and the value formats to use when reading or writing card data. When you fetch the board schema, each field includes a `type` property. This type determines what value format the API expects when you create or update a card. Always fetch the schema before writing to a board so you know the key and type for each field. ## Field types ### `text` A plain text string. ```json theme={null} { "": "123 Main Street" } ``` ### `number` A numeric value. Do not wrap in quotes. ```json theme={null} { "": 3 } ``` ### `money` A numeric value representing a dollar amount. Do not include currency symbols or commas. ```json theme={null} { "": 1850.00 } ``` ### `date` An ISO 8601 date string. ```json theme={null} { "": "2025-09-01" } ``` ### `datetime` An ISO 8601 datetime string with timezone. ```json theme={null} { "": "2025-09-01T10:00:00.000Z" } ``` ### `boolean` `true` or `false`. ```json theme={null} { "": true } ``` ### `select` A single string value matching one of the field's configured options exactly. Option values are case-sensitive. ```json theme={null} { "": "Active" } ``` ### `multiselect` An array of strings, each matching a configured option exactly. ```json theme={null} { "": ["Dog", "Cat"] } ``` ### `person` A single contact ID string referencing a contact in your company. ```json theme={null} { "": "" } ``` ### `persons` An array of contact ID strings. ```json theme={null} { "": ["", ""] } ``` ### `email` A valid email address string. ```json theme={null} { "": "jane@example.com" } ``` ### `phone` A phone number string. Include country code for best compatibility. ```json theme={null} { "": "+14155551234" } ``` ### `url` A fully qualified URL string. ```json theme={null} { "": "https://example.com" } ``` ### `file` Files are attached via the [Upload a file to a card](/api-reference/cards/upload-a-file-to-a-card) endpoint, not via field keys in the card body. ## Reading field values When you fetch a card, field values are returned using the same key/value structure. Null or unset fields are returned as `null`. ## Unknown types If the schema returns a type not listed here, treat the value as a plain string and contact Aptly support to confirm the expected format. # Introduction Source: https://docs.getaptly.com/introduction Aptly is a property management platform. This API gives external tools, scripts, and AI agents direct read/write access to your boards, cards, contacts, and more. **Documentation Index** Fetch the complete index of all available pages at: [https://docs.getaptly.com/llms.txt](https://docs.getaptly.com/llms.txt) Start here to discover all endpoints before exploring further. Aptly is a property management platform that helps companies run leasing, maintenance, resident operations, and more. The Aptly API gives external tools, scripts, and AI agents direct read/write access to your Aptly data. Use this API to sync data from a PMS, build a custom integration, automate workflows, or connect an AI agent to your boards. ## How Aptly is structured Understanding the data model makes the API much easier to use. | Object | Description | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Company** | The top-level account. Your API key is scoped to a company. | | **Board** | A workspace that tracks a specific workflow. Examples: a Leasing board for prospects and applications, a Maintenance board for work orders, a Residents board for active leases. Identified by a `boardId` (UUID). | | **Card** | An individual record on a board. A card might represent a lead, a lease, a work order, or a vendor. Identified by `_id`. | | **Field** | A structured data column on a board. Each field has a UUID key, a human-readable label, and a type (text, date, money, multiselect, etc.). Fetch the board schema to get the full list before reading or writing. | | **Contact** | A person record (prospect, resident, owner, vendor) scoped to the company. Contacts can be linked to cards across multiple boards. | ## What you can do * **Read and list cards** — paginate through all cards on a board or fetch a single card by ID * **Create and update cards** — push records into any board using field keys from the schema * **Manage contacts** — create, update, and look up people records by email across all boards * **Add comments and files** — attach notes or documents to any card * **Add tab views** — embed external URLs as tabs directly on a board * **Read the board schema** — discover field keys and types before reading or writing data ## Who this API is for **Developers** building integrations between Aptly and other tools like PMS platforms, telephony systems, or CRMs. **AI agents** that need to read board data, create or update cards, look up contacts, or take action based on property management workflows. The Aptly MCP server is the recommended interface for agentic use. **Operators and automators** running scripts or no-code workflows to push data in and out of Aptly boards. ## MCP server Aptly exposes a Model Context Protocol (MCP) server for AI agent integrations. This is the recommended way to connect LLMs and AI agents to Aptly. ## Base URL ``` https://core-api.getaptly.com ``` ## Quick start In Aptly, open the board → **Card Sources** → **API** and toggle the API on. Under the API section, click **Create New Key**, give it a name, and copy the key value. ```bash theme={null} curl https://core-api.getaptly.com/api/schema/{boardId} \ -H "x-token: YOUR_API_KEY" ``` This returns the list of field keys you'll need for reading and writing cards. ```bash theme={null} curl -X POST https://core-api.getaptly.com/api/board/{boardId} \ -H "x-token: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "John Smith", "": "" }' ``` # LLM Context Reference Source: https://docs.getaptly.com/llm-context Machine-readable SDK reference. Paste as context into an AI assistant to generate accurate Aptly SDK code. ## Identity * Script tag: `` * Global: `window.aptly` — auto-init on script load * API base: `https://core-api.getaptly.com` *** ## State (read-only after init) ``` aptly.token string|null current delegate token aptly.org {id, name} logged-in user's org aptly.user {id, email, logged-in user firstName, lastName, phone, role, teams} aptly.config object merged admin config — read any key directly aptly.board {id, schema}|null board context; null unless board-tab embed aptly.boards object|null prefetched standard boards (marketplace apps) aptly.actions string[] action IDs admin enabled for this app (may be empty) ``` *** ## Methods ### `aptly.requestToken(opts?)` ``` opts undefined → Promise v1, backward compat opts {version:2} → Promise<{token:string|null, error:TokenError|null}> v2, preferred TokenError: { code: string, message: string, retryAfterMs: number|null } Error codes: 'RATE_LIMITED' | 'TOKEN_ERROR' ``` ### `aptly.fetch(path, options?)` ``` → Promise Auth-aware. Prepends API base URL. Auto-retries once on 401. path example: '/board/UUID?page=0' Do NOT set Authorization header manually. ``` ### `aptly.startEmulation(token, opts?)` ``` → Promise Dev/test only. token = long-lived dev token from Settings → Profile → Developer Tools. opts: { config: {}, org: {}, user: {}, boards: {} } Ignored when running inside Aptly iframe. ``` ### `aptly.action(actionId, payload?)` ``` → Promise<{ success: boolean, error?: string }> Raw action dispatcher. actionId must be in aptly.actions or returns { success: false, error: 'Not permitted' }. Timeout after 3s → { success: false, error: 'timeout' }. ``` ### Named action wrappers (generated from aptly.actions) ``` aptly.openEmailComposer({ to?, subject?, content?, composeMode? }) aptly.createCard({ boardId, fields? }) aptly.createTask({ title?, dueAt?, priority?, assigneeId?, aptletInstanceId? }) aptly.createContact({ firstname?, lastname?, phone?, email? }) aptly.navigateToContact({ contactId }) aptly.openCardPane({ cardId }) aptly.openCardView({ cardId }) aptly.startDialer({ number, name? }) aptly.createEvent({ date?, comments?, recipients? }) All return Promise<{ success: boolean, error?: string }>. Only callable if the action ID is in aptly.actions (admin-configured allowlist). ``` *** ## Canonical pattern (always use this) ```html theme={null} ``` *** ## Dev testing (pick one, remove before deploy) **Option A — `startEmulation` (recommended):** real identity, requires dev token from Settings → Profile → Developer Tools ```html theme={null} ``` **Option B — `window.APTLY_DEV` (mock, no network):** set BEFORE script tag ```html theme={null} ``` **Option C — URL params:** no code change ``` ?aptly_token=KEY&aptly_config_BOARD_ID=lease&aptly_org_name=Acme ``` *** ## Rate limits ``` 60 token requests / 60s / browser connection (not per user) Error code: 'RATE_LIMITED' retryAfterMs: ms until bucket resets ``` *** ## Config scoping ``` contextScope 'company' one config per org default contextScope 'board' one config per board contextScope 'user' one config per user contextScope 'all' all three; user > board > company merge ``` App code always reads `aptly.config.KEY` — Aptly merges before delivery. *** ## Scopes ``` Read scopes: boards:*, contacts:*, knowledge:* Write scopes: boards:* Declared on app definition. Enforced by core-api. 403 = valid token, missing scope. ``` *** ## Error reference ``` 400 — missing required field 400 INVALID_DATA bad scope format 401 INVALID_ACCESS_TOKEN expired / wrong company 401 UNAUTHORIZED user/app not found or revoked 403 FORBIDDEN valid token, missing scope 429 RATE_LIMITED too many token requests; retryAfterMs in error object ``` *** ## Rules for LLM code generation 1. Always use `requestToken({ version: 2 })` — not `requestToken()`. 2. Always handle `error?.code === 'RATE_LIMITED'` with `setTimeout(init, error.retryAfterMs)`. 3. Never set `Authorization` header manually — `aptly.fetch` does it. 4. `aptly.config` is flat — read keys directly, no scope logic needed. 5. `aptly.board` is null unless the app is embedded as a board tab. 6. `startEmulation` + dev block must be removed before deploy. 7. SDK script tag goes in ``, not ``. # Mcp server Source: https://docs.getaptly.com/mcp-server # MCP tools Auto-generated from `src/mcp/**/*.tools.ts`. Do not edit by hand — run `/mcp-docs` to regenerate. ## board ### `search-boards-by-name` Search for boards by name, optionally filtered by type * **Scopes:** `boards:read` * **Throttle:** `1` **Parameters** | Name | Type | Required | Default | Description | | ------------ | --------------------------------------------------------------------------------- | -------- | ------- | ----------------------------------- | | `name` | `string` | no | — | The name of the board to search for | | `aptletType` | `enum: ticket \| deal \| location \| adsource \| reviews \| screening \| answers` | no | — | Filter by board type | | `limit` | `number` | no | `100` | Number of results to return | | `offset` | `number` | no | `0` | Number of results to skip | ### `add-tab-view-to-board` Create a page and attach it as a tab view to a board * **Scopes:** `boards:write` * **Throttle:** `5` **Parameters** | Name | Type | Required | Default | Description | | ------------- | --------------- | -------- | ------- | ------------------------------------------------------------------------------ | | `aptletUuid` | `string` | yes | — | The UUID of the board to add the page to | | `tabViewName` | `string` | yes | — | The name of tab view | | `files` | `array` | yes | — | List of files with name and content. Each: `{ name: string, content: string }` | ### `create-card` Create a new card * **Scopes:** `boards:write` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | ---------------- | -------- | -------- | ------------- | ---------------------------------------------- | | `aptletUuid` | `string` | yes | — | The UUID of the board to create a card of | | `name` | `string` | yes | — | The name of the card | | `description` | `string` | no | — | The description of the card | | `assignee` | `string` | no | — | The assignee ID of the card | | `createdConduit` | `string` | no | `"aptly-mcp"` | The conduit through which the card was created | ### `update-card-field` Update a single field on a card * **Scopes:** `boards:write` * **Throttle:** `5` **Parameters** | Name | Type | Required | Default | Description | | ----------- | --------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cardId` | `string` | yes | — | The ID of the card to update | | `fieldName` | `string` | yes | — | The display name of the field to update, exactly as returned by search-cards (e.g. "Stage", "Assignee", or a custom field name). When two standard fields share a name, the board's own field takes precedence. | | `value` | `unknown` | yes | — | The new value, in the same shape search-cards returns it: a string, number, boolean, ISO date string, or `{ amount, currency }` for money fields. | ### `create-card-comment` Create a comment on a card * **Scopes:** `boards:write` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | ---------- | --------------- | -------- | ------- | --------------------------------- | | `cardId` | `string` | yes | — | The ID of the card to comment on | | `content` | `string` | yes | — | The comment content | | `mentions` | `array` | no | — | User IDs mentioned in the comment | ### `search-cards` Search for cards by a search query, optionally filtered by board UUID * **Scopes:** `boards:read` * **Throttle:** `1` **Parameters** | Name | Type | Required | Default | Description | | ------------ | -------- | -------- | ------- | ------------------------------------------------- | | `query` | `string` | no | — | Search term matched against the searchIndex field | | `aptletUuid` | `string` | no | — | Filter cards by board UUID | | `limit` | `number` | no | `1000` | Number of results to return | | `offset` | `number` | no | `0` | Number of results to skip | ### `get-card-tasks` Get tasks for a card * **Scopes:** `boards:read` * **Throttle:** `1` **Parameters** | Name | Type | Required | Default | Description | | -------- | -------- | -------- | ------- | ----------------------------------- | | `cardId` | `string` | yes | — | The ID of the card to get tasks for | | `limit` | `number` | no | `100` | Number of results to return | | `offset` | `number` | no | `0` | Number of results to skip | ### `create-card-task` Create a task for a card * **Scopes:** `boards:write` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | --------- | --------- | -------- | ------- | ----------------------------------------- | | `cardId` | `string` | yes | — | The ID of the card to create the task for | | `title` | `string` | yes | — | The task title | | `checked` | `boolean` | yes | — | Whether the task is completed | | `dueAt` | `string` | no | — | Due date in ISO 8601 format | ### `assign-user-to-card-task` Assign a user to a card task * **Scopes:** `boards:write` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | ------------ | -------- | -------- | ------- | ---------------------------------------- | | `taskId` | `string` | yes | — | The ID of the task to assign | | `assigneeId` | `string` | yes | — | The ID of the user to assign to the task | ## calendar ### `get-user-calendars` Get all calendars the authenticated user can access (owned + shared), without events. * **Scopes:** `inboxes:read` * **Throttle:** `1` **Parameters** *No parameters.* ### `get-user-calendars-with-events` Get all calendars the authenticated user can access (owned + shared) along with their events. Optionally filter events by a date range and/or by a case-insensitive substring of the event title; recurring event instances overlapping the range are included. * **Scopes:** `inboxes:read` * **Throttle:** `2` **Parameters** | Name | Type | Required | Default | Description | | ---------- | ---------------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ | | `dateFrom` | `string (ISO 8601 datetime)` | no | — | Only include events ending on or after this ISO 8601 datetime (e.g. 2026-04-01T00:00:00Z). Required together with dateTo. | | `dateTo` | `string (ISO 8601 datetime)` | no | — | Only include events starting on or before this ISO 8601 datetime (e.g. 2026-05-01T00:00:00Z). Required together with dateFrom. | | `name` | `string` | no | — | Case-insensitive substring match against the event title (e.g. "standup") | ### `create-calendar-event` Create a new calendar event in one of the user’s calendars * **Scopes:** `inboxes:write` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | ------------- | ---------------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `calendarId` | `string` | yes | — | ID of the calendar to create the event in (from get-user-calendars-with-events) | | `cardId` | `string` | no | — | ID of the card to associate the event with | | `title` | `string` | yes | — | Event title | | `description` | `string` | no | — | Event description | | `location` | `string` | no | — | Event location (formatted address or free-form text) | | `start` | `string (ISO 8601 datetime)` | yes | — | Event start ISO 8601 datetime (e.g. 2026-05-12T15:00:00Z) | | `end` | `string (ISO 8601 datetime)` | yes | — | Event end ISO 8601 datetime (e.g. 2026-05-12T16:00:00Z) | | `allDay` | `boolean` | no | `false` | Whether the event spans the entire day | | `attendees` | `array` | no | — | Event attendees. Each: `{ email: string (email), name?: string, response?: unknown \| needsAction \| accepted \| declined \| tentative (default needsAction) }` | ## files ### `create-file-upload-url` Step 1 of attaching a file to an entity (e.g. a channel). Uploading a file is a three-step flow: 1. Call this tool to create a pre-signed S3 upload URL. It returns `{ fileId, url, fields }`. 2. Upload the file bytes by sending an HTTP POST to `url` as multipart/form-data, including every key/value from `fields` first, then a `file` field containing the file contents (the `file` field must be last). S3 responds with 204 No Content on success. 3. Call `complete-file-upload` with the returned `fileId` to finalize the upload and attach the file to the entity. The URL expires after 5 minutes, so perform step 2 promptly. * **Throttle:** `1` **Parameters** | Name | Type | Required | Default | Description | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | yes | — | The file name, including its extension | | `extension` | `enum: vcf \| docx \| csv \| xml \| doc \| pdf \| png \| jpg \| svg \| jpeg \| wav \| mpg \| mp4 \| gif \| xls \| xlsx \| numbers \| zip \| txt \| md \| ppt \| pptx \| tiff \| tar \| rar \| ics \| mp3 \| heic \| heif` | yes | — | Lowercase file extension without the leading dot | | `contentType` | `enum: audio/wav \| audio/x-wav \| audio/basic \| audio/L24 \| audio/mp4 \| audio/mpeg \| audio/ogg \| audio/vorbis \| audio/vnd.rn-realaudio \| audio/3gpp \| audio/3gpp2 \| audio/ac3 \| audio/vnd.wave \| audio/webm \| audio/amr-nb \| audio/amr \| video/mpeg \| video/mp4 \| video/quicktime \| video/webm \| video/3gpp \| video/3gpp2 \| video/3gpp-tt \| video/H261 \| video/H263 \| video/H263-1998 \| video/H263-2000 \| video/H264 \| image/jpg \| image/jpeg \| image/gif \| image/png \| image/bmp \| image/heic \| image/heif \| text/vcard \| text/csv \| text/xml \| text/rtf \| text/richtext \| text/calendar \| text/directory \| text/x-markdown \| application/pdf \| application/xls \| application/xlsx \| application/vnd.openxmlformats-officedocument.spreadsheetml.sheet \| application/vnd.openxmlformats-officedocument.wordprocessingml.document \| application/numbers \| application/zip \| application/x-zip-compressed \| application/zip-compressed` | yes | — | The MIME type of the file | | `size` | `number` | yes | — | File size in bytes (max 50 MB) (max 52428800) | | `attachEntityType` | `enum: channel` | yes | — | Type of entity to attach the file to. Currently only 'channel'. | | `attachEntityId` | `string` | yes | — | ID of the entity named by attachEntityType. For 'channel' this is the channel's account id; it must reference an existing channel. | ### `complete-file-upload` Step 3 (final) of attaching a file to an entity. Call this only after `create-file-upload-url` (step 1) and successfully POSTing the file bytes to the returned url (step 2), passing the `fileId` from step 1. Marks the upload complete, finalizes the file record, and returns the `fileId` and its download `url`. * **Throttle:** `1` **Parameters** | Name | Type | Required | Default | Description | | -------- | -------- | -------- | ------- | --------------------------------------------- | | `fileId` | `string` | yes | — | The fileId returned by create-file-upload-url | ## inbox ### `get-user-email-inboxes` Get the email inboxes the authenticated user selected on the connect page * **Scopes:** `inboxes:read` * **Throttle:** `1` **Parameters** *No parameters.* ### `get-user-phone-inboxes` Get all phone (SMS/voice) inboxes available for the authenticated user * **Scopes:** `inboxes:read` * **Throttle:** `1` **Parameters** *No parameters.* ### `get-inbox-history` Get inbox history (status changes, assignments, folder moves, topic edits, email sends, email opens, automation runs) for an inbox. Optionally narrow to a specific thread (streamId) or to entries performed by a specific user (userId). When filtered by userId, only user-driven entries are returned — email-open and automation events have no user actor and are excluded. * **Scopes:** `inboxes:read` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | ----------- | -------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------- | | `channelId` | `string` | yes | — | ID of the inbox (email or phone) to fetch history from (from get-user-email-inboxes or get-user-phone-inboxes) | | `streamId` | `string` | no | — | Filter to history on a specific thread within the inbox (from search-emails or search-phone-messages results) | | `userId` | `string` | no | — | Filter to history entries performed by a specific user (from get-company-users) | | `limit` | `number` | no | `1000` | Maximum number of entries to return | ### `search-emails` Search emails across From/To names and email addresses, subject, body, and attachment content. Optionally filter by inbox and date range. * **Scopes:** `inboxes:read` * **Throttle:** `2` **Parameters** | Name | Type | Required | Default | Description | | -------------- | ---------------------------- | -------- | ------- | --------------------------------------------------------------------------------------- | | `query` | `string` | no | — | Search by From name/email, To name/email, subject, email body, or attachment content | | `channelId` | `string` | no | — | Filter emails by inbox id | | `from` | `array` | no | — | Filter to emails sent from any of these email addresses (matched against From) | | `to` | `array` | no | — | Filter to emails sent to any of these email addresses (matched against To) | | `assigneeId` | `string` | no | — | Filter to email threads assigned to this user id | | `personTypeId` | `string` | no | — | Filter to email threads tagged with this person type id (from get-person-types) | | `topicId` | `string` | no | — | Filter to email threads tagged with this topic id (from get-topics) | | `direction` | `enum: in \| out` | no | — | Filter by direction: "in" for received/inbound emails, "out" for sent/outbound emails | | `dateFrom` | `string (ISO 8601 datetime)` | no | — | Filter emails published on or after this ISO 8601 datetime (e.g. 2026-04-01T00:00:00Z) | | `dateTo` | `string (ISO 8601 datetime)` | no | — | Filter emails published on or before this ISO 8601 datetime (e.g. 2026-04-17T23:59:59Z) | | `limit` | `number` | no | `1000` | Number of results to return | | `offset` | `number` | no | `0` | Number of results to skip | ### `send-email` Send a new email message from one of the user's email inboxes. To include files, upload them first via create-file-upload-url + complete-file-upload, then either pass their fileIds as attachmentIds (regular attachments) or embed an image inline by adding an `` tag in the HTML body whose src is the file's download url. * **Scopes:** `inboxes:write` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | --------------- | --------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `channelId` | `string` | yes | — | ID of the email inbox to send from | | `to` | `array` | yes | — | Recipients (at least 1). Each: `{ email: string (email), name?: string }` | | `cc` | `array` | no | — | CC recipients. Each: `{ email: string (email), name?: string }` | | `bcc` | `array` | no | — | BCC recipients. Each: `{ email: string (email), name?: string }` | | `subject` | `string` | yes | — | Email subject | | `body` | `string` | yes | — | Email body. Plain text or HTML. To embed an uploaded image inline, put an `` tag whose src is the download url returned by complete-file-upload (.../cdn/storage/AptlyFiles/``/original/...). Inline images are auto-detected from these urls, tagged with a data-inline-image-id attribute, and registered on the draft; do not also list them in attachmentIds. | | `attachmentIds` | `array` | no | — | IDs of files/images to attach. To obtain an id, upload each file first: (1) call create-file-upload-url with attachEntityType "channel" and the same channelId to get `{ fileId, url, fields }`; (2) HTTP POST the file bytes to `url` as multipart/form-data (all `fields` first, then a `file` field); (3) call complete-file-upload with the `fileId`. Then pass the resulting fileIds here. Each must reference a finished upload. | | `streamId` | `string` | no | — | If provided, send the email as a reply into this existing thread instead of starting a new one (from search-emails results) | ### `create-email-draft` Create a draft email in one of the user's email inboxes. To include files, upload them first via create-file-upload-url + complete-file-upload, then either pass their fileIds as attachmentIds (regular attachments) or embed an image inline by adding an `` tag in the HTML body whose src is the file's download url. * **Scopes:** `inboxes:write` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | --------------- | --------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `channelId` | `string` | yes | — | ID of the email inbox to send from | | `to` | `array` | yes | — | Recipients (at least 1). Each: `{ email: string (email), name?: string }` | | `cc` | `array` | no | — | CC recipients. Each: `{ email: string (email), name?: string }` | | `bcc` | `array` | no | — | BCC recipients. Each: `{ email: string (email), name?: string }` | | `subject` | `string` | yes | — | Email subject | | `body` | `string` | yes | — | Email body. Plain text or HTML. To embed an uploaded image inline, put an `` tag whose src is the download url returned by complete-file-upload (.../cdn/storage/AptlyFiles/``/original/...). Inline images are auto-detected from these urls, tagged with a data-inline-image-id attribute, and registered on the draft; do not also list them in attachmentIds. | | `attachmentIds` | `array` | no | — | IDs of files/images to attach. To obtain an id, upload each file first: (1) call create-file-upload-url with attachEntityType "channel" and the same channelId to get `{ fileId, url, fields }`; (2) HTTP POST the file bytes to `url` as multipart/form-data (all `fields` first, then a `file` field); (3) call complete-file-upload with the `fileId`. Then pass the resulting fileIds here. Each must reference a finished upload. | | `streamId` | `string` | no | — | If provided, append the draft to this existing email thread instead of creating a new stream (from search-emails results) | ### `send-sms` Send an SMS from one of the user's phone inboxes * **Scopes:** `inboxes:write` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | ----------- | --------------- | -------- | ------- | ---------------------------------------------------------------------- | | `channelId` | `string` | yes | — | ID of the phone inbox to send from | | `to` | `array` | yes | — | SMS recipients (at least 1). Each: `{ number: string, name?: string }` | | `body` | `string` | yes | — | SMS message text | ### `archive-email` Archive an email thread * **Scopes:** `inboxes:write` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | ---------- | -------- | -------- | ------- | ------------------------------------------------------------------------ | | `streamId` | `string` | yes | — | The streamId of the email thread to archive (from search-emails results) | ### `reopen-email` Reopen an archived email thread * **Scopes:** `inboxes:write` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | ---------- | -------- | -------- | ------- | ----------------------------------------------------------------------- | | `streamId` | `string` | yes | — | The streamId of the email thread to reopen (from search-emails results) | ### `archive-phone-message` Archive a phone message thread * **Scopes:** `inboxes:write` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | ---------- | -------- | -------- | ------- | ---------------------------------------------------------------------------------------- | | `streamId` | `string` | yes | — | The streamId of the phone message thread to archive (from search-phone-messages results) | ### `reopen-phone-message` Reopen an archived phone message thread * **Scopes:** `inboxes:write` * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | ---------- | -------- | -------- | ------- | --------------------------------------------------------------------------------------- | | `streamId` | `string` | yes | — | The streamId of the phone message thread to reopen (from search-phone-messages results) | ### `search-phone-messages` Search SMS and voice messages across From/To names and phone numbers, and message content. Optionally filter by inbox and date range. * **Scopes:** `inboxes:read` * **Throttle:** `2` **Parameters** | Name | Type | Required | Default | Description | | -------------- | ---------------------------- | -------- | ------- | ----------------------------------------------------------------------------------------- | | `query` | `string` | no | — | Search by From name/number, To name/number, or SMS message content | | `channelId` | `string` | no | — | Filter messages by phone inbox id | | `from` | `array` | no | — | Filter to messages sent from any of these phone numbers (matched against From) | | `to` | `array` | no | — | Filter to messages sent to any of these phone numbers (matched against To) | | `assigneeId` | `string` | no | — | Filter to phone message threads assigned to this user id | | `personTypeId` | `string` | no | — | Filter to phone message threads tagged with this person type id (from get-person-types) | | `topicId` | `string` | no | — | Filter to phone message threads tagged with this topic id (from get-topics) | | `direction` | `enum: in \| out` | no | — | Filter by direction: "in" for received/inbound messages, "out" for sent/outbound messages | | `dateFrom` | `string (ISO 8601 datetime)` | no | — | Filter message published on or after this ISO 8601 datetime (e.g. 2026-04-01T00:00:00Z) | | `dateTo` | `string (ISO 8601 datetime)` | no | — | Filter message published on or before this ISO 8601 datetime (e.g. 2026-04-17T23:59:59Z) | | `limit` | `number` | no | `1000` | Number of results to return | | `offset` | `number` | no | `0` | Number of results to skip | ### `get-topics` Get all inbox topics defined for the company * **Scopes:** `inboxes:read` * **Throttle:** `1` **Parameters** *No parameters.* ## knowledge ### `retrieve-knowledge-documents` Retrieve relevant knowledge documents from the knowledge base using a search query * **Throttle:** `2` **Parameters** | Name | Type | Required | Default | Description | | ------- | -------- | -------- | ------- | ------------------------------------------------ | | `query` | `string` | yes | — | The search query to retrieve knowledge documents | | `limit` | `number` | no | `10` | Number of documents to return (min 5, max 20) | ### `create-knowledge-document` Create a new knowledge document with optional HTML content. Can be linked to a board, card, or parent document. * **Throttle:** `2` **Parameters** | Name | Type | Required | Default | Description | | ------------------ | ------------------------- | -------- | ---------- | ------------------------------------------------------------ | | `name` | `string` | yes | — | Title of the knowledge document | | `markdown` | `string` | no | — | Initial markdown content of the document. Defaults to empty. | | `aptletUuid` | `string` | no | — | Board UUID to associate the document with | | `aptletInstanceId` | `string` | no | — | Card ID to link the document to | | `parentId` | `string` | no | — | Parent knowledge document ID for nested pages | | `accessType` | `enum: public \| private` | no | `"public"` | Access level. Defaults to public. | ### `update-knowledge-document` Update the title, content, or access level of an existing knowledge document. When html is provided, it replaces the entire document body. * **Throttle:** `3` **Parameters** | Name | Type | Required | Default | Description | | ------------ | ------------------------- | -------- | ------- | --------------------------------------------------------------------------- | | `id` | `string` | yes | — | ID of the knowledge document to update | | `name` | `string` | no | — | New title for the document | | `markdown` | `string` | no | — | New markdown content — replaces the existing content entirely when provided | | `accessType` | `enum: public \| private` | no | — | New access level for the document | ## person ### `search-persons` Search persons by full name, email, phone number or address, and/or filter by person type. All parameters are optional. * **Scopes:** `contacts:read` * **Throttle:** `1` **Parameters** | Name | Type | Required | Default | Description | | -------- | -------- | -------- | ------- | ---------------------------------------------------------------- | | `query` | `string` | no | — | Search by full name, email, phone number, or address | | `typeId` | `string` | no | — | Filter to persons of this person type id (from get-person-types) | | `limit` | `number` | no | `100` | Number of results to return | | `offset` | `number` | no | `0` | Number of results to skip | ### `get-person-types` Get all person types (contact categories) defined for the company * **Scopes:** `contacts:read` * **Throttle:** `1` **Parameters** *No parameters.* ## user ### `get-company-users` Get all users in the authenticated user’s company. Returns each user’s id and full name. * **Scopes:** `contacts:read` * **Throttle:** `1` **Parameters** *No parameters.* # Pagination Source: https://docs.getaptly.com/pagination How to paginate through list endpoints and detect when you have reached the last page. List endpoints in the Aptly API return results one page at a time. Use the `page` query parameter to walk through all records. ## Parameters | Parameter | Type | Default | Description | | --------- | ------- | ------- | ---------------------------------------------------------------------------------------- | | `page` | integer | `0` | Zero-indexed page number. Start at `0` and increment by `1` for each subsequent request. | | `limit` | integer | `50` | Number of records per page. Maximum is `100`. | ## Example ```bash theme={null} # First page curl "https://core-api.getaptly.com/api/board/{boardId}?page=0&limit=50" \ -H "x-token: YOUR_API_KEY" # Second page curl "https://core-api.getaptly.com/api/board/{boardId}?page=1&limit=50" \ -H "x-token: YOUR_API_KEY" ``` ## Detecting the last page The response includes a `total` field representing the total number of records on the board. You have reached the last page when the following is true: ```js theme={null} (page + 1) * limit >= total ``` Example: if `total` is `112` and your `limit` is `50`, you need three pages (pages `0`, `1`, and `2`). On page `2` you will receive the remaining `12` records. ## Iterating all records To collect all cards on a board: 1. Fetch page `0` and read `total` from the response 2. Calculate the number of pages: `Math.ceil(total / limit)` 3. Fetch each subsequent page until you have all records Avoid storing page numbers as persistent cursors. If cards are added or removed between requests, page boundaries can shift. For reliable sync, use a timestamp filter if available or reconcile by card ID after fetching all pages. # Context & Locations Source: https://docs.getaptly.com/portal/api-context Company/org config, property listings, location search, and nearby schools. Provides company/organization configuration, property listings, and related data. Most endpoints are public (no auth required) and are used to configure the landing page and application forms for a specific organization or property. ## Data Shapes Returned by `/context/{contextId}` and embedded in Application objects. ```json theme={null} { "id": "org_abc123", "companyId": "org_abc123", "companyInfo": { "requireBankConnect": false, "disableApplicantGuarantors": false, "disableBankScreening": false, "requireEmergencyContact": true, "applicationLock": "onClose", "enableSection8": false, "enableRegionalScreeningFilters": false, "collectPayment": true, "taxDocumentCount": 2, "bankStatementCount": 3, "payStubCount": 2, "toggleChatWindow": false }, "applicationConfig": { "availableDate": "2026-04-01", "marketRent": "$1,500", "marketRentValue": 150000, "marketingTitle": "Beautiful 2BR near downtown", "applicationRequirements": "

Min credit score 620...

", "incomeToRent": 3, "coverPhoto": ["https://cdn.example.com/photos/cover.jpg"], "marketingImages": ["https://cdn.example.com/photos/img1.jpg"], "agent": { "name": "Sarah Agent", "email": "sarah@example.com", "phone": "+15551112222", "photo": "https://cdn.example.com/agents/sarah.jpg" } } } ``` **`companyInfo` fields** | Field | Type | Description | | -------------------------------- | ------- | ------------------------------------------------------------------------------ | | `requireBankConnect` | boolean | Require VeriFast bank connect for income verification | | `disableApplicantGuarantors` | boolean | Prevent applicants from adding guarantors themselves | | `disableBankScreening` | boolean | Disable bank account screening entirely | | `requireEmergencyContact` | boolean | Make emergency contact required | | `applicationLock` | string | When app locks from editing. `"onClose"` = locks when applicant closes browser | | `enableSection8` | boolean | Enable Section 8 / housing voucher flow | | `enableRegionalScreeningFilters` | boolean | Enable state-specific screening rule exceptions | | `collectPayment` | boolean | Require application fee payment | | `taxDocumentCount` | number | How many tax documents to require | | `bankStatementCount` | number | How many bank statements to require | | `payStubCount` | number | How many pay stubs to require | **`applicationConfig` fields** | Field | Type | Description | | ------------------------- | --------- | ------------------------------------------------------------ | | `marketRentValue` | number | Monthly rent in **cents** (e.g., `150000` = \$1,500) | | `marketRent` | string | Pre-formatted display string (e.g., `"$1,500"`) | | `incomeToRent` | number | Income multiplier required (e.g., `3` = must earn 3× rent) | | `applicationRequirements` | string | HTML string for displaying requirements | | `petRestrictions` | string | HTML string for pet policy | | `leaseTerms` | string | HTML string for lease terms | | `coverPhoto` | string\[] | Array of cover photo URLs | | `marketingImages` | string\[] | Array of gallery photo URLs | | `regionalExceptions` | string\[] | Features disabled for regulatory reasons in specific regions |
```json theme={null} { "_id": "loc_xyz789", "name": "The Residences at Oak Creek - Unit 204", "companyId": "org_abc123", "address": { "formattedAddress": "123 Oak St Unit 204, Austin TX 78701", "state": "TX", "countryName": "United States" }, "bedCount": 2, "bathCount": 1, "squareFeet": 950, "marketRent": "$1,500", "marketRentValue": 150000, "rentMin": 140000, "rentMax": 160000, "videoUrl": "https://www.youtube.com/watch?v=example", "virtualTourUrl": "https://my.matterport.com/show/?m=example", "applicationConfig": { "...": "See above" }, "companyInfo": { "...": "See above" } } ``` ```json theme={null} { "_id": "sch_001", "name": "Austin Elementary School", "type": "Elementary", "rating": 8, "distance": 0.4, "address": "456 School Rd, Austin TX 78702", "url": "https://www.greatschools.org/..." } ```
*** ## GET `/context/{contextId}` Load configuration for a property location. Used on landing/listing pages to configure the UI for a specific property. **Auth required:** No Location ID (same as `locationId` in applications). Filter config for audience: `"applicant"`, `"approver"`, or `"showing"`. Pass `true` to show property information regardless of publish status. Useful for displaying data for offline or unpublished locations for historical purposes. **Response:** Location/Context Object merged with Location/Listing Object. Called on the application landing page to load property details, branding, and feature flags before displaying the application form. *** ## GET `/company/{contextId}` Load organization-level configuration (the company overall, not a specific property). **Auth required:** No Organization/company ID. If provided, also load config for this specific location. Bypass publish status filter. **Response:** Company config object scoped to the organization. *** ## GET `/locations/{orgId}` List all active property locations for an organization. **Auth required:** Optional (token enhances results for authenticated users) Organization ID. **Query params (optional):** Arbitrary key-value filters. | Param | Description | | ----------- | --------------------------------------- | | `available` | `"true"` to return only available units | | `bedCount` | Filter by number of bedrooms | | `search` | Text search on location name/address | **Response:** Array of Location objects. *** ## GET `/listings/{orgId}/{segmentId}` List public property listings, optionally filtered by a segment (e.g., a specific building or community). **Auth required:** No Organization ID. Optional sub-segment/portfolio ID. **Query params:** Same filter params as `/locations/{orgId}`. **Response:** Array of Listing objects. Powers the public property search portal. *** ## GET `/listing/{locationId}` Get full details for a single public listing. **Auth required:** No Property location ID. **Response:** Single Location/Listing Object with full `applicationConfig`. Called on the individual listing detail page before a user starts an application. *** ## GET `/schools/{locationId}` Get nearby schools for a property (powered by GreatSchools API). **Auth required:** No Property location ID. Search radius in miles. Default: `25`. Maximum number of schools to return. Default: `10`. **Response** ```json theme={null} [ { "_id": "sch_001", "name": "Austin Elementary School", "type": "Elementary", "rating": 8, "distance": 0.4, "address": "456 School Rd, Austin TX 78702", "url": "https://www.greatschools.org/..." } ] ``` Displayed on the listing detail page as neighborhood information. # Forms Source: https://docs.getaptly.com/portal/api-forms Embeddable external forms with location search for third-party websites. Handles external embeddable forms — dynamic forms that can be embedded in third-party websites. These forms collect lead or inquiry data and can search for available property locations. All endpoints are public (no auth required). ## GET `/forms/{formId}/{cardId}/{boardId}` Retrieve the configuration and field definitions for an embeddable form. The form definition ID. The card context ID (integration-specific, e.g., a CRM card ID). The board context ID (integration-specific, e.g., a CRM board ID). **Response** ```json theme={null} { "_id": "form_abc123", "title": "Schedule a Tour", "fields": [ { "uuid": "f_001", "type": "string", "label": "Full Name", "placeholder": "Jane Doe", "required": true }, { "uuid": "f_002", "type": "email", "label": "Email Address", "required": true }, { "uuid": "f_005", "type": "select", "label": "Bedrooms", "options": ["Studio", "1BR", "2BR", "3BR+"], "required": false } ], "submitLabel": "Request a Tour", "companyId": "org_abc123", "locationId": "loc_xyz789" } ``` **Field `type` values** | Value | Description | | ------------ | -------------------------------- | | `"string"` | Single-line text input | | `"email"` | Email address input | | `"phone"` | Phone number input | | `"date"` | Date picker | | `"select"` | Dropdown with predefined options | | `"boolean"` | Yes/No toggle | | `"money"` | Currency amount input | | `"textarea"` | Multi-line text area | *** ## POST `/forms/submit` Submit a completed form. **Request body** ```json theme={null} { "formId": "form_abc123", "cardId": "card_001", "boardId": "board_001", "data": { "f_001": "Jane Doe", "f_002": "jane@example.com", "f_004": "2026-04-01", "f_005": "2BR" } } ``` Form definition ID. CRM card ID. CRM board ID. Key-value pairs of field UUID → user-entered value. **Response** ```json theme={null} { "status": true, "message": "Thank you! We'll be in touch soon." } ``` *** ## POST `/forms/searchLocations` Search for available property locations to populate a location picker in embeddable forms. **Request body** ```json theme={null} { "query": "Austin", "companyId": "org_abc123", "bedCount": 2, "maxRent": 200000 } ``` Text search against location name or address. Organization ID to search within. Filter by number of bedrooms. Maximum rent in cents (e.g., `200000` = \$2,000). **Response** ```json theme={null} { "locations": [ { "_id": "loc_xyz789", "name": "The Residences at Oak Creek - Unit 204", "address": { "formattedAddress": "123 Oak St, Austin TX 78701" }, "bedCount": 2, "bathCount": 1, "squareFeet": 950, "marketRent": "$1,500", "marketRentValue": 150000, "coverPhoto": ["https://cdn.example.com/photos/cover.jpg"] } ] } ``` Called in embeddable forms that let users select a specific unit before submitting a tour request or inquiry. # Knowledge Base Source: https://docs.getaptly.com/portal/api-knowledge Retrieve help and knowledge documents for display in the portal. Knowledge base documents are public — no authentication required. ## GET `/knowledge/{knowledgeId}` Retrieve a knowledge base document by its ID. The unique knowledge document ID. **Response** ```json theme={null} { "_id": "know_abc123", "title": "How to complete your rental application", "slug": "how-to-complete-rental-application", "content": "

Step 1: Personal Information

...

", "contentType": "html", "category": "Applications", "tags": ["getting-started", "application"], "publishedAt": "2026-01-15T00:00:00.000Z", "updatedAt": "2026-02-20T00:00:00.000Z" } ``` Document ID. Document title. URL-friendly identifier. Document body — HTML or Markdown depending on `contentType`. `"html"` or `"markdown"`. Top-level category grouping. Searchable tags. ISO datetime of first publication. ISO datetime of last update. **Error responses** | Status | Reason | Description | | ------ | ------------- | ------------------------------- | | 404 | `"Not found"` | No document with this ID exists | *** ## Notes for Developers * Content may be HTML with embedded formatting. Render with a sanitized HTML renderer. * Document IDs are typically configured in the organization's CMS and referenced from within the application UI. # Aptly Portal API Source: https://docs.getaptly.com/portal/introduction HTTP API for the Aptly rental application screening portal. ## Environments The base URL for all API calls is: ``` https://app.getaptly.com/api/portal ``` All endpoint paths are relative to it. ## Request & Response Format | Method | Content-Type | Body | | ------------------ | ------------------------------- | ---------- | | GET | `application/json` | none | | POST (JSON) | `application/json` | JSON | | POST (file upload) | *(omitted, multipart auto-set)* | `FormData` | | DELETE | `application/json` | none | All responses are JSON. On success the HTTP status is `2xx`. On failure: ```json theme={null} { "reason": "human-readable error string" } ``` ## Error responses All errors return a JSON body with a `reason` field: ```json theme={null} { "reason": "human-readable error string" } ``` | Reason | Status | Description | | ---------------------------------------- | ------ | -------------------------------------------------------------- | | `"jwt expired"` | 401 | Token has expired — re-authenticate via the verification flow | | `"Unauthorized"` | 401 | Token is missing or invalid | | `"Not found"` | 404 | The requested resource does not exist | | `"Invalid orgId"` | 400 | The organization ID is missing or does not match any known org | | `"Form not found"` | 404 | The form ID does not exist or is not active for this org | | `"Location not found"` | 404 | The property or location ID does not exist within this org | | `"Verification code invalid or expired"` | 400 | The submitted code was incorrect, already used, or has expired | ## Multi-tenancy Every organization has a unique identifier that scopes all API calls. This ID appears in different fields depending on the endpoint: | Name | Where it appears | | ----------- | ---------------------------------------------------- | | `orgId` | Query parameters on most Portal API endpoints | | `companyId` | Returned in company config responses | | `contextId` | Used internally in some form and knowledge endpoints | These all refer to the same organization. When an endpoint asks for `orgId`, use your organization's ID regardless of what it is called elsewhere. If you are unsure of your `orgId`, call the [Load company-level config](/portal/api-context) endpoint — it is returned in the response. ## Common Data Types ```json theme={null} { "formattedAddress": "123 Main St, Austin TX 78701", "state": "TX", "countryName": "United States" } ``` ## Public API Company/org config, property listings, location search, nearby schools Retrieve help/knowledge documents Embeddable external forms with location search # Quickstart Source: https://docs.getaptly.com/quickstart Add Aptly data to your app in under 5 minutes. One script tag, copy-paste example, working code. Add one script tag to your HTML ``: ```html theme={null} ``` That's the entire install. `window.aptly` is ready immediately after the tag. *** ## Copy-paste starting point This is a complete working app. Replace `BOARD_ID` with your config variable name and fill in the render logic. ```html theme={null}
Loading…
``` *** ## Testing before your app is embedded in Aptly Get a **dev token** from **Settings → Profile → Developer Tools** (your Aptly admin must grant you the Developer Tools permission first). Paste this block right after the SDK script tag. Remove it before you ship. ```html theme={null} ``` `startEmulation` verifies the token against the Aptly API and populates `aptly.user`, `aptly.org`, and `aptly.config` with real data — the same values your app will see when embedded. Tokens can be issued with up to 7-day expiry. > **Note:** When your app runs inside Aptly, the SDK ignores `startEmulation` entirely — the live postMessage context always takes priority. *** ## What's in `window.aptly` ```js theme={null} aptly.org.id // logged-in user's company ID aptly.org.name // company display name aptly.user.email // logged-in user's email aptly.user.firstName aptly.user.lastName aptly.user.role // role name assigned in this org aptly.user.teams // array of team IDs aptly.config // admin-declared config variables — read any key directly aptly.board // { id, schema } if the app is embedded as a board tab, else null ``` For the full reference — all properties, config scoping rules, URL param testing — see the [SDK Reference](/aptly-sdk-reference). *** ## Next steps * **Deploy to Replit** — publish your app, then paste the URL into an Aptly board tab or dashboard panel * **Declare config variables** — add fields like `BOARD_ID` in the app store admin panel so users can configure them on install * **UI actions** — use `aptly.openEmailComposer()`, `aptly.createCard()`, `aptly.createTask()`, and other named methods to trigger actions in the parent Aptly window. Actions are gated by `aptly.actions` (admin-configured allowlist) * **Full API reference** — [Delegate Tokens](/delegate-tokens) covers server-side token exchange, scopes, and advanced auth patterns # Rate Limits Source: https://docs.getaptly.com/rate-limits Request limits per API key and how to handle 429 responses. The Aptly API enforces rate limits per API key to ensure stability across all integrations. ## Limits | Scope | Limit | | ----------- | ---------------------------- | | Per API key | 120 requests per minute | | Burst | Up to 20 requests per second | These are the current defaults. Limits may be adjusted for specific plans or use cases. Contact Aptly support if your integration requires higher throughput. ## 429 responses When you exceed the rate limit, the API returns a `429 Too Many Requests` response: ```json theme={null} { "reason": "Rate limit exceeded. Try again shortly." } ``` The response includes a `Retry-After` header indicating how many seconds to wait before retrying. ## Handling rate limits Implement exponential backoff when you receive a `429`: ```javascript theme={null} async function fetchWithRetry(url, options, retries = 3) { const response = await fetch(url, options); if (response.status === 429 && retries > 0) { const retryAfter = response.headers.get("Retry-After") || 2; await new Promise(res => setTimeout(res, retryAfter * 1000)); return fetchWithRetry(url, options, retries - 1); } return response; } ``` ## Best practices * Fetch the board schema once and cache it. Do not fetch the schema on every request. * Batch card updates where possible instead of making one request per card. * When paginating through large boards, add a small delay between page requests. * Use a single API key per integration. Multiple keys from the same company share the same underlying account limits.