# Spitball publishing API

Production endpoint: https://getspitball.com/api/publishing/
OpenAPI: https://getspitball.com/publishing-api.json

Store the revocable key in your runtime's secret settings as SPITBALL_PUBLISH_API_KEY. Send `Authorization: Bearer <key>` with every request. Never include the key in generated text or logs. Keys permit publishing and updating live blog articles; this API has no access to users' iPhone thoughts.

GET returns `{etag, posts:[{id,draft,published}]}`. Find an existing post before creating one. Preserve its ID and published slug when updating. Read the latest full draft, then reconcile intended changes.

POST JSON example (replace UUIDs, etag and article):
```json
{
  "action":"publish",
  "etag":"ETAG_FROM_GET",
  "requestId":"e1d451fb-8800-49b9-a91b-a716a29ad13d",
  "post":{
    "id":"a689b917-28ce-4dd7-ad68-9023351da765",
    "slug":"a-useful-article-slug",
    "title":"A useful article title",
    "excerpt":"A specific, accurate summary of what this article helps someone do.",
    "author":"Spitball team",
    "category":"Guides",
    "body":"<p>Write the complete article here, including at least 100 characters of useful text. Verify product claims against current documentation before publishing.</p><h2>A clear section</h2><p>Make the answer practical and cite primary sources where appropriate.</p>",
    "cover":"",
    "coverAlt":""
  }
}
```
Use action `save` for a private draft. Publishing uses the supplied full post, not a patch. Supported categories: Guides, Ideas, Product updates. Allowed HTML: p, h2, h3, ul, ol, li, strong, em, blockquote, a, img, br, hr. Scripts, embeds and unsafe URLs are stripped. Titles up to 110 characters; summaries up to 260; body up to 150,000. Author required on publish. Supply descriptive alt text for images.

POST image upload: `{"action":"uploadImage","base64":"BASE64_IMAGE_BYTES"}`. PNG, JPEG, WebP only, under 3 MB and 20 megapixels. Returns `{"path":"/media/UUID.webp","bytes":123}`. Use path in `cover` with `coverAlt`, or `<img src="/media/UUID.webp" alt="Description">` in body. Identical images deduplicate. Private uploads become public only when referenced by published articles. Removed images can remain cached for up to one minute.

POST save/publish returns `{id,etag,replayed,status,url}`. A save does not change an existing published snapshot. It may therefore return status `published` with the existing URL even though your latest changes are still a draft.

Retry an uncertain write using the identical requestId, action, etag and payload. Request receipts are retained for the latest 500 writes; after that window, GET and compare before retrying. Reusing a requestId with different content returns 409. When another writer changed the library, GET again, reconcile edits, and use a new requestId. 401: invalid/revoked key. 409: conflict. 429: wait at least 60 seconds. 503: temporarily unavailable; retry with backoff. Do not retry validation errors unchanged.

Public pages and feeds refresh within approximately one minute. Public article URL: https://getspitball.com/blog/SLUG/. Verify the public title and body after publishing. Never claim indexing or AI citations are guaranteed.

Editorial requirements: no invented product features, reviews, metrics, quotes or comparisons. No personal captured thoughts or confidential material. State the beta status where relevant. App content is local by default, with optional iCloud sync; website publishing is a separate service. Optional analytics must not be described as no data leaving the device. Cite reliable primary sources for external claims.

Manage keys and unpublish or restore prior drafts in https://spitball-studio.vercel.app/. The agent API cannot delete posts, unpublish them, or manage credentials.

## Rich articles (API 1.1)

All new fields are optional so old articles and clients keep working. Save/publish still sends a complete post. Always GET the latest draft and preserve fields you are not changing. Omitting a field clears it. Do not send server timestamps: `publishedAt` stays fixed and `updatedAt` changes only when published content changes. Saving a draft does not change the public modification date.

| Field | Limit and fallback |
| --- | --- |
| `tags` | 1 to 8 items, 2 to 40 lowercase letters/digits/hyphens each, starting and ending with a letter or digit. Duplicates removed. Omit to clear. |
| `seoTitle` | 60 characters; falls back to title. |
| `metaDescription` | 160 characters; falls back to excerpt. |
| `ogImage` | Uploaded `/media/UUID.webp`; falls back to cover, then generated sharing art. |
| `authorUrl` | Optional HTTPS author profile, 300 characters. `Spitball team` is an Organization; other names are Persons. Never invent an author. |
| `tldr` | 2 to 5 plain-text bullets, 200 characters each. Omit to clear. |
| `faq` | 0 to 8 items; question up to 200 characters and plain-text answer up to 600. |
| `video` | Completed upload path, poster image, posterAlt (200), title (110), description (600), verified durationSeconds, transcript (30,000), optional captionsPath. Omit to remove. |

