DocumentationDotallio Public API Keys

Product and integration details for teams building with Dotallio.

Browse documentation

Dotallio Public API Keys

The complete guide to creating, securing, using, rotating, and operating Dotallio's Unkey-backed public API keys.

Audience: integration developers, AI-agent authors, workspace owners, security administrators, and Dotallio deployment maintainers.

API version: /api/v1

Machine-readable contract: /api/v1/openapi.json


Table of contents

  1. Start here
  2. What a public API key is
  3. Security model
  4. Create a key
  5. Choose permissions
  6. Choose board access
  7. Plans and limits
  8. Authenticate requests
  9. Five-minute quickstart
  10. Magician-suite Prospects walkthrough
  11. Endpoint reference
  12. Understand board schemas
  13. Field names and addressing
  14. Supported value formats
  15. AI-powered columns
  16. Create complete rows
  17. Batch ingestion
  18. Convert CSV to JSON
  19. Build a board-aware AI agent
  20. Movie-board example
  21. Research-and-expand example
  22. Read and paginate items
  23. Deduplication and retry safety
  24. Responses and rate-limit headers
  25. Error reference
  26. Key lifecycle
  27. Security practices
  28. Incident response
  29. Deployment and Unkey setup
  30. Extension keys are separate
  31. Troubleshooting
  32. Frequently asked questions
  33. Production checklists

Start here

The normal integration flow is:

Create a scoped key
  → GET /api/v1/boards
  → GET /api/v1/boards/{boardId}/schema
  → normalize source data to JSON rows
  → POST /api/v1/boards/{boardId}/items
  → inspect warnings and created IDs
  → GET items when reconciliation is needed

The three rules that prevent most integration problems are:

  1. Fetch the board schema before writing. Do not guess field names or option values.
  2. Send complete, grounded rows. If your agent already knows a value, including a value for an AI-powered column, send it.
  3. Leave unknown AI cells empty. Dotallio can run their configured autocomplete when the cell is omitted, null, or empty.

Choose the shortest path that matches your job:

You want to…Start with…
make one authenticated requestFive-minute quickstart
install Magician and POST a Prospects leadMagician-suite Prospects walkthrough
import a CSVConvert CSV to JSON
build a research agentBuild a board-aware AI agent
understand every endpointEndpoint reference
respond to a leaked keyIncident response
configure Unkey for a deploymentDeployment and Unkey setup

The public API is deliberately schema-first. Examples in this guide show the current contract, but an integration must still read the installed board's schema. Template updates, renamed columns, and workspace customizations can change the correct field addresses without changing the endpoint URL.

Minimal quickstart

export DOTALLIO_ORIGIN="https://your-dotallio.example"
export DOTALLIO_API_KEY="dot_live_replace_with_your_key"

curl \
  -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  "$DOTALLIO_ORIGIN/api/v1/boards"

Never put a real API key in source control, a screenshot, a browser bundle, an AI prompt, a support ticket, or a client-side environment variable.


What a public API key is

A public API key is a server-to-server credential that represents one Dotallio user inside one workspace.

It is intended for:

  • backend services;
  • scripts and command-line tools;
  • workflow runners;
  • trusted AI agents;
  • scheduled data importers;
  • serverless functions;
  • integration platforms that can store secrets securely.

It is not intended for:

  • browser JavaScript;
  • mobile binaries where a secret can be extracted;
  • public forms;
  • URLs or query strings;
  • shared spreadsheets;
  • frontend NEXT_PUBLIC_* variables;
  • an internal Dotallio board-chat tool call.

Public API keys versus internal board chat

Dotallio's authenticated board chat uses server-side tools and the current user session. It does not need, receive, or expose a public API key.

Board chat can already:

  • read existing columns and rows;
  • learn preferences from current board contents;
  • parse CSV and JSON;
  • research current facts, links, and images;
  • fill known values in AI-powered columns;
  • leave unknown AI cells empty for autocomplete;
  • deduplicate titles;
  • insert large results in bounded batches;
  • report partial failures and progress.

External agents use the public REST API. CSV is an input format for the agent, not a second REST dialect: the agent converts CSV to the documented JSON row contract before sending it.

Public API keys versus extension keys

Browser-extension keys use a separate Unkey API, root key, namespaces, identities, and limits. They are not interchangeable with public API keys.


Security model

Authorization is the intersection of three independent boundaries:

Unkey
  verifies the secret, expiry, enabled state, and endpoint permission

AND

Dotallio public API policy
  restricts workspace, board mode, board allowlist, tier, and quotas

AND

Dotallio product authorization + Supabase RLS
  rechecks current membership, app role, hidden/deleted state, and row policy

All three must allow the operation.

This matters because a key is not a frozen copy of access. If the represented user is removed from the workspace, downgraded from editor to viewer, loses app membership, or loses access to a board, the key loses that access immediately on the next request.

Fail-closed behavior

The API does not fall back to local secret verification or unmetered access.

If Unkey, metering, or the local authorization store is unavailable, the API returns a retryable 503. It does not continue with weaker security.

One-time secrets

Unkey generates the secret. Dotallio shows it once and never stores the plaintext value. Dotallio stores only safe identifiers and authorization metadata such as:

  • local key ID;
  • Unkey key ID;
  • display prefix;
  • owner user ID;
  • workspace ID;
  • permissions;
  • board-scope mode;
  • selected board IDs;
  • expiry;
  • provider metadata;
  • bounded usage and lifecycle audit records.

If the secret is lost, rotate the key or create a replacement. It cannot be recovered.


Create a key

Open:

Account → Public API

Then:

  1. Select the workspace.
  2. Enter a recognizable key name.
  3. Choose an expiry from 1 through 365 days.
  4. Choose a permission preset or explicit permissions.
  5. Choose selected-board access or all-board access.
  6. Review the effective tier limits.
  7. Create the key.
  8. Copy the secret before dismissing the one-time panel.
  9. Store it in a production secret manager.
  10. Make a low-risk GET /api/v1/me request to verify it.

Key naming

Names are limited to 60 characters. Use names that answer both of these questions:

  • Which system uses the key?
  • Which environment uses it?

Good names:

Research agent — production
CRM nightly import — staging
Customer portal sync — production
Illusion catalog expander — test

Avoid names such as Key 1, API, or New key.

Key count

One user may have up to 10 active provider-backed public API keys. Revoke keys that are no longer used before creating more.

Environment prefixes

EnvironmentPrefixExample shape
Productiondot_live_dot_live_…
Development/testdot_test_dot_test_…

A production deployment rejects test-prefixed credentials, and a test deployment rejects live-prefixed credentials.


Choose permissions

Every endpoint requires one explicit permission.

PermissionAllowsEndpoint
profile.readVerify identity and read the key's workspace profileGET /api/v1/me
boards.listList boards allowed by the key policyGET /api/v1/boards
board.schema.readRead columns, accepted values, and examplesGET /api/v1/boards/{boardId}/schema
board.items.readRead board rowsGET /api/v1/boards/{boardId}/items
board.items.writeCreate complete rowsPOST /api/v1/boards/{boardId}/items

Discovery-only agent

profile.read
boards.list
board.schema.read

Read-only data agent

profile.read
boards.list
board.schema.read
board.items.read

Read-and-write board agent

profile.read
boards.list
board.schema.read
board.items.read
board.items.write

Least privilege

Grant only the permissions the integration actually calls. A write-only ingestion service may technically need only board.items.write, but agents usually also need schema and read access to avoid bad field mappings and duplicates.

Write scope can be created only when the owner has an editor, admin, or owner role on eligible boards. Live role checks still run on every write.


Choose board access

A key belongs to exactly one workspace and uses one of two explicit board policies.

Selected boards: allowlist

This is the recommended default.

The key may access only the selected board IDs. An empty allowlist never means "all boards." Creation and policy update require at least one selected board, and a policy may include at most 200 board IDs.

Use selected-board mode for:

  • single-purpose integrations;
  • customer-specific sync services;
  • agents that should not discover unrelated boards;
  • write-enabled credentials;
  • third-party systems with a narrow purpose.

All boards: all

The key may access every current and future board in the workspace that the represented user can access through current product permissions.

Use this only when future-board discovery is an intentional requirement.

All-board mode does not bypass:

  • app membership;
  • app role;
  • workspace membership;
  • hidden-board rules;
  • deleted/inactive state;
  • Supabase RLS;
  • endpoint permissions.

Local and provider policy intersection

Dotallio stores the board policy locally and also places policy metadata on the Unkey key. The effective policy is the intersection. If synchronization is partial, the result can deny too much, but it cannot authorize too much.


Plans and limits

Public API availability and quotas are workspace-level subscription features.

TierRequests per minute per keyWeighted operations per workspace per UTC month
FREEAPI unavailableAPI unavailable
MEMBER6025,000
PLUS120100,000
PRO300500,000
ENTERPRISEAdmin override, otherwise PRO fallbackAdmin override, otherwise PRO fallback

Weighted costs

OperationCost
Any authenticated GET1
Item POSTNumber of fully validated rows, from 1 to 100

Examples:

  • Listing boards costs 1.
  • Reading a schema costs 1.
  • Reading a page of 200 items costs 1.
  • Creating 1 row costs 1.
  • Creating 75 rows costs 75.
  • Creating 100 rows costs 100.

