EchtPost API v2 — AI consumer guide
This guide is written for LLM-based clients and developers wiring up integrations. Endpoints, error shapes, and examples are kept as copy-paste-ready as possible.
Base URL
https://api.echtpost.de/v2
Authentication
The API accepts two credential types — both sent in a header. Query-string
parameters such as ?apikey= are not accepted on v2.
Recommended: OAuth 2.1 (user-delegated access)
Use OAuth when the integration acts on behalf of a real EchtPost user (Claude, ChatGPT, third-party apps with a UI). Tokens are short-lived, scoped to the user's chosen account, and can be revoked from the user's side without touching API keys.
Discovery URLs:
https://api.echtpost.de/.well-known/oauth-protected-resource # RFC 9728
https://my.echtpost.de/.well-known/oauth-authorization-server # RFC 8414
A 401 response from any v2 endpoint sets WWW-Authenticate: Bearer
resource_metadata="https://api.echtpost.de/.well-known/oauth-protected-resource",
which points at the protected-resource document. That document declares
https://my.echtpost.de as the authorization server; clients then walk
the RFC 8414 metadata there to find the authorize / token / register
endpoints. Tokens must include the api scope to be accepted on the
REST API. A token scoped only for mcp will be rejected here (and
vice versa on the MCP server). Request scope=mcp api if you need both.
The flow is OAuth 2.1 Authorization Code with PKCE. Once you have an access token:
curl -sS -H "Authorization: Bearer $ECHTPOST_OAUTH_TOKEN" \
"https://api.echtpost.de/v2/me"
Dynamic client registration (RFC 7591) is supported at
POST https://my.echtpost.de/oauth/register if you don't want to manually
register a client.
Alternative: API key (server-to-server)
Use an API key for server-side scripts and automations where there is no end-user to consent. Create keys in the EchtPost web app under Account → API keys.
Supported headers (in order of precedence):
X-Api-Key: <your key>Authorization: Bearer <your key>
V1's X-apikey header is not accepted on v2.
curl -sS -H "X-Api-Key: $ECHTPOST_API_KEY" \
"https://api.echtpost.de/v2/me"
Sandbox mode
An API key is created in one of two modes, visible in its prefix:
| Prefix | Mode | Behaviour |
|---|---|---|
ep_live_ |
live | Cards are billed, printed and posted. |
ep_test_ |
sandbox | Cards, contacts and groups are stored separately, never billed, never printed. |
The mode is fixed at creation. A sandbox key sees only sandbox records and a live
key only live records — including on GET endpoints, so a sandbox integration
cannot read or cancel production cards.
Sandbox data can be wiped for the whole account from Account → API keys in the web app.
Because sandbox skips the funds check, 402 Insufficient Funds never occurs
naturally there. To exercise that error path, pass simulate=insufficient_funds
when creating a card:
curl -sS -X POST \
-H "X-Api-Key: ***" \
-H "Content-Type: application/json" \
-d '{ "motive_id":42, "content":"Hi", "deliver_at":"tomorrow", "recipients":[{ "contact_id":123 }], "simulate":"insufficient_funds" }' \
"https://api.echtpost.de/v2/cards"
The parameter is ignored on live keys.
The MCP server honours the same distinction — see mcp_guide.md.
Rate limits
Requests are throttled per API key. Exceeding a limit returns 429 Too Many Requests
with a Retry-After header (seconds until the window resets).
| Scope | Limit |
|---|---|
| All endpoints | 100 requests / minute |
POST /v2/cards |
10 requests / minute |
POST /v2/cards/from_template |
10 requests / minute |
POST /v2/cards/preview_fit |
30 requests / minute |
POST /v2/motives |
account-scoped temporary upload limit per 24 hours; exceeding it returns 429 with Retry-After |
Spread bulk card creation over time or batch recipients into a single card request
using recipient_ids to stay within limits.
Error format (RFC 7807)
Errors use Content-Type: application/problem+json. Each response
includes:
type— stable error-type URI (match on this, not on free text)title— short human-readable namestatus— HTTP status codedetail— imperative fix hint (tells the integrator how to retry)instance— URL path that produced the errorrequest_id— for support ticketserrors[]— optional structured field errors (path,code,message)
Example:
{
"type": "https://api.echtpost.de/v2/errors/text-does-not-fit",
"title": "Message does not fit on the card",
"status": 422,
"detail": "Shorten the message or reduce the font size; 1 line overflows.",
"instance": "/v2/cards",
"request_id": "req-abc123",
"errors": [
{ "path": "message", "code": "overflow", "message": "line 17 overflows" }
]
}
Retry guidance
detail is phrased imperatively — it tells you exactly what to change
before retrying. Treat 422 responses as recoverable after applying
the fix described in detail. 401 is terminal (bad key). 402 means
top-up required. 409 on card cancel means the state transition is no
longer possible.
Pagination
List endpoints return a JSON array. Read pagination from response headers:
X-Total-Count— total rowsX-Total-Pages— total pages for currentper_pageLink— RFC 5988 links withrel="next",rel="last"
Request parameters:
page(default 1)per_page(default 50, max 200)
List filters (query parameters)
Use top-level query parameters only. v2 does not support nested
filter[...].
| Endpoint | Parameters |
|---|---|
GET /v2/contacts |
country_code, group_id, external_id, search, plus sorting |
GET /v2/cards |
status (pending, scheduled, sent, canceled; comma-separated allowed), deliver_at, motive_id, plus sorting |
GET /v2/motives |
search (name, description, content, searchterms; falls back to tag names when those return nothing), temporary (true or false), plus sorting |
GET /v2/groups |
sorting only |
GET /v2/templates |
source (defaults to regular) |
List sorting
List endpoints accept optional sort (one allowed field name) and
direction (asc or desc, case-insensitive). Omitted values use
the defaults below. Results always include a stable tie-breaker on the
row id ascending.
Unknown sort or invalid direction (when direction is sent) returns
422 with RFC 7807 application/problem+json.
| Endpoint | Default sort |
Default direction |
Allowed sort values |
|---|---|---|---|
GET /v2/contacts |
created_at |
desc |
last_name, created_at, updated_at |
GET /v2/cards |
created_at |
desc |
created_at, deliver_at |
GET /v2/motives |
priority¹ | — | name, created_at |
GET /v2/groups |
name |
asc |
name, created_at, updated_at |
¹ When no sort parameter is given, motives are ordered by relevance:
account-specific motives first, then highlighted, then by popularity.
Card status in JSON
Card responses expose four statuses: pending (draft through
uploaded), scheduled, sent, canceled (includes canceling).
Internal workflow states are not exposed in the API.
Fonts
Three families, each with its own valid size range:
| family | valid sizes |
|---|---|
architects_daughter |
12..14 |
reenie_beanie |
15..17 |
special_elite |
11..13 |
Colors: black, blue, or a hex value.
Line breaks in card content
Card content is plain text. Use \n (Unix newline) as the line-break character
in your JSON strings — the API normalizes it to the internal \r\n format used
by the print renderer. Sending \r\n directly is also accepted.
A card with no line breaks at all will render as one unbroken text block.
The create endpoint returns a warnings array in the 201 response with a
no_line_breaks entry if this is detected — treat it as a hint to add
paragraph breaks before submitting.
Quick flow — one card in three calls
GET /v2/me— confirm balance and account metadata.POST /v2/cards/preview_fit— iteratecontent+fontuntilfits: true.POST /v2/cards— create the mailing withrecipient_idsor inlinerecipients.
Step 1 — check balance
curl -sS -H "X-Api-Key: $ECHTPOST_API_KEY" \
"https://api.echtpost.de/v2/me"
Step 2 — check that the text fits
curl -sS -H "X-Api-Key: $ECHTPOST_API_KEY" \
-H "Content-Type: application/json" \
-X POST "https://api.echtpost.de/v2/cards/preview_fit" \
-d '{
"content": "Liebe Grüße aus Berlin,\ndas Team",
"font": { "family": "architects_daughter", "size": 13 }
}'
Response on success:
{ "fits": true, "lines_used": 3, "max_lines": 18 }
On overflow the response includes overflow_line and a
suggested_font_size that would make the text fit:
{
"fits": false,
"lines_used": 20,
"max_lines": 18,
"overflow_line": 19,
"suggested_font_size": 12
}
Step 3 — create the card
curl -sS -H "X-Api-Key: $ECHTPOST_API_KEY" \
-H "Content-Type: application/json" \
-X POST "https://api.echtpost.de/v2/cards" \
-d '{
"motive_id": 42,
"content": "{anrede},\nwir wünschen frohe Ostern.\nHerzlich, das Team",
"font": { "family": "architects_daughter", "size": 13 },
"recipient_ids": [101, 102]
}'
The {anrede} token is substituted per recipient at render time with the
recipient's salutation (e.g. "Sehr geehrte Frau Müller" or "Liebe Jana").
Use it anywhere in content, content_ps, or content_vertical.
The canonical form is {anrede} (lowercase); casing is ignored at render time.
Endpoint quick reference
GET /v2/me
Account, user, API key, balance.
curl -sS -H "X-Api-Key: $KEY" "https://api.echtpost.de/v2/me"
GET /v2/credits
Balance plus current prices:
curl -sS -H "X-Api-Key: $KEY" "https://api.echtpost.de/v2/credits"
Contacts
# list with filter + sort
curl -sS -H "X-Api-Key: $KEY" \
"https://api.echtpost.de/v2/contacts?country_code=de&sort=last_name&direction=asc"
# create
curl -sS -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-X POST "https://api.echtpost.de/v2/contacts" \
-d '{
"last_name":"Doe","first_name":"Jane",
"street":"Musterstr. 1","zip":"10115","city":"Berlin","country_code":"de",
"greeting_style":"formal"
}'
# update
curl -sS -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-X PATCH "https://api.echtpost.de/v2/contacts/42" \
-d '{ "city":"München" }'
# delete
curl -sS -H "X-Api-Key: $KEY" -X DELETE "https://api.echtpost.de/v2/contacts/42"
Groups
# list
curl -sS -H "X-Api-Key: $KEY" "https://api.echtpost.de/v2/groups"
# create
curl -sS -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-X POST "https://api.echtpost.de/v2/groups" -d '{ "name": "VIPs" }'
Deleting a group that still has members returns 409 Conflict.
Templates
curl -sS -H "X-Api-Key: $KEY" "https://api.echtpost.de/v2/templates"
curl -sS -H "X-Api-Key: $KEY" "https://api.echtpost.de/v2/templates/3"
Motives
Browse available postcard designs. Use the returned id as motive_id
when creating cards.
# list all motives
curl -sS -H "X-Api-Key: $KEY" "https://api.echtpost.de/v2/motives"
# search by keyword
curl -sS -H "X-Api-Key: $KEY" \
"https://api.echtpost.de/v2/motives?search=geburtstag"
# show a specific motive
curl -sS -H "X-Api-Key: $KEY" "https://api.echtpost.de/v2/motives/42"
Each motive includes feature flags (content_available,
content_ps_available, vertical_text_available, qr_code_available)
that indicate which content fields are supported, plus a url linking
to the motive's page on echtpost.de.
Temporary API motives
Accounts enabled by support can upload one-off custom motives with
POST /v2/motives using multipart/form-data:
curl -sS -X POST \
-H "X-Api-Key: ***" \
-F "name=API Messekarte" \
-F "orientation=landscape" \
-F "image=@front.jpg" \
"https://api.echtpost.de/v2/motives"
The endpoint creates an account-owned motive with temporary: true.
Use the returned id as motive_id on POST /v2/cards; API and MCP
list/show motive calls include temporary motives for the same account.
Temporary motives are not exposed to the regular web motive picker or
template workflows.
Uploads are deliberately gated. The normal API key is still available
immediately after signup; only the custom-motive upload capability needs
support to enable it after account verification (know your customer).
Disabled accounts receive feature-not-available with a support hint.
Accepted uploads are PNG or JPEG files up to 5 MB and must match the
postcard motive pixel dimensions exactly: 1795x1287 for landscape or
1287x1795 for vertical. Invalid files return a 422 validation problem.
Temporary motive image assets are retained only for the configured card
lifecycle. Cards created with a temporary motive get asset_expires_at
automatically (deliver_at + 7 days by default); clients may set an ISO
date between deliver_at + 7 days and deliver_at + 90 days. Once all
referencing cards are past that lifecycle window and no card still needs
the image for print, the image blobs are purged and assets_purged_at is
set on the motive. The motive row and card/print history stay available,
but the original image can no longer be listed or reused.
Cards
content_ps field
The P.S. is printed as a single line in the card footer — content_ps
is limited to 40 characters. Longer values are rejected with a 422
(errors[].path: "content_ps", code: "too_long").
deliver_at field
The deliver_at field accepts several formats:
| Value | Resolves to |
|---|---|
"YYYY-MM-DD" |
Exact date |
"today" |
Today |
"asap" |
Today (alias) |
"tomorrow" |
Tomorrow |
"N-days-from-now" |
N calendar days from now, e.g. "5-days-from-now" |
Constraints: must be a weekday; must not be a printer holiday. If omitted, the card is scheduled for the next available business day.
Natural-language strings like "in two days" are not accepted — use
the numeric hyphenated form "2-days-from-now" instead.
asset_expires_at field
asset_expires_at is accepted only when motive_id points to a
temporary motive. It controls how long the uploaded front image remains
available after the card's print date. Omit it for the default retention
(deliver_at + 7 days), or send an ISO date between deliver_at + 7 days
and deliver_at + 90 days. Permanent motives reject this field with a
422 validation problem.
# list only scheduled + sent
curl -sS -H "X-Api-Key: $KEY" \
"https://api.echtpost.de/v2/cards?status=scheduled,sent&sort=deliver_at"
# show
curl -sS -H "X-Api-Key: $KEY" \
"https://api.echtpost.de/v2/cards/555"
# create from template
curl -sS -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-X POST "https://api.echtpost.de/v2/cards/from_template" \
-d '{
"template_id": 3,
"recipient_ids": [101, 102],
"deliver_at": "2026-05-12"
}'
# cancel (async)
curl -sS -H "X-Api-Key: $KEY" -X DELETE "https://api.echtpost.de/v2/cards/555"
Cancel returns 202 Accepted when the cancellation is queued, or
409 Conflict with type: card-not-cancelable if the state transition
is no longer possible (e.g. the card is already sent).
Notifications (optional on create)
"notification": { "type": "on_send", "email": "ops@acme.de", "send_on": "send" }
Stable error types
Match integrations on these type URIs:
| URI suffix | HTTP | When |
|---|---|---|
authentication-required |
401 | Missing or invalid API key |
not-found |
404 | Resource not found or not in scope |
validation |
422 | Generic validation — see errors[] |
text-does-not-fit |
422 | Message overflows card at given font |
invalid-delivery-date |
422 | deliver_at is not a valid business date — use YYYY-MM-DD, today, tomorrow, or N-days-from-now and pick a weekday that is not a public holiday |
template-not-available |
422 | Template not usable by this account |
feature-not-available |
422 | E.g. QR-code cards not enabled |
insufficient-funds |
402 | Balance too low for the requested cards |
card-not-cancelable |
409 | Card is past the cancelable window |
rate-limited |
429 | Too many requests — check Retry-After header |
All full URIs are prefixed with https://api.echtpost.de/v2/errors/.