mockmsg Agent API
mockmsg is in beta. The conversation schema may change. The current schema, with its version, is always at https://www.mockmsg.com/schema/conversation.json; check it before you send.
Agent resources: short index, complete plain-text documentation and numbered agent skill.
Use HTTPS at https://mvp-api.mockmsg.com. Locally use http://127.0.0.1:4320. Set API to that origin for the commands below.
The OpenAPI contract is available at /api-docs/openapi.json on the API origin. No OAuth
client registration is needed. Remote MCP and a CLI are not offered by this guide.
1. Create an account and get a device access token
To delete your account, email [email protected]; deletion is completed within thirty days.
Create an account at https://www.mockmsg.com/login: enter your email and the emailed sign-in code. The same flow signs in an existing account. Then request device access below and have the account owner approve it in the browser.
Request only needed scopes. This endpoint takes JSON, not form data:
API=https://mvp-api.mockmsg.com
curl -sS "$API/oauth/device/code" -H 'Content-Type: application/json' \
-d '{"scopes":["render:read","export:write","balance:read"]}'The response contains device_code, user_code, verification_uri, expires_in: 600 and interval: 5. Keep the device code secret. Open the
returned verification URI in a browser, sign in using the account email flow, enter the user
code, inspect the scopes and approve. An agent can ask its account owner to approve; never
request their password or put a device code in a URL.
The account owner can revoke all device access at any time from the same verification page they approved on: sign in and choose Revoke all device access. This also cancels approvals the agent has not yet collected; ordinary API keys are unaffected. Revocation prevents subsequent authentication but does not stop a request already authenticated before withdrawal.
Set DEVICE_CODE from the response. Wait at least the returned interval between polls.
The token endpoint uses URL-encoded form data:
curl -sS "$API/oauth/token" \ --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' \ --data-urlencode "device_code=$DEVICE_CODE"
On HTTP 400 with authorization_pending, keep waiting. On slow_down,
increase the polling interval by at least five seconds (also handle HTTP 429). Stop on access_denied, expired_token or invalid_grant; start a
new authorization if appropriate. Successful polling returns access_token, token_type: Bearer, expires_in: 3600 and a space-separated scope. Save it as TOKEN. There is no refresh token: authorize again
after expiry. Issuance, polling and revocation have per-IP limits of 10, 60 and 30
requests/minute respectively.
# Revoke the device token when finished curl -sS "$API/oauth/revoke" --data-urlencode "token=$TOKEN"
Authorization returns 400 invalid_scope for invalid scopes or a missing
Content-Type. A wrong Content-Type returns 415; malformed JSON returns 400, and wrongly typed or
unknown fields return 422, as framework plain text. HTTP 400 covers two distinct failures: the
response Content-Type distinguishes the JSON OAuth error (application/json) from a
framework rejection (text/plain). Correct the request before retrying. On 503 temporarily_unavailable, retry with backoff.
2. Preview without spending balance
Save this request as preview.json. The input is a single event timeline; snapshots
select moments, not duplicated histories. Text and image messages use separate content kinds.
The snapshot selector can be a name; this example selects hero.
{
"input": {
"settings": {
"contact": {
"firstName": "Alex"
},
"startAt": "2026-05-01T14:00:00"
},
"events": [
{
"type": "send",
"content": {
"kind": "text",
"text": "Meet at six?"
}
}
],
"snapshots": [
{
"name": "hero",
"at": "end"
}
]
},
"snapshot": "hero"
}curl --fail-with-body "$API/v1/preview" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' --data-binary @preview.json -o preview.webp -w 'HTTP %{http_code}\n'Success is binary image/webp, spoiled/watermarked, not an export. Requires render:read. Invalid timelines return 422; worker failures return 502.
3. Balance and funding
Balances are pre-funded by a person in the browser. An agent spends a balance; it does not top one up. Top-ups are not refundable, except where the law requires it.
Each top-up creates credit that lapses twelve months later; bonus credit follows its top-up, and spending draws on the oldest credit first. Expiry rows are written when a balance is read, so the ledger is correct at every observation, not at every instant.
curl --fail-with-body "$API/v1/balance" -H "Authorization: Bearer $TOKEN"
Requires balance:read. The JSON includes balance_cents, limits, bonus
rules and ledger entries. Money is integer cents. To add balance, the person signs
in on the Account page and uses its top-up
action. Checkout is a browser-session operation, not POST /v1/balance/topups. In Stripe test mode use test card 4242 4242 4242 4242, a
future expiry and any three-digit CVC. Never use real card details in test mode. Wait for the
webhook credit; a checkout return URL alone does not prove payment. Read balance again before
buying.
4. Save a thread and quote before buying
Save as thread.json:
{
"title": "Marketing example",
"input": {
"settings": {
"contact": {
"firstName": "Alex"
},
"startAt": "2026-05-01T14:00:00"
},
"events": [
{
"type": "send",
"content": {
"kind": "text",
"text": "Meet at six?"
}
}
],
"snapshots": [
{
"name": "hero",
"at": "end"
}
]
}
}curl --fail-with-body "$API/v1/threads" -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' --data-binary @thread.json
Use the returned id as thread_id below, replacing the placeholder
UUID. Save as quote.json. Use all instead of a snapshot name to quote every
snapshot.
{
"thread_id": "00000000-0000-4000-8000-000000000001",
"snapshot": "hero"
}curl --fail-with-body "$API/v1/exports/quote" -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' --data-binary @quote.json
Quoting requires export:write and charges nothing. The response contains quote_id, price_cents, screens, expires_at and schema_id. Money is integer cents. expires_at is RFC 3339, fifteen
minutes after issue; schema_id identifies the conversation schema used for validation.
Check the price against the operator's budget and available balance before buying. You can quote before
rendering a preview; neither operation spends balance.
A quote is single use and bound to the user, thread input, snapshot selector and pricing inputs. Export recomputes that fingerprint and charges the stored quoted price, not a newly calculated price. Editing the input or changing the selector or pricing inputs requires a fresh quote. An idempotent replay returns the original export without consuming a second quote.
QUOTE_REQUIRED: no quote ID supplied. Request a quote and include its ID.QUOTE_EXPIRED: the expiry has passed. Request a fresh quote and check its price.QUOTE_NOT_YOURS: not found. Use a quote obtained with your own account; the message does not disclose another user's quote.QUOTE_STALE: the fingerprint changed. Quote the current input and selector again, then check the new price.
5. Pay from balance and export
Save as export.json, replacing quote_id with the returned quote ID and keeping
the same thread ID and selector. If balance is insufficient, stop and ask the operator to fund it
in the browser; the agent must not initiate a top-up. If the quote expires while waiting, quote again
before purchasing.
{
"thread_id": "00000000-0000-4000-8000-000000000001",
"snapshot": "hero",
"quote_id": "00000000-0000-4000-8000-000000000002"
}curl --fail-with-body "$API/v1/exports" -H "Authorization: Bearer $TOKEN" \ -H 'Idempotency-Key: marketing-hero-001' -H 'Content-Type: application/json' \ --data-binary @export.json # Set EXPORT_ID to the returned id curl --fail-with-body "$API/v1/exports/$EXPORT_ID" -H "Authorization: Bearer $TOKEN"
All these operations require export:write. Purchase returns HTTP 200 with an export
record, not image bytes. Poll every few seconds until status is rendered or failed. Download the rendered urls; on failure inspect error. The record includes price, screen count and photo surcharge. HTTP 402 means
insufficient balance. An export still in paid has not been refunded. After a few minutes, stop
polling and check balance rather than buying again. A failed export is refunded automatically;
the refund appears in balance entries as export_refund.
Retries, limits and errors
Export keys are optional but strongly recommended: 1–255 ASCII bytes, scoped to the user and
exact thread ID/snapshot pair. Retry uncertain purchases with the same key and body, including
after a timeout. If no key was used, call GET /v1/exports (export:write) to inspect
your purchases before doing anything that could debit again; do not blindly re-post. GET /v1/threads lists your saved threads (render:read or export:write). Idempotency-Replayed: true means the existing export was returned without another debit.
Reusing a key for a different pair returns 409. Thread edits do not reset the key. Failures before
creation do not consume it. After a terminal render failure, use a new key only when intentionally
buying fresh work.
Every /v1 request needs Authorization: Bearer TOKEN. Limits are 60 requests per 60
seconds per credential AND per user across all credentials. HTTP 429 means one caller asked too
often: wait at least 60 seconds with jitter; 429 responses have no Retry-After header.
HTTP 502 means a genuine renderer failure, not a capacity refusal. Retry transient 500/502 with
bounded exponential backoff; never blindly retry non-idempotent writes. On 401 replace an expired/revoked
credential; 403 means missing scope or another user's resource, 404 missing resource, 422
invalid input. Authentication errors use {"code":"INVALID_KEY"}; handler errors have their own schemas in OpenAPI. 500
may originate from either layer. Malformed JSON, path parameters and body limits can return
framework text errors (400/413/415/422), not the JSON handler schema.
Internal failures (a renderer or storage fault, HTTP 500/502) never include internal detail. The
body keeps its usual code and a generic message, and adds an error_id, for example {"code":"WORKER_ERROR","message":"Preview rendering failed","context":"preview.worker","error_id":"UUID"}. A failed export shows "error":"Rendering failed. Error id: UUID" and is refunded.
Quote the error id when you report the problem. Validation errors (422) keep their full detail and
have no error id.
When render capacity is full, the server refuses work with HTTP 503 and code RENDER_BUSY. Previewing, quoting, buying an export, and every thread create and
update can be refused this way: POST /v1/preview, POST /v1/exports/quote, POST /v1/exports, POST /v1/threads and PUT /v1/threads/{id}.
The response has Retry-After: 1 and JSON body {"code":"RENDER_BUSY","message":"Render capacity is full; retry later","context":{"retry_after_seconds":1}}.
Read the Retry-After value, also supplied as the same number of seconds in context.retry_after_seconds; wait at least that long and try again, preserving the
same idempotency key and body for a purchase. The current one-second value is a fixed constant,
not a load estimate; do not hard-code it. This is temporary capacity exhaustion, not the
per-caller rate limit (429) or a broken renderer (502).
Long-lived API keys
Key management requires a signed-in browser session, not a bearer token. Use the account key UI
if available, or the session-authenticated API operations: POST /api-keys with this
JSON, GET /api-keys to list metadata, and DELETE /api-keys/KEY_ID to revoke (204).
Keep the session cookie private; do not send it to an unrelated origin. At most ten active keys are
allowed; a 409 on creation means revoke one first. Blank or over-long names return 422.
{
"name": "Marketing agent",
"scopes": [
"render:read",
"export:write",
"balance:read"
]
}Creation returns key metadata and a one-time secret beginning mmk_. Store that secret as TOKEN. Revocation is immediate. Never commit keys,
device codes or access tokens. Thread reads accept either render:read or export:write; thread
writes and all export operations require export:write. Device tokens use the same scopes and
limits as keys.
Timeline input alone
{
"settings": {
"contact": {
"firstName": "Alex"
},
"startAt": "2026-05-01T14:00:00"
},
"events": [
{
"type": "send",
"content": {
"kind": "text",
"text": "Meet at six?"
}
}
],
"snapshots": [
{
"name": "hero",
"at": "end"
}
]
}