Try it
This runs the real service against the real models, with the same eight guards. It takes
no key, so it is capped tightly and the fields it accepts are the safe subset: no
forbid, no custom schema, and {name} is the only
token allowed. It also keeps to everyday subjects: a voice or ask that names news
events, politics, war, religion, health, money or similar comes back as
blocked_topic without reaching a model. Keyed calls are not under that
list; forbid is how you set your own.
Do not type anything about a real person here. Send {name}
and picture the rest. Demo prompts are not stored, and the tokens are the whole point.
Before you start
There is no sign-up page. Keys are issued by hand, one per application, so write to getmeme@warmhop.com and say what you are building and roughly how often you will call.
A key is shown once, when it is created. Only its hash is stored here, so a lost key cannot be looked up or recovered: ask for it to be rotated and the old one stops working on the next request.
Keep your key on your server. getmeme sends no CORS headers, so a call from a browser page fails, but that is not what keeps the key safe: a key in a page's JavaScript can be read out of the bundle and used from anywhere. If one leaks, mail getmeme@warmhop.com and it will be rotated.
Authentication
Every request carries the key as a bearer token, except the four that take none:
/health, /openapi.json, this page and its icon.
Authorization: Bearer gm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
A missing, unknown, revoked or expired key is 401 invalid_api_key with a
WWW-Authenticate header. The four cases are not told apart on purpose.
Create a line
One endpoint, and it knows nothing about what the line is for. There is no
/greeting and no /welcome, because the wording belongs to you:
you send the voice, getmeme holds it to a shape.
| Field | Type | What it does |
|---|---|---|
| system * | string, 1 to 2000 | The voice. Who is writing, in what tone, about what product. |
| prompt * | string, 1 to 4000 | The ask, with your own variables already filled in. |
| maxLength * | integer, 1 to 500 | A hard cap in characters. A longer line is rejected, never trimmed. |
| placeholders | string[], up to 8 | Token names without braces: a letter, then letters, digits or underscores, up to 31 characters. ["name"] lets {name} appear. Matching ignores case, so {Name} counts as declared. |
| forbid | string[], up to 50 | Banned substrings, 1 to 100 characters each, matched case insensitively. Never sent to the model. |
| schema | object | JSON Schema for the answer. Must declare a string property text. Under 4KB. Defaults to { "text": string }. Only text is read back, so extra properties are not returned to you. |
| timeoutMs | integer, 500 to 30000 | The budget for the model calls, shared across every model tried. Defaults to 30000. No single attempt takes more than a third of it, so values under about 3000 mostly return deadline_exceeded. Set your own socket timeout above this, not equal to it. |
Fields marked * are required.
Unknown fields are rejected outright, the body is capped at 16KB, and
Content-Type: application/json is required.
A call, in full
curl -sS https://getmeme.warmhop.com/api/v1/lines \\
-H "Authorization: Bearer $GETMEME_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"system": "You write one friendly, funny greeting line for a link tracking app.",
"prompt": "Greet them on a Monday morning. Address them as {name}, exactly once.",
"maxLength": 80,
"placeholders": ["name"],
"forbid": ["acme"]
}'
{
"text": "Happy Monday, {name}. The coffee already forgave you.",
"model": "qwen/qwen3.8-27b:free",
"reason": null
}
model is the model that actually answered, which is not always the one the
service asked for: when a router picks a different model, this is the one it picked. It is
null whenever text is.
The same request does not give the same line twice, and there is no idempotency key, so a retry is a second call: a second model request and a second slot off your rate limit.
And when there is no line
{
"text": null,
"model": null,
"reason": "guard:max_length"
}
Still 200. Responses are never cached: everything carries
Cache-Control: no-store.
You write the voice, getmeme writes the shape
Whatever you send as system is kept, and the service appends its own rules
after it: answer with one JSON object, one line, under the cap, no markdown, no em dash,
no links, no stray brackets, and exactly which placeholders are allowed.
Those appended rules are the same ones the guards enforce, so the model is told the
contract it is being held to instead of being failed for a rule it never saw. Use
system for voice. The formatting is handled.
Send tokens, not people
placeholders exists so that personal data never reaches a model provider.
Ask for {name}, get a line with {name} in it, and substitute on
your side. A token you did not declare is rejected, so the model cannot invent a hole you
have no value for.
maxLength counts the token, not the value you put in it.
{name} is six characters. If the name might be twenty, ask for fourteen
fewer and check the finished line yourself.
What getmeme keeps, and who sees it
Every call is stored for 14 days: your system and prompt as
they were sent, each model's raw answer, and the line you got back. It is kept so a call
that went wrong can be explained afterwards, it is readable only by getmeme, and a daily
job deletes anything older. Your forbid terms are never stored, only how
many there were.
Prompts are sent to OpenRouter, which passes them to whichever model answers. When the named model is unavailable the request falls through to OpenRouter's free router, which picks among free models that are up at that moment, so the provider that sees a given prompt is not fixed in advance. getmeme does not train on your prompts and has no say in what a downstream provider does with them.
So: do not put personal data in prompt. Send {name} and
substitute on your side. If you need retention set to zero for your key, ask at
getmeme@warmhop.com.
Words you never want printed
forbid is checked against the answer and is never put in a
prompt. That is deliberate. These are usually the words you are not ready to publish, an
unreleased name or a partner under wraps, and listing them in a prompt would hand every
one of them to the model provider in order to avoid printing them.
Matching is case insensitive and runs after Unicode normalisation, so a full-width lookalike does not slip past it.
The eight guards
Every line is checked in this order. The first rejection wins, nothing is repaired, and
the guard that stopped it is named in reason. They cannot be turned off.
The guards are about shape, not meaning. They check formatting, length and your
forbid list. Nothing here decides whether a line is accurate, kind or wise
to publish, so treat what comes back as model output you are responsible for.
- 1normalise Folds Unicode to NFKC, trims, strips wrapping quotes. Empty after that is a rejection.
- 2single_line Any line break at all.
- 3no_markdown Backticks, asterisks, tildes, bullets, headings, link syntax.
- 4max_length Longer than your cap, counted in characters rather than bytes.
- 5no_em_dash An em dash. House style, and rejected rather than swapped for something narrower.
- 6placeholders
Square or angle brackets, any
{token}you did not declare, and other template syntax:${'$'}{,%s,%d. - 7no_urls_or_emails
A link, a bare domain on a common suffix, or an
@between two non-spaces. It is a regex, not a validator, so an unusual suffix can pass. - 8forbid
Any substring from your
forbidlist. - What survives all eight is the line you get.
A rejection ends the request. getmeme does not go and ask another model for another
line, so a guard: reason means one model wrote one line and it failed. Call
again if you want another, and know that it costs a slot.
When there is no line
The response is 200 with "text": null whenever nothing usable
came back. It is never an error status, so the happy path of a page render needs no error
handling at all. Keep a static line, show it immediately, and swap in a generated one when
it arrives.
| reason | What happened |
|---|---|
| not_configured | The service has no model or no provider token configured. Nothing you sent caused this. |
| no_usable_answer | Every model was tried and none gave a usable answer: rate limited, erroring, unreachable, or replying with something that is not JSON. Often nothing you sent caused it. |
| deadline_exceeded | Your timeoutMs ran out first. |
| refused_by_model | No model answered, and at least one refused on content grounds through a structured signal (a content filter, or a provider's moderation response). Rephrasing the ask is more likely to help than retrying it. |
| invalid_shape | Valid JSON came back, with no string text in it. |
| guard:<name> | A line came back and one rule rejected it, for example guard:max_length. |
A guard: reason is the useful one: it is the only one that points at your
own request rather than at the state of a model provider. If you keep seeing
guard:max_length, your maxLength is tighter than the line you are
asking for. Treat a reason you do not recognise as "no line": the list can
grow.
Rate limits
Each key has a per-minute and a per-day limit. A new key starts at 15 requests a minute and 500 a day unless you asked for something else. Ask if you need them raised: a change applies on your next request, with no new key and no redeploy.
| Header | On every /lines response once your key is accepted |
|---|---|
| RateLimit-Limit | Your per-minute limit, or the per-day limit on the response that refused you. |
| RateLimit-Remaining | Requests left in the current minute. The daily window is not reported here, so pace that yourself from RateLimit-Policy. |
| RateLimit-Reset | Seconds until that window resets. |
| RateLimit-Policy | Both of your windows, for example 15;w=60, 500;w=86400. |
Over a limit is 429 rate_limited with Retry-After in seconds.
Back off and show your static line in the meantime.
A 401 carries none of these headers, because the limiter runs after your key
is accepted. A 400 or 415 carries them and costs you a request:
the limiter runs before the body is validated.
Errors
Every error has the same shape, and requestId matches the
X-Request-Id response header. Quote it when you report a problem and the call
can be looked up exactly.
{
"error": {
"code": "invalid_request",
"message": "maxLength: too small",
"requestId": "9f1c2e5a-..."
}
}
| Status | code | When |
|---|---|---|
| 400 | invalid_json | The body is not JSON. |
| 400 | invalid_request | A field failed validation, or you sent a field that does not exist. |
| 401 | invalid_api_key | Missing, unknown, revoked or expired key. |
| 404 | not_found | No such route. |
| 413 | payload_too_large | The body is over 16KB. |
| 415 | unsupported_media_type | The Content-Type is not application/json. |
| 429 | rate_limited | Over a window. See Retry-After. |
| 500 | internal_error | Our fault. The details are in our logs under your requestId. |
Note what is not in this table: a line that no model could write, and a line
every model wrote badly. Those are both 200. See
when there is no line.
Health and the spec
Two API routes take no key.
Returns { "ok": true, "db": true, "version": "0.1.0" }, or
503 when the database is unreachable.
The machine-readable spec, OpenAPI 3.1, generated by the running service and covering the same routes this page does. Point your client generator at /api/v1/openapi.json rather than at this page: it is the version that cannot drift.
Treat /health as the status page. If it answers and your call does not, the
problem is more likely your key or your request than the service.