Docs

SEO API

Generate long-form SEO articles stored in your account. Every request must send your SEO API key as Authorization: Bearer vsk_…

Create or rotate a key in Settings after you sign in. The full secret is shown once. Store it server-side. These HTTP routes do not accept email, password, or session cookies — only the API key.

# GaaS SEO API

Generate long-form SEO articles stored in your GaaS account. Every request must send your **SEO API key** as `Authorization: Bearer vsk_…`. Create or rotate a key in **Dashboard → Settings** (signed-in users only). The full secret is shown once when generated; store it in a password manager or env var.

## What the API writes about

Articles are written for **the business you describe** in the generate request: your `siteUrl`, `businessSummary`, and `allowedPaths` (the only on-site paths the draft may link to). The model is instructed to match that publisher, not to produce GaaS product marketing. GaaS hosts storage and generation; the output is meant to publish on **your** site.

## Base URL

Use your deployment origin (production example):

```
{{BASE_URL}}
```

## Authentication

- Sign in to GaaS, open **Dashboard → Settings**, and use **Generate API key** (or Rotate). Copy the value immediately; it is not shown again.
- Keys look like `vsk_<uuid>_<64 hex chars>`. Send them on every SEO API request: `Authorization: Bearer <full key>`.
- Use the key from **server-side** code or scripts whenever possible. Do not embed it in public frontends.

### Dashboard vs SEO API

Signing in at `/sign-in` is only for the GaaS web app (including creating an API key in Settings). The SEO HTTP routes do **not** accept email, password, or Supabase JWTs. They only validate the **API key** string.

## Endpoints

| Method | Path | Auth |
| ------ | ---- | ---- |
| GET | `/api/seo/v1/session` | API key |
| GET | `/api/seo/v1/quota` | API key |
| GET | `/api/seo/v1/posts` | API key |
| GET | `/api/seo/v1/posts?slug=…` | API key |
| DELETE | `/api/seo/v1/posts?slug=…` | API key |
| GET | `/api/seo/v1/posts/[id]` | API key |
| POST | `/api/seo/v1/generate` | API key |

## Generate: request body

JSON body for `POST /api/seo/v1/generate`. Required fields:

- `topic` — working title or subject (at least **10** characters).
- `siteUrl` — canonical site URL for the publisher.
- `businessSummary` — description of the business (at least **20** characters, max 4000; used to steer tone and examples).
- `allowedPaths` — non-empty array of site-relative paths (each starts with `/`); internal links in the draft are limited to these.
- `author` — object with all of `name`, `title`, and `linkedin` (use `null` if there is no LinkedIn URL; `linkedin` must be a valid HTTP/HTTPS URL or `null`, not an empty string).

### Common validation mistakes (400 responses)

These return **400** with `success: false`, **`errorCode: "validation_error"`**, and **no article saved** (generation never runs):

- Missing JSON body, or omitting `Content-Type: application/json`.
- Using `keywords` as an array. Use optional `targetKeyword` as a **single string** (or omit).
- `author` with only `name`. You must also send `title` and `linkedin` (valid URL string or `null`).
- Extra keys inside `author` or `reviewedBy` (strict JSON objects).
- `businessSummary` shorter than 20 characters, or `allowedPaths` empty or paths not starting with `/`.

Common optional fields:

- `targetKeyword`, `audience`, `tone`, `ctas`
- `audienceTargetUrl` — optional landing page URL for extra context
- `reviewedBy` — omit, `null`, or `{ name, title, linkedin }` with the same rules as `author.linkedin`
- `evidencePack` — optional structured facts (proof points, stats, source URLs, etc.) to ground the draft
- `slug` — optional. Lowercase kebab-case (`a-z`, `0-9`, hyphens), max 200 characters. Fixes the saved URL slug for batch jobs and reruns.
- `replaceExistingSlug` — optional boolean (default `false`). If `true`, overwrites an existing post with the same slug in one request (upsert). If `false`, duplicate slug → **409** with `meta.conflictingSlug`.

## Generate: successful response

On **200 OK**, the JSON envelope includes `success: true`, `data`, and `meta` with remaining quota headroom. Shape (abbreviated):

