Skip to content
Sursa Developers

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 haveUse
A record with a stable ID in your systemPUT /v1/cards-by-external-id/{external_id}: create or replace by your ID.
Text to add once, with no ID of your ownPOST /v1/cards/text
A file to add oncePOST /v1/cards
A ZIP archive of many filesPOST /v1/cards/archive
A file larger than the single-request limitAn upload in parts, then any of the endpoints above.
New content for a card you already havePUT /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 200 with "status": "skipped" and "reason": "unchanged". Repeating the request is always safe, even without an Idempotency-Key.
  • The content changed: the card gets a new version and is indexed again. Its title, tags, owner, category and metadata take the values you sent; metadata is 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_typeThe body is
markdown (default)Markdown.
htmlHTML. 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.
textPlain 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 409 with the code cards.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:

  1. Send "async": true, so each request returns as soon as the card is queued.
  2. Run a few requests in parallel (4 to 8), and slow down when the RateLimit header shows few requests left. See Rate limits and quotas.
  3. On 429, wait for Retry-After; on a network error or 5xx, retry the same request. Repeats are harmless.
  4. Record the external_id of 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

Common errors

CodeCauseWhat to do
collections.not_foundNo collection with this ID or name is visible to the key.Check collection_id with GET /v1/collections.
collections.no_write_accessThe key may not write to this collection.Use a key with the editor role and access to the collection.
cards.external_id_takenPOST with an external_id that another card already has.Use PUT /v1/cards-by-external-id/… to replace it.
cards.managed_by_sourceThe card belongs to a source.Change the document in its source.
files.type_mismatchThe file's name, type and content disagree.Send the file with its real extension.
files.too_largeThe file is above the upload limit or the organization's storage.Use an upload in parts, or free up storage.
limits.cardsThe organization has reached the card limit of its plan.Delete cards that are no longer needed, or change the plan.
api.metadata_invalidmetadata breaks a limit.See Metadata.