API Reference
The Playtest API base URL is https://api-playtest.plxi.us. Every endpoint accepts and returns JSON and is authenticated with your API key.
curl -X POST https://api-playtest.plxi.us/v1/example/analyze \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"text": "The quick brown fox jumps over the lazy dog. The dog sleeps.",
"top": 3
}'Authentication
Send an X-API-Key header with every request. Keys are created and revoked in the dashboard; a key is shown once, when it is created, so store it somewhere safe.
X-API-Key: sk_live_...
Plans & limits
Limits apply per account, not per key: every key on the account (and teammates working in it) shares them. There is a per-minute rate, a cap on requests in flight at once, and a monthly quota. On the Free plan the quota resets on the 1st of each month (UTC); on paid plans it resets on your billing-period anniversary. Requests that fail validation are never charged.
| Plan | Price | Requests / min | In flight | Requests / month |
|---|---|---|---|---|
| Free | Free | 10 | 1 | 100 |
| Starter | $10/mo | 60 | 3 | 2,000 |
| Pro | $50/mo | 300 | 6 | 20,000 |
Errors
Errors return JSON with a stable error code, an optional message, and, on rate-limit and monthly-quota 429s, reset_at. A request that returns an error is never charged.
{
"error": "rate_limit_exceeded",
"reset_at": "2026-07-22T18:04:00Z"
}Fix the request
| Status | Code | When | What to do |
|---|---|---|---|
| 400 | bad_request | The body is not valid JSON. | Send the body as JSON. |
| 400 | invalid_input | A body field failed the operation’s checks; message says which one and why. | Correct that field. |
| 401 | invalid_api_key | The X-API-Key header is missing, malformed, revoked or not a key we issued. | Check the key, or create a new one in the dashboard. |
| 403 | account_blocked | The account is suspended. | Contact [email protected]. |
| 404 | not_found | No endpoint at that path for that method: operations take POST, /v1/usage takes GET. | Check the path and the method. |
| 413 | payload_too_large | The body is larger than the endpoint takes: 1 MiB unless it says otherwise. | Send a smaller body. |
Wait, then retry
| Status | Code | When | What to do |
|---|---|---|---|
| 429 | rate_limit_exceeded | The per-minute request limit is reached (per account, across all its keys). Requests that then fail, for example on invalid input, count toward it too. | Retry after reset_at, the start of the next minute. |
| 429 | concurrency_limit_exceeded | The account has as many requests in flight as its plan allows. | Retry with backoff once one of them finishes. There is no reset_at. |
| 429 | monthly_quota_exceeded | The quota for this billing period is used up. | Retry after reset_at: the 1st of next month (UTC) on Free, the end of your billing period on paid plans. Or upgrade. |
Temporary: retry with backoff
| Status | Code | When | What to do |
|---|---|---|---|
| 400 | body_read_failed | The connection failed while the body was being sent. | Retry. |
| 408 | request_body_timeout | The body took too long to arrive. | Retry, sending the whole body at once. |
| 500 | internal_error | Something failed on our side. | Retry with backoff. |
| 503 | capacity_unavailable | The API is handling as many requests as it can. | Retry with backoff. |
| 503 | database_unavailable limits_unavailable | Our database, or the store that counts rate limits, is unavailable. | Retry with backoff. |
| 503 | auth_busy | API key checks are busy. | Retry with backoff. |
| 503 | billing_period_stale billing_period_invalid | On a paid plan, the current billing period can’t be confirmed, for example because a renewal hasn’t been recorded yet. | Retry later. If it lasts, the account owner can open Billing in the dashboard to refresh it, or contact [email protected]. |
| 504 | request_timeout | The operation ran past the API’s time limit. | Retry with backoff; if it keeps happening, send smaller input. |
Use with AI agents (MCP)
Playtest API is also an MCP server, so Claude and other AI agents can call its main operations as tools (listed below). Tool calls are charged exactly like API calls, to the same plan and quota, and failed calls are free.
https://api-playtest.plxi.us/mcp
Claude (claude.ai and the desktop app): Settings → Connectors → Add custom connector, then paste the URL above. Claude asks you to sign in with your Plexsicity account; calls are then charged to your own account.
Claude Code, Cursor, VS Code and other clients: send an API key from the dashboard as a header. Calls are charged to the account that owns the key, so a team’s key spends the team’s quota. Clients that only take a bearer token can send Authorization: Bearer sk_live_… instead.
1. Connect your agent
claude mcp add --transport http playtest-api https://api-playtest.plxi.us/mcp \
--header "X-API-Key: sk_live_..."2. Tell it when to use the tools
Add this to the repository’s AGENTS.md or CLAUDE.md. Agents also get these rules from the server when they connect; the file makes them part of the project.
## Playtest API
- To count words, sentences or characters, or to estimate reading time, call `analyze_text` instead of counting yourself.
- To turn a title into a URL slug, call `slugify_text`.
POST/v1/example/analyze
Word, sentence and character counts, reading time and the most used words. Costs 1 request.
| Field | Type | Description |
|---|---|---|
| text | string | Required. The text to analyze. |
| top? | any | How many of the most used words to list, 1 to 20. Any value is read as JavaScript's Number() reads it and kept within that range. Default 5. |
{
"text": "The quick brown fox jumps over the lazy dog. The dog sleeps.",
"top": 3
}{
"characters": 60,
"characters_no_spaces": 49,
"words": 12,
"sentences": 2,
"paragraphs": 1,
"reading_time_seconds": 4,
"top_words": [
{
"word": "dog",
"count": 2
},
{
"word": "brown",
"count": 1
},
{
"word": "fox",
"count": 1
}
],
"credits": 1
}curl -X POST https://api-playtest.plxi.us/v1/example/analyze \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"text": "The quick brown fox jumps over the lazy dog. The dog sleeps.",
"top": 3
}'The response is the operation’s result plus credits, what the request was charged. Try it in the Playground. MCP tool: analyze_text.
POST/v1/example/slugify
Turn text into a URL-safe slug (accents removed, lowercase). Costs 1 request.
| Field | Type | Description |
|---|---|---|
| text | string | Required. The title or phrase to turn into a slug. |
| separator? | "-" | "_" | "." | The character between words. Default "-". |
| max_length? | any | The longest slug to return, 1 to 200 characters. Any value is read as JavaScript's Number() reads it and kept within that range. Default 80. |
{
"text": "Héllo Wörld: a new post!",
"separator": "-",
"max_length": 80
}{
"slug": "hello-world-a-new-post",
"credits": 1
}curl -X POST https://api-playtest.plxi.us/v1/example/slugify \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"text": "Héllo Wörld: a new post!",
"separator": "-",
"max_length": 80
}'The response is the operation’s result plus credits, what the request was charged. Try it in the Playground. MCP tool: slugify_text.
GET/v1/usage
Your plan and usage this billing period, for alerting before you reach the quota.
curl https://api-playtest.plxi.us/v1/usage \
-H "X-API-Key: sk_live_..."{
"plan": "starter",
"used": 487,
"limit": 2000,
"period_start": "2026-07-18T09:12:44Z",
"period_end": "2026-08-18T09:12:44Z"
}Webhooks
Add an HTTPS endpoint under Webhooks in the dashboard and Playtest API POSTs a request.completed event to it after each charged request. Each endpoint gets its own signing secret (whsec_…), shown once when you add it.
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Playtest-Webhooks/1
X-Playtest-Event: request.completed
X-Playtest-Event-ID: 6f1c2a7e-…:0d4e9b2c-…
X-Playtest-Signature: sha256=3b5f0c9e41d7…
X-Playtest-Webhook-Signature: t=1790690591,v1=9c2e41f07a3d…
{"id": "6f1c2a7e-…:0d4e9b2c-…", "data": {"ms": 4, "credits": 1, "endpoint": "example.analyze"}, "event": "request.completed", "created_at": "2026-09-29T14:03:11.482107+00:00"}X-Playtest-Webhook-Signature is t=<unix seconds>,v1=<hex>: the HMAC-SHA256 of <t>.<event id>.<raw body>, keyed with the endpoint’s full secret including whsec_. Compute it over the bytes you received, before parsing, and refuse a timestamp more than 5 minutes old. The older X-Playtest-Signature (sha256= of the body alone) is still sent while integrations move over, and will be removed after notice; it can’t stop a replayed delivery.
import crypto from 'node:crypto';
// rawBody: the request body exactly as received (Buffer or string). Verify it
// before parsing -- JSON that has been parsed and re-serialised won't match.
// secret: the endpoint's full signing secret, "whsec_" prefix included.
// The header is "t=<unix seconds>,v1=<hex>": the HMAC-SHA256 of
// "<t>.<event id>.<body>". One more than 5 minutes old is refused, so a
// captured delivery can't be replayed.
function verifySignature(rawBody, header, eventId, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(String(header || '').split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret)
.update(t + '.' + eventId + '.').update(rawBody).digest('hex');
const given = Buffer.from(String(parts.v1 || ''));
const want = Buffer.from(expected);
return given.length === want.length && crypto.timingSafeEqual(given, want);
}
// Express: take this route's body raw, verify, then parse.
app.post('/your/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const id = req.get('X-Playtest-Event-ID');
const ok = verifySignature(req.body, req.get('X-Playtest-Webhook-Signature'), id, process.env.WEBHOOK_SECRET);
if (!ok) return res.sendStatus(401);
// "id" in the body is the same as the header: a retry sends it again, so skip one already handled.
const { event, data } = JSON.parse(req.body);
res.sendStatus(200);
});X-Playtest-Event-ID, so use it to ignore duplicates. Recent attempts are listed under Deliveries on the Webhooks page. Endpoints must be public HTTPS URLs.