```json
{
  "success": true,
  "data": {
    "id": "<uuid>",
    "slug": "your-post-slug",
    "frontmatter": {
      "title": "...",
      "metaTitle": "...",
      "metaDescription": "...",
      "slug": "your-post-slug",
      "tldr": "...",
      "author": { "name": "...", "title": "...", "linkedin": null },
      "targetKeyword": "...",
      "tags": ["..."],
      "internalLinkSuggestions": [{ "anchorText": "...", "targetSlug": "/pricing" }]
    },
    "body": "<MDX string>",
    "provider": "vertex",
    "model": "...",
    "grounding": { "...": "search grounding metadata" },
    "usage": { "promptTokenCount": 0, "candidatesTokenCount": 0 }
  },
  "meta": {
    "remainingArticles": 99,
    "remainingFailures": 200,
    "windowDays": 30
  },
  "error": null
}
```

## List posts: query parameters

`GET /api/seo/v1/posts` supports `limit` and `offset` for pagination (newest first). Defaults: `limit=20`, `offset=0`. Maximum `limit` is **100**. If `slug=...` is present, the handler returns a single full post (or **404**) and ignores pagination.

## Delete post by slug

`DELETE /api/seo/v1/posts?slug=your-article-slug` removes one saved article for the authenticated account. **404** if no matching post. **400** if `slug` is missing, empty, too long, or reserved. Uses the same read-bucket rate limit as `GET` (90/min).

## Article format (MDX)

The article body is a **string of MDX** (Markdown with optional JSX-style components, depending on what the model emits). Your site or CMS needs an MDX pipeline, a Markdown renderer, or a conversion step to HTML. Metadata for SEO and display is duplicated in `data.frontmatter` for convenience. When fetching a saved post via `GET .../posts/...`, the same body is stored in the `content` field on the row.

## Examples by business type

One `POST /generate` call creates **one** article. Reuse the same `siteUrl`, `businessSummary`, and `allowedPaths` for your business; change `topic` (and optional fields) for each new angle.

### Startups (product, pricing, hiring, content pillars)

```json
{
  "topic": "Why teams outgrow spreadsheets when syncing ads across Meta and Google",
  "targetKeyword": "cross-platform ad workflow",
  "audience": "Marketing leads at Series A–B SaaS companies",
  "tone": "Direct and practical, no hype",
  "siteUrl": "https://example.com",
  "businessSummary": "We sell workflow software that connects ad platforms and reporting. Teams use us to ship campaigns faster and see spend in one place.",
  "allowedPaths": ["/pricing", "/product", "/security", "/blog", "/contact"],
  "author": { "name": "Alex Kim", "title": "CEO", "linkedin": null }
}
```

### Local and service businesses (areas, services, comparisons)

```json
{
  "topic": "Self-service laundry vs wash-and-fold near Inman Park: what locals choose and why",
  "targetKeyword": "laundromat Inman Park Atlanta",
  "audience": "Residents and students within 15 minutes of the store",
  "siteUrl": "https://example-laundry.com",
  "businessSummary": "Family-run laundromat with self-service washers, wash-and-fold, and pickup windows. We emphasize speed, fair pricing, and clean facilities.",
  "allowedPaths": ["/services", "/pricing", "/hours", "/contact", "/blog"],
  "author": { "name": "Jordan Lee", "title": "Owner", "linkedin": null },
  "evidencePack": {
    "proofPoints": ["Same-day wash-and-fold cut-off at 2pm", "Free dry on self-service Tuesdays"],
    "sourceUrls": ["https://example-laundry.com/hours", "https://example-laundry.com/pricing"]
  }
}
```

### E-commerce (categories, buying guides, trust)

```json
{
  "topic": "How to choose the right size when buying running shoes online",
  "targetKeyword": "running shoe size guide",
  "audience": "First-time buyers comparing brands",
  "ctas": "Link to size chart and to best-selling trainers; encourage free returns",
  "siteUrl": "https://shop.example.com",
  "audienceTargetUrl": "https://shop.example.com/collections/running-shoes",
  "businessSummary": "Independent footwear retailer with free returns, expert fit notes, and curated brands for road and trail running.",
  "allowedPaths": ["/collections/running-shoes", "/shipping-returns", "/about", "/contact"],
  "author": { "name": "Sam Rivera", "title": "Merchandising Lead", "linkedin": null }
}
```

