Skip to content
Rechnungskit
Live demo

Donation receipts via the API

Updated

This guide is for nonprofits that take donations on their own website through Stripe and want to show the donor their donation receipt (Zuwendungsbestätigung) afterwards. It covers both sides: how to mark the payment as a donation on Stripe, and how the website fetches the receipt from Rechnungskit. What the receipt contains and how the association sets it up is in the help page on donation receipts.

API · Donation receipts
From a donation on the website to the receipt in the donor's hands
Association website
Stripe Checkoutpayment_intent_data.metadata.rk_kind = donation
Rechnungskit
Donation bookedfrom the Stripe webhook, receipt archived and emailed
Website backend
GET /v1/donation-receipts?paymentRef=pi_…polls with backoff until the receipt exists
GET …/{number}/pdfpasses the PDF on to the donor
The API key stays in the backend. The donor's browser never talks to Rechnungskit directly.

Requirements

  • Stripe is connected in Rechnungskit (connecting Stripe). Rechnungskit learns about the payment from the Stripe webhook, not from your website.
  • Donation receipts are enabled for the account and fully set up under Settings → Donation receipts. Otherwise the API answers 403 donations_not_enabled, or the donation stays on receipt_pending.
  • An API key with the documents:read permission. You create it under Connections → API. This integration needs no other permission.

Rechnungskit (German interface), Connections → API, "Create key" form: name "Website Spendenformular" (website donation form), validity "Unbefristet (empfohlen für Produktion)" (no expiry, recommended for production), and under permissions only documents:read ("Belege abrufen", retrieve documents) is checked; orders:write, invoices:read and subscriptions:write are empty. At the bottom the note "Nur serverseitig verwenden" (server side only) and the "Schlüssel erstellen" (create key) button

Item Value
Base URL https://api.rechnungskit.de/v1
Authentication Authorization: Bearer rk_key_…
Permission documents:read
Format JSON, amounts in integer cents
Rate limit 300 requests per minute per key, short bursts up to 600. Every response carries RateLimit-Limit and RateLimit-Remaining; over the limit you get 429 rate_limited with Retry-After
Machine readable https://api.rechnungskit.de/v1/openapi.json

The full key is shown exactly once when you create it. It belongs in your server's environment variables, never in JavaScript that runs in the browser.

Marking a donation in Stripe Checkout

Rechnungskit only turns a payment into a donation when it is marked. For a one-off donation, the metadata sits on the PaymentIntent. In Checkout you set it through payment_intent_data.metadata:

Key Value Meaning
rk_kind donation or membership_fee Required. Donation or membership fee. The German values spende and mitgliedsbeitrag work too, and case doesn't matter. Rechnungskit turns no other value into a donation
rk_purpose free text Optional, for example a campaign. Shown as the purpose under Donations
rk_recurring true Optional, for recurring donations and sponsorships you charge without a Stripe subscription. For Stripe subscriptions Rechnungskit sets it itself

Add billing_address_collection: 'required'. Without an address there is no donation receipt.

Example with stripe-node on the website's server:

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

const session = await stripe.checkout.sessions.create({
  mode: 'payment',
  line_items: [
    {
      price_data: {
        currency: 'eur',
        unit_amount: 5000, // 50.00 EUR from your donation form, in cents
        product_data: { name: 'Spende Naturschutz' }
      },
      quantity: 1
    }
  ],
  billing_address_collection: 'required',
  customer_email: 'jonas.becker@example.org', // optional, from the form
  payment_intent_data: {
    metadata: { rk_kind: 'donation', rk_purpose: 'Streuobstwiese' }
  },
  success_url: 'https://verein.example/danke?session_id={CHECKOUT_SESSION_ID}',
  cancel_url: 'https://verein.example/spenden'
});
// Redirect the donor to session.url.

The same with curl:

curl https://api.stripe.com/v1/checkout/sessions \
  -u "$STRIPE_SECRET_KEY:" \
  -d mode=payment \
  -d "line_items[0][price_data][currency]=eur" \
  -d "line_items[0][price_data][unit_amount]=5000" \
  -d "line_items[0][price_data][product_data][name]=Spende Naturschutz" \
  -d "line_items[0][quantity]=1" \
  -d billing_address_collection=required \
  -d "payment_intent_data[metadata][rk_kind]=donation" \
  -d "payment_intent_data[metadata][rk_purpose]=Streuobstwiese" \
  --data-urlencode "success_url=https://verein.example/danke?session_id={CHECKOUT_SESSION_ID}" \
  -d cancel_url=https://verein.example/spenden

