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.

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):

  1. X-Api-Key: <api key>
  2. 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:

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:

Request parameters:

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

  1. GET /v2/me — confirm balance and account metadata.
  2. POST /v2/cards/preview_fit — iterate content + font until fits: true.
  3. POST /v2/cards — create the mailing with recipient_ids or inline recipients.

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:

  1. Upload the artwork with POST /v2/motives and keep the returned id.
  2. Open the preview_image_url (and back_side_image_url) from that response to see what was stored.
  3. Create the card with POST /v2/cards, passing the id as motive_id.
  4. Check the print result in the EchtPost web UI. There is no endpoint that renders a card preview; POST /v2/cards/preview_fit only 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/.

Send Your First Postcard via the EchtPost API

This tutorial walks you through creating a real, printed postcard using the EchtPost REST API v2. By the end you'll have made four HTTP calls that result in a physical card in someone's mailbox.

What you need - An EchtPost account (sign up at echtpost.de) - A credential — either an OAuth access token (preferred for user-facing apps; see authentication section for the full flow) or an API key for server-to-server scripts (create one at Account → API keys in the web app) - Enough credit to send at least one card (top up under Account → Credits) - curl (any OS) or your HTTP client of choice

Set your credential in the shell. The examples below use an OAuth token, but you can substitute an API key with -H "X-Api-Key: $ECHTPOST_API_KEY" on every request:

export ECHTPOST_TOKEN=your_oauth_access_token_or_api_key

Step 1 — Confirm your account and balance

Before anything else, verify your key works and that your account has credit.

curl -sS \
  -H "Authorization: Bearer $ECHTPOST_TOKEN" \
  "https://api.echtpost.de/v2/me"

A successful response looks like this:

{
  "account": { "id": 1, "name": "Acme GmbH", "country_code": "de" },
  "user":    { "id": 7, "email": "you@acme.de" },
  "api_key": { "id": 3, "description": "CI pipeline" },
  "balance": {
    "credits_eur": "12.50",
    "cards_regular": 5,
    "cards_foreign": 0,
    "currency": "EUR"
  }
}

balance.cards_regular is the number of cards you can still send with your current credit. If it's 0, top up before continuing.


Step 2 — Pick a motive

Every postcard needs a motive_id — the front-side image. Browse the full motive gallery at echtpost.de/postkarten, or list the motives available to your account via the templates endpoint:

curl -sS \
  -H "Authorization: Bearer $ECHTPOST_TOKEN" \
  "https://api.echtpost.de/v2/templates"

Each item in the response includes id and motive_id. We'll use motive_id: 42 for the rest of this tutorial.

Bringing your own artwork? Accounts that support has enabled can upload a one-off motive with POST /v2/motives and pass the returned id as motive_id. The file must be PNG or JPEG at exactly 1795x1287 px for landscape or 1287x1795 px for vertical. The Temporary API motives section of the AI consumer guide covers the upload, the optional custom back side, and how long the image is kept.


A postcard has a fixed printable area, and it's worth checking that your message fits before submitting — especially for longer texts or when you're building an automated flow. The preview_fit endpoint tells you exactly how many lines your content will use and suggests a smaller font size if it overflows.

Note that preview_fit checks the text, not the finished card: there is no endpoint that renders a preview image of the front and back. Check the print result in the EchtPost web UI before you send a production mailing.

curl -sS \
  -H "Authorization: Bearer $ECHTPOST_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "https://api.echtpost.de/v2/cards/preview_fit" \
  -d '{
    "content": "Liebe {anrede},\n\nwir möchten uns herzlich bei Ihnen für Ihr Vertrauen bedanken und wünschen Ihnen eine wunderbare Zeit.\n\nMit freundlichen Grüßen,\nIhr Acme-Team",
    "font": { "family": "architects_daughter", "size": 13 }
  }'

Success — text fits: json { "fits": true, "lines_used": 14, "max_lines": 18 }

max_lines depends on the font family and size — it ranges from 10 (reenie_beanie at 17) to 19 (architects_daughter at 12).

Text overflows — a longer message sent with "size": 14: json { "fits": false, "lines_used": 20, "max_lines": 17, "overflow_line": 18, "suggested_font_size": 12 }

