# Envelopify API (full reference for AI assistants) Envelopify sends real paper letters with real postage stamps from your software, in Belgium. One API call per letter: we print, envelope, stamp and hand the letter to bpost. Standard: handed to bpost within 3 working days. Express: the same working day when received before 12:00 (Europe/Brussels). Flex: within 5 working days. bpost delivers within 2 to 3 working days after hand-over. Base URL: `https://envelopify.lovable.app/api/v1` Human docs: https://envelopify.lovable.app/developers (Dutch). OpenAPI 3.1: https://envelopify.lovable.app/openapi.json. All amounts are in euro cents, excl. VAT. All error messages are in Dutch. ## Quick start 1. **Create a key.** Create a free account, open API-sleutels (API keys) and create a test key. Replace `test_YOUR_KEY` in the examples. 2. **Send a test letter.** The example under Send a letter uses our sample PDF and works as is. Nothing is printed or charged. 3. **Check the status.** Take the `id` from the response and call `GET /api/v1/letters/{id}`. Add `/pdf` to get the PDF exactly as we print it. 4. **Set up webhooks.** Register an https URL with `POST /api/v1/webhooks` to be notified when a letter is printed and posted. 5. **Go live.** Top up credit in the portal and click Go live next to your key. New keys always start in test mode. The key itself does not change. ## Authentication Send your key with every request in the header `Authorization: Bearer `. New keys start in test mode; you switch them live in the portal (API keys, Go live). The mode belongs to the key, not to its prefix. With a key in test mode letters are never printed, sent or charged; they get status `received`. A key is shown only once: store it as a server-side secret, never in browser code. A missing, invalid or revoked key returns `401 unauthorized`. ## Queue a flow event: `POST /api/v1/flow-events` For an active reminder flow and saved contact. Queues a one-time plan, not an immediate letter. Daily processing uses shared pricing and stop checks; insufficient credit retains the queue entry. event_key deduplicates per flow and key mode. Daily and recipient cooldown limits apply; test plans are admin-executed only and never send or charge. Request body (JSON): | Field | Type | Required | Description | |---|---|---|---| | `flow_id` | string | yes | Active flow id. | | `contact_id` | string | yes | Own saved contact id. | | `event_key` | string | yes | Unique event reference, 1 to 120 characters. | | `order_cents` | integer | no | Amount for condition, default 0. | | `variables` | object | no | Required template fields only. | cURL: ```bash curl -X POST https://envelopify.lovable.app/api/v1/flow-events \ -H "Authorization: Bearer test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "flow_id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "contact_id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "event_key": "herinnering-104", "variables": {} }' ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/flow-events", { method: "POST", headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json", }, body: JSON.stringify({ "flow_id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "contact_id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "event_key": "herinnering-104", "variables": {} }), }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.post( "https://envelopify.lovable.app/api/v1/flow-events", headers={"Authorization": "Bearer " + KEY}, json={ "flow_id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "contact_id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "event_key": "herinnering-104", "variables": {} }, ) print(res.status_code, res.json()) ``` PHP: ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, "Content-Type: application/json", ], CURLOPT_POSTFIELDS => '{ "flow_id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "contact_id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "event_key": "herinnering-104", "variables": {} }', ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` Response `202`: ```json { "status": "queued", "test": true } ``` ## Send a letter: `POST /api/v1/letters` Creates a letter. Provide exactly one source: a PDF (`pdf_base64` or `pdf_url`) or a template (`template_id` or `template_code`) with `variables`. PDFs follow the account default: a free address sheet or a white address box on page one using `options.address_on_first_page`. Small is up to 50 g, medium up to 100 g and large up to 350 g. Medium and large require a confirmed and enabled surcharge. The address sheet counts for weight, not the base price. Returns `201` with id, status, price with breakdown (`price_breakdown`), sheet count (address sheet included), latest post date for the chosen speed and `test`. With a test key the status is `received` and `post_date` is null. A rejected letter returns `422` with an error code and `letter_id`. Optional header `Idempotency-Key`. Request body (JSON): | Field | Type | Required | Description | |---|---|---|---| | `options.address_on_first_page` | boolean | no | For uploaded PDFs: fixed opaque white address window on page one, without an extra address sheet. Covered content is erased. Defaults to account preference (initially separate sheet). | | `options.corrections` | object | no | PDF corrections: region {left,top,width,height} in mm on A4, window left/right/both, first_page, white_margins, flatten and a4_portrait. Omit to use the saved account rule. The old address region is whitened. The original is retained. | | `recipient.language` | string | no | Optional: nl, fr, en or de. Selects a saved template language variant, otherwise the base text. Defaults by Belgian postcode. | | `recipient.company` | string | no | Optional. Company name. | | `recipient.name` | string | yes | Recipient name. Name or company is required. | | `recipient.street` | string | yes | Street. | | `recipient.number` | string | yes | House number. | | `recipient.box` | string | no | Optional. Box number. | | `recipient.postal_code` | string | yes | Postal code. 4 digits in Belgium. | | `recipient.city` | string | yes | City. | | `recipient.country` | string | no | ISO country code (2 letters), default BE. All other EU countries too (e.g. NL, FR, DE), +€1,50 per letter, delivery 5 to 10 working days after dispatch, no express. | | `pdf_base64` | string | no | PDF as base64, A4, max 20 MB. | | `pdf_url` | string | no | Direct public HTTPS URL of your PDF, max 20 MB. No redirects, credentials, private networks or nonstandard ports. | | `template_id` | string | no | Template id, instead of a PDF. | | `template_code` | string | no | Or the template code, e.g. herinnering. | | `variables` | object | no | Values for the template fields, e.g. { "voornaam": "An" }. | | `options.flat` | boolean | no | Unfolded in a large envelope, packed by hand. Only when activated. Surcharge from /price. | | `options.registered` | boolean | no | Registered paper mail, BE and standard/express only, when activated. Tracking: registered_tracking, registered_handed_in_at and temporary registered_receipt_url. | | `options.color` | boolean | no | Colour print. Default black and white. Surcharge per sheet via /price. | | `options.duplex` | boolean | no | Double-sided: two pages per sheet. Default single-sided. | | `options.qr_tracking` | boolean | no | Dynamic QR code with scan tracking, +€0,05 per letter. | | `options.speed` | string | no | Speed: `standard` (handed to bpost within 3 working days), `express` (same working day when received before 12:00, +€0,15 per letter, minimum €15,00 per day) or `flex` (within 5 working days, €-0,05 per letter). Default `standard`. | | `options.qr_target_url` | string | no | Required with qr_tracking. https URL the QR code redirects to. Can be changed later in the portal. | | `enclosure_batch_id` | string | no | Received material id from the portal. One copy per letter after activation and receipt inspection. Pre-production cancellation restores stock. | | `reference` | string | no | Optional. Your own reference, e.g. invoice number. | | `post_on` | string | no | Optional hand-over day (YYYY-MM-DD), a weekday up to 90 calendar days ahead. Not a delivery date. Credit is reserved by charging on creation and refunded if cancelled before production. | cURL: ```bash curl -X POST https://envelopify.lovable.app/api/v1/letters \ -H "Authorization: Bearer test_YOUR_KEY" \ -H "Idempotency-Key: factuur-2026-104" \ -H "Content-Type: application/json" \ -d '{ "recipient": { "name": "An Peeters", "street": "Meir", "number": "12", "postal_code": "2000", "city": "Antwerpen" }, "pdf_url": "https://envelopify.lovable.app/voorbeeld.pdf", "options": { "color": false }, "reference": "factuur-2026-104" }' ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/letters", { method: "POST", headers: { Authorization: "Bearer " + KEY, "Idempotency-Key": "factuur-2026-104", "Content-Type": "application/json", }, body: JSON.stringify({ "recipient": { "name": "An Peeters", "street": "Meir", "number": "12", "postal_code": "2000", "city": "Antwerpen" }, "pdf_url": "https://envelopify.lovable.app/voorbeeld.pdf", "options": { "color": false }, "reference": "factuur-2026-104" }), }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.post( "https://envelopify.lovable.app/api/v1/letters", headers={"Authorization": "Bearer " + KEY, "Idempotency-Key": "factuur-2026-104"}, json={ "recipient": { "name": "An Peeters", "street": "Meir", "number": "12", "postal_code": "2000", "city": "Antwerpen" }, "pdf_url": "https://envelopify.lovable.app/voorbeeld.pdf", "options": { "color": False }, "reference": "factuur-2026-104" }, ) print(res.status_code, res.json()) ``` PHP: ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, "Idempotency-Key: factuur-2026-104", "Content-Type: application/json", ], CURLOPT_POSTFIELDS => '{ "recipient": { "name": "An Peeters", "street": "Meir", "number": "12", "postal_code": "2000", "city": "Antwerpen" }, "pdf_url": "https://envelopify.lovable.app/voorbeeld.pdf", "options": { "color": false }, "reference": "factuur-2026-104" }', ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` Response `201`: ```json { "id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "status": "scheduled", "price_cents": 129, "price_breakdown": { "unit_price_cents": 129, "tier": 1, "tier_fixed": false, "volume_30d": 120, "letters_to_next_tier": 379, "pages": 1, "sheets": 1, "address_sheet": true, "total_sheets": 2, "color": false, "duplex": false, "speed": "standard", "destination": "BE", "options": [], "total_cents": 129, "on_request": false, "currency": "EUR", "vat_included": false }, "sheet_count": 2, "post_date": "2026-11-13", "free_trial": false, "test": false } ``` ## Get a letter: `GET /api/v1/letters/{id}` Returns all letter fields, timestamps, the price breakdown, the number of QR scans (`scan_count`) and, once posted, a temporary link to the batch photo (`batch_photo_url`, valid 1 hour). cURL: ```bash curl -X GET https://envelopify.lovable.app/api/v1/letters/b1d1a7f7-3db2-43df-a896-bc9dc10c6029 \ -H "Authorization: Bearer test_YOUR_KEY" ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/letters/b1d1a7f7-3db2-43df-a896-bc9dc10c6029", { method: "GET", headers: { Authorization: "Bearer " + KEY, }, }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.get( "https://envelopify.lovable.app/api/v1/letters/b1d1a7f7-3db2-43df-a896-bc9dc10c6029", headers={"Authorization": "Bearer " + KEY}, ) print(res.status_code, res.json()) ``` PHP: ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, ], ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` Response `200`: ```json { "id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "tracking_code": "B1D1A7F7", "reference": "factuur-2026-104", "status": "posted", "test": false, "reject_reason": null, "return_reason": null, "recipient": { "company": null, "name": "An Peeters", "street": "Meir", "number": "12", "box": null, "postal_code": "2000", "city": "Antwerpen", "country": "BE" }, "source": "pdf", "color": false, "duplex": false, "qr_tracking": false, "sheet_count": 2, "price_cents": 129, "price_breakdown": { "unit_price_cents": 129, "tier": 1, "tier_fixed": false, "volume_30d": 120, "letters_to_next_tier": 379, "pages": 1, "sheets": 1, "address_sheet": true, "total_sheets": 2, "color": false, "duplex": false, "speed": "standard", "destination": "BE", "options": [], "total_cents": 129, "on_request": false, "currency": "EUR", "vat_included": false }, "speed": "standard", "post_deadline": "2026-11-13", "created_at": "2026-11-12T10:02:11Z", "scheduled_at": "2026-11-12T10:02:11Z", "printed_at": "2026-11-13T09:40:00Z", "posted_at": "2026-11-13T16:02:11Z", "returned_at": null, "post_date": "2026-11-13", "scan_count": 0, "batch_photo_url": null } ``` ## Download the PDF: `GET /api/v1/letters/{id}/pdf` Returns the final PDF exactly as we print it (`application/pdf`), including the address sheet. Only for your own letters. A rejected letter has no PDF and returns `404`. cURL: ```bash curl -X GET https://envelopify.lovable.app/api/v1/letters/b1d1a7f7-3db2-43df-a896-bc9dc10c6029/pdf \ -H "Authorization: Bearer test_YOUR_KEY" ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/letters/b1d1a7f7-3db2-43df-a896-bc9dc10c6029/pdf", { method: "GET", headers: { Authorization: "Bearer " + KEY, }, }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.get( "https://envelopify.lovable.app/api/v1/letters/b1d1a7f7-3db2-43df-a896-bc9dc10c6029/pdf", headers={"Authorization": "Bearer " + KEY}, ) print(res.status_code, res.json()) ``` PHP: ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, ], ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` Response `200`: `application/pdf` ## List letters: `GET /api/v1/letters` Returns your most recent letters, newest first, in `data`. When there are more, pass `next_cursor` back as `cursor`. Query parameters: | Field | Type | Required | Description | |---|---|---|---| | `status` | string | no | Filter by status, e.g. posted. | | `reference` | string | no | Filter by reference. | | `from` | string | no | From date, e.g. 2026-11-01. | | `to` | string | no | Up to and including date. | | `limit` | integer | no | Default 25, max 100. | | `cursor` | string | no | Value of next_cursor. | cURL: ```bash curl -X GET https://envelopify.lovable.app/api/v1/letters?status=posted&limit=50 \ -H "Authorization: Bearer test_YOUR_KEY" ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/letters?status=posted&limit=50", { method: "GET", headers: { Authorization: "Bearer " + KEY, }, }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.get( "https://envelopify.lovable.app/api/v1/letters?status=posted&limit=50", headers={"Authorization": "Bearer " + KEY}, ) print(res.status_code, res.json()) ``` PHP: ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, ], ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` Response `200`: ```json { "data": [ { "id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "status": "posted", "reference": "factuur-2026-104" } ], "next_cursor": "MjAyNi0xMS0xMlQxMDo..." } ``` ## Cancel a letter: `DELETE /api/v1/letters/{id}` Cancels a letter while the batch of that day is still open. The amount is credited back to your Envelopify balance, not paid out and the letter is returned with status `rejected`. Once the batch is closed you get `409 cutoff_passed`. cURL: ```bash curl -X DELETE https://envelopify.lovable.app/api/v1/letters/b1d1a7f7-3db2-43df-a896-bc9dc10c6029 \ -H "Authorization: Bearer test_YOUR_KEY" ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/letters/b1d1a7f7-3db2-43df-a896-bc9dc10c6029", { method: "DELETE", headers: { Authorization: "Bearer " + KEY, }, }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.delete( "https://envelopify.lovable.app/api/v1/letters/b1d1a7f7-3db2-43df-a896-bc9dc10c6029", headers={"Authorization": "Bearer " + KEY}, ) print(res.status_code, res.json()) ``` PHP: ```php "DELETE", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, ], ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` ## Price quote: `POST /api/v1/price` Calculates the price of a letter with your current tier, without creating or charging anything. Same options as a letter, but with the number of printed pages instead of a PDF. `address_sheet` (default true) counts the free address sheet towards the sheet limit, like an uploaded PDF; set it to false for a template letter. Unavailable weight bands return `422 weight_class_unavailable`. Use `options.address_on_first_page` to exclude the extra address sheet. Request body (JSON): | Field | Type | Required | Description | |---|---|---|---| | `options.address_on_first_page` | boolean | no | For uploaded PDFs: fixed opaque white address window on page one, without an extra address sheet. Covered content is erased. Defaults to account preference (initially separate sheet). | | `options.flat` | boolean | no | Unfolded in a large envelope, packed by hand. Only when activated. Surcharge from /price. | | `options.registered` | boolean | no | Registered paper mail, BE and standard/express only, when activated. Tracking: registered_tracking, registered_handed_in_at and temporary registered_receipt_url. | | `enclosure_batch_id` | string | no | Received material id from the portal. One copy per letter after activation and receipt inspection. Pre-production cancellation restores stock. | | `pages` | integer | yes | Number of printed pages (address sheet not included). | | `address_sheet` | boolean | no | Count the free address sheet towards the sheet limit. Default true. | | `options.country` | string | no | Destination country, default BE. Another EU country adds the EU surcharge. | | `options.color` | boolean | no | Colour, surcharge per sheet via /price. | | `options.duplex` | boolean | no | Double-sided. | | `options.qr_tracking` | boolean | no | QR code with scan tracking. | | `options.speed` | string | no | standard, express or flex. | cURL: ```bash curl -X POST https://envelopify.lovable.app/api/v1/price \ -H "Authorization: Bearer test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "pages": 3, "address_sheet": true, "options": { "color": false, "duplex": false, "qr_tracking": false, "speed": "standard" } }' ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/price", { method: "POST", headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json", }, body: JSON.stringify({ "pages": 3, "address_sheet": true, "options": { "color": false, "duplex": false, "qr_tracking": false, "speed": "standard" } }), }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.post( "https://envelopify.lovable.app/api/v1/price", headers={"Authorization": "Bearer " + KEY}, json={ "pages": 3, "address_sheet": True, "options": { "color": False, "duplex": False, "qr_tracking": False, "speed": "standard" } }, ) print(res.status_code, res.json()) ``` PHP: ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, "Content-Type: application/json", ], CURLOPT_POSTFIELDS => '{ "pages": 3, "address_sheet": true, "options": { "color": false, "duplex": false, "qr_tracking": false, "speed": "standard" } }', ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` Response `200`: ```json { "price_breakdown": { "unit_price_cents": 129, "tier": 1, "tier_fixed": false, "volume_30d": 120, "letters_to_next_tier": 379, "pages": 3, "sheets": 3, "address_sheet": true, "total_sheets": 4, "color": false, "duplex": false, "speed": "standard", "destination": "BE", "options": [ { "code": "extra_sheets", "label": "Extra vellen (2)", "amount_cents": 20 } ], "total_cents": 149, "on_request": false, "currency": "EUR", "vat_included": false } } ``` ## List templates: `GET /api/v1/templates` Returns your templates in `data`. cURL: ```bash curl -X GET https://envelopify.lovable.app/api/v1/templates \ -H "Authorization: Bearer test_YOUR_KEY" ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/templates", { method: "GET", headers: { Authorization: "Bearer " + KEY, }, }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.get( "https://envelopify.lovable.app/api/v1/templates", headers={"Authorization": "Bearer " + KEY}, ) print(res.status_code, res.json()) ``` PHP: ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, ], ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` Response `200`: ```json { "data": [ { "id": "6c1f...", "name": "Herinnering", "code": "herinnering", "body": "Beste {{voornaam}}, ...", "created_at": "2026-11-12T10:00:00Z" } ] } ``` ## Create a template: `POST /api/v1/templates` Creates a template. Fields go between double curly braces, e.g. `{{voornaam}}`. An existing code returns `409 conflict`. Request body (JSON): | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | Name. | | `code` | string | yes | Unique code: letters, digits, - and _. | | `body` | string | yes | Text with fields. | cURL: ```bash curl -X POST https://envelopify.lovable.app/api/v1/templates \ -H "Authorization: Bearer test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Herinnering", "code": "herinnering", "body": "Beste {{voornaam}},\n\nWe ontvingen nog geen betaling van {{bedrag}}." }' ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/templates", { method: "POST", headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json", }, body: JSON.stringify({ "name": "Herinnering", "code": "herinnering", "body": "Beste {{voornaam}},\n\nWe ontvingen nog geen betaling van {{bedrag}}." }), }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.post( "https://envelopify.lovable.app/api/v1/templates", headers={"Authorization": "Bearer " + KEY}, json={ "name": "Herinnering", "code": "herinnering", "body": "Beste {{voornaam}},\n\nWe ontvingen nog geen betaling van {{bedrag}}." }, ) print(res.status_code, res.json()) ``` PHP: ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, "Content-Type: application/json", ], CURLOPT_POSTFIELDS => '{ "name": "Herinnering", "code": "herinnering", "body": "Beste {{voornaam}},\\n\\nWe ontvingen nog geen betaling van {{bedrag}}." }', ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` ## Balance and prices: `GET /api/v1/balance` Returns your credit, free trial letters left (`free_letters_remaining`), current tier, number of real letters in the past 30 days, letters to the next discount and the price list in cents, excl. VAT: price per letter per tier, option surcharges, speeds and the express rules. cURL: ```bash curl -X GET https://envelopify.lovable.app/api/v1/balance \ -H "Authorization: Bearer test_YOUR_KEY" ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/balance", { method: "GET", headers: { Authorization: "Bearer " + KEY, }, }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.get( "https://envelopify.lovable.app/api/v1/balance", headers={"Authorization": "Bearer " + KEY}, ) print(res.status_code, res.json()) ``` PHP: ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, ], ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` Response `200`: ```json { "credit_cents": 4950, "free_letters_remaining": 3, "company_verified": true, "tier": 1, "tier_fixed": false, "volume_30d": 120, "letters_to_next_tier": 380, "unit_price_cents": 129, "tiers": [ { "tier": 1, "from": 0, "unit_price_cents": 129 }, { "tier": 2, "from": 500, "unit_price_cents": 109 }, { "tier": 3, "from": 5000, "unit_price_cents": 99 } ], "options": { "extra_sheet_cents": 10, "qr_tracking_cents": 5 }, "speeds": [ { "speed": "standard", "name": "Standaard", "amount_cents": 0, "working_days": 3 }, { "speed": "express", "name": "Express", "amount_cents": 15, "working_days": 0 }, { "speed": "flex", "name": "Flex", "amount_cents": -5, "working_days": 5 } ], "express": { "minimum_cents": 1500, "max_letters_per_day": 1000, "before_hour": 12 }, "heavy_letter": { "from_grams": 50, "amount_cents": 60 }, "eu": { "surcharge_cents": 150, "countries": [ "AT", "DE", "FR", "NL", "..." ], "delivery": "5 tot 10 werkdagen na verzending", "express": false }, "daily_letter_max": 1000, "max_sheets": 8, "currency": "EUR", "vat_included": false } ``` ## Your account and company check: `GET /api/v1/account` Returns your own account details. `company_verified` is `true` when your VAT number has been checked with the EU VAT check (VIES) or manually by us. `verification_status` is `verified`, `none`, `pending` (check will follow later) or `invalid`. For a verified company, `official_name` and `official_address` come from the official register. `GET /balance` also includes `company_verified`. You cannot change the status through the API: it only changes after a real check in the portal. cURL: ```bash curl -X GET https://envelopify.lovable.app/api/v1/account \ -H "Authorization: Bearer test_YOUR_KEY" ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/account", { method: "GET", headers: { Authorization: "Bearer " + KEY, }, }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.get( "https://envelopify.lovable.app/api/v1/account", headers={"Authorization": "Bearer " + KEY}, ) print(res.status_code, res.json()) ``` PHP: ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, ], ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` Response `200`: ```json { "customer_id": "4f6c2a1e-...", "company": "Jouw Bedrijf", "email": "post@jouwbedrijf.be", "company_verified": true, "verification_status": "verified", "vat_number": "BE0123456749", "official_name": "Jouw Bedrijf BV", "official_address": "Kerkstraat 1\n2000 Antwerpen", "checked_at": "2026-11-12T10:00:00Z", "free_letters_remaining": 3 } ``` ## List webhooks: `GET /api/v1/webhooks` Lists your registered URLs and notification_groups. null means all existing events; an array limits notifications to selected groups. cURL: ```bash curl -X GET https://envelopify.lovable.app/api/v1/webhooks \ -H "Authorization: Bearer test_YOUR_KEY" ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/webhooks", { method: "GET", headers: { Authorization: "Bearer " + KEY, }, }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.get( "https://envelopify.lovable.app/api/v1/webhooks", headers={"Authorization": "Bearer " + KEY}, ) print(res.status_code, res.json()) ``` PHP: ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, ], ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` Response `200`: ```json { "data": [ { "id": "a3c2...", "url": "https://jouwsite.be/hooks/envelopify", "active": true, "created_at": "2026-11-12T10:00:00Z" } ] } ``` ## Create a webhook: `POST /api/v1/webhooks` Registers an https URL. The response contains the `secret` once; use it to verify signatures. Request body (JSON): | Field | Type | Required | Description | |---|---|---|---| | `url` | string | yes | https URL that receives our POST. | | `notification_groups` | array | no | Optional: problems (review/rejected), sent (posted/registered_handed_in), undeliverable (returned), delivered (coming soon, no events yet). Omit to preserve all existing events; [] receives nothing. Editable per URL in the portal. | cURL: ```bash curl -X POST https://envelopify.lovable.app/api/v1/webhooks \ -H "Authorization: Bearer test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://jouwsite.be/hooks/envelopify" }' ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/webhooks", { method: "POST", headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json", }, body: JSON.stringify({ "url": "https://jouwsite.be/hooks/envelopify" }), }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.post( "https://envelopify.lovable.app/api/v1/webhooks", headers={"Authorization": "Bearer " + KEY}, json={ "url": "https://jouwsite.be/hooks/envelopify" }, ) print(res.status_code, res.json()) ``` PHP: ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, "Content-Type: application/json", ], CURLOPT_POSTFIELDS => '{ "url": "https://jouwsite.be/hooks/envelopify" }', ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` Response `201`: ```json { "id": "a3c2...", "url": "https://jouwsite.be/hooks/envelopify", "active": true, "created_at": "2026-11-12T10:00:00Z", "secret": "whsec_..." } ``` ## Delete a webhook: `DELETE /api/v1/webhooks/{id}` Deletes a webhook. cURL: ```bash curl -X DELETE https://envelopify.lovable.app/api/v1/webhooks/a3c2... \ -H "Authorization: Bearer test_YOUR_KEY" ``` JavaScript: ```js // Node 18 or newer, save as example.mjs const KEY = "test_YOUR_KEY"; const res = await fetch("https://envelopify.lovable.app/api/v1/webhooks/a3c2...", { method: "DELETE", headers: { Authorization: "Bearer " + KEY, }, }); console.log(res.status, await res.json()); ``` Python: ```python import requests # pip install requests KEY = "test_YOUR_KEY" res = requests.delete( "https://envelopify.lovable.app/api/v1/webhooks/a3c2...", headers={"Authorization": "Bearer " + KEY}, ) print(res.status_code, res.json()) ``` PHP: ```php "DELETE", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . $key, ], ]); $body = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $body, "\n"; ``` Response `200`: ```json { "id": "a3c2...", "deleted": true } ``` ## Webhooks On every event we POST `{ event, letter_id, status, occurred_at, letter }` to all your active webhook URLs. Each request carries the header `x-envelopify-signature`: the hex HMAC-SHA256 of the raw body with your secret. Verify it before trusting the payload and answer with a 2xx status. Failed deliveries (no 2xx or no answer within 8 seconds) are retried up to 3 times (on your next event and on every production round), so 4 attempts in total. Test letters stay `received` and send no webhooks. | Event | When | |---|---| | `letter.scheduled` | Scheduled for a post day, credit charged | | `letter.review` | Address under review: we check it before printing | | `letter.checked` | Address approved after review, scheduled | | `letter.rejected` | Rejected after review or cancelled before production, credit refunded | | `letter.printed` | Printed and stamped | | `letter.posted` | Handed to bpost | | `letter.returned` | Returned as undeliverable | | `letter.registered_handed_in` | Registered hand-over with tracking and proof | | `letter.scanned` | QR code scanned | ```json { "event": "letter.posted", "letter_id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "status": "posted", "occurred_at": "2026-11-13T16:02:12Z", "letter": { "id": "b1d1a7f7-3db2-43df-a896-bc9dc10c6029", "tracking_code": "B1D1A7F7", "reference": "factuur-2026-104", "status": "posted", "posted_at": "2026-11-13T16:02:11Z" }, "created_at": "2026-11-13T16:02:12Z" } ``` Verifying the signature: ```js // Node 18+ with Express: verify a webhook signature import crypto from "node:crypto"; import express from "express"; const SECRET = "whsec_JOUW_GEHEIM"; // returned once when you create the webhook const app = express(); app.post("/hooks/envelopify", express.raw({ type: "application/json" }), (req, res) => { const expected = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex"); const given = req.get("x-envelopify-signature") ?? ""; const ok = given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected)); if (!ok) return res.status(401).end(); const { event, letter } = JSON.parse(req.body.toString("utf8")); console.log(event, letter.id, letter.status); res.status(200).end(); }); app.listen(3000); ``` ## Idempotency Send an `Idempotency-Key` header, e.g. your invoice number. Sending the same key again within 24 hours returns the existing letter (status 200) instead of creating a new one, and nothing extra is charged. After 24 hours the key counts as new. ## Statuses | Status | Meaning | |---|---| | `received` | Received. Test letters stay in this status. | | `scheduled` | Scheduled for a post day, credit charged. | | `review` | Under review: the postal code does not exist or does not match the municipality. We approve or reject the address (credit refunded). See address_warning. | | `printed` | Printed and stamped. | | `posted` | Handed to bpost. | | `returned` | Returned as undeliverable. | | `rejected` | Rejected or cancelled. The reason is in reject_reason. | ## Errors Errors always look like `{ "error": { "code": "...", "message": "..." } }`. `code` is language-independent and stable: use it in your logic. `message` is Dutch and safe to show to end users. | Code | HTTP | Meaning | Example message (Dutch) | |---|---|---|---| | `sender_not_verified` | 422 | Real shipments, including free postal trials, require a confirmed email and verified company. Test mode remains available. | Bevestig je e-mailadres en laat je bedrijf controleren vóór echte verzending. | | `weight_class_unavailable` | 422 | Weight band is not enabled or exceeds 350 grams. | Voor deze gewichtsklasse is nog geen prijs bevestigd | | `service_unavailable` | 422 | The selected service or received enclosure stock is not available. | Deze dienst is nog niet beschikbaar. | | `invalid_post_date` | 422 | The posting day is not a valid weekday within 90 days. | Kies een geldige werkdag vanaf de eerst mogelijke postdag, maximaal 90 dagen vooruit. | | `address_incomplete` | 422 | An address field is missing or the postal code is invalid. | Een Belgische postcode bestaat uit 4 cijfers | | `insufficient_balance` | 422 | Not enough credit. | Onvoldoende tegoed | | `pdf_invalid` | 422 | The PDF cannot be opened, is not a PDF or exceeds 20 MB. | We kunnen deze pdf niet openen. Is hij beveiligd of beschadigd? | | `too_many_sheets` | 422 | More than 8 sheets, address sheet included. | Te veel vellen: 9 (adresblad inbegrepen), een standaardbrief gaat tot 8 vellen. Grotere brieven kunnen op aanvraag: stuur ons een WhatsApp op 0473 39 95 89. | | `express_limit` | 422 | More than 1000 express letters on one day. | Maximum 1.000 expressbrieven per dag bereikt. Kies Standaard of vraag het via WhatsApp 0473 39 95 89. | | `country_not_supported` | 422 | Country outside Belgium and the EU. | Land US wordt niet ondersteund: we versturen naar België en de andere EU-landen | | `express_not_available` | 422 | Express is not available to other EU countries. | Express is niet beschikbaar voor brieven naar andere EU-landen. Kies Standaard of Flex. | | `daily_limit` | 422 | Daily letter maximum reached (default 1000, can be raised on request). | Dagelijks maximum van 1.000 brieven bereikt. Vraag een hoger maximum via WhatsApp 0473 39 95 89. | | `template_invalid` | 422 | Template not found or fields are missing. | Ontbrekende velden voor het sjabloon: voornaam | | `invalid_request` | 400 | Invalid request, e.g. no source or an invalid QR URL. | Geef precies één bron op: pdf_base64, pdf_url, of template_id / template_code. | | `unauthorized` | 401 | Key missing, invalid or revoked. | Deze API-sleutel is ongeldig of ingetrokken. | | `not_found` | 404 | Letter, webhook or endpoint does not exist. Also without a key. | Dit eindpunt bestaat niet. Bekijk de documentatie op /developers. | | `cutoff_passed` | 409 | Too late to cancel, the letter is in production. | Deze brief is al in productie en kan niet meer geannuleerd worden. | | `not_cancellable` | 409 | Cannot cancel, e.g. already rejected or cancelled. | Deze brief kan niet meer geannuleerd worden. | | `conflict` | 409 | A template with this code already exists. | Er bestaat al een sjabloon met deze code. | | `rate_limited` | 429 | More than 60 requests per minute. See the Retry-After header. | Je hebt meer dan 60 aanvragen per minuut gedaan. Probeer het binnen een minuut opnieuw. | | `server_error` | 500 | Something went wrong on our side. | Er liep iets mis aan onze kant. Probeer opnieuw. | ## Limits Max 60 requests per minute per key; above that you get `429 rate_limited` with a `Retry-After` header. PDFs up to 20 MB, letters up to 350 g, medium and large only when enabled, max 1000 express letters per day and by default max 1000 letters per day per account. `error.code` is language-independent and stable; `message` is in Dutch. CORS is open, but never use your key in browser code. ## Pricing One price per letter, excl. VAT, everything included: 1 sheet (single or double-sided, black and white), envelope, real stamps and posting. By your real letters in a rolling 30-day window, including the new letter (no monthly reset): €1,29 under 500, €1,09 from 500, €0,99 from 5000. Colour: +€0,08 per sheet. Double-sided has no surcharge. Extra sheet: +€0,10 per sheet after the first (double-sided is two pages per sheet). QR code with scan tracking: +€0,05. The address sheet is free and not counted for the price. A standard letter holds up to 8 sheets in total, address sheet included; larger letters on request. Speeds (`options.speed`): `standard` (handed to bpost within 3 working days), `express` (+€0,15, same working day when received before 12:00 Europe/Brussels, otherwise the next working day) and `flex` (€-0,05, within 5 working days). bpost delivers within 2 to 3 working days after hand-over. Express minimum via the API: if your express letters of one day total less than €15,00 at 12:00, the difference is charged as a ledger line "Aanvulling minimum express"; your credit must cover it when creating the letter. Max 1000 express letters per day (`422 express_limit`). Examples, tier 1: 1 sheet €1,29; 1 sheet colour €1,37; 3 sheets €1,49; 4 pages double-sided (2 sheets) €1,39; with QR €1,34; express €1,44; flex €1,24. Quotes are estimates for the next real letter; simultaneous orders recheck their tariff atomically. Test letters never count. Every response with a price contains `price_breakdown` with `unit_price_cents`, `tier`, `volume_30d`, `letters_to_next_tier`, `sheets`, `total_sheets`, `speed`, `options` (list of `code`, `label`, `amount_cents`) and `total_cents`. Prices can change; read them from `GET /balance` or `POST /price`. Free trial letters: every new account gets 3 real letters for free, also through the API with your key in live mode. Conditions: speed `standard`, at most 3 content sheets, confirmed e-mail address and at most 3 trial letters per recipient address. A letter that does not qualify is paid from your credit as usual. A trial letter has `free_trial: true` and `price_cents: 0`; cancelling before production gives it back. `GET /balance` shows `free_letters_remaining`. Heavy letter: above 50 g we add +€0,60 per letter (option `heavy_letter`). Other EU countries: +€1,50 per letter (option `eu`), delivery 5 to 10 working days after dispatch, no express. Address check: for a Belgian address we check that the postal code exists and matches the municipality. When in doubt the letter gets status `review` with `address_warning` and we approve or reject it (credit refunded). ## Integration recipe 1. Store the API key as a server-side secret (environment variable or your platform's secret store). Never put it in browser or mobile code; call Envelopify only from your backend. 2. Start with your key in test mode. Test letters return status `received`, are never printed and cost nothing. Activate live mode for the same key in the portal only after the flow works; the token prefix does not determine its mode. 3. Create the letter from your backend when the business event happens (invoice created, reminder due, signup...). Send an `Idempotency-Key` (e.g. the invoice number) so retries never create duplicates. 4. Store the returned `id`, `status`, `price_cents` and `post_date` next to your own record. 5. Optionally call `POST /price` first to show the price to the user before sending. 6. Register a webhook (`POST /webhooks`), store the `secret` as a secret, verify the `x-envelopify-signature` header on every request with HMAC-SHA256 over the raw body, and update the letter status in your database. Answer 2xx quickly. 7. Show errors to the user: on 4xx read `error.code` for logic and `error.message` (Dutch, user-friendly) for display. Handle `insufficient_balance` by asking the account owner to top up credit, and `429` by retrying after `Retry-After` seconds.