On the thank-you page, your server reads the session and uses the PaymentIntent ID as paymentRef:

const session = await stripe.checkout.sessions.retrieve(req.query.session_id);
const paymentRef = session.payment_intent; // "pi_…"

Other ways to take the payment

  • Stripe subscription (recurring donation, sponsorship, membership fee): mode: 'subscription' with the marker in subscription_data.metadata, for example { rk_kind: 'membership_fee' }. It can also sit on the price. Rechnungskit reads it from every paid Stripe invoice of the subscription and books each charge as a recurring donation.
  • Payment Links: through the API with payment_intent_data.metadata or subscription_data.metadata. Rechnungskit does not read metadata that sits only on the Payment Link or the Checkout Session.
  • Your own PaymentIntents (for example sponsorships charged off session without a Stripe subscription): set metadata directly on the PaymentIntent, and add rk_recurring: 'true' for recurring amounts.
  • A marker on the price only works for subscriptions. For one-off payments only the marker on the PaymentIntent counts.

Never mix a donation and a sale

A marked payment is a donation as a whole. So never put a donation and a sale (calendar, T-shirt, ticket) into the same Checkout Session. For subscriptions it depends on where the marker sits. On the subscription, every paid invoice of the subscription counts as a donation as a whole, including any sales line on it. On the price, an invoice only counts as a donation when every line carries the same marker; otherwise the payment goes through as a sale. Always split donations and sales into two subscriptions.

Test mode

While the Rechnungskit project is in test mode, you connect Stripe with test keys and pay with Stripe test cards. Rechnungskit creates test documents (TEST-ZB-…), and the same API key sees only those. After go-live it sees only real receipts, and you don't need to replace it (test mode).

Fetching the receipt

GET https://api.rechnungskit.de/v1/donation-receipts?paymentRef=pi_3QdemoA1LindentalSp01
Authorization: Bearer rk_key_…
Parameter In Required Meaning
paymentRef query yes The payment's ID at the payment provider; on Stripe the PaymentIntent (pi_…). The charge ID (ch_…) works too. At most 200 characters

Response 200 (real output with demo data):

{
  "paymentRef": "pi_3QdemoA1LindentalSp01",
  "donation": {
    "amountCents": 5000,
    "refundedCents": 0,
    "receivedOn": "2026-10-28"
  },
  "data": [
    {
      "number": "ZB-2026-0001",
      "kind": "single",
      "issueDate": "2026-11-09",
      "totalCents": 5000,
      "currency": "EUR",
      "donorName": "Jonas Becker",
      "withdrawsNumber": null,
      "withdrawn": false,
      "sha256": "a88c22710c51cacd381cadb93eda05960ceb051c66a86602a376c75b0c27643e",
      "test": false,
      "pdfPath": "/v1/donation-receipts/ZB-2026-0001/pdf"
    }
  ]
}
Field Meaning
paymentRef the payment ID you asked for
donation.amountCents donation amount in cents
donation.refundedCents amount refunded so far, in cents
donation.receivedOn date of the donation (YYYY-MM-DD)
data[] every receipt for this payment, including withdrawals, sorted by issue date and number
data[].number receipt number, ZB-YYYY-NNNN, in test mode TEST-ZB-YYYY-NNNN
data[].kind single (single receipt), collective (annual receipt) or withdrawal
data[].issueDate issue date (YYYY-MM-DD)
data[].totalCents confirmed amount. For an annual receipt the sum of all donations on it, for a withdrawal the refunded amount
data[].currency currency, EUR
data[].donorName donor name as printed on the receipt
data[].withdrawsNumber only for withdrawal: the number of the withdrawn receipt, otherwise null
data[].withdrawn true if a later withdrawal made this receipt invalid
data[].sha256 SHA-256 of the archived original
data[].test true for test documents
data[].pdfPath path to the PDF from the host: https://api.rechnungskit.de + pdfPath

The valid receipt is the entry whose kind is not withdrawal and whose withdrawn is false. There is at most one such entry per payment.

Status and error codes