The monthly counter uses one shared identifier for the workspace and UTC calendar month. Multiple keys cannot multiply the workspace budget.

What happens after a downgrade

If the workspace becomes FREE, existing keys immediately fail with API_UNAVAILABLE_FOR_TIER. A previously issued secret is not a promise of continued plan access.


Authenticate requests

Send exactly one credential header.

Bearer authentication

Authorization: Bearer dot_live_your_secret

API-key header

X-API-Key: dot_live_your_secret

Do not send both headers. Conflicting credential headers return AMBIGUOUS_API_KEY.

Why CORS is not enabled

Public keys are server credentials, not browser tokens. The API intentionally does not provide wildcard browser CORS.

Use this architecture:

Browser or mobile client
  → your trusted backend
  → Dotallio public API

Do not use this architecture:

Browser bundle containing Dotallio secret
  → Dotallio public API

Five-minute quickstart

1. Verify the key

curl \
  -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  "$DOTALLIO_ORIGIN/api/v1/me"

Example response:

{
	"ok": true,
	"user": {
		"id": "user-uuid",
		"email": "owner@example.com",
		"name": "Board Owner"
	},
	"workspaces": [
		{
			"id": "workspace-uuid",
			"name": "Research Workspace"
		}
	]
}

Only the key's immutable workspace is returned.

2. List boards

curl \
  -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  "$DOTALLIO_ORIGIN/api/v1/boards"

Example response:

{
	"ok": true,
	"boards": [
		{
			"id": "board-uuid",
			"name": "Movie Research",
			"workspaceId": "workspace-uuid",
			"workspaceName": "Research Workspace",
			"appId": "app-uuid",
			"appName": "Media Catalog",
			"canInsert": true,
			"schemaUrl": "/api/v1/boards/board-uuid/schema"
		}
	]
}

3. Read the schema

export BOARD_ID="board-uuid"

curl \
  -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  "$DOTALLIO_ORIGIN/api/v1/boards/$BOARD_ID/schema"

4. Create a row

curl -X POST \
  -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Arrival",
    "release_year": 2016,
    "watched": true
  }' \
  "$DOTALLIO_ORIGIN/api/v1/boards/$BOARD_ID/items"

5. Read it back

curl \
  -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  "$DOTALLIO_ORIGIN/api/v1/boards/$BOARD_ID/items?limit=50&offset=0"

Magician-suite Prospects walkthrough

This chapter is the Magician Add Contact worked example: install a current Hebrew or English magician/mentalist suite, create a least-privilege public API key allowlisted to the installed Prospects board, and POST a lead row there.

Prospects, not Companies. Website / server Add Contact for Magician suites writes to PROSPECTS_BOARD_ID — titled לידים (Hebrew) or Leads (English). Do not allowlist or POST to לקוחות / Companies for this path. Companies remains the quote-CRM customer board; quote intake may still match/create customers there. Add Contact does not.

This matches the in-app App Settings → Add Contact / API intake panel (T5), which resolves PROSPECTS_BOARD_ID and emits the same curl shape and field titles used below.

Current packaged Magician templates:

PropertyHebrew suiteEnglish suite
Template nameHebrew Magician/Mentalist Quote SuiteEnglish Magician/Mentalist Quote Suite
Template IDservice-provider-quote-suite-he-magician-mentalistservice-provider-quote-suite-en-magician-mentalist
Prospects boardלידים (PROSPECTS_BOARD_ID)Leads (PROSPECTS_BOARD_ID)
Fragmentfragments/prospect-leads-board.he.v1fragments/prospect-leads-board.en.v1
Companies (quotes)לקוחות (COMPANIES_BOARD_ID) — not Add Contactsame token; Magician Add Contact still uses Prospects
Quote/intake boardהצעות מחיר (EVENTS_BOARD_ID)הצעות מחיר (EVENTS_BOARD_ID)
Default localeHebrew / RTLEnglish

Template IDs and board tokens are installation-time identifiers. External API calls use the real board UUID from App Settings or GET /api/v1/boards.

Understand the three input paths

  1. App setup questionnaire: the owner answers this once after installation. It configures branding, language, tax, pricing defaults, and generated documents.
  2. Quote intake questionnaire: the performer or customer answers this for each inquiry. It runs the suite-specific intake workflow, creates a quote, and may match or create a Companies customer by phone.
  3. Public API Add Contact: a trusted server posts complete rows to the Prospects / Leads board. It does not impersonate quote intake and does not invoke template.intake.submit.
App setup questionnaire
  → configures the installed suite

Quote intake questionnaire
  → quote workflow (+ Companies match/create by phone)

POST /api/v1/boards/{prospectsBoardId}/items
  → creates Prospects / Leads row(s) directly

Use the intake form when you want a quote and its suite workflow. Use Add Contact / the public API when a website form or trusted agent already has a lead to land on לידים / Leads.

Step 1: install the suite with AI chat

This is the preferred natural-language path:

  1. Open the workspace where the suite should live.
  2. Open the main AI chat.
  3. Click the Create App mode chip. Its input changes to What app should I build?
  4. Paste a request for the locale you need:
Install a Hebrew magician/mentalist business suite with quote intake, a
Prospects/Leads board, quote PDFs, follow-up, and the existing magician-specific
package. Prefer the existing Hebrew Magician/Mentalist Quote Suite template
instead of creating a duplicate. Keep the לידים (Prospects), לקוחות
(Companies), and הצעות מחיר boards and the public quote-intake form.

For English, ask for English Magician/Mentalist Quote Suite (service-provider-quote-suite-en-magician-mentalist) and keep Leads (Prospects).

  1. Send the message.
  2. Read the AI's proposed template/persona, language, feature groups, and branding plan. If it proposes removing Prospects/Leads, reply before the install runs.
  3. Let the AI use the existing template search and install flow.
  4. When the installed app opens, continue to the setup questionnaire.

The AI chat installs the packaged app through Dotallio's authenticated internal tooling. Do not give the chat a public API key. The key is only for the external server or agent that will send Prospects rows later.

Alternative: install it from the template picker

If you prefer the manual UI:

  1. Open the app switcher in the sidebar.
  2. Click Create New App.
  3. In Create a new app, stay on App Templates.
  4. In Search app templates…, search for magician.
  5. Select Hebrew Magician/Mentalist Quote Suite or English Magician/Mentalist Quote Suite.
  6. Review What this template creates and confirm a לידים / Leads board is listed.
  7. Click Install.
  8. Wait for the app to open; do not click Install twice while it is working.

Do not use the deprecated magician-apps/ tree. Live Magician suites ship from app-templates.

Step 2: answer the app setup questionnaire

The first-run dialog is titled Finish app setup and contains Set up your quote app. Complete it before wiring the external integration.

QuestionRequired?Current default or choicesWhat it controls
Feature selectionvariestemplate feature groupsWhich optional packaged capabilities stay enabled
Document languageyesHebrew, English, Hebrew + EnglishQuote PDFs and intake-form language
Your name or stage nameyesemptyTitle printed on every generated quote PDF
PhonenoemptyBooking phone in the quote footer
EmailnoemptyReply/booking email in the quote footer
LogonoPNG or JPEG, up to 5 MBBrand logo in generated material
VAT rateyes17%Tax calculations and document defaults
Validityyes30 daysDefault quote validity, 1–365 days
CurrencyyesILS, USD, or EURMonetary formatting and defaults
Starting price for a 1-hour shownoemptyContext for AI pricing suggestions
Brand voicenoPlayful, Professional, MysticalTone used when AI personalizes quote copy

Recommended magician example:

Document language: Hebrew + English
Your name or stage name: Maya Lev — Mentalist
Phone: +972 50 555 0142
Email: bookings@example.com
VAT rate: 17
Validity: 30
Currency: ILS
Starting price for a 1-hour show: 3500
Brand voice: Mystical

Then:

  1. Upload a logo only if it is ready and approved.
  2. Click Continue.
  3. Wait for Setup complete.
  4. Follow the product tour. Identify Your intake form, Prospects / Leads, Companies, and quotes.
  5. Complete or skip the optional photo wizard according to your content policy.

The stage/business name is the one field you should not leave as a template placeholder. It becomes customer-facing document copy.

To edit these answers later, open the installed app's Pack Settings, choose the setup-answer control, edit the same questionnaire, and click Save.

Step 3: know when quote intake still uses Companies

The installed הצעת מחיר חדשה form still drives the quote workflow and may match or create a לקוחות customer by phone. That is correct for quotes and is not the Add Contact API path.

To test the native suite flow:

  1. Open the app's intake page.
  2. Find the form titled הצעת מחיר חדשה.
  3. Fill the event, audience, and pricing fields.
  4. Click יצירת הצעה.
  5. Confirm the quote appears on הצעות מחיר.
  6. Open לקוחות only to confirm quote-side customer match/create.

Website lead capture and the public API examples below target לידים / Leads instead.

Step 4: preferred path — App Settings Add Contact panel

