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