If fits is false, you have two easy options: trim the text a little, or pass the suggested_font_size as size in your create request — the API will handle the rest.

Valid font options are:

family Valid size range
architects_daughter 12 – 14
reenie_beanie 15 – 17
special_elite 11 – 13

Colors: black, blue, or any hex value (e.g. "#1a2b3c").

Tip: Pass "sample_greeting": "Sehr geehrte Frau Müller" to preview_fit for an accurate line-count when your content starts with {anrede}.


Step 4 — Create the card

There are two ways to create a card via the API. Choose based on how much control you need over the content.


The easy path: send from a saved template

If the postcard content stays the same and you just want to trigger a send via the API — perhaps on a schedule or from an external system — use the from-template endpoint. Design the card once in the EchtPost editor, save it as a template, then reference its ID here. No message text, no font settings, no length checks needed.

curl -sS \
  -H "Authorization: Bearer $ECHTPOST_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "https://api.echtpost.de/v2/cards/from_template" \
  -d '{
    "template_id": 3,
    "deliver_at": "2026-05-12",
    "recipient_ids": [101, 102]
  }'

Find your template_id via GET /v2/templates (Step 2), or copy it from the template URL in the EchtPost editor. The same recipient options apply as below — recipient_ids, group_ids, and inline recipients can all be combined in a single request.

deliver_at formats — the field accepts more than just ISO dates:

Value Meaning
"YYYY-MM-DD" Exact date, e.g. "2026-05-12"
"today" or "asap" Today's date
"tomorrow" Tomorrow
"N-days-from-now" N calendar days ahead, e.g. "5-days-from-now"

The chosen date must be a weekday, not a printer holiday, and at most 90 days in the future. Omitting deliver_at schedules the card for the next available business day.


The flexible path: compose content inline

Use this when the card content varies per send — for example, a dynamic message generated by your system. This is where preview_fit (Step 3) is worth running first.

{anrede} is optional. If you include it in content, the API replaces it per recipient with their personalised salutation at print time (e.g. "Sehr geehrte Frau Müller" or "Lieber Max") — pulled from the contact's name and greeting_style. You don't have to use it at all: for a single inline recipient where you're composing the greeting yourself, simply write it out directly in content. {anrede} becomes most valuable in mailings (Option B or C) where the greeting needs to differ for each recipient without you having to generate personalised texts yourself.

Option A: Inline recipients (no prior contact needed)

Best for one-off sends or when recipient data comes from an external system.

curl -sS \
  -H "Authorization: Bearer $ECHTPOST_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "https://api.echtpost.de/v2/cards" \
  -d '{
    "motive_id": 42,
    "content": "{anrede},\n\nwir wünschen Ihnen alles Gute.\n\nHerzlich,\nIhr Acme-Team",
    "font": { "family": "architects_daughter", "size": 13, "color": "black" },
    "deliver_at": "2026-05-12",
    "recipients": [
      {
        "first_name": "Maria",
        "last_name": "Müller",
        "street": "Musterstraße 1",
        "zip": "10115",
        "city": "Berlin",
        "country_code": "de",
        "greeting_style": "formal"
      }
    ]
  }'

Option B: Saved contacts (by ID)

Use this when the recipient already exists in your EchtPost contacts.

curl -sS \
  -H "Authorization: Bearer $ECHTPOST_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "https://api.echtpost.de/v2/cards" \
  -d '{
    "motive_id": 42,
    "content": "{anrede},\n\nwir bedanken uns für Ihr Vertrauen.\n\nHerzlich,\nIhr Team",
    "font": { "family": "architects_daughter", "size": 13 },
    "recipient_ids": [101, 102, 103]
  }'

Option C: An entire contact group

Perfect for mailings — send to everyone in a group with a single call.

curl -sS \
  -H "Authorization: Bearer $ECHTPOST_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "https://api.echtpost.de/v2/cards" \
  -d '{
    "motive_id": 42,
    "content": "{anrede},\n\nwir freuen uns, Sie als Kunden begrüßen zu dürfen.\n\nMit freundlichen Grüßen,\nIhr Acme-Team",
    "font": { "family": "architects_daughter", "size": 13 },
    "group_ids": [7]
  }'