After install, open the Magician app's App Settings. The Add Contact / API intake card:

  1. Resolves the live PROSPECTS_BOARD_ID UUID and shows the board label (לידים (Prospects) or Leads (Prospects)).
  2. Mints a public API key named like Add Contact — Prospects, allowlisted to that UUID only, with:
    • board.schema.read
    • board.items.read
    • board.items.write
  3. Shows a copy-paste curl that fetches schema, then POSTs a Prospects row using the packaged HE or EN field titles, Intake source / מקור פנייה = website, and optional sandboxed talkingPoints.

Use that panel when you can. The rest of this chapter documents the same contract manually so an integration developer can reproduce it from Public API settings or a script.

Step 5: inspect Prospects columns before writing

Open לידים (Hebrew) or Leads (English). Packaged writable intake columns currently include:

HE titleEN titleTypeNotes
שם מלאFull nametextprimary lead name
טלפוןPhonephoneprimary contact phone
אימיילEmailtextcontact email
מקצועProfessiontextused in the settings-panel example
חברהCompanytextoptional organization
סוג אירוע מתענייןInterested event typelabeluse live options
יום הולדתBirthdaydateYYYY-MM-DD
ילדים וימי הולדתKids and birthdayslongTextfree text
הרגע הקסוםThe magic momentlongTextshow-side opener context
נפגשנו באירועMet at eventtextwhere you met
תאריך היכרותDate metdateYYYY-MM-DD
פולו אפ הבאNext follow-updateYYYY-MM-DD
סטטוסStatuslabeluse live options
מקור פנייהIntake sourcelabelallowlisted presets (Add Contact uses website; see below)
נקודות לשיחהTalking pointslongTextprefer reserved talkingPoints / talking_points enrichment (sandboxed plain text)
התנדבותVolunteercheckboxboolean
מועד האירועEvent date/timedatetimeISO-8601 when writable
פוטנציאלPotentiallabeluse live options
הערותNoteslongTextfree text; also fallback target if Talking points column is absent

Intake source / מקור פנייה on Magician Prospects boards accepts the packaged preset values whatsapp, facebook, manual, website, and other (send the option value, or its label). Website / server Add Contact examples use website.

talkingPoints (or talking_points) is a locale-independent reserved enrichment key on POST …/items. Send a plain string or string[] only — values are HTML/control-stripped and size-bounded, then mapped onto נקודות לשיחה / Talking points (Notes fallback). Objects and executable payloads are rejected. Do not invent board columns named talkingPoints.

Action/button columns (Call / Email / WhatsApp) and AI-assisted columns such as פתיח שיחה / Call opener are not for raw POST in the first test. Omit them, or fill only when the live schema marks them writable and you have a grounded value.

The installed board is editable. A workspace owner may rename or add columns, so this table is orientation—not an API contract. The schema endpoint is the contract.

Step 6: create a Prospects-only public API key (manual UI)

Create the key only after לידים / Leads exists, because allowlist scope needs a real board UUID.

  1. Open the user menu → Public API (or use App Settings → Add Contact).
  2. Under Create a scoped key, select the suite's workspace.
  3. Enter a name such as Add Contact — Prospects.
  4. Set Expires (days) to the shortest practical rotation period.
  5. Under Permissions, select at least:
    • Read board schemas;
    • Read board items; and
    • Create full board rows.
  6. Add List boards only if this integration must call GET /api/v1/boards for discovery. The settings panel already exposes the UUID, so many website relays skip boards.list.
  7. Under Board access, keep Selected boards only (recommended).
  8. Select לידים or Leads — the Prospects board. Do not select לקוחות / Companies for Magician Add Contact.
  9. Do not add הצעות מחיר unless the same key truly needs quote-board access.
  10. Review per-minute and monthly limits → Create key.
  11. Copy the one-time secret into the backend secret manager immediately.

Do not choose All current and future boards merely to skip discovery.

The older API Keys page is for browser-extension credentials. It cannot be used here.

Step 7: configure the trusted sender

DOTALLIO_ORIGIN=https://your-dotallio.example
DOTALLIO_API_KEY=dot_live_the_one_time_secret
DOTALLIO_PROSPECTS_BOARD_ID=the-real-prospects-board-uuid

Prefer copying DOTALLIO_PROSPECTS_BOARD_ID from the Add Contact panel. For development, use the deployment's dot_test_… key. A live deployment rejects a test prefix, and a test deployment rejects a live prefix.

Never put DOTALLIO_API_KEY in NEXT_PUBLIC_*, browser JavaScript, public form definitions, source control, AI prompts, analytics, request logs, screenshots, or support tickets.

Step 8: discover the Prospects board ID (if not using the panel)

curl \
  -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  "$DOTALLIO_ORIGIN/api/v1/boards"

Find the entry whose name is לידים or Leads and whose appName is the installed magician suite. A representative Hebrew response is:

{
	"ok": true,
	"boards": [
		{
			"id": "4bff7e69-1111-4444-8888-703a8f987654",
			"name": "לידים",
			"workspaceId": "workspace-uuid",
			"workspaceName": "Magic Studio",
			"appId": "app-uuid",
			"appName": "Hebrew Magician/Mentalist Quote Suite",
			"canInsert": true,
			"schemaUrl": "/api/v1/boards/4bff7e69-1111-4444-8888-703a8f987654/schema"
		}
	]
}

Save the returned id as DOTALLIO_PROSPECTS_BOARD_ID. The UUID above is illustrative.

If Prospects is absent, check workspace, allowlist, app membership, active app, and that the key has boards.list when using this discovery call. If canInsert is false, the represented user also needs owner/admin/editor access to that app.

Step 9: fetch the live Prospects schema

curl \
  -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  "$DOTALLIO_ORIGIN/api/v1/boards/$DOTALLIO_PROSPECTS_BOARD_ID/schema"

Copy advertised key values for production. Exact titles are accepted and match the settings-panel first test; stable integrations should map to schema keys.

Before sending any field, enforce:

writable === true
value conforms to format
option is one of fields[].options[].value or .label

Step 10: create a Prospects row (parity with the settings panel)

The App Settings snippet, /api/v1/openapi.json createItems examples (magicianHebrewProspectsAddContact / magicianEnglishProspectsAddContact), and this guide share the same title-based payloads from public-api-add-contact.utils.ts.

Use a key allowlisted to Prospects / Leads only. OpenAPI also keeps a Companies / לקוחות example (companiesQuoteCrmContrast) for non-Magician quote CRM — do not use that payload for Magician Add Contact.

Hebrew Magician (service-provider-quote-suite-he-magician-mentalist):

curl -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  "$DOTALLIO_ORIGIN/api/v1/boards/$DOTALLIO_PROSPECTS_BOARD_ID/schema"

curl -X POST \
  -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  -H "Content-Type: application/json" \
  "$DOTALLIO_ORIGIN/api/v1/boards/$DOTALLIO_PROSPECTS_BOARD_ID/items" \
  -d '{
  "items": [
    {
      "שם מלא": "דנה כהן",
      "טלפון": "+972501234567",
      "אימייל": "dana@example.com",
      "מקצוע": "מפיקת אירועים",
      "מקור פנייה": "website",
      "talkingPoints": [
        "נפגשנו באירוע חברה — התלהבה מהקסם עם הקלפים",
        "מפיקה אירועים ארגוניים; מחפשת מופע של ~90 דק׳"
      ]
    }
  ]
}'

English Magician (service-provider-quote-suite-en-magician-mentalist):

curl -X POST \
  -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  -H "Content-Type: application/json" \
  "$DOTALLIO_ORIGIN/api/v1/boards/$DOTALLIO_PROSPECTS_BOARD_ID/items" \
  -d '{
  "items": [
    {
      "Full name": "Dana Cohen",
      "Phone": "+15551234567",
      "Email": "dana@example.com",
      "Profession": "Event producer",
      "Intake source": "website",
      "talkingPoints": [
        "Met at a corporate offsite — loved the card bit",
        "Produces company events; asking about a ~90 min show"
      ]
    }
  ]
}'

Title-address form is fine for a first manual test. For production, replace board titles with the live schema key values. Keep the reserved talkingPoints / talking_points key as documented (it is not a board title).

Step 11: send a fuller Prospects row

{
	"items": [
		{
			"שם מלא": "דנה כהן",
			"טלפון": "+972501234567",
			"אימייל": "dana@example.com",
			"מקצוע": "מפיקת אירועים",
			"חברה": "Orion Events",
			"מקור פנייה": "website",
			"הרגע הקסום": "Chose the three of hearts during the table hop",
			"פולו אפ הבא": "2026-10-05",
			"talkingPoints": [
				"Website contact form — birthday lead",
				"Chose the three of hearts during the table hop"
			],
			"הערות": "Optional free-text notes separate from sandboxed talking points"
		}
	]
}

For English installs, use the EN titles from Step 5 (Full name, Phone, Email, Profession, Company, Intake source, The magic moment, Next follow-up, Talking points via talkingPoints, Notes, …). Label fields must use a live option value or label (Intake source → whatsapp|facebook|manual|website|other).

Step 12: PowerShell (UTF-8 Hebrew titles)

$headers = @{
	Authorization = "Bearer $env:DOTALLIO_API_KEY"
	"Content-Type" = "application/json"
}

$uri = "$env:DOTALLIO_ORIGIN/api/v1/boards/$env:DOTALLIO_PROSPECTS_BOARD_ID/items"
$body = Get-Content -LiteralPath ".\prospect-lead.json" -Raw -Encoding utf8

Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -Body $body

