getmeme
Beta

getmeme is running and in use, and it has not launched publicly yet. Keys are issued on request while that is true. If you try it and something is wrong, missing or confusing, getmeme@warmhop.com reaches a person who wants to hear it. There is no uptime guarantee and no SLA yet: keep the static line you were going to show anyway.

One short line, held to eight rules.

getmeme turns a prompt into one short line of text, then refuses to hand it over unless it clears all eight. When nothing clears them you get nothing back and a reason why, rather than a failure in the middle of a page render.

you send

POST /api/v1/lines

{
  "system": "You write one friendly,
     funny greeting for a link tracker.",
  "prompt": "Greet them on a Monday
     morning. Address them as {name},
     exactly once.",
  "maxLength": 80,
  "placeholders": ["name"]
}

you get back

Happy Monday, {name}. The coffee already forgave you.

200 OK   "reason": null

The token comes back as a token. You put the real name in on your side, which is how a line about a person gets written without that person reaching a model.

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

POST/api/v1/lines JSON in, JSON out

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.

FieldTypeWhat it does
system *string, 1 to 2000The voice. Who is writing, in what tone, about what product.
prompt *string, 1 to 4000The ask, with your own variables already filled in.
maxLength *integer, 1 to 500A hard cap in characters. A longer line is rejected, never trimmed.
placeholdersstring[], up to 8Token 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.
forbidstring[], up to 50Banned substrings, 1 to 100 characters each, matched case insensitively. Never sent to the model.
schemaobjectJSON 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.
timeoutMsinteger, 500 to 30000The 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.

  1. 1normalise Folds Unicode to NFKC, trims, strips wrapping quotes. Empty after that is a rejection.
  2. 2single_line Any line break at all.
  3. 3no_markdown Backticks, asterisks, tildes, bullets, headings, link syntax.
  4. 4max_length Longer than your cap, counted in characters rather than bytes.
  5. 5no_em_dash An em dash. House style, and rejected rather than swapped for something narrower.
  6. 6placeholders Square or angle brackets, any {token} you did not declare, and other template syntax: ${'$'}{, %s, %d.
  7. 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.
  8. 8forbid Any substring from your forbid list.
  9. 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.

reasonWhat happened
not_configuredThe service has no model or no provider token configured. Nothing you sent caused this.
no_usable_answerEvery 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_exceededYour timeoutMs ran out first.
refused_by_modelNo 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_shapeValid 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.

HeaderOn every /lines response once your key is accepted
RateLimit-LimitYour per-minute limit, or the per-day limit on the response that refused you.
RateLimit-RemainingRequests left in the current minute. The daily window is not reported here, so pace that yourself from RateLimit-Policy.
RateLimit-ResetSeconds until that window resets.
RateLimit-PolicyBoth 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-..."
  }
}
StatuscodeWhen
400invalid_jsonThe body is not JSON.
400invalid_requestA field failed validation, or you sent a field that does not exist.
401invalid_api_keyMissing, unknown, revoked or expired key.
404not_foundNo such route.
413payload_too_largeThe body is over 16KB.
415unsupported_media_typeThe Content-Type is not application/json.
429rate_limitedOver a window. See Retry-After.
500internal_errorOur 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.

GET/api/v1/health

Returns { "ok": true, "db": true, "version": "0.1.0" }, or 503 when the database is unreachable.

GET/api/v1/openapi.json

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.