You can combine recipient_ids, group_ids, and inline recipients in a single request. Add "deduplicate_recipients": true to avoid sending duplicate cards if contacts appear in multiple groups.

Successful response (HTTP 201)

{
  "id": "a1b2c3d4-0000-0000-0000-111122223333",
  "status": "pending",
  "type": "CardSingle",
  "motive_id": 42,
  "content": "{anrede},\n\nwir wünschen Ihnen alles Gute.\n\nHerzlich,\nIhr Acme-Team",
  "font": { "family": "architects_daughter", "size": 13, "color": "black" },
  "contacts_count": 1,
  "deliver_at": "2026-05-12T00:00:00Z",
  "cancelable": true,
  "created_at": "2026-04-28T09:00:00Z",
  "warnings": []
}

Save the id — you'll need it to check status or cancel the card.

warnings array: A no_line_breaks warning means your content has no \n characters. The card will render but may look like a wall of text. Add paragraph breaks for better readability.


Step 5 — Verify the card

Poll the card at any time to check its status:

curl -sS \
  -H "Authorization: Bearer $ECHTPOST_TOKEN" \
  "https://api.echtpost.de/v2/cards/a1b2c3d4-0000-0000-0000-111122223333"

The status field follows this lifecycle:

pending → scheduled → sent
               ↓
           canceled

A card is cancelable as long as it hasn't reached the print queue. Cancel it with a DELETE request:

curl -sS \
  -H "Authorization: Bearer $ECHTPOST_TOKEN" \
  -X DELETE \
  "https://api.echtpost.de/v2/cards/a1b2c3d4-0000-0000-0000-111122223333"

This returns 202 Accepted — cancellation is asynchronous. If the card is already past the cancelable window, you'll get a 409 Conflict.


Handling errors

All errors use Content-Type: application/problem+json. Always match on the type URI — the detail field is a human-readable, imperative hint telling you exactly what to fix before retrying.

type (suffix) HTTP What to do
authentication-required 401 Check your API key
insufficient-funds 402 Top up your account balance
validation 422 See errors[] for per-field details
text-does-not-fit 422 Shorten the message or use suggested_font_size
invalid-delivery-date 422 Use a valid format (YYYY-MM-DD, today, tomorrow, N-days-from-now), pick a weekday that is not a public holiday, and stay within 90 days
feature-not-available 422 Remove qr_code_url or enable the feature
card-not-cancelable 409 Card is past the cancellable window — cannot cancel
rate-limited 429 Slow down and retry after the Retry-After window
rate-limit-exceeded 429 Temporary motive upload cap reached; retry later or contact support with request_id

Example error response:

{
  "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"
}

Optional: get notified when the card is sent

Add a notifications list to the create request. Each entry is one email notification, so a card can have a reminder before sending and a mail once it is sent:

"notifications": [
  { "type": "before_send", "email": "ops@acme.de", "send_on": "2026-10-13" },
  { "type": "after_sent", "email": "ops@acme.de" }
]
Field Required Meaning
type yes after_sent or before_send (see below)
email yes Address that receives the notification
send_on no before_send only: ISO date (YYYY-MM-DD) of the reminder, on or before the send date. Defaults to the send date. Ignored for after_sent.
type When you get notified
after_sent The card was handed over to the print shop (state sent)
before_send On send_on, as a reminder that the card is about to be sent

A single notification object is still accepted for existing integrations but is deprecated. Sending both notification and notifications returns 422.

There is no notification for failed cards. To get notified for every card of the account without setting notifications on each request, register a webhook for the card.sent event in the account settings.


Full reference

EchtPost MCP server — AI consumer guide

This guide is for developers and AI agents connecting to the EchtPost Model Context Protocol (MCP) server. It exposes the same postcard capabilities as the REST API v2 (account, contacts, groups, templates, cards) — but as MCP tools that AI clients (Claude, Mistral, or any MCP-compatible host) can call directly.

If you are integrating from your own backend, prefer the REST API v2 (see the AI consumer guide tab). Use the MCP server when you want an LLM-driven client to send EchtPost postcards as part of a chat or agent workflow.

Endpoint

https://mcp.echtpost.de

Transport: Streamable HTTP (MCP spec 2025-06-18). The MCP server runs on its own subdomain; the OAuth authorization server lives at https://my.echtpost.de (where the user consent screen needs the session).