Use one credential header only. Do not add X-API-Key when Authorization is already present.

Step 13: understand the success response

{
	"ok": true,
	"createdIds": ["new-item-uuid"],
	"boardId": "the-real-prospects-board-uuid",
	"warnings": []
}

Treat non-empty warnings as integration defects. Unknown fields are ignored with warnings, so HTTP 200 alone does not prove every requested cell was stored.

Each validated row costs one weighted workspace operation.

Step 14: read the lead back

curl \
  -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  "$DOTALLIO_ORIGIN/api/v1/boards/$DOTALLIO_PROSPECTS_BOARD_ID/items?limit=20&offset=0"

Verify:

  1. the returned item ID matches createdIds;
  2. name, phone, email, and profession are correct;
  3. warnings were empty; and
  4. the row is visible in the לידים / Leads board UI — not only in Companies.

Step 15: batch Prospects rows

One request may contain 1–100 rows. Validation is batch-atomic: one invalid row yields 422 and inserts none of the validated rows. A later 503 can be partially applied — reconcile before retry.

Step 16: prevent duplicate leads

The generic public create endpoint does not promise Magician-specific upsert.

Before importing:

  1. normalize phones to one international representation;
  2. read existing Prospects rows;
  3. index non-empty normalized phones (then email);
  4. send only new records; and
  5. optionally add an immutable source_lead_id column, refetch schema, and deduplicate on that value.

Do not use display name alone as the stable identity.

Step 17: safe agent operating instruction

Keep the key in the runtime, then give the agent a contract like this:

You are adding leads to the installed magician suite's Prospects board
(לידים / Leads). Never write Magician website contacts to Companies/לקוחות.

1. Prefer the board UUID from App Settings Add Contact (PROSPECTS_BOARD_ID).
   If discovering via API, GET /api/v1/boards and select name "לידים" or
   "Leads" in the magician-suite app. Never guess its UUID.
2. Call that board's schema endpoint before writing.
3. Use fields[].key as the production field address. Respect writable, format,
   and options exactly.
4. Deduplicate primarily by normalized phone, then email or external source ID.
5. Fill grounded lead fields (name, phone, email, profession, notes, …).
6. Omit action/button columns and uncertain AI cells unless schema-writable and
   grounded.
7. Send no more than 100 rows per request; inspect warnings and createdIds.
8. A direct Prospects POST creates lead rows only. It does not create a quote
   or run template intake.
9. Never print, log, return, or place the Dotallio key in model context.

Step 18: form vs API

Use the native quote intake form when the desired result includes quote defaults, event capture, Companies match/create, quote artifacts, or intake workflow side effects.

Use Prospects Add Contact / public API when the desired result is:

  • website contact → לידים / Leads;
  • synchronizing researched prospects as leads;
  • enriching Prospects columns the external agent already knows; or
  • creating lead rows for later human follow-up.

Step 19: Magician Prospects troubleshooting

SymptomLikely causeFix
לידים / Leads missing from /boardswrong workspace, allowlist, or app membershipReopen Public API / Add Contact panel; verify workspace and selected boards
Key allowlisted to לקוחותMagician Add Contact pointed at CompaniesRetire that key; mint Prospects-scoped key (PROSPECTS_BOARD_ID)
canInsert is falseuser is a viewerGrant editor/admin/owner or create the key for an authorized owner
403 INSUFFICIENT_SCOPEmissing endpoint permissionAdd only required permissions (board.schema.read, board.items.*)
404 BOARD_NOT_FOUNDwrong UUID or board outside policyCopy UUID from Add Contact panel or rediscover with /boards
422 on a label fieldinvalid optionUse a value/label from the live field options
Row created but a field is absentunknown field ignoredInspect warnings, fetch schema, use advertised key
Lead missing from Companiesexpected — Add Contact writes ProspectsOpen לידים / Leads; Companies is for quote CRM
Duplicate leadcreate-only endpointReconcile by phone/email/source ID before POST
429 after a batchminute or shared monthly limitHonor rate-limit headers; each row is one weighted operation

Step 20: production handoff checklist

  • Correct Magician HE or EN template is installed once (app-templates, not deprecated magician-apps/).
  • Setup answers contain the real stage/business name and approved branding.
  • Prospects board (לידים / Leads, token PROSPECTS_BOARD_ID) is present.
  • Key is on Public API (or minted from Add Contact panel), not the extension-key page.
  • Key is scoped to the Prospects board only — not Companies / לקוחות.
  • Permissions include schema read + items read + items write (and boards.list only if needed).
  • One-time secret is in a backend secret manager.
  • Sender uses the Prospects UUID and fetches schema at job start.
  • Curl/snippet field titles match the locale (HE: שם מלא/טלפון/אימייל/מקצוע/מקור פנייה; EN: Full name/Phone/Email/Profession/Intake source).
  • Example includes Intake source / מקור פנייה = website (or another allowlisted preset) and optional talkingPoints plain text / string[].
  • Production mappings use advertised field keys (except reserved talkingPoints / talking_points).
  • Phone/email/source-ID deduplication runs before create.
  • Warnings and created IDs are recorded without logging the credential.
  • Usage, expiry, audit, rotation, and revocation have named owners.

Endpoint reference

GET /api/v1/me

Required permission: profile.read

Weighted cost: 1

Use it to:

  • verify a newly created secret;
  • identify the represented user;
  • confirm the key's workspace;
  • detect a removed user or unavailable workspace.

GET /api/v1/boards

Required permission: boards.list

Weighted cost: 1

Returns only boards that satisfy all of these conditions:

  • inside the key's workspace;
  • included by the key's board policy;
  • visible to the represented user;
  • inside an active, non-deleted app and workspace;
  • not hidden from the represented role.

canInsert tells the caller whether the current app role can write.

GET /api/v1/boards/{boardId}/schema

Required permission: board.schema.read

Weighted cost: 1

Returns:

  • board ID and name;
  • every active column;
  • preferred field key;
  • immutable column ID;
  • title and type;
  • whether the field is writable;
  • primary-field marker;
  • option values and labels;
  • accepted format;
  • read-only reason;
  • a ready-to-send example.

GET /api/v1/boards/{boardId}/items

Required permission: board.items.read

Weighted cost: 1

Query parameters:

ParameterDefaultRangeBehavior
limit501–200Out-of-range values are clamped
offset00 or greaterInvalid/negative values become 0

Rows are returned newest first. Empty fields are omitted.

POST /api/v1/boards/{boardId}/items

Required permission: board.items.write

Weighted cost: validated row count

Limits:

  • 1 through 100 rows per request;
  • 1 MB maximum request body;
  • JSON only;
  • one object per row.

Accepts either one row object or a batch envelope.

Machine-readable examples in /api/v1/openapi.json lead with Magician Add Contact → Prospects (HE then EN), keep a Companies / לקוחות quote-CRM contrast example, and document optional reserved talkingPoints / talking_points (plain string or string[], sandboxed onto Talking points / Notes). Magician Prospects boards also accept Intake source / מקור פנייה with preset values whatsapp|facebook|manual|website|other.

When Idempotency-Key is a UUID, create also returns boardId, version, updatedAt, and etag for every created item. An identical retry returns the stored response without another row, activity, workflow, or realtime event. Reusing the key with a different board or canonical payload returns 409 idempotency_key_reused. Requests without the header retain the legacy response contract.

GET /api/v1/boards/{boardId}/items/{itemId}

Required permission: board.items.read

Returns one item with its current numeric version, exact-title cells, and a strong ETag such as "item:<itemId>:v7" in both the item and response header.

PATCH /api/v1/boards/{boardId}/items/{itemId}

Required permission: board.items.write

Weighted cost: 1

Every request requires a UUID Idempotency-Key and the exact current ETag in If-Match. Send exactly one non-empty cells object with 1–50 exact, case-sensitive column titles:

{
	"cells": {
		"Small Talk Details": "Preferred reply: email",
		"Email": "buyer@example.com"
	}
}

Omitted cells remain unchanged. null explicitly clears a nullable cell; empty string retains that column's normal value semantics. Unknown, duplicate, read-only, formula, system, computed, or invalid columns fail the whole request with 422 validation_failed and bounded repair issues. A stale ETag changes nothing and returns 412 version_conflict with only currentVersion and currentEtag. A successful non-no-op increments the version exactly once.

An identical retry returns the stored body and ETag with replayed: true and does not create a second activity or downstream dispatch. Use a fresh UUID for each new intended update.


Understand board schemas

Never design an agent around a screenshot of a board. Read the schema endpoint at the start of a job and refresh it when a validation response indicates that the board changed.

Example field descriptor:

{
	"key": "status",
	"id": "column-uuid",
	"title": "Status",
	"type": "status",
	"writable": true,
	"options": [
		{ "value": "researching", "label": "Researching" },
		{ "value": "ready", "label": "Ready" }
	],
	"format": "one of the listed options (label or value, case-insensitive)"
}

Example read-only formula descriptor:

{
	"key": "display_score",
	"id": "column-uuid",
	"title": "Display Score",
	"type": "number",
	"writable": false,
	"reason": "computed by a formula"
}

Schema-driven agent rule

For each field:

If writable is false:
  never send it

If writable is true and the agent has grounded information:
  send the normalized value

If writable is true, AI-powered, and the agent lacks grounded information:
  omit it or send an empty value

Field names and addressing

A payload may address a column by:

  1. the advertised schema key — preferred;
  2. the immutable column UUID — always unambiguous;
  3. the column's exact title, case-insensitive.

Preferred keys

When a column has no explicit key, Dotallio derives one from its title:

"Release Year" → "release_year"
"IMDb.URL" → "imdb_url"

If multiple columns derive the same key, later keys receive suffixes such as _2 and _3. Use exactly what the schema endpoint advertises.

Duplicate addresses in one row

Do not address the same column twice:

{
	"title": "Arrival",
	"Title": "Duplicate address for the same column"
}

The API rejects ambiguous or duplicate addressing with a repair hint.

Unknown fields

Unknown fields are ignored and returned as warnings. This allows integrations to evolve without turning one extra source field into a failed import.

However, if no field in a row matches a board column, the row fails validation.

Treat warnings as actionable telemetry. They often indicate:

  • a renamed column;
  • a stale schema cache;
  • a typo;
  • a source-field mapping that no longer belongs in the payload.

Supported value formats

Text family

Column types include:

text, longText, email, phone, url, link, markdown,
code, richText, color, location

Accepted values:

  • strings;
  • numbers and booleans, converted to strings.

Objects and arrays are rejected for text fields.

{
	"title": "Arrival",
	"imdb_url": "https://www.imdb.com/title/tt2543164/",
	"notes": "First-contact science fiction"
}

Number family

Column types include:

number, rating, percentage, progress, slider, duration

Accepted values:

  • finite JSON numbers;
  • numeric strings.
{
	"release_year": 2016,
	"rating": "8.0"
}

NaN, infinity, and non-numeric strings are rejected.

Boolean family

Column types:

checkbox, toggle

Accepted true values:

true, "true", "yes", 1, "1"

Accepted false values:

false, "false", "no", 0, "0"

Date family

Column types:

date, datetime

Send ISO 8601 strings:

{
	"release_date": "2016-11-11",
	"researched_at": "2026-07-16T12:30:00Z"
}

Ambiguous locale values such as 07/06/2026 are rejected.

Single-option family

Column types include:

select, label, radio, status, priority

Send one option value or label. Matching is case-insensitive, and Dotallio stores the stable option value.

{
	"status": "ready"
}

Always prefer the schema's value over the display label.

Multi-option family

Column types:

multiSelect, multiLabel

Send an array of option values or labels:

{
	"genres": ["science-fiction", "drama"]
}

A bare string is accepted as a one-element array, but explicit arrays are clearer and more portable.

Conditional options

If a child option list depends on a parent field, send the parent and child in the same row. The child value must be allowed by the supplied parent value.

JSON family

Send an object or array:

{
	"source_metadata": {
		"source": "official-site",
		"confidence": 0.94,
		"retrievedAt": "2026-07-16T12:30:00Z"
	}
}

Strings are also accepted for JSON-backed fields, but structured JSON is recommended when the source is structured.

Formula and unsupported fields

Formula columns are always read-only. Other column types that the API cannot write are returned with writable: false and a reason.

Never guess how to serialize a read-only field. Remove it from the payload.


AI-powered columns

AI-powered does not mean API-read-only.

If the agent already has a grounded value, it may write that value directly. The autocomplete trigger does not need to repeat work for an already populated cell.

Fill the cell directly

Use this when the information is already known or was deliberately researched:

{
	"title": "Arrival",
	"imdb_url": "https://www.imdb.com/title/tt2543164/",
	"summary": "A linguist is recruited to communicate with extraterrestrial visitors."
}

Leave the cell for autocomplete

Use omission when the agent does not know the value:

{
	"title": "Arrival"
}

The following also remain effectively unset at insertion:

{
	"title": "Arrival",
	"imdb_url": null,
	"summary": ""
}

Decision policy for agents

Grounded and current?
  Yes → fill the AI-powered cell.
  No  → leave it empty and allow configured autocomplete.

Formula field?
  Never send it.

Uncertain but plausible?
  Do not fabricate. Leave it empty or research it first.

When a board asks for links or images:

  • use canonical or primary sources where possible;
  • verify the URL resolves;
  • avoid search-result redirect URLs;
  • do not invent an image URL;
  • respect licensing and source terms;
  • include source metadata when the board has appropriate columns;
  • leave the field empty when the agent cannot establish a reliable value.

Create complete rows

Single-row body

{
	"title": "Arrival",
	"release_year": 2016,
	"genres": ["science-fiction", "drama"],
	"watched": true,
	"rating": 8.0,
	"imdb_url": "https://www.imdb.com/title/tt2543164/"
}

Batch body

{
	"items": [
		{
			"title": "Arrival",
			"release_year": 2016,
			"watched": true
		},
		{
			"title": "The Prestige",
			"release_year": 2006,
			"watched": false
		}
	]
}

A board field whose key is items

At the top level, items is reserved for the batch envelope. If the board itself has a writable field whose advertised key is items, send the row inside a batch envelope so the two meanings are unambiguous:

{
	"items": [
		{
			"title": "Inventory record",
			"items": "The value for the board column named items"
		}
	]
}

Do not send a single top-level row with a scalar items property; it will be interpreted as a malformed batch envelope.

Successful response

{
	"ok": true,
	"created": [
		{
			"id": "created-item-uuid",
			"url": "https://your-dotallio.example/app/board/board-uuid"
		}
	],
	"warnings": []
}

Validation atomicity

The entire request is validated before insertion begins. If validation returns 422, no row from that request was inserted.

Runtime partial failure

Rows are inserted in payload order. A database or downstream failure after insertion begins can leave earlier rows created and later rows absent. A 500 or 503 message tells the caller when preceding rows may already exist.

Before retrying a runtime failure:

  1. read the board;
  2. reconcile by a stable source identifier or normalized title;
  3. remove rows that already exist from the retry payload;
  4. retry only the missing rows.

Batch ingestion

One request accepts at most 100 rows. Split larger jobs into bounded batches.

Recommended batch sizes:

Job typeSuggested batch size
Simple text/number rows50–100
Rows with many fields25–50
Research-heavy agent output10–25 after research completes
Reconciliation-sensitive imports10–25

The API itself accepts 100; smaller batches reduce the reconciliation surface if a runtime failure occurs.

JavaScript batch helper

function chunk(values, size) {
	const chunks = []
	for (let index = 0; index < values.length; index += size) {
		chunks.push(values.slice(index, index + size))
	}
	return chunks
}

async function insertRows({ origin, key, boardId, rows }) {
	const results = []

	for (const batch of chunk(rows, 50)) {
		const response = await fetch(
			`${origin}/api/v1/boards/${boardId}/items`,
			{
				method: 'POST',
				headers: {
					Authorization: `Bearer ${key}`,
					'Content-Type': 'application/json',
				},
				body: JSON.stringify({ items: batch }),
			},
		)

		const payload = await response.json()
		if (!response.ok) {
			throw new Error(JSON.stringify(payload))
		}
		results.push(payload)
	}

	return results
}

For production, add the status-aware retry and reconciliation rules described later in this guide.


Convert CSV to JSON

The HTTP API does not accept text/csv. Parse the file in your trusted service or agent, map headers to schema field keys, normalize types, then POST JSON.

Example CSV:

Title,Release Year,Watched,Genres,IMDb URL
Arrival,2016,true,"science-fiction|drama",https://www.imdb.com/title/tt2543164/
The Prestige,2006,false,"drama|mystery",https://www.imdb.com/title/tt0482571/

JavaScript conversion

import { parse } from 'csv-parse/sync'

const records = parse(csvText, {
	columns: true,
	skip_empty_lines: true,
	trim: true,
})

const rows = records.map((record) => ({
	title: record['Title'],
	release_year: Number(record['Release Year']),
	watched: record['Watched'].toLowerCase() === 'true',
	genres: record['Genres'].split('|').filter(Boolean),
	imdb_url: record['IMDb URL'] || undefined,
}))

Python conversion

import csv
import io

reader = csv.DictReader(io.StringIO(csv_text))
rows = []

for record in reader:
    rows.append({
        "title": record["Title"],
        "release_year": int(record["Release Year"]),
        "watched": record["Watched"].lower() == "true",
        "genres": [value for value in record["Genres"].split("|") if value],
        "imdb_url": record["IMDb URL"] or None,
    })

CSV mapping procedure

  1. Fetch the current board schema.
  2. Build a mapping from source header to schema key.
  3. Reject or quarantine required source columns that are absent.
  4. Normalize each source value using the schema's type, format, and options.
  5. Omit source columns with no intended board mapping.
  6. Deduplicate rows.
  7. Split into batches.
  8. POST JSON.
  9. Persist created IDs or source-to-Dotallio mapping in your own integration.

Do not map by column position. Board columns can be reordered without changing their IDs or advertised keys.


Build a board-aware AI agent

A reliable agent separates reasoning/research from deterministic API enforcement.

Agent workflow