### SEO habits that work well with this API

- **One main intent per article.** Split “zip posts,” “neighborhood posts,” and “service explainer” into separate generates so each URL targets one search intent clearly.
- **Stable business context, specific topics.** Keep `businessSummary` accurate and refresh it when your offers change; rotate `topic` for each new piece.
- **Keywords in the right fields.** Put the phrase you care about in `targetKeyword` and reinforce it naturally in `topic`.
- **Internal links that match your site.** List real paths in `allowedPaths` only; the draft will not invent URLs outside that list.
- **Facts in evidencePack.** Hours, prices, service areas, or source URLs help grounded, trustworthy copy.
- **Pace and quotas.** Many short posts means many calls: watch `GET /quota`, respect per-minute limits, and space out batches if you hit **429**.

## Quotas

Rolling **30 days**: up to **100** successful article generations and up to **200** recorded failures per account. Check `GET /api/seo/v1/quota` for remaining headroom.

## Rate limits

Per account, rolling **one minute**: up to **90** requests to GET endpoints (session, quota, posts) and **`DELETE /api/seo/v1/posts?slug=`**, and up to **12** `POST /api/seo/v1/generate` calls. These limits are separate from the 30-day article quotas above. If you exceed them, the API returns **429** with a `Retry-After` header (seconds). Rolling **quota** **429** responses also include `Retry-After` as a **conservative hint** (not an exact reset time for the 30-day window).

## HTTP errors

- **400** — Invalid JSON or validation (e.g. missing topic, bad URL).
- **401** — Missing or invalid API key in `Authorization: Bearer`.
- **404** — Post not found (id or slug).
- **409** — Slug already exists for your account (when `replaceExistingSlug` is false). Response may include `meta.conflictingSlug`.
- **429** — Rolling article or failure quota exceeded, or per-minute request rate limit exceeded (`Retry-After` header when present).
- **500** — Server or upstream error.

## Examples (curl)

Verify session:

```bash
curl -sS "{{BASE_URL}}/api/seo/v1/session" \
  -H "Authorization: Bearer YOUR_SEO_API_KEY"
```

Generate an article (`businessSummary` must be at least 20 characters):

```bash
curl -sS -X POST "{{BASE_URL}}/api/seo/v1/generate" \
  -H "Authorization: Bearer YOUR_SEO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"topic":"Your headline or working title here (10+ chars)","targetKeyword":"optional single phrase","siteUrl":"https://example.com","businessSummary":"At least twenty characters describing the business for tone and context.","allowedPaths":["/pricing","/contact"],"author":{"name":"Jane Doe","title":"Founder","linkedin":null}}'
```

Same request using a JSON file:

```bash
# body.json
{
  "topic": "5 laundry hacks for busy professionals",
  "targetKeyword": "laundry pickup and delivery",
  "siteUrl": "https://your-site.com",
  "businessSummary": "Your business in at least twenty characters so the draft matches your offer and geography.",
  "allowedPaths": ["/services", "/contact", "/blog"],
  "author": { "name": "Your Name", "title": "Editorial", "linkedin": null }
}

curl -sS -X POST "{{BASE_URL}}/api/seo/v1/generate" \
  -H "Authorization: Bearer YOUR_SEO_API_KEY" \
  -H "Content-Type: application/json" \
  -d @body.json
```

Get one saved article by id:

```bash
curl -sS "{{BASE_URL}}/api/seo/v1/posts/POST_ID_UUID" \
  -H "Authorization: Bearer YOUR_SEO_API_KEY"
```

Same article by URL slug:

```bash
curl -sS "{{BASE_URL}}/api/seo/v1/posts?slug=your-article-slug" \
  -H "Authorization: Bearer YOUR_SEO_API_KEY"
```

Delete a saved article by slug:

```bash
curl -sS -X DELETE "{{BASE_URL}}/api/seo/v1/posts?slug=your-article-slug" \
  -H "Authorization: Bearer YOUR_SEO_API_KEY"
```

Fetch from JavaScript (server-side recommended):

```javascript
const r = await fetch("{{BASE_URL}}/api/seo/v1/session", {
  headers: { Authorization: `Bearer ${process.env.VERSAUNT_SEO_API_KEY}` },
});
const json = await r.json();
```

Questions? hello@trygaas.com