Authentication

The MCP server accepts two credential types. Use whichever fits your client:

Use OAuth when the MCP client acts on behalf of a real EchtPost user — this is the standard for Claude, ChatGPT, and any client that enforces user consent. Tokens are short-lived, scoped to one account, and revocable without touching API keys.

An unauthenticated request returns 401 Unauthorized with a WWW-Authenticate header pointing to the protected-resource metadata document on the MCP server's own origin:

WWW-Authenticate: Bearer realm="EchtPost MCP",
  resource_metadata="https://mcp.echtpost.de/.well-known/oauth-protected-resource"

MCP clients that support OAuth (Claude, ChatGPT) follow the discovery chain automatically:

  1. Fetch https://mcp.echtpost.de/.well-known/oauth-protected-resource (RFC 9728) — declares https://my.echtpost.de as the authorization server.
  2. Fetch https://my.echtpost.de/.well-known/oauth-authorization-server (RFC 8414) — returns authorization_endpoint, token_endpoint, registration_endpoint, revocation_endpoint, supported scopes, PKCE methods, etc.
  3. Walk the Authorization Code + PKCE flow against those endpoints.
  4. Attach the resulting token as Authorization: Bearer <token> on subsequent MCP requests.

Dynamic client registration (RFC 7591) — clients can self-register instead of requiring a pre-shared client_id. The exact URL is advertised by the authorization-server metadata as registration_endpoint (currently https://my.echtpost.de/oauth/register).

Key details:

Alternative: API key (server-to-server)

Use an API key for server-side scripts, automations, or clients that don't support OAuth (e.g. Mistral). Two header formats are accepted:

Format Example
Authorization: Bearer <key> Authorization: Bearer ep_live_abc123
X-Api-Key: <key> X-Api-Key: ep_live_abc123

Create keys in the EchtPost web app under Account → API keys. Keys are tied to a single account and user; both must be active.

Sandbox. A key created in sandbox mode (ep_test_ prefix, see llm_guide.md) puts the whole connection into sandbox: every tool reads and writes sandbox records only, and create_card produces a card that is neither billed nor printed. Motives and templates are shared between the modes. get_me returns mode: "sandbox" or mode: "live", so the agent can check before it creates a card.

OAuth connections are always live — the mode travels in the key prefix, and an OAuth token has none. To test through an OAuth client, address a card to yourself and cancel it with cancel_card before it goes to print.

Connecting an MCP client

Claude supports OAuth-based MCP servers natively. Just provide the URL — Claude handles discovery, registration, and the consent flow:

{
  "mcpServers": {
    "echtpost": {
      "type": "http",
      "url": "https://mcp.echtpost.de"
    }
  }
}

On first connection Claude will open a browser for you to log in and authorize access. No API key needed.

Claude Code (CLI):

claude mcp add --transport http echtpost https://mcp.echtpost.de

Claude Code (CLI) — API key

If you prefer a static key (no browser consent flow):

claude mcp add --transport http echtpost https://mcp.echtpost.de \
  --header "Authorization: Bearer $ECHTPOST_API_KEY"

Claude API / Desktop config — API key

{
  "mcpServers": {
    "echtpost": {
      "type": "http",
      "url": "https://mcp.echtpost.de",
      "headers": {
        "Authorization": "Bearer YOUR_ECHTPOST_API_KEY"
      }
    }
  }
}

Mistral

Provide the URL https://mcp.echtpost.de. Mistral reads the WWW-Authenticate: Bearer header from the 401 response and prompts for a key automatically.

Quick connectivity check (raw HTTP)

# 401 — confirms the server is reachable and auth is required
curl -i https://mcp.echtpost.de

# Tool list — confirms your key works (API key or OAuth token)
curl -sS \
  -H "Authorization: Bearer $ECHTPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -X POST https://mcp.echtpost.de \
  -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'

Tools

All tools resolve through the same business logic as the REST API v2, so concepts such as recipients, fonts, delivery dates, and error types behave identically. Where this guide is silent, fall back to the AI consumer guide tab on this page — it is the canonical reference.

Account

Tool Purpose
get_me Account, user, API key metadata, and current credit balance. Start here to verify the connection.
list_credits Current balance plus how many local/foreign postcards you can send.

Contacts

Tool Purpose
list_contacts List contacts with optional search and pagination.
get_contact Full details of a single contact, including group memberships.
create_contact Create a contact. Required: last_name, street, zip, city, country_code.
update_contact Update fields on a contact. Omitted fields stay unchanged.
delete_contact Permanently delete a contact.

Groups

Tool Purpose
list_groups List contact groups (usable as recipients on cards).
get_group Group details: name, external_id, recipient count.
create_group Create a group with optional external_id.
update_group Rename a group or change its external_id.
delete_group Delete a group. Returns an error if attached workflows exist.

Templates

Tool Purpose
list_templates List saved card templates (pre-configured content, font, motive). Drafts auto-saved while editing a card are excluded.
get_template Template details by ID, regardless of source.

Motives

Tool Purpose
list_motives Browse available postcard designs with optional search, including account-owned temporary API motives. Motive metadata includes has_back_side, a preview_image_url for the front and a back_side_image_url for the custom back side.
get_motive Motive details, including which content fields it supports. Temporary motive metadata stays visible to the owning account after asset purge; only the original image assets become unavailable.

Cards

Tool Purpose
preview_fit Check whether a message fits at a given font before creating a card. Returns lines_used, max_lines, and a suggested_font_size on overflow.
create_card Create a postcard with custom content and font.
create_card_from_template Create a card from a saved template (template provides content and font).
list_cards List cards with optional status filter (pending, scheduled, sent, canceled) and pagination.
get_card Card details by ID, including delivery status and the cancelable flag.
cancel_card Cancel a scheduled card. Only works while cancelable is true.

Temporary motives uploaded through the REST API can be used as motive_id in create_card. Their image lifetime is managed by EchtPost: assets_expires_at is set on upload and may move further out when cards use the motive. Assets are kept at least until that date, and at least ten days after the delivery date of every card that uses the motive. The motive row and the card and print history stay available; get_motive shows assets_expires_at and assets_purged_at so you can tell a live motive from a purged one. A purged motive cannot be used for a new card.

Custom back sides for temporary motives are uploaded through REST POST /v2/motives, not through an MCP tool. MCP clients can still use those motives for create_card: check has_back_side and the motive feature flags before deciding which dynamic back-side fields to send. For custom back sides, EchtPost prints the recipient address and every enabled dynamic field, but it does not render the standard postmark image or footer logo; the uploaded back-side file must include that static print design itself. See the AI consumer guide's temporary-motive section for the exact 1795×1287 px back-side requirement and safe areas.

Recipient specification

create_card and create_card_from_template accept three recipient sources, all combinable; at least one must yield ≥ 1 contact:

Group membership on update_contact

Fonts

Family Valid sizes
architects_daughter 12–14 (default 14)
reenie_beanie 15–17
special_elite 11–13

Colors: black (default) or blue.

Delivery dates

The deliver_at field accepts:

Value Resolves to
"YYYY-MM-DD" Exact date
"today" / "asap" Today
"tomorrow" Tomorrow
"N-days-from-now" N calendar days from now (e.g. "5-days-from-now")

Must be a weekday, not a printer holiday, and at most 90 days in the future — MCP card creation runs through the same REST validation. Natural-language strings like "in two days" are not accepted — use the numeric hyphenated form.

Errors

Tool errors are returned as MCP tool results with isError: true and a human-readable message in the content text. The underlying error categories mirror the REST API's stable error types — for example a message that overflows the card maps to the same overflow shape and includes a suggested_font_size. See the REST AI consumer guide's Stable error types section for the full list.

401 Unauthorized is terminal: the credential (API key or OAuth token) is missing, invalid, expired, or the account/user is deactivated. For OAuth tokens, the client should attempt a token refresh or re-run the authorization flow. For API keys, verify the key in the EchtPost web app.

Rate limits

Rate limits are shared with the REST API v2 and applied per credential (API key or OAuth token). The strict limits (10/min on card creation, 30/min on preview_fit) apply to the matching MCP tools (create_card, create_card_from_template, preview_fit). Spread bulk runs over time or batch recipients with recipient_ids / group_ids on a single card call.

See also