Errors always come in the same format, with the request ID in the X-Request-Id header:

{
  "error": {
    "code": "receipt_pending",
    "message": "The donation is booked, its receipt is not issued yet (missing address, yearly mode or pending issue). Retry later.",
    "details": {
      "donation": { "amountCents": 3000, "refundedCents": 0, "receivedOn": "2026-11-08" }
    }
  },
  "requestId": "110a0117-1dc5-4fdc-a082-b601f988ac88"
}
Status error.code Meaning What your website does
200 donation and receipts show the valid receipt
400 invalid_request paymentRef is missing fix the call, don't retry
401 unauthorized key missing, invalid, expired or revoked check the key
403 insufficient_scope the key lacks documents:read; details lists the required and granted permissions create a new key with documents:read
403 donations_not_enabled donation receipts are not enabled for the account contact support@rechnungskit.de
404 not_found Rechnungskit knows no donation for this payment (yet) retry briefly with backoff, see below
404 receipt_pending donation booked, receipt not issued yet; details.donation shows amount and date show a note, ask again later
429 rate_limited rate limit exceeded wait for Retry-After
5xx temporary server error retry with backoff

not_found stays for good if the payment was not marked, ran in the other mode (test or live), or belongs to a different project.

When the receipt is there

There are no webhooks for donation receipts. Your website polls. When there is something to fetch depends on the case:

  • One-off donation in "Immediately, per payment" mode with an address: Rechnungskit books the donation and issues the receipt while it processes the Stripe webhook payment_intent.succeeded. The receipt is emailed to the donor at the same time.
  • Address missing: Rechnungskit asks Stripe for it. If that fails, the donation stays on receipt_pending until the association adds the address under Donations. A daily run at 06:15 (Berlin time) then issues whatever is due.
  • "Once a year as an annual receipt" mode: the donation stays on receipt_pending until after the year ends. Only when the association closes the year under Donations does the API return the annual receipt.

Recommended behavior on the thank-you page:

  1. Ask the first time after about 2 seconds, then with growing gaps (2, 4, 8, 16, 30 seconds), for about 2 minutes in total. During that time you get not_found while the Stripe webhook is still on its way.
  2. On 200, show the valid receipt and offer the PDF.
  3. On receipt_pending, stop asking at short intervals. Show a note such as "You'll get your donation receipt by email", and if you need to, ask again at most once a day.
  4. If you still get not_found after 2 minutes, show the same note. The receipt is emailed in any case once it is issued.
const BASE = 'https://api.rechnungskit.de/v1';

async function fetchReceipt(paymentRef) {
  const delays = [2000, 2000, 4000, 8000, 16000, 30000, 30000, 30000];
  for (const ms of delays) {
    await new Promise((r) => setTimeout(r, ms));
    const res = await fetch(
      `${BASE}/donation-receipts?paymentRef=${encodeURIComponent(paymentRef)}`,
      { headers: { Authorization: `Bearer ${process.env.RECHNUNGSKIT_API_KEY}` } }
    );
    if (res.status === 200) {
      const body = await res.json();
      return body.data.find((r) => r.kind !== 'withdrawal' && !r.withdrawn) ?? null;
    }
    const err = await res.json().catch(() => null);
    if (res.status === 404 && err?.error?.code === 'receipt_pending') return 'pending';
    if (res.status === 429) {
      await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 5) * 1000));
      continue;
    }
    if (res.status === 404 || res.status >= 500) continue; // webhook still on its way
    throw new Error(`Rechnungskit ${res.status} ${err?.error?.code}`);
  }
  return 'pending';
}

Refunds: withdrawal and replacement receipt

If the association refunds a confirmed donation on Stripe in full or in part, Rechnungskit withdraws the receipt with a separate, numbered letter and issues a new receipt for the remaining amount. The API then returns all three entries. Example: 150.00 EUR donated, 50.00 EUR refunded:

{
  "paymentRef": "pi_3QdemoA1LindentalSp02",
  "donation": { "amountCents": 15000, "refundedCents": 5000, "receivedOn": "2026-11-02" },
  "data": [
    { "number": "ZB-2026-0002", "kind": "single", "totalCents": 15000, "withdrawsNumber": null, "withdrawn": true, "pdfPath": "/v1/donation-receipts/ZB-2026-0002/pdf", "…": "…" },
    { "number": "ZB-2026-0006", "kind": "withdrawal", "totalCents": 5000, "withdrawsNumber": "ZB-2026-0002", "withdrawn": false, "pdfPath": "/v1/donation-receipts/ZB-2026-0006/pdf", "…": "…" },
    { "number": "ZB-2026-0007", "kind": "single", "totalCents": 10000, "withdrawsNumber": null, "withdrawn": false, "pdfPath": "/v1/donation-receipts/ZB-2026-0007/pdf", "…": "…" }
  ]
}

(Shortened: every entry has the same fields as above.) Only ZB-2026-0007 is valid. The withdrawal's totalCents is the refunded amount. If everything is refunded, there is only the receipt and the withdrawal, and no valid receipt is left. Rechnungskit does not email the withdrawal and the replacement receipt itself; the association sends them from Donations with "Send again". If donors have an account on your website, you can also show both there.

Annual receipt

In "Once a year as an annual receipt" mode, all donations a donor made in a calendar year go on one receipt with an annex. For each of those payments the API returns the same receipt with kind: "collective" and the yearly total in totalCents:

{
  "paymentRef": "pi_3QdemoA1LindentalPa01",
  "donation": { "amountCents": 1500, "refundedCents": 0, "receivedOn": "2025-10-15" },
  "data": [
    { "number": "ZB-2026-0005", "kind": "collective", "issueDate": "2026-11-09", "totalCents": 6000, "donorName": "Miriam Schulte", "withdrawn": false, "pdfPath": "/v1/donation-receipts/ZB-2026-0005/pdf", "…": "…" }
  ]
}

The number carries the year of issue, not the year of the donations. If you only confirm once a year, the thank-you page usually needs no polling; it can simply say "Your annual receipt arrives after the end of the year".

Downloading the PDF

curl -o ZB-2026-0001.pdf \
  -H "Authorization: Bearer $RECHNUNGSKIT_API_KEY" \
  https://api.rechnungskit.de/v1/donation-receipts/ZB-2026-0001/pdf

pdfPath from the response is the path from the host: https://api.rechnungskit.de + pdfPath. The number in the path is URL encoded.

Header Value
Content-Type application/pdf
Content-Disposition attachment; filename="ZB-2026-0001.pdf"
Cache-Control private, no-store
X-SHA256 SHA-256 of the archived original, same as the sha256 field
X-Request-Id request ID

The PDF is rendered fresh on every download from the archived original, after Rechnungskit has checked its checksum. So X-SHA256 belongs to the original, not to the PDF bytes: don't compare it with the downloaded PDF. Because of Cache-Control: no-store, your server downloads the PDF again when needed or stores it itself. An unknown number, a number from the other mode, or one from a different project returns 404 not_found. The endpoint also serves annual receipts and withdrawals.

Example of a route on the website that passes the PDF on to the donor:

app.get('/spende/bestaetigung/:number', async (req, res) => {
  // First check that this number belongs to the donor's payment
  // (for example through the stored paymentRef of their Checkout Session).
  const r = await fetch(
    `https://api.rechnungskit.de/v1/donation-receipts/${encodeURIComponent(req.params.number)}/pdf`,
    { headers: { Authorization: `Bearer ${process.env.RECHNUNGSKIT_API_KEY}` } }
  );
  if (!r.ok) return res.status(r.status === 404 ? 404 : 502).end();
  res.set('Content-Type', 'application/pdf');
  res.set('Content-Disposition', r.headers.get('content-disposition'));
  res.set('Cache-Control', 'private, no-store');
  res.send(Buffer.from(await r.arrayBuffer()));
});

Security

  • The API key lives only on the server. It can read every receipt of the project.
  • A receipt contains the donor's name and address. Only hand the PDF to the person who donated, for example by tying the paymentRef to their Checkout Session. Don't use guessable public links.
  • The tenant and project always come from the key, never from the request.

Where to find it in the app

Rechnungskit is not a tax advisory or law firm. This article explains general principles and does not replace advice from a tax advisor (Steuerberater, § 5 StBerG) or a lawyer (§ 3 RDG). Rechnungskit is built for businesses based in Germany and prepares documents, tax rates and bookings automatically. How your specific case is treated remains your decision, ideally together with your tax advisor or a lawyer.

de en