Import documents
This guide shows how to put your documents into Sursa with the cards API: a few documents from a script, or every document of a system through a regular job. Each document becomes a card in a collection. People find it in search, the assistant answers from it, and it has an owner, tags, verification and permissions like any other card.
If you want Sursa to work out which documents were deleted in your system, or you push documents from an integration platform, use a custom source instead. It is built for full and incremental syncs. This guide covers direct writes into a collection.
You need an API key with the cards:write scope and a role that can write to the target collection (editor or
admin). The examples use the helper from the Quickstart.
Choose the right endpoint
| You have | Use |
|---|---|
| A record with a stable ID in your system | PUT /v1/cards-by-external-id/{external_id}: create or replace by your ID. |
| Text to add once, with no ID of your own | POST /v1/cards/text |
| A file to add once | POST /v1/cards |
| A ZIP archive of many files | POST /v1/cards/archive |
| A file larger than the single-request limit | An upload in parts, then any of the endpoints above. |
| New content for a card you already have | PUT /v1/cards/{card_id}/content |
For anything you import more than once, use external_id. It makes every import repeatable: running it again
updates the cards it created instead of adding copies.
Create or replace a card by your ID
PUT /v1/cards-by-external-id/{external_id} takes the ID of the record in your system. If no card has this
external_id, it creates one (201). If a card has it, it replaces the card's content and details (200).
curl -X PUT https://api.example.com/v1/cards-by-external-id/kb-article-1042 \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"collection_id": 3,
"folder_id": "0f8e2d7c-4b6a-4e1f-9c3d-2a5b8e7f1c40",
"title": "Returning a damaged item",
"content_type": "html",
"body": "<h2>Damaged items</h2><p>Send a photo within 7 days and we ship a replacement.</p>",
"tags": ["returns", "shipping"],
"metadata": {"source": "helpdesk", "article_id": "1042"}
}'What happens on a repeat:
- The content did not change: no new version is made. The response is
200with"status": "skipped"and"reason": "unchanged". Repeating the request is always safe, even without anIdempotency-Key. - The content changed: the card gets a new version and is indexed again. Its title, tags, owner, category and
metadatatake the values you sent;metadatais replaced as a whole. - The collection and folder apply only when the card is created. To move a card later, change it with
PATCH /v1/cards/{card_id}.
A new version removes the card's verification, as any change of content does: a verifier confirms it again.
Text formats
content_type | The body is |
|---|---|
markdown (default) | Markdown. |
html | HTML. It is cleaned down to simple markup: headings, paragraphs, lists, tables and http, https and mailto links. Scripts, styles, frames, forms and images are removed with their content. |
text | Plain text. Paragraphs are separated by blank lines. |
Wait for the card, or not
By default, the request returns once the card is ready to be found, with "status": "ready" and the card in
card. That takes from a fraction of a second for a short text to a minute or more for a long file.
For bulk imports, send "async": true. The response comes at once with "status": "queued" and the indexing job in
job (with its job_id). Check the job with GET /v1/jobs/{job_id}, or follow all jobs
in GET /v1/jobs/stream. A job ends with status done, error or canceled.
curl https://api.example.com/v1/jobs/48213 \
-H "Authorization: Bearer $SURSA_API_KEY"Upload a file
Files go in a multipart/form-data request, in the file field. Sursa reads PDF, Word, Excel, PowerPoint,
OpenDocument spreadsheets, CSV, Markdown, HTML, AsciiDoc, e-mail (.eml), plain text and common image formats. It
checks that the file's name, declared type and content agree, and rejects a mismatch with 415.
To create or replace a file card by your ID, send the file to the same PUT /v1/cards-by-external-id/{external_id}:
curl -X PUT https://api.example.com/v1/cards-by-external-id/handbook-2026 \
-H "Authorization: Bearer $SURSA_API_KEY" \
-F "file=@employee-handbook.pdf" \
-F "collection_id=3" \
-F "tags=hr,policies" \
-F 'metadata={"owner_team":"people"}'In a form, tags is a comma-separated string and metadata is a JSON object written as a string. A new card without
title gets the file name as its title.
To add a file once, without an ID of your own, use POST /v1/cards with the same form
and an Idempotency-Key. Uploading a file that the collection already has returns "status": "skipped" with
"duplicate": true and the existing card_id, unless you pass an external_id.
The size of one file is limited by the installation (200 MB by default). For larger files, or for unreliable networks, upload the file in parts first.
Replace the content of a card
When you already have the Sursa ID of a card, PUT /v1/cards/{card_id}/content
replaces its text or file and keeps everything else: owner, tags, verifiers, verification interval and a category
set by hand.
curl -X PUT https://api.example.com/v1/cards/3f2c9a7e-5b1d-4c8e-9f0a-1b2c3d4e5f60/content \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"content_type": "markdown", "body": "# Refund policy\n\nRefunds are processed within 10 days."}'- The same content again returns
"status": "skipped"with"reason": "unchanged". - A card that is being indexed right now returns
409with the codecards.indexing_in_progress. Retry in a few seconds. - A card that comes from a source, such as a connector or a custom source, is changed only in its source:
cards.managed_by_source. - The key must be allowed to see the whole card; a partly visible card returns
cards.content_whole_access.
Import many documents
A loop over your records with PUT /v1/cards-by-external-id/… is the simplest bulk import, and it is safe to stop
and run again at any point. To make it fast and friendly to the rate limit:
- Send
"async": true, so each request returns as soon as the card is queued. - Run a few requests in parallel (4 to 8), and slow down when the
RateLimitheader shows few requests left. See Rate limits and quotas. - On
429, wait forRetry-After; on a network error or5xx, retry the same request. Repeats are harmless. - Record the
external_idof each record that ends in an error, and retry those at the end.
import { api } from "./sursa.mjs";
async function importAll(records, concurrency = 6) {
const failed = [];
const queue = [...records];
async function worker() {
for (let record = queue.shift(); record; record = queue.shift()) {
try {
await api("PUT", `/v1/cards-by-external-id/${encodeURIComponent(record.id)}`, {
collection_id: 3,
title: record.title,
content_type: "html",
body: record.html,
metadata: { source: "helpdesk" },
async: true,
});
} catch (error) {
failed.push({ id: record.id, error: String(error) });
}
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
return failed;
}For a ZIP archive of files, POST /v1/cards/archive turns each supported
file inside into a card with one request. It answers at once with the plan (which files become cards and which are
skipped, and why) and a job; GET /v1/archives/{job_id} reports the result.
Find and remove imported cards
- Read a card by your ID with
GET /v1/cards-by-external-id/{external_id}. - List the cards of one import with a metadata filter:
GET /v1/cards?metadata[source]=helpdesk. See Metadata. - Delete a card with
DELETE /v1/cards/{card_id}.
Common errors
| Code | Cause | What to do |
|---|---|---|
collections.not_found | No collection with this ID or name is visible to the key. | Check collection_id with GET /v1/collections. |
collections.no_write_access | The key may not write to this collection. | Use a key with the editor role and access to the collection. |
cards.external_id_taken | POST with an external_id that another card already has. | Use PUT /v1/cards-by-external-id/… to replace it. |
cards.managed_by_source | The card belongs to a source. | Change the document in its source. |
files.type_mismatch | The file's name, type and content disagree. | Send the file with its real extension. |
files.too_large | The file is above the upload limit or the organization's storage. | Use an upload in parts, or free up storage. |
limits.cards | The organization has reached the card limit of its plan. | Delete cards that are no longer needed, or change the plan. |
api.metadata_invalid | metadata breaks a limit. | See Metadata. |