Content Sync

ProEnterprise

Push documentation content to Firstrun and automatically extract interactive steps.

Interactive firstrun guide available

Step-by-step with copy-to-clipboard and progress tracking.

Overview

Push raw documentation (markdown, text, or a URL) and get a response back instantly. Fire and forget — the API accepts your content and returns a wizard_id immediately. Step extraction runs in the background. Poll the status endpoint if you need to know when it's done.

Prerequisite: Content Sync is available on the Pro or Enterprise plan. Create an API key first (admin or owner role required).

Sync Content

POST /api/v1/sync

Send raw content or a URL. The response returns immediately with a wizard_id and a status telling you what happened.

Option 1: Send a URL (simplest)

curl -X POST https://api.firstrun.dev/api/v1/sync \
  -H "Authorization: Bearer fr_key_..." \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "setup-guide",
    "url": "https://docs.acme.dev/setup"
  }'

Option 2: Send raw content

curl -X POST https://api.firstrun.dev/api/v1/sync \
  -H "Authorization: Bearer fr_key_..." \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "setup-guide",
    "content": "# Getting Started\n\nInstall with npm install..."
  }'

Option 3: Upload a file

POST /api/v1/sync/upload — multipart form data. Supports PDF, HTML, Markdown, and plain text (max 5 MB).

curl -X POST https://api.firstrun.dev/api/v1/sync/upload \
  -H "Authorization: Bearer fr_key_..." \
  -F "external_id=setup-guide" \
  -F "file=@docs/setup.pdf" \
  -F "auto_publish=true"

Response (all three options)

{
  "wizard_id": "d4f5a6b7-...",
  "external_id": "setup-guide",
  "status": "created",
  "wizard_status": "generating",
  "message": "Wizard created, extracting steps."
}

Response statuses

statuswizard_statusWhat happened
createdgeneratingNew wizard created. Pipeline is running — poll for completion.
updatedgeneratingContent changed since last sync. Steps are being re-extracted — poll for completion.
unchangeddraft / publishedContent identical to last sync (SHA-256 match). Nothing happens — no need to poll.

Most CI runs will hit unchanged and return instantly. Only actual content changes trigger the pipeline.

Request Body

FieldTypeDescription
external_idstringRequired. A name you choose to identify this wizard. You make this up — pick anything descriptive like "setup", "deploy-guide", or "docs/api/auth". In CI, use the filename (e.g. docs/setup from docs/setup.md). Same external_id always maps to the same wizard — first call creates, subsequent calls update. You never get duplicates, and you never need to store a wizard ID on your side. Max 255 characters.
contentstringRaw markdown or text (min 50 chars, max 1M chars). Provide exactly one of content or url.
urlstringURL to scrape. Provide exactly one of content or url.
auto_publishbooleanPublish the wizard automatically after extraction succeeds. Default: false.
visibilitystring"public" or "internal". Only used when auto_publish is true. Default: "public".

Poll for Completion

GET /api/v1/sync/{wizard_id}

When status is created or updated, the pipeline is running in the background. Poll until status is no longer generating:

# Poll until extraction completes
while true; do
  RESP=$(curl -s https://api.firstrun.dev/api/v1/sync/{wizard_id} \
    -H "Authorization: Bearer fr_key_...")

  STATUS=$(echo "$RESP" | jq -r '.status')

  if [ "$STATUS" != "generating" ]; then
    echo "Done: status=$STATUS, steps=$(echo "$RESP" | jq '.step_count')"
    break
  fi

  echo "Still generating (stage: $(echo "$RESP" | jq -r '.pipeline_stage'))..."
  sleep 5
done

Response

{
  "wizard_id": "d4f5a6b7-...",
  "external_id": "setup-guide",
  "status": "draft",
  "pipeline_stage": null,
  "title": "Getting Started",
  "step_count": 5,
  "error_message": null
}

Status values

statusMeaning
generatingPipeline is running. Check pipeline_stage for progress (scraping cleaning extracting enriching). Keep polling.
draftExtraction complete. Steps are ready. Wizard is unpublished.
publishedExtraction complete and auto-published.
failedPipeline failed. Check error_message for details.

List Wizards

GET /api/v1/sync/wizards

List all wizards in your organization with their external_id mappings. Useful for discovering what's already been synced.

curl https://api.firstrun.dev/api/v1/sync/wizards \
  -H "Authorization: Bearer fr_key_..."

How It Works

You pick the external_id, Firstrun handles the rest

The external_id is a name you choose — it can be anything (a slug, a file path, a label). Firstrun uses it to link your content to a wizard. First call with a given external_id creates a new wizard. Every subsequent call with the same external_id updates that same wizard. You never get duplicates, and you never need to store a wizard ID on your side.

Content hash skips unnecessary work

Firstrun computes a SHA-256 hash of the content (or URL) you send. If the hash matches the previous sync, the response is instant "unchanged" with no pipeline run. This means you can sync on every CI build without waste — only actual content changes trigger extraction.

Concurrent sync protection

If a wizard is currently being generated (pipeline running), a new sync for the same external_id returns 409 with the wizard_id and a suggested retry_after delay. Wait for the current extraction to finish before syncing again.

# 409 response body:
{
  "detail": {
    "error": "wizard_generating",
    "message": "Wizard is currently generating. Wait for completion before syncing again.",
    "wizard_id": "d4f5a6b7-...",
    "retry_after": 30
  }
}

Version snapshots

When content changes and steps are re-extracted, the previous version is automatically saved. Your analytics history is never lost.

CI/CD Examples

The recommended pattern is to sync all your doc files on every push. Unchanged files return instantly (content hash match), so only files that actually changed trigger extraction. Engineers never need to touch the CI config when adding or editing docs.

GitHub Actions — sync all docs (recommended)

Every push to main syncs every file in docs/. Unchanged files are instant no-ops. New files automatically create new wizards.

name: Sync docs to Firstrun
on:
  push:
    branches: [main]
    paths: [docs/**]

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Sync all docs
        run: |
          for file in docs/*.md; do
            SLUG=$(basename "$file" .md)
            echo "Syncing $SLUG..."
            RESP=$(curl -sf -X POST https://api.firstrun.dev/api/v1/sync \
              -H "Authorization: Bearer ${{ secrets.FIRSTRUN_API_KEY }}" \
              -H "Content-Type: application/json" \
              -d @- <<EOF
          {
            "external_id": "docs/$SLUG",
            "content": $(jq -Rs . < "$file"),
            "auto_publish": true
          }
          EOF
            )
            echo "  → $(echo "$RESP" | jq -r '.status')"
          done

A typical run where one file changed out of twenty:

Syncing setup...       → unchanged
Syncing quickstart...  → unchanged
Syncing auth...        → updated     ← only this file triggers extraction
Syncing deploy...      → unchanged
...

GitLab CI

# .gitlab-ci.yml
sync-docs:
  stage: deploy
  script:
    - |
      for file in docs/*.md; do
        SLUG=$(basename "$file" .md)
        curl -sf -X POST https://api.firstrun.dev/api/v1/sync \
          -H "Authorization: Bearer $FIRSTRUN_API_KEY" \
          -H "Content-Type: application/json" \
          -d @- <<EOF
      {
        "external_id": "docs/$SLUG",
        "content": $(jq -Rs . < "$file"),
        "auto_publish": true
      }
      EOF
      done
  rules:
    - changes:
        - docs/**

Single URL (no checkout needed)

If you just want to sync one hosted page, you can point Firstrun at the URL directly:

curl -X POST https://api.firstrun.dev/api/v1/sync \
  -H "Authorization: Bearer fr_key_..." \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "setup-guide",
    "url": "https://docs.acme.dev/setup",
    "auto_publish": true
  }'

Limits & Best Practices

Wizard limit

Each organization has a wizard limit based on your plan. Each unique external_id creates one wizard. If you hit the limit, you'll get a 403. Delete unused wizards or upgrade your plan.

Large doc sets (first-time sync)

Each sync request extracts steps using AI, which takes 10–30 seconds per wizard. If you're syncing many files for the first time, add a short delay between requests to avoid overwhelming the pipeline:

for file in docs/*.md; do
  SLUG=$(basename "$file" .md)
  curl -sf -X POST https://api.firstrun.dev/api/v1/sync \
    -H "Authorization: Bearer ${{ secrets.FIRSTRUN_API_KEY }}" \
    -H "Content-Type: application/json" \
    -d @- <<EOF
{
  "external_id": "docs/$SLUG",
  "content": $(jq -Rs . < "$file"),
  "auto_publish": true
}
EOF
  sleep 2  # Pace requests during initial import
done

After the initial import, subsequent runs are fast — only changed files trigger extraction, and unchanged files return instantly.

One file per wizard

Content Sync creates one wizard per external_id. If your docs are split across many small files, consider syncing only the pages that make sense as standalone step-by-step guides rather than every file in your repo.

Error Codes

StatusDescription
401Missing or invalid API key.
403Wizard limit reached. Delete a wizard or upgrade your plan.
404Wizard not found (GET endpoints).
409Wizard is currently generating. Response includes wizard_id and retry_after (seconds). Poll the status endpoint and retry after extraction completes.
422Validation error: missing external_id, both url and content provided (or neither), or content too short.