Metadata-Version: 2.4
Name: sendletter
Version: 1.1.0
Summary: Send real, physical letters by post. Print, frank and deliver across Europe, including registered mail with proof of posting.
License: MIT
Project-URL: Homepage, https://sendletter.eu
Project-URL: Documentation, https://sendletter.eu/en/developers
Keywords: letter,post,mail,printing,registered-mail,netherlands,europe
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.28
Dynamic: license-file

# sendletter

Send a real letter — printed, folded, franked and handed to the post — from
Python.

```bash
pip install sendletter
```

```python
from sendletter import SendLetter, Address

client = SendLetter("sk_live_...")

letter = client.send(
    sender=Address(
        company="Twilper",
        name="Gert Snijder",
        street="Merelstraat",
        number="64",
        postal_code="8916 AX",
        city="Leeuwarden",
        country="NL",
    ),
    recipient=Address(
        name="Jan de Vries",
        street="Keizersgracht",
        number="123",
        postal_code="1015 CJ",
        city="Amsterdam",
        country="NL",
    ),
    text="Beste heer De Vries,\n\nBijgaand de herinnering voor factuur 2026-0042.",
)

print(letter["id"], letter["status"], letter["totalCents"])
```

Get a key at [sendletter.eu](https://sendletter.eu/en/developers). Keys starting
`sk_test_` post nothing, charge nothing, and still run the whole status chain,
so you can build against the real thing.

`number` is separate from `street` on purpose: France prints it first, the
Netherlands last, and joining them here would make that undecidable.

## Sending

Give the content exactly one way: `text`, `file_base64` or `file_url`. Two is
refused before the request leaves, because the wrong document in a postbox
cannot be recalled.

```python
import base64

with open("invoice.pdf", "rb") as handle:
    pdf = base64.b64encode(handle.read()).decode()

client.send(
    sender=sender,
    recipient=recipient,
    file_base64=pdf,
    product="registered",          # "standard" | "priority" | "registered"
    idempotency_key=f"invoice-{invoice.id}",
)
```

**Pass `idempotency_key` on anything that can be retried.** A repeat with the
same key returns the original letter instead of a second envelope. That is what
makes a retry after a timeout safe, and a timeout tells you nothing about
whether the letter was accepted.

A dunning run is the shape this package exists for:

```python
for invoice in overdue_invoices():
    try:
        client.send(
            sender=us,
            recipient=Address(**invoice.customer_address),
            file_base64=render_reminder(invoice),
            idempotency_key=f"reminder-{invoice.id}-{invoice.reminder_count}",
        )
    except SendLetterError as error:
        if error.code == "insufficient_balance":
            raise                      # stop the run, top up, start again
        log.warning("skipped %s: %s", invoice.id, error)
```

## Reading

```python
client.get(letter_id)
client.list(status="posted")
client.cancel(letter_id, "order withdrawn")
client.download(letter_id)                  # the PDF as printed, as bytes
client.download(letter_id, proof=True)      # proof of posting

for letter in client.all(mode="live"):      # walks every page for you
    ...
```

## Invoices and credit notes

Every paid live letter receives its invoice number at payment time. Test
letters never consume the statutory series. A full refund keeps that invoice
and adds a separately numbered negative credit note.

```python
from pathlib import Path

invoice = client.list_invoices()["data"][0]
Path("invoice.pdf").write_bytes(client.download_invoice(invoice["id"]))

if invoice["creditNote"]:
    Path("credit-note.pdf").write_bytes(
        client.download_credit_note(invoice["creditNote"]["id"])
    )
```

## Checking before you spend

```python
check = client.validate_address(recipient)
if not check["valid"]:
    print(check["problems"])

quote = client.quote(destination="DE", pages=3)
```

`validate_address` answers rather than raises when the address is wrong: a bad
postcode is the successful outcome of asking, and raising would make a workflow
retry a permanent condition forever. `supported: false` is the one that cannot
be fixed by editing — we do not carry to that country.

## Errors

```python
from sendletter import SendLetterError

try:
    client.send(sender=sender, recipient=recipient, text=body)
except SendLetterError as error:
    if error.code == "insufficient_balance":
        notify(error.top_up_url)        # the wallet is short, nothing else is wrong
    elif error.is_retryable:
        time.sleep(error.retry_after or 5)
```

`is_retryable` covers 429 and 5xx. Nothing else should be retried: a 400 means
the letter will be refused just as firmly the second time.

## Webhooks

Status changes arrive as a POST to the URL you registered. **Verify them.**
Without that, anyone who learns your endpoint can tell your system a letter was
delivered.

```python
from flask import Flask, request
from sendletter import verify_webhook, SendLetterError

app = Flask(__name__)

@app.post("/webhooks/sendletter")
def sendletter_webhook():
    try:
        event = verify_webhook(
            request.get_data(),                              # raw bytes
            request.headers.get("X-SendLetter-Signature"),
            os.environ["SENDLETTER_WEBHOOK_SECRET"],
        )
    except SendLetterError:
        return "", 400

    if already_handled(event["id"]):
        return "", 200

    handle(event)          # letter.posted, letter.delivered, ...
    return "", 200
```

Two things this gets right that hand-rolled verification usually does not:

- **The raw body, not a re-serialised dict.** The signature covers the exact
  bytes we sent, and `json.dumps` does not promise to reproduce them. Verify
  first, and use the dict `verify_webhook` returns.
- **Deduplicate on `event["id"]`.** Delivery is at-least-once, so a timeout on
  your side means the same event arrives again. `letter.posted` handled twice
  should not bill a customer twice.

Comparison is constant time via `hmac.compare_digest`, and an event older than
five minutes is refused even when correctly signed, so a captured request
cannot be replayed later. Pass `tolerance_seconds=0` to switch that off if you
queue callbacks and verify them long after arrival.

Events: `letter.submitted`, `letter.printed`, `letter.posted`,
`letter.delivered`, `letter.failed`, `letter.refunded`.

## Test mode

A `sk_test_` key runs the full chain — `submitted → printed → posted →
delivered` — with webhooks firing exactly as in production, touching no wallet
and no printer. `client.is_test_mode` says which kind of key you are holding,
which is worth asserting at start-up: the two look alike in a log and only one
of them costs money.

## Reference

[sendletter.eu/en/developers](https://sendletter.eu/en/developers) ·
OpenAPI at `/api/v1/openapi.json` · Requires Python 3.9+ and `requests` · MIT
