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: <api key>Authorization: Bearer <api key>
V1's X-apikey header is not accepted on v2.
-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: $ECHTPOST_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 |
10 requests / minute |
Spread bulk card creation over time or batch recipients into a single card request
using recipient_ids to stay within limits.
On top of that throttle, POST /v2/motives has an account-scoped limit of
1,000 temporary motive uploads per rolling 24 hours. Exceeding it returns
429 with the rate-limit-exceeded slug but no Retry-After header,
and notifies EchtPost support — contact us with your request_id if you need
a higher limit.
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
-H "X-Api-Key: $ECHTPOST_API_KEY" \
"https://api.echtpost.de/v2/me"
Step 2 — check that the text fits
-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
-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.
-H "X-Api-Key: $ECHTPOST_API_KEY" \
"https://api.echtpost.de/v2/me"
GET /v2/credits
Balance plus current prices:
-H "X-Api-Key: $ECHTPOST_API_KEY" \
"https://api.echtpost.de/v2/credits"
Contacts
# list with filter + sort
-H "X-Api-Key: $ECHTPOST_API_KEY" \
"https://api.echtpost.de/v2/contacts?country_code=de&sort=last_name&direction=asc"
# create
-H "X-Api-Key: $ECHTPOST_API_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
-H "X-Api-Key: $ECHTPOST_API_KEY" \
-H "Content-Type: application/json" \
-X PATCH "https://api.echtpost.de/v2/contacts/42" \
-d '{ "city":"München" }'
# delete
-H "X-Api-Key: $ECHTPOST_API_KEY" \
-X DELETE "https://api.echtpost.de/v2/contacts/42"
Groups
# list
-H "X-Api-Key: $ECHTPOST_API_KEY" \
"https://api.echtpost.de/v2/groups"
# create
-H "X-Api-Key: $ECHTPOST_API_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
-H "X-Api-Key: $ECHTPOST_API_KEY" \
"https://api.echtpost.de/v2/templates"
-H "X-Api-Key: $ECHTPOST_API_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
-H "X-Api-Key: $ECHTPOST_API_KEY" \
"https://api.echtpost.de/v2/motives"
# search by keyword
-H "X-Api-Key: $ECHTPOST_API_KEY" \
"https://api.echtpost.de/v2/motives?search=geburtstag"
# show a specific motive
-H "X-Api-Key: $ECHTPOST_API_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, has_back_side to show
whether a custom back-side image is attached, plus a url linking to the
motive's page on echtpost.de. Account-owned motives have no url because
they are not part of the public catalog, but preview_image_url and
back_side_image_url return signed image links you can open to check
what will be printed. Those links stay valid until the assets are purged
and do not require an API key, so treat them like a shared secret.
Temporary API motives
The flow end to end:
- Upload the artwork with
POST /v2/motivesand keep the returnedid. - Open the
preview_image_url(andback_side_image_url) from that response to see what was stored. - Create the card with
POST /v2/cards, passing theidasmotive_id. - Check the print result in the EchtPost web UI. There is no endpoint that
renders a card preview;
POST /v2/cards/preview_fitonly checks text length.
The rest of this section covers each step in detail.
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: $ECHTPOST_API_KEY" \
-F "name=API Messekarte" \
-F "orientation=landscape" \
-F "image=@front.jpg" \
"https://api.echtpost.de/v2/motives"
If the account has booked custom back-side printing, the same endpoint
also accepts an optional back_side file and per-motive layout switches.
When omitted, content_available, content_ps_available,
vertical_text_available, and qr_code_available use the persisted
Motive defaults and are enabled (true):
curl -sS -X POST \
-H "X-Api-Key: $ECHTPOST_API_KEY" \
-F "name=API Messekarte mit Rückseite" \
-F "orientation=landscape" \
-F "image=@front.jpg" \
-F "back_side=@back-side.jpg" \
-F "content_ps_available=false" \
-F "vertical_text_available=false" \
-F "qr_code_available=false" \
"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.
Sending back_side has additional gates: the account must have both
regular custom back-side bookings active — logo replacement and vertical
text replacement. There is no separate API-only back-side add-on. If one
of those bookings is missing, the request returns 422 with
feature-not-available. Front-only uploads are not affected by that
additional booking check.
Accepted front 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.
Accepted back_side uploads are PNG or JPEG files with their own 5 MB
limit and must be exactly 1795x1287 px, always landscape. This is
independent of the motive's orientation: vertical front motives are
rotated on the front side, but the back side is drawn full-bleed at
152×109 mm without rotation, so a portrait back side would be distorted.
A custom back side replaces the static background/design of the back side.
EchtPost still prints the recipient address, the main message text when
content_available is true, the vertical text when
vertical_text_available is true, the P.S. footer line when
content_ps_available is true, and the QR code when a card has one and
qr_code_available is true. With back_side attached, EchtPost does
not render the standard postmark image and does not render the
standard footer logo. The uploaded file must therefore already contain
the franking area, center dividing line, footer line, and every static
branding element that should appear behind or around the dynamic text.
Do not include recipient address lines in the uploaded file: EchtPost
adds the real recipient address during card creation.
Use the back-side template at
https://echtpost.de/images/rueckseite.jpg as the design basis. Keep the
message box within 5–77 mm from the left edge, leave the right-side
recipient area free for the address EchtPost prints later, and keep the
9 mm footer area clear unless you disable content_ps_available. The
known manual full-custom back-side case used back_side together with
content_ps_available=false.
API clients submit the motive artwork and card data, not the final print PDF. During development, the API consumer is responsible for checking the final print result in the EchtPost web UI before sending production mailings.
Temporary motive image assets have a limited lifetime, and it is managed
server-side — there is no client-facing retention parameter. New uploads
receive assets_expires_at immediately, initially 30 days after upload.
Every card that uses a temporary motive may move assets_expires_at
further out to at least ten days after that card's deliver_at. A daily
job never purges before the reported assets_expires_at, and additionally
waits until no referencing card is still waiting for print and every
referencing delivery date is at least ten days in the past. The purge
sets assets_purged_at.
Both fields are on the motive payload, so GET /v2/motives/{id} tells
you where a motive stands. After the purge the motive row, its metadata
and the card and print history stay available, but the image is gone:
POST /v2/cards rejects a purged motive with a 422 validation problem.
Upload a fresh motive for a new card.
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; must be at most 90 days in the future. 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.
# list only scheduled + sent
-H "X-Api-Key: $ECHTPOST_API_KEY" \
"https://api.echtpost.de/v2/cards?status=scheduled,sent&sort=deliver_at"
# show
-H "X-Api-Key: $ECHTPOST_API_KEY" \
"https://api.echtpost.de/v2/cards/555"
# create from template
-H "X-Api-Key: $ECHTPOST_API_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)
-H "X-Api-Key: $ECHTPOST_API_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)
"notifications": [
{ "type": "before_send", "email": "ops@acme.de", "send_on": "2026-10-13" },
{ "type": "after_sent", "email": "ops@acme.de" }
]
type is after_sent (mail once the card is sent) or before_send (reminder
on send_on, an ISO date on or before the send date; defaults to the send
date). type and email are required; send_on is ignored for after_sent.
The single notification object is deprecated; don't send it together with
notifications.
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, pick a weekday that is not a public holiday, and stay within 90 days |
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 against per-minute throttles — check Retry-After header |
rate-limit-exceeded |
429 | Account-scoped temporary motive upload cap reached — retry later or contact support with request_id; no Retry-After header is guaranteed |
All full URIs are prefixed with https://api.echtpost.de/v2/errors/.