1. Authenticate and resolve workspace identity.
2. List boards allowed by the key.
3. Select the intended board by ID, not a fuzzy title alone.
4. Read the schema.
5. Read current rows.
6. Infer preferences and exclusions from current data.
7. Plan additions and stable deduplication keys.
8. Research current facts, links, and images with bounded workers.
9. Normalize research output to exact schema keys and value formats.
10. Fill known AI-powered cells; omit unknown AI-powered cells.
11. Validate locally against schema metadata.
12. Send bounded batches.
13. Record created IDs and warnings.
14. Re-read/reconcile after any uncertain runtime failure.
15. Produce a user-facing completion and partial-failure report.

Suggested agent instruction

You are writing structured rows to a Dotallio board.

Before proposing or writing rows:
- list allowed boards;
- read the target board schema;
- read existing rows;
- use the schema's field keys and option values;
- never write fields where writable is false;
- deduplicate against existing data;
- research current facts and canonical URLs when needed;
- fill an AI-powered cell only when its value is grounded;
- omit unknown AI-powered cells so Dotallio autocomplete may fill them;
- never invent an image or source URL;
- write no more than 100 rows per request;
- after an uncertain write failure, reconcile before retrying.

Schema cache policy

Schema data can be cached for one job, but refresh it when:

  • a validation error includes expectedSchema;
  • the user says the board changed;
  • an option no longer validates;
  • a field becomes read-only;
  • a previously valid key becomes unknown;
  • a long-running job crosses an administrative edit window.

Live authorization changes

Research may take minutes. The user's role can change between research and write. Treat a final 403 as authoritative. Do not attempt to work around it with another board or credential.


Movie-board example

Suppose the board schema exposes:

title             text, primary, writable
release_year      number, writable
genres            multiSelect, writable
director          text, AI-powered, writable
imdb_url          url, AI-powered, writable
poster_url        url, AI-powered, writable
personal_note     longText, writable
display_label     formula, read-only

If the agent has researched the data, send it:

{
	"items": [
		{
			"title": "Arrival",
			"release_year": 2016,
			"genres": ["science-fiction", "drama"],
			"director": "Denis Villeneuve",
			"imdb_url": "https://www.imdb.com/title/tt2543164/",
			"poster_url": "https://trusted-image-host.example/arrival.jpg",
			"personal_note": "Strong fit for cerebral first-contact stories."
		}
	]
}

Do not send display_label; it is a formula.

If the agent knows only title and year:

{
	"title": "Arrival",
	"release_year": 2016
}

The configured AI columns remain empty and may autocomplete.

Movie deduplication

Title alone is often insufficient. Normalize and compare a composite:

lower(trim(title)) + "|" + release_year

When an IMDb URL exists, its title identifier is a stronger external key.

Do not send a duplicate simply because punctuation, casing, or localized title differs.


Research-and-expand example

Goal: a board contains 25 preferred visual illusions, and an agent should add 75 more with complete details, sources, and images where available.

Step 1: inspect existing preferences

Read enough current rows to understand:

  • repeated categories;
  • complexity level;
  • whether the collection favors physical, optical, interactive, or digital illusions;
  • excluded or overrepresented topics;
  • title style;
  • link and image expectations;
  • how options are encoded.

Step 2: generate candidates

Generate more than 75 candidates, then remove:

  • exact duplicates;
  • normalized-title duplicates;
  • near-identical concepts with a different name;
  • items outside observed preferences;
  • candidates without enough grounded information for required fields.

Step 3: research bounded groups

Use bounded research concurrency. Each worker should return structured data, not prose:

{
	"title": "Ames Room",
	"category": "forced-perspective",
	"description": "A distorted room that appears rectangular from one viewpoint.",
	"source_url": "https://reliable-source.example/ames-room",
	"image_url": "https://reliable-image-source.example/ames-room.jpg",
	"confidence": 0.97
}

Step 4: map to schema

Use only current schema keys and allowed option values. Drop internal research properties such as confidence unless the board has a matching field.

Step 5: insert in batches

Three batches of 25 are easier to reconcile than one batch of 75, while all remain below the 100-row limit.

Step 6: report completion

The agent's final report should include:

  • requested count;
  • researched count;
  • duplicate count removed;
  • successfully created count;
  • batches attempted;
  • warnings;
  • rows that still need attention;
  • whether any AI-powered fields were intentionally left empty.

Read and paginate items

Example:

curl \
  -H "Authorization: Bearer $DOTALLIO_API_KEY" \
  "$DOTALLIO_ORIGIN/api/v1/boards/$BOARD_ID/items?limit=100&offset=0"

Response:

{
	"ok": true,
	"items": [
		{
			"id": "item-uuid",
			"createdAt": "2026-07-16T12:30:00Z",
			"fields": {
				"title": "Arrival",
				"release_year": 2016,
				"watched": true,
				"genres": ["science-fiction", "drama"]
			}
		}
	],
	"page": {
		"limit": 100,
		"offset": 0,
		"returned": 1,
		"hasMore": false
	}
}

Pagination loop:

async function readAllItems({ origin, key, boardId }) {
	const all = []
	let offset = 0

	while (true) {
		const response = await fetch(
			`${origin}/api/v1/boards/${boardId}/items?limit=200&offset=${offset}`,
			{ headers: { Authorization: `Bearer ${key}` } },
		)

		if (!response.ok) {
			throw new Error(`Read failed with ${response.status}`)
		}

		const page = await response.json()
		all.push(...page.items)

		if (!page.page.hasMore) break
		offset += page.page.returned
	}

	return all
}

The loop terminates on hasMore: false; it does not guess from a full page.


Deduplication and retry safety

The create endpoint accepts an optional UUID Idempotency-Key; exact-item PATCH requires one. Generate one UUID for an atomic operation and reuse that same value only when retrying the identical request. Reordered JSON object keys are equivalent. A changed board, item, value, or If-Match with the same key returns 409 idempotency_key_reused. Receipts are retained for at least 24 hours. If no key was supplied to legacy create, read the board and reconcile an uncertain outcome before submitting another create request.

Stable source identifier

When possible, add a board column such as:

source_id
external_id
canonical_url
catalog_key

Populate it from the external system and deduplicate before writing.

Title normalization

For idea/catalog boards, normalize titles by:

  • Unicode normalization;
  • trimming;
  • case folding;
  • collapsing whitespace;
  • optionally removing punctuation that is not semantically meaningful.

Use semantic judgment for near-duplicates. A regex alone cannot determine whether two differently named ideas are the same concept.

Retry categories

OutcomeRetry?Action
400 malformed requestNo automatic retryFix request construction
401 invalid/expiredNo automatic retryReplace or rotate credential
403 permission/tierNo automatic retryCorrect policy, role, or plan
404 boardNo automatic retryRefresh board list and policy
409 idempotency mismatchNo automatic retryGenerate a new key only for a genuinely new write
412 item version conflictReconcile firstGET the item; do not overwrite team-entered values
413 size/countRetry after changeSplit payload
422 validationRetry after changeApply issues and resend full batch
429 rate limitYesWait for Retry-After, then retry
503 unavailableYes, boundedWait for Retry-After; use backoff and jitter
uncertain connection loss during POSTReconcile firstRead board, remove created rows, retry missing rows

Bounded retry example

async function requestWithRetry(url, init, maxAttempts = 4) {
	for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
		const response = await fetch(url, init)

		if (response.status !== 429 && response.status !== 503) {
			return response
		}

		if (attempt === maxAttempts) return response

		const retryAfter = Number(response.headers.get('retry-after') ?? '1')
		const jitter = Math.floor(Math.random() * 250)
		await new Promise((resolve) =>
			setTimeout(resolve, retryAfter * 1000 + jitter),
		)
	}

	throw new Error('unreachable')
}

Never retry forever.


Responses and rate-limit headers

Every authenticated response includes:

HeaderMeaning
X-Request-IdDotallio request correlation ID
Cache-Control: private, no-storeDo not cache credential-bound content
Vary: Authorization, X-API-KeyCredential-sensitive response variance
RateLimit-LimitLimit for the reported window
RateLimit-RemainingRemaining operations in the reported window
RateLimit-ResetUnix time in seconds when that window resets

Rate denials also include:

Retry-After: 12

Retryable 503 responses include a short Retry-After hint.

On a 429, the rate-limit headers may describe either:

  • the per-key one-minute window; or
  • the shared workspace monthly window.

Use the error message and reset time to distinguish them.

Store X-Request-Id with your own integration log entry. Do not log the Authorization header.


Error reference

All non-success responses use this envelope:

{
	"ok": false,
	"error": {
		"code": "VALIDATION_ERROR",
		"message": "Human-readable explanation"
	}
}

Error codes

CodeTypical statusMeaningRecovery
INVALID_JSON400Body is not valid JSONFix serialization and content type
INVALID_REQUEST400Request shape or parameter is invalidFollow endpoint contract
MISSING_API_KEY401No usable credential was sentSend exactly one credential header
INVALID_API_KEY401Key is invalid, disabled, retired, or revokedCreate/rotate a key
EXPIRED_API_KEY401Key expiry passedRotate or replace it
AMBIGUOUS_API_KEY400Both credential headers were sentKeep only one header
INSUFFICIENT_SCOPE403Endpoint permission is missingUpdate key permissions or use another key
FORBIDDEN403Current product role/state denies accessRestore legitimate access; do not bypass
API_UNAVAILABLE_FOR_TIER403Workspace plan does not include API accessUpgrade or change plan
BOARD_NOT_FOUND404Board is absent or intentionally hiddenRefresh allowed boards and check policy
TOO_MANY_ITEMS413Batch exceeds 100 rowsSplit it
PAYLOAD_TOO_LARGE413Body exceeds 1 MBSplit or reduce fields
VALIDATION_ERROR422One or more rows failed schema validationApply issues and resend
RATE_LIMITED429Per-key or monthly budget exhaustedHonor headers and retry when allowed
API_NOT_PROVISIONED503Deployment schema is not readyMaintainer runs schema rollout
PROVIDER_UNAVAILABLE503Unkey/metering is unavailableBounded retry
INTERNAL_ERROR503Board data operation could not completeReconcile uncertain writes, then retry

Validation repair response

A 422 may include:

{
	"ok": false,
	"error": {
		"code": "VALIDATION_ERROR",
		"message": "1 of 2 item(s) failed validation. Nothing was inserted.",
		"issues": [
			{
				"itemIndex": 1,
				"field": "status",
				"received": "Finished",
				"expected": "one of: researching, ready",
				"hint": "Send an option value or its label (case-insensitive)."
			}
		],
		"expectedSchema": [],
		"example": {
			"items": [{ "title": "Jane Doe" }]
		},
		"schemaUrl": "/api/v1/boards/board-uuid/schema"
	}
}

itemIndex is zero-based within the submitted items array.

Do not expose provider details to callers

Your integration should show users a safe recovery message. Keep raw network or SDK errors in protected operational logs, and ensure those logs never contain the key.


Key lifecycle

Policy updates

The key owner may change:

  • permissions;
  • selected/all-board mode;
  • selected board IDs.

Workspace and expiry are immutable after creation. Create a replacement if those need to change.

When broadening policy, review both permission scope and board scope. Granting write plus all-board access is substantially more powerful than either change alone.

Rotation

Rotation uses Unkey reroll with zero overlap:

old key expires immediately
new key is returned once

Only the key owner may rotate it. Workspace admins may revoke another member's key but do not rotate it and assume that member's identity.

Safe rotation runbook:

  1. Open Account → Public API.
  2. Select Rotate.
  3. Confirm immediate invalidation.
  4. Copy the new secret.
  5. Update the secret manager.
  6. restart/redeploy only the consuming service if needed;
  7. verify with /me;
  8. verify one read operation;
  9. verify one low-risk write when the integration writes;
  10. confirm the old secret returns 401.

Because overlap is zero, coordinate the consuming service before clicking Rotate.

Revocation

Revocation disables/deletes the provider key first, then hard-deletes local key metadata and board scopes. Usage and immutable audit snapshots remain.

Repeated revocation is idempotent: the desired state is still "no active key."

Expiry

Expiry is selected at creation from 1 through 365 days. Treat expiry as a scheduled security event:

  • alert before expiry in your own operations process;
  • rotate during a maintenance window;
  • do not wait for production 401 errors;
  • remove the old secret from every secret store after rotation.

Legacy local keys

Keys without an Unkey key ID are retired and do not authenticate. Replace and revoke them. There is no local-hash compatibility fallback.

Usage and audit

The account page exposes:

  • bounded daily request aggregates;
  • weighted operation totals;
  • key creation events;
  • policy updates;
  • rotations;
  • revocations;
  • immutable key-name and prefix snapshots;
  • actor IDs and timestamps.

Use audit history to answer who changed a key and when. It does not recover the secret.


Security practices

Do

  • create one key per integration and environment;
  • use selected-board mode by default;
  • use read-only permissions unless writing is required;
  • choose the shortest practical expiry;
  • store secrets in a managed secret store;
  • inject secrets at runtime;
  • redact Authorization and X-API-Key headers;
  • record request IDs, status codes, endpoint, and timing;
  • rotate after personnel or vendor changes;
  • revoke unused keys immediately;
  • reconcile privileges regularly;
  • keep production and test deployments separate.

Do not

  • commit keys to Git;
  • paste keys into chat or issue trackers;
  • send keys to an AI model as prompt content;
  • store keys in board cells or documents;
  • include keys in URLs;
  • expose keys in frontend code;
  • reuse one key across unrelated integrations;
  • share public API and extension keyspaces;
  • implement a local verification fallback;
  • continue unmetered when Unkey is unavailable;
  • log complete provider error objects when they may contain request data.

Secret-manager examples

Use platform-supported encrypted environment variables or a dedicated secret manager. The application should receive only the secret value it needs at runtime.

DOTALLIO_API_KEY → secret value
DOTALLIO_ORIGIN  → non-secret deployment URL
DOTALLIO_BOARD_ID → non-secret board UUID

Key exposure boundaries

Prefixes and provider key IDs are safe identifiers. The full secret is not.

Safe to log:

local key ID
provider key ID
display prefix
request ID
operation
workspace ID, when operational policy permits
status code
weighted cost

Never log:

Authorization header
X-API-Key header
full request URL if it incorrectly contains a secret
full provider request object
environment dump
one-time key response

Incident response

Suspected key exposure

  1. Revoke the key immediately.
  2. Do not wait to confirm exploitation.
  3. Identify every system that stored or used it.
  4. Review usage and lifecycle audit history.
  5. Review application logs by safe prefix, local key ID, and request ID.
  6. Confirm board scope and write permission exposure.
  7. Create a replacement with narrower policy if possible.
  8. update the consuming secret store;
  9. remove the exposed value from logs, tickets, and build artifacts where feasible;
  10. verify the revoked value returns 401.

Unexpected high usage

  1. Identify whether the denial is per-key minute or shared monthly budget.
  2. Review recent deploys for retry loops.
  3. Check whether a 429 handler is retrying without waiting.
  4. Check whether pagination restarts from offset zero.
  5. Check whether failed batches are being resent without reconciliation.
  6. Revoke the key if usage appears malicious.
  7. Narrow permissions and board scope on replacement keys.

Provider outage

The API fails closed with 503.

The client should:

  • stop writes after a bounded number of retries;
  • preserve the source job state outside Dotallio;
  • show an actionable retry-later message;
  • avoid switching to another secret automatically;
  • reconcile any POST whose connection outcome is uncertain.

Deployment and Unkey setup

This section is for Dotallio deployment maintainers.

Database rollout

Apply the Drizzle schema using the repository's maintained workflow:

npm run db:push

The schema includes:

  • api_keys — provider/local authorization mirror;
  • api_key_board_scopes — explicit board allowlist;
  • public_api_usage_daily — bounded UTC daily aggregates;
  • public_api_key_audit — immutable lifecycle snapshots;
  • public_api_limit_overrides — Enterprise platform-admin overrides.

RLS behavior:

  • key owners manage their credentials;
  • workspace owners/admins may view metadata, usage, and audit;
  • workspace owners/admins may revoke workspace keys;
  • usage and audit writes are server-only;
  • board mutations still run through the represented user's RLS context.

Public API environment variables

Generate a copy-ready 256-bit idempotency HMAC configuration from the repository root:

node scripts/generate-secret-key.js --public-api-idempotency

The command prints the two PUBLIC_API_IDEMPOTENCY_HMAC_* assignments once. Store them only in the Dotallio server environment; never commit or share the printed secret.

UNKEY_PUBLIC_API_ROOT_KEY="..."
UNKEY_PUBLIC_API_ID="..."
UNKEY_PUBLIC_API_BURST_NAMESPACE="..."
UNKEY_PUBLIC_API_WORKSPACE_NAMESPACE="..."
PUBLIC_API_IDEMPOTENCY_HMAC_ACTIVE_VERSION="1"
PUBLIC_API_IDEMPOTENCY_HMAC_KEYS='{"1":"at-least-32-secret-bytes"}'
PUBLIC_API_ITEM_PATCH_ROLLOUT_MODE="disabled"
# allowlist mode only: comma-separated local UUIDs
PUBLIC_API_ITEM_PATCH_ROLLOUT_KEY_IDS=""
PUBLIC_API_ITEM_PATCH_ROLLOUT_BOARD_IDS=""

Never prefix these with NEXT_PUBLIC_.

The active idempotency HMAC key signs new request receipts. Keep previous key versions in the JSON map for at least the receipt-retention window so retries created before rotation can still be verified. Never log these keys or the canonical payload.

PATCH rollout fails closed. Use disabled while applying schema, then allowlist with both a local API-key UUID and board UUID for staging, and only set enabled after contract verification. Targets outside the rollout boundary receive the same non-enumerating item_not_found response.

Required Unkey permissions

Provision these public permissions in the public API keyspace:

profile.read
boards.list
board.schema.read
board.items.read
board.items.write

The Dotallio operation registry maps exactly one permission to each endpoint.

Namespace design

Create two public API rate-limit namespaces:

  1. per-key burst namespace;
  2. shared workspace-month namespace.

The burst identifier is the stable local key ID.

The monthly identifier follows this shape:

workspaceId:YYYY-MM

The month is UTC.

Root-key least privilege

The public root key should be authorized only for:

  • creating keys in the public API;
  • verifying keys in the public API;
  • updating public key permissions/metadata;
  • rerolling public keys;
  • deleting public keys;
  • applying limits in the two public namespaces.

Do not grant cross-API or cross-namespace wildcards when narrower Unkey root-key permissions are available.

Key configuration

Public keys are created with:

  • 32 random bytes;
  • environment-specific prefix;
  • recoverable: false;
  • explicit expiry;
  • explicit permissions;
  • local key ID and authorization metadata;
  • enabled state.

Provider and local persistence compensation

Creation order:

create stable local UUID
  → issue Unkey key
  → persist local metadata and audit
  → if persistence fails, delete provider key
  → return plaintext once

Revocation order:

delete/disable at Unkey
  → write immutable audit snapshot
  → hard-delete local key and cascading board scopes

Rollout validation

After schema and environment configuration:

  1. create a test key in a non-production deployment;
  2. confirm the dot_test_ prefix;
  3. call /me;
  4. list selected boards;
  5. verify an excluded board returns 404;
  6. verify a missing permission returns 403;
  7. create one row with a known AI field;
  8. create one row with the AI field omitted;
  9. confirm the first remains unchanged and the second can autocomplete;
  10. rotate and confirm the old key fails immediately;
  11. revoke and confirm repeated revoke is safe;
  12. confirm usage and audit records remain.

Extension keys are separate

Extension-key configuration uses separate variables:

UNKEY_EXTENSION_ROOT_KEY="..."
UNKEY_EXTENSION_API_ID="..."
UNKEY_EXTENSION_BURST_NAMESPACE="..."
UNKEY_EXTENSION_WORKSPACE_NAMESPACE="..."

Public API and extension systems may share the generic lazy Unkey v2 SDK adapter, but must not share:

  • root keys;
  • API IDs;
  • user-facing secrets;
  • permission names;
  • identities;
  • namespaces;
  • quotas;
  • metadata assumptions;
  • verification routes.

If an extension credential is sent to /api/v1, verification must fail.


Troubleshooting

401 INVALID_API_KEY

Check:

  • correct secret value;
  • no surrounding quotes or whitespace;
  • correct environment prefix;
  • key has not been revoked or retired;
  • secret manager deployed the new version;
  • request sends exactly one credential.

401 EXPIRED_API_KEY

Rotate or replace the key. Expiry is immutable.

400 AMBIGUOUS_API_KEY

Your HTTP library, gateway, or proxy sent both Authorization and X-API-Key. Remove one.

403 INSUFFICIENT_SCOPE

Compare the endpoint to the permission table. Update the key policy only if the integration legitimately needs the permission.

403 FORBIDDEN

Check the represented user's current:

  • workspace membership;
  • app membership;
  • app role;
  • workspace/app active state;
  • write eligibility.

403 API_UNAVAILABLE_FOR_TIER

The workspace is FREE or otherwise lacks API availability. The key itself may still exist, but requests remain denied.

404 BOARD_NOT_FOUND

The API intentionally avoids revealing inaccessible boards. Refresh GET /boards and verify:

  • key workspace;
  • selected-board allowlist;
  • board deletion/hidden state;
  • current app access.

413 TOO_MANY_ITEMS

Split the job into batches of at most 100.

413 PAYLOAD_TOO_LARGE

The body exceeds 1 MB. Split the batch, remove unused source fields, or reduce large text/JSON values.

422 VALIDATION_ERROR

Read every issue. Then:

  1. refresh schemaUrl;
  2. update option values;
  3. remove read-only fields;
  4. correct field addresses;
  5. fix value formats;
  6. resend the entire corrected batch.

Nothing was inserted for a 422.

429 RATE_LIMITED

Honor Retry-After and RateLimit-Reset. Do not hammer the endpoint. If the monthly workspace budget is exhausted, another key will share the same denial.

503 API_NOT_PROVISIONED

The deployment maintainer must apply the Drizzle schema.

503 PROVIDER_UNAVAILABLE

Unkey or metering is temporarily unavailable. Use bounded retries with jitter.

Created rows are missing fields

Check whether:

  • the source sent null or empty values;
  • an unknown field produced a warning;
  • the field key changed;
  • the field is formula/read-only;
  • the AI autocomplete is configured and completed;
  • the source value failed before the request was sent.

Duplicate rows after retry

The previous POST may have partially succeeded before a runtime failure. Reconcile against source IDs, canonical URLs, or normalized title composites before resending.


Frequently asked questions

Can I recover a key after closing the dialog?

No. Public keys are non-recoverable. Rotate or create a replacement.

Can I use a public key in React?

Not in browser-delivered React code. Call Dotallio from your backend.

Can one key access multiple workspaces?

No. Workspace is immutable and fixed at creation.

Can a key access future boards?

Only when its board mode is all, and only when the represented user can currently access those boards.

Can I create a key with an empty board allowlist?

No. Selected-board mode requires at least one board. An empty relation never means unrestricted access.

Can an admin rotate another user's key?

No. An admin may revoke it. Rotation is restricted to the owner because the key represents that user's identity.

Does a write key bypass RLS?

No. Board work executes through the represented user's authorization context, and product access is rechecked before the operation.

Does a known AI value get overwritten?

The public API inserts the supplied value. Configured autocomplete is intended for cells that remain empty.

What should I do when an AI value is uncertain?

Research it, or leave it empty. Do not fabricate a plausible value.

Can I upload CSV directly?

Not to /api/v1. Convert it to the JSON row contract first. Authenticated board chat/upload flows may accept CSV and perform this normalization internally.

Are batch writes transactional?

Validation is atomic: a 422 inserts nothing. Runtime insertion is ordered and can partially succeed if infrastructure fails after earlier rows were created.

Are unknown fields errors?

They are warnings and are ignored. A row with no recognized fields is an error.

Can I send column titles instead of keys?

Yes, exact titles are matched case-insensitively. Advertised keys or column IDs are safer.

Why did a valid key suddenly stop writing?

Check expiry, plan, permissions, board allowlist, workspace membership, app role, hidden/deleted state, and quota.

Why does another key also get a monthly 429?

The monthly weighted budget is shared by the workspace.

Is the OpenAPI document authenticated?

No. It is the public machine-readable API description. It contains no secret or tenant data.

Should an agent send 100 rows every time?

Not necessarily. One hundred is the maximum. Use smaller batches when reconciliation cost matters.


Production checklists

New integration checklist

  • Create a dedicated key for this integration and environment.
  • Choose selected-board scope unless all-board is required.
  • Grant only required permissions.
  • Set a deliberate expiry.
  • Copy the secret once into a managed secret store.
  • Verify /me.
  • List allowed boards.
  • Read the target schema.
  • Implement schema-key mapping.
  • Implement type and option normalization.
  • Implement source-level deduplication.
  • Split writes into bounded batches.
  • Handle warnings.
  • Handle 422 repair details.
  • Honor 429 and 503 retry headers.
  • Reconcile uncertain POST failures.
  • Redact credential headers from logs.
  • Store X-Request-Id for troubleshooting.
  • Document rotation ownership.
  • Document emergency revocation.

AI-agent checklist

  • Read boards before selecting a target.
  • Read schema before composing output.
  • Read current rows before proposing additions.
  • Learn preferences without overfitting to one row.
  • Deduplicate exact and semantic duplicates.
  • Use bounded research workers.
  • Verify links and images.
  • Use schema option values.
  • Never send formula/read-only fields.
  • Fill grounded AI cells directly.
  • Leave unknown AI cells empty.
  • Never fabricate missing facts.
  • Stop on live authorization loss.
  • Batch at no more than 100 rows.
  • Report progress and partial failures.
  • Reconcile before retrying an uncertain write.

Rotation checklist

  • Identify every consumer.
  • Schedule zero-overlap rotation.
  • Rotate as the key owner.
  • Copy the new secret immediately.
  • Update the secret manager.
  • Reload/redeploy consumers as needed.
  • Verify the new key.
  • Confirm the old key fails.
  • Remove old local secret copies.
  • Review the audit event.

Revocation checklist

  • Revoke immediately when unused or exposed.
  • Confirm integration traffic stops.
  • Confirm repeated revoke is harmless.
  • Remove the secret from consuming systems.
  • Preserve and review usage/audit evidence.
  • Create a narrower replacement only when needed.

Deployment-maintainer checklist

  • Apply the Drizzle schema with npm run db:push.
  • Create separate Unkey public and extension APIs.
  • Create separate burst and workspace namespaces.
  • Provision the five public permissions.
  • Create least-privilege root keys.
  • Configure all public API environment variables.
  • Configure all separate extension variables.
  • Confirm secrets are server-only.
  • Validate live/test prefixes.
  • Validate FREE denial and paid-tier limits.
  • Validate board allowlists and cross-workspace denial.
  • Validate role downgrade behavior.
  • Validate 100-row weighted accounting.
  • Validate rotation with immediate predecessor failure.
  • Validate idempotent revocation and audit retention.
  • Replace retired local keys.

When this guide and the live OpenAPI document appear to disagree about an HTTP shape, treat the deployed /api/v1/openapi.json document and actual response as the runtime contract, then report the documentation drift.


Last updated: 2026-07-16

Credential provider: Unkey v2

Dotallio public API version: v1