Customer API reference
Every supported organization operation available with a personal API key.
One API, two ways to use it
Python methods and HTTP requests use the same authorization and resource contracts. Owners can use all organization operations; admins cannot grant or remove ownership, and members cannot manage teammates. Keys never expand their user’s access.
The API origin is https://api.periplus.dev. Send Authorization: Bearer followed by your key. The customer-only OpenAPI document is at /api/v1/openapi.json on that origin. It excludes operator endpoints.
SQL and schema
| HTTP | Python · client. | Required access |
|---|---|---|
| POST /api/v1/sql | execute() / prepare() / stream() | organization:sql:exec |
| GET /api/v1/schema | schema() | organization:sql:exec |
| GET /api/v1/autocomplete | autocomplete() | organization:sql:exec |
A buffered execute result stays within your organization's max_result_bytes, which counts the whole body, including the echoed sql and parameters. Send max_bytes (64 KiB–64 MiB; execute(max_bytes=…) in Python) to bound it further: the compact UTF-8 JSON body without the echoed sql and parameters, guidance included, stays within it. max_bytes applies to buffered execute only; prepare and streamed requests refuse it with 422. Rows past either budget are withheld from the end of the output order, truncated is true and truncation_reason is max_bytes or max_result_bytes for the budget that withheld them, also when no row fits (rows exist; this is not an empty result), and the result's guidance gives the recovery. If the body without rows exceeds a budget, the request fails with resource_limit at stage result. result_bytes is the exact size of the returned body. The MCP sql_execute tool sends max_bytes of 1 MiB.
Captures and crawls
Capture one URL for instant feedback (waits and summarizes); crawl for coverage (any list of URLs, optionally following links). POST /captures takes one url (a urls list is refused with 422 capture_one_url; use a crawl without follow) and, as query parameters outside the request's identity, wait_seconds (0–60, default 40: the total server time for this call, waiting for the capture first and then producing the view with what remains) and view=summary, which adds the capture's bounded summary (or summary_reason when there is none) and also requires organization:sql:exec. Capture endpoints return a capture request view: request_id (the work you retry, poll and cancel), url, status, capture_id (the capture, an observation that succeeded or failed, and the SQL key; null until the page is attempted, and possibly shared with concurrent requests for the same URL), queryable (false for a failed capture: there is no page to query), failure, capture_assessment, effective_url, http_status, waiting, finish_reason, pin_id, resolve_bot_challenges, project_id, name, description, version, created_at, finished_at and guidance, plus result_unavailable when the page's result could not be read (the result fields are then unknown, not absent). A crawl's view has id, urls, crawl, and progress (captured, failed and pending pages). GET /crawls/{identity}/captures lists the captures it produced, successful and failed (status filters; failed captures keep their capture_id and are never queryable); URLs not yet attempted are counted in progress, not listed. POST /crawls takes 1–1,000 urls, optional accepted_queue_wait_seconds (only after a 429 crawl_queue_delay refusal: the longest wait, in seconds, you accept before the crawl starts; by default 1 hour, at most 30 days) and optional follow (max_depth ≥ 1 and page_limit required; scope, allowed_hosts, excluded_hosts, excluded_paths, rules); without follow it fetches exactly urls, and the view's crawl.follow is null. POST /access/crawl-preview takes urls. GET with wait_seconds (0–60, default 0) returns as soon as the request is finished and queryable. pin_days on creation also requires organization:pins:write. resolve_bot_challenges (default false; part of the request's identity) asks for paid bot-challenge resolution and needs the organization's allow_challenge_resolution allowance, otherwise the create is refused with 403 challenge_resolution_not_allowed; the view echoes it. Optional name, Markdown description and project_id are presentation only; PATCH changes them with expected_version, and a same-ID create retry keeps the first ones. GET /captures and GET /crawls accept search (a case-insensitive substring of the name or a supplied URL, with at least three consecutive letters or digits; otherwise 422) and host (an exact host of a supplied URL; a leading www. is ignored), together with finished, project_id, limit and cursor.
| HTTP | Python · client. | Required access |
|---|---|---|
| GET /captures | captures.list() | organization:captures:read |
| POST /captures | captures.create() | organization:captures:write |
| GET /captures/{identity} | captures.get() | organization:captures:read |
| PATCH /captures/{identity} | captures.update() / move() | organization:captures:write |
| POST /captures/{identity}/cancel | captures.cancel() | organization:captures:write |
| POST /captures/batch | captures.batch_cancel() | organization:captures:write |
| GET /crawls | crawls.list() | organization:crawls:read |
| POST /crawls | crawls.create() | organization:crawls:write |
| GET /crawls/{identity} | crawls.get() | organization:crawls:read |
| PATCH /crawls/{identity} | crawls.update() / move() | organization:crawls:write |
| GET /crawls/{identity}/captures | crawls.captures() | organization:crawls:read |
| POST /crawls/{identity}/cancel | crawls.cancel() | organization:crawls:write |
| POST /crawls/batch | crawls.batch_cancel() | organization:crawls:write |
Pins and schedules
Pins and schedules are active on creation. SQL selections run once and also require organization:sql:exec; a pin from saved_query_id also requires organization:sql:read; creating a schedule also creates its retention pin, so it needs organization:pins:write as well as organization:schedules:write: each successful capture is kept in that pin for pin_days (an optional integer, 1–3,650; by default every_days + 7, which follows later every_days changes; null is refused); resolve_bot_challenges on a schedule needs the organization's allowance at creation, and each run uses resolution only while it remains. Updates require expected_version; deletion does not. Both accept an optional project_id on create and update. GET /pins lists newest first; order=expires lists the nearest expiry first, with pins that have no captures yet last, and status=active or status=expired filters by expiry. Both combine with project_id and cursor.
| HTTP | Python · client. | Required access |
|---|---|---|
| GET /pins | pins.list() | organization:pins:read |
| POST /pins | pins.create() | organization:pins:write |
| GET /pins/{identity} | pins.get() | organization:pins:read |
| PATCH /pins/{identity} | pins.update() / move() | organization:pins:write |
| DELETE /pins/{identity} | pins.delete() | organization:pins:write |
| POST /pins/batch | pins.batch_delete() | organization:pins:write |
| GET /pins/{identity}/captures | pins.captures() | organization:pins:read |
| GET /schedules | schedules.list() | organization:schedules:read |
| POST /schedules | schedules.create() | organization:schedules:write and organization:pins:write |
| GET /schedules/{identity} | schedules.get() | organization:schedules:read |
| PATCH /schedules/{identity} | schedules.update() / move() | organization:schedules:write (and organization:pins:write when an every_days change moves the retention pin's days) |
| DELETE /schedules/{identity} | schedules.delete() | organization:schedules:write |
| GET /schedules/{identity}/urls | schedules.urls() | organization:schedules:read |
| GET /schedules/{identity}/captures | schedules.captures() | organization:schedules:read |
| POST /schedules/{identity}/pause | schedules.pause() | organization:schedules:write |
| POST /schedules/{identity}/resume | schedules.resume() | organization:schedules:write |
| POST /schedules/batch | schedules.batch_pause() / batch_resume() / batch_delete() | organization:schedules:write |
Capture inspection
Periplus no longer provides external web search. Discover URLs with your own search tools, then submit known URLs for capture. Internal SQL corpus search is unchanged.
GET /api/v1/captures/{capture_id}/summary gives a bounded first look at one existing capture: its metrics plus small format-appropriate samples (HTML headings, text and repeated groups; XML groups; fully indexed JSON paths). It returns capture_id, status (complete, partial, timed_out or unavailable), parts keyed by name, reason, next_steps and a sampling boundary. It takes no query parameters; any are rejected with 422. Each part repeats its SQL and parameters and preserves source_snapshot, errors and truncation. Parts run sequentially through ordinary SQL admission within a best-effort budget (about 15 s; each statement is limited to the remaining budget; a stalled server or slow database can extend it); if the serialized summary, including its metadata, exceeds 24,000 bytes, sample rows are omitted while their SQL is kept. Sampling never proves complete source or result coverage. To look at new pages, create a capture, wait for it, and summarize its captured pages.
The other five inspection routes read one existing capture through fixed public_v1 helper SQL on the same admitted SQL path as POST /api/v1/sql, and never capture again. Each returns capture_id, operation, status (complete, unavailable for unknown, expired, not-yet-queryable or unsupported-format captures, or failed), data (columns with types, rows), truncated, the exact sql and parameters for reuse with POST /api/v1/sql, source_snapshot, next_steps and error. Continue a page with the operation and arguments in next_steps: every paged route takes the opaque cursor it returns.
| HTTP | Python · client. | Required access |
|---|---|---|
| GET /api/v1/captures/{capture_id}/summary | HTTP only | organization:sql:exec |
| GET /api/v1/captures/{capture_id}/metrics | HTTP only | organization:sql:exec |
| GET /api/v1/captures/{capture_id}/text | HTTP only | organization:sql:exec |
| GET /api/v1/captures/{capture_id}/outline | HTTP only | organization:sql:exec |
| GET /api/v1/captures/{capture_id}/structure | HTTP only | organization:sql:exec |
| GET /api/v1/captures/{capture_id}/links | HTTP only | organization:sql:exec |
metrics takes no options and returns one row: the capture's format, status, byte, node, text and link counts, from capture_metrics. Inspection options: text max_chars (1–2000, default 500), limit (1–100, default 20; limit × max_chars ≤ 40,000) and cursor; outline limit (1–200, default 50) and cursor; structure limit (1–50, default 20) and cursor (siblings mode); links limit (1–100, default 50) and cursor.
Saved queries
| HTTP | Python · client. | Required access |
|---|---|---|
| GET /saved-queries | saved_queries.list() | organization:sql:read |
| POST /saved-queries | saved_queries.create() | organization:sql:write |
| GET /saved-queries/{key} | saved_queries.get() | organization:sql:read |
| PATCH /saved-queries/{key} | saved_queries.update() / rename() / move() | organization:sql:write |
| DELETE /saved-queries/{key} | saved_queries.delete() | organization:sql:write |
| POST /saved-queries/batch | saved_queries.batch_delete() | organization:sql:write |
Saved queries have an optional Markdown description. In PATCH bodies an omitted field is unchanged, and null or an empty string clears the description. A query's project_id names one project in your organization and cannot be null. Every saved query carries a version that starts at 1 and grows with each edit. Send the version you read as an optional expected_version in PATCH: if the query changed since, the API returns 409 and changes nothing. Without it, the update applies to the current version. GET /saved-queries?project_id= lists one project's queries, and order=name lists by name instead of most recently updated.
Projects
Every saved query, pin, schedule, capture and crawl belongs to exactly one project; projects are the only way to organize them. Each organization has a Default Project, created on first use: anything created without project_id lands there, so existing integrations keep working. There is no active or remembered project on the server or in the SDK: name the project explicitly on each call. Projects organize resources, not data: SQL reads the organization's whole corpus whatever project a query belongs to. Project reads accept any one read permission among organization:sql:read, organization:pins:read, organization:schedules:read, organization:captures:read and organization:crawls:read; project writes accept any one of the matching write permissions.
| Operation | SDK | Permission |
|---|---|---|
| GET /projects | projects.list() | Any one of the read permissions below |
| POST /projects | projects.create() | Any one of the write permissions below |
| GET /projects/{key} | projects.get() | Any one of the read permissions below |
| PATCH /projects/{key} | projects.update() | Any one of the write permissions below |
| DELETE /projects/{key} | projects.delete() | Any one of the write permissions below |
| POST /projects/{key}/items | projects.move_items() | Any one of the write permissions below, plus each moved item's own write permission |
GET /projects returns the default project first, then the rest by name. Each project has name, optional Markdown description, is_default, item_counts (saved_queries, pins, schedules, captures, crawls) and version; names are unique within the organization. PATCH requires expected_version and changes name or description. Only empty projects can be deleted, and never the default project; both return 409.
Moving many items
POST /projects/{key}/items moves 1–200 items into one project in a single transaction: every item moves, or none does. The body lists each item's kind (saved_query, capture, crawl, pin or schedule), id and an optional expected_version; an ID may appear once.
{"items": [
{"kind": "pin", "id": "<pin>", "expected_version": 3},
{"kind": "saved_query", "id": "<query>"}
]}Each item follows its single move: it must belong to your organization, its version must still match when you send one, and you need its own write permission. A missing item (404), a stale version (409) or a missing permission (403) fails the whole request, moves nothing and names the item in item (kind and id) and detail. Items already in the project are reported as unchanged, not errors. The response lists moved and unchanged items with their current version plus counts. A schedule's own pin, and the pin a capture or crawl created with pin_days, moves with it, as in a single move.
result = client.projects.move_items(project, [pin, query, ("crawl", crawl_id, 2)])
print(result.counts.moved, result.counts.unchanged)Acting on many items
POST /saved-queries/batch, /pins/batch, /schedules/batch, /captures/batch and /crawls/batch apply one action to 1–200 distinct IDs: delete for saved queries and pins, pause, resume or delete for schedules, and cancel for captures and crawls.
{"action": "delete", "ids": ["<pin>", "<pin>"]}Each item runs exactly as its single-item request, in order and on its own, so one failure never stops or undoes the others. The response is 200 with one result per ID in request order: {"results": [{"id": "<pin>", "ok": true, "error": null}, {"id": "<pin>", "ok": false, "error": {"status": 404, "detail": "Pin not found"}}]}. An item's error carries the status and detail its single request would have returned. An invalid request (an unknown action, no IDs, more than 200 or a repeated ID) is rejected whole with 422. The Python batch methods accept more than 200 IDs and send them in several requests.
Placing items
Send project_id when creating a saved query, pin, schedule, capture or crawl; omitted or null, the item lands in the Default Project. Send project_id in its PATCH body to move it to another project; an omitted field keeps its project and null is rejected with 422. Pins, schedules, captures and crawls also need expected_version in PATCH (optional for saved queries), and moving needs the item's own write permission. A project that does not exist or belongs to another organization returns 404. Every list accepts project_id=<project>, together with the list's other filters and pagination:
curl --fail-with-body "${PERIPLUS_API_URL:-https://api.periplus.dev}/captures?project_id=$PROJECT_ID&limit=50" \
-H "Authorization: Bearer $PERIPLUS_API_KEY"
curl --fail-with-body -X PATCH "${PERIPLUS_API_URL:-https://api.periplus.dev}/pins/$PIN_ID" \
-H "Authorization: Bearer $PERIPLUS_API_KEY" -H "Content-Type: application/json" \
--data '{"expected_version":3,"project_id":"5f0a8a52-7f5e-4d8e-9a43-2a4e9f1b7c20"}'A capture's or crawl's project is presentation like its name: a create retry with the same ID keeps the project it was first created with. Moving never changes what an item captures, protects or schedules.
Members and invitations
Membership lists are bounded snapshots, not paginated directories. Role edits include expected_role. Only owners can grant, change, or remove ownership; the last owner cannot be removed.
| HTTP | Python · client. | Required access |
|---|---|---|
| GET /members | members.list() | organization:members:read |
| PUT /members/{membership_id} | members.update_role() / remove() | organization:members:write |
| POST /members/invitations | invitations.create() | organization:members:write |
| DELETE /members/invitations/{invitation_id} | invitations.cancel() | organization:members:write |
Your API keys
| HTTP | Python · client. | Required access |
|---|---|---|
| GET /api-keys | api_keys.list() | organization:api_keys:read |
| POST /api-keys | api_keys.create() | organization:api_keys:write |
| DELETE /api-keys/{key_id} | api_keys.revoke() | organization:api_keys:write |
| DELETE /api-keys/{key_id}/record | HTTP only (revoked keys) | organization:api_keys:write |
Usage and query history
| HTTP | Python · client. | Required access |
|---|---|---|
| GET /usage | usage.get() | organization:usage:read |
| GET /usage/queries | query_history.summary() | organization:usage:read |
| GET /usage/query-history | query_history.list() | organization:usage:read |
| GET /usage/queries/{identity} | query_history.get() | organization:usage:read |
Bug reports
Report Periplus behaving incorrectly: a one-line title and a Markdown body_markdown with operation IDs and expected versus actual behaviour, without secrets. Any organization credential may report (an API key, an app session or an MCP sign-in), and the report is attributed to that credential.
| HTTP | Python · client. | Required access |
|---|---|---|
| POST /bug-reports | bug_reports.create() | Any organization credential |
Feedback
Tell Periplus where a task worked but was harder than it should have been, so tools, descriptions and docs improve. Send model (the agent's model name and version), task (the kind of work, described abstractly), suggestion (what would have made it easier) and at least one of agent_friction (what the agent hit: misleading or hard-to-understand tools, responses or docs, retries, workarounds) and user_friction (what the user went through: slowness, repeated iterations, frustration). Leave out user names, data, URLs, captured content and secrets. Defects belong in bug reports. Feedback is attributed like a bug report.
| HTTP | Python · client. | Required access |
|---|---|---|
| POST /feedback | feedback.create() | Any organization credential |
Lists and cursors
Collection responses contain items and next_cursor. Every list takes cursor: pass the previous page's next_cursor unchanged with the same filters, and omit it for the first page. Cursors are opaque, and lists are not stable snapshots while resources change. In Python, pass next_cursor back as cursor, or use iter() on paginated resources. Pin and schedule detail report counts—use their captures and URLs endpoints for the full membership.
Saved-query and audit pages contain up to 50 entries. Other lists accept limit up to 100. There is no background polling or implicit bulk fetch.
Next steps and guidance
Some responses suggest how to continue. A next step has a reason, an optional operation (the customer operation ID in the OpenAPI document, such as crawls_captures or sql_execute) and optional arguments keyed by that operation's path, query or body parameter names; omitted arguments are yours to choose. Steps are suggestions: nothing runs automatically, and they never establish analytical completeness.
guidance(stage,next_steps,boundary) on single capture and crawl responses (create, get, update and cancel;nullin lists), on bufferedPOST /api/v1/sqlresults, on every classified SQL error body, and on saved-query create and update (nullon reads).- SQL result guidance includes pin advice when a non-empty result has a
capture_idcolumn and your key holdsorganization:pins:read: it reads your organization's own pins over at most 10,000 distinct capture IDs in the result and falls back to generic advice without that permission or if the lookup is slow, busy or unavailable. Guidance counts towardmax_result_bytesandmax_bytes: rows past either limit are withheld from the end, andresult_bytesis the exact size of the response body. Streamed results carry no guidance. - Error bodies are published as
CustomerError(with optionalrecovery); operations that run SQL can also returnQueryErrorwith itsguidance. next_stepson capture lists (a crawl's or schedule's captures): ansql_executestep with the exact queryable capture IDs of that page of the list, the continuation with the same filters, and a rerun note when a capture reportsbot_challenge. Capture inspection and summary responses usenext_stepsfor their continuations.recoveryon limit and credential refusals (codes such aspage_limit,request_capacity,rate_limited,organization_budget, and 401/403): what was refused, who can change it and how to continue.- Saved queries return
app_path(/sql/<id>) andapp_url, that page in the app for this environment, ornullwhen the API has no app origin configured.
GET /api/v1/schema?name= returns one relation or helper (entry) with its notes, examples and relationships; an unknown name returns 404 with the available names.
Handle uncertainty explicitly
ApiError exposes status_code, code, request_id, fields, and retry_after_seconds when available. A 403 means insufficient permission or disabled access; a 409 means a conflict, such as a stale pin or schedule version. Fetch the current resource and review it rather than automatically repeating a write.
The SDK never retries. A transport failure or timeout can mean a mutation succeeded without delivering its response. Keep creation IDs and check the resource. API-key secrets are returned once and cannot be recovered after a lost response. Pin and schedule updates require expected_version; passing a fetched Pin or Schedule supplies that version.
SQL uses a longer timeout than resource reads. Streaming results must end with a valid completion frame; disconnects and truncation are not complete answers. SQL remains read-only even though the resource API supports mutations.