Use H2 for major sections and H3 for subsections. The page title supplies the only H1. Upload three genuinely different original images per new article, including a strong hero. Describe each image with specific alt text. Add an original video for the article, its transcript, and English captions. These are editorial requirements and Studio readiness checks, not API requirements for legacy posts.

### Upload a video

MP4 requires H.264 video and optional AAC audio. WebM supports VP8, VP9 or AV1 with optional Opus/Vorbis audio. One video track, at most one audio track, maximum 4096 px per dimension, 100,000,000 bytes and 180 seconds. The server checks the actual file. It does not transcode video. Export MP4 with fast-start for best playback.

For files up to 3 MB, POST `{"action":"uploadVideo","base64":"BASE64_VIDEO_BYTES"}`. The result is `{path,bytes,durationSeconds,width,height}`. Keep the returned duration exactly.

For larger files, upload directly to private storage using the scoped, short-lived token:

```js
import {readFile} from 'node:fs/promises';
import {put} from '@vercel/blob/client';
const endpoint = 'https://getspitball.com/api/publishing/';
async function api(body) {
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: {'Content-Type':'application/json', Authorization:'Bearer '+process.env.SPITBALL_PUBLISH_API_KEY},
    body: JSON.stringify(body)
  });
  const result = await response.json();
  if (!response.ok) throw new Error(result.error);
  return result;
}
const bytes = await readFile('./article.mp4');
const upload = await api({action:'uploadVideo', contentType:'video/mp4', bytes:bytes.length});
// The token can only upload this object. Do not log it or store it in article content.
await put(upload.pathname, bytes, {
  access:'private', token:upload.clientToken, multipart:true, contentType:'video/mp4'
});
const video = await api({action:'uploadVideo', uploadId:upload.uploadId});
// Use video.path and video.durationSeconds in the article below.
```

The upload token expires after 30 minutes. Finalize with the same publishing key that started the upload. Finalization can be retried with the same uploadId; successful results replay. Identical verified files deduplicate. Raw staging files are removed after successful finalization; abandoned uploads stay private and cannot be published. Never place a staging pathname or Blob URL into the post. No ETag/requestId is needed for media uploads; the existing ETag/requestId contract remains required for post writes.

### Upload captions

POST plain WebVTT text, up to 100 KB. Cue times must be ordered, have positive duration and stay within 180 seconds:

```json
{"action":"uploadCaptions","text":"WEBVTT\n\n00:00.000 --> 00:02.000\nKeep the thought before it disappears.\n"}
```

Returns `{path:"/media/UUID.vtt",bytes:123}`. Use this as `video.captionsPath`. Identical captions deduplicate.

### Complete publish example

Replace the sample media paths with actual upload results. Replace the ETag with the latest GET result and use a new requestId for each intentional write. Keep the same requestId and exact payload when retrying an uncertain write.

```json
{
  "action":"publish",
  "etag":"ETAG_FROM_GET",
  "requestId":"4bfb51b3-b329-4f91-a003-45974c23e09b",
  "post":{
    "id":"a689b917-28ce-4dd7-ad68-9023351da765",
    "slug":"catch-an-idea-before-it-disappears",
    "title":"Catch an idea before it disappears",
    "excerpt":"A practical way to capture an unfinished thought and find it when you are ready to use it.",
    "author":"Spitball team",
    "authorUrl":"https://getspitball.com/about/",
    "category":"Guides",
    "tags":["voice-notes","ideas","iphone"],
    "seoTitle":"How to capture an idea on your iPhone",
    "metaDescription":"Capture an unfinished thought, add useful context, and revisit it later with Spitball for iPhone.",
    "cover":"/media/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.webp",
    "coverAlt":"A paper idea beside an iPhone recording screen",
    "ogImage":"/media/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.webp",
    "tldr":["Capture the rough thought before polishing it.","Add enough context to make it useful later."],
    "body":"<p>An idea does not need to be finished to be worth keeping. Capture the words you have now, then return when you have time to think about them.</p><h2>Start with the thought</h2><p>Say what you noticed and why it matters.</p><img src=\"/media/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb.webp\" alt=\"A voice note with a short descriptive title\"><h2>Give it context</h2><p>Add a relevant photo or place if it will help you remember the moment.</p><img src=\"/media/cccccccc-cccc-cccc-cccc-cccccccccccc.webp\" alt=\"A saved thought with an attached photo\">",
    "faq":[{"question":"Where are my thoughts stored?","answer":"On your iPhone by default. You can enable optional iCloud sync in Settings."}],
    "video":{
      "path":"/media/dddddddd-dddd-dddd-dddd-dddddddddddd.mp4",
      "poster":"/media/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.webp",
      "posterAlt":"An iPhone ready to record a thought",
      "title":"Capture your first thought",
      "description":"A short demonstration of capturing an unfinished idea in Spitball.",
      "durationSeconds":2,
      "transcript":"Keep the thought before it disappears.",
      "captionsPath":"/media/eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee.vtt"
    }
  }
}
```

Uploaded video, poster, social images and captions remain private until referenced by a published article. Public video supports byte-range requests for scrubbing. Removing a reference or unpublishing removes public access after the existing cache window (up to one minute); other published articles can still reference the same file.

### Discovery and verification

Published articles render takeaways, a native video player without autoplay, expandable transcript, body, FAQs and linked tags. Tag archives live at `/blog/tag/TAG/`. The page emits BlogPosting, BreadcrumbList, optional VideoObject and optional FAQPage JSON-LD matching the visible content. Dedicated social/search metadata includes publication and modification dates.

Check `/sitemap.xml`, `/feed.xml` (full-content RSS), `/atom.xml` (full-content Atom) and `/llms.txt` after publishing. Drafts never appear in these endpoints. Validate public article markup with Google's Rich Results Test and Schema.org's validator where appropriate. Valid structured data does not guarantee eligibility, a search feature, indexing or citations. `llms.txt` is an additional discovery file, not a ranking guarantee.

400 errors name the invalid field. Correct the content before retrying. Video upload errors explain the size, codec, duration or incomplete-upload issue. A revoked key cannot start or finalize uploads; a previously issued upload token can only finish its one private staging object until expiry.

### Template search metadata (1.1.1 — backward compatible)

Existing requests, required fields, ETags, request IDs and authentication are unchanged. Image uploads add intrinsic `width` and `height` to the existing `path` and `bytes` response. Include these on inline images when convenient; public articles resolve dimensions for older uploads automatically and add lazy loading. The cover remains eager.

Use exactly `author: "Nik Sharma"` for Nik's articles. The template supplies his canonical author/video identity, photo, short bio and profile links. Do not add a new “About the author” section to the body. Existing bodies are preserved.

Use focused tags for topics/clusters. Related posts rank by shared tags, then category, recency and slug for stable ties. Tags with fewer than three published posts remain accessible with `noindex, follow` and are excluded from the sitemap.

The template adds language, Blog membership, app subject, word count and unique outbound HTTPS citations from body links. Sources must still support the article. Titles receive ` · Spitball` only when the full title fits within 60 characters; an existing suffix is not duplicated.

Published `updatedAt` / `dateModified` changes for title, excerpt, author, body, cover/alt text, takeaways, FAQ or video edits. SEO title/description, social image, profile URL, taxonomy and image-dimension housekeeping preserve it. Draft save timestamps and ETags still advance normally. `publishedAt` stays fixed.

Production publishes/updates from Studio or this API notify IndexNow after committing storage. Unpublishes notify it too. Drafts, local/preview writes and replayed request IDs do not. Network/server failures retry once with a three-second timeout per attempt. Failures are logged without failing publication; operators can resubmit affected URLs. Acceptance is receipt, not guaranteed indexing. Google continues to use the sitemap separately.

The retired `/blog/record-a-thought-with-your-iphone-action-button/` URL redirects (301) to `/blog/action-button-voice-note/`. Link to the destination. Static sitemap dates live in `src/static-dates.mjs` and change only after meaningful page edits, never on every build.

Optional public-site configuration `GOOGLE_SITE_VERIFICATION` (HTML token, not DNS TXT) and `BING_SITE_VERIFICATION` (msvalidate.01 value) adds ownership meta tags. These are not publishing fields; use owner/account-provided values. Missing values render no placeholders.

Uploaded cover and custom social images are normalized to 1200×630 for link previews, preserving the complete artwork. Original media URLs and upload inputs remain unchanged.
