Dokumentacja API

Produkcja — prawdziwe płatności https://chrupkie.vercel.app/api/shop
Sandbox — do developmentu https://chrupkie-test.vercel.app/api/shop

Klucz API

Każde żądanie wymaga klucza w nagłówku. Działają dwa warianty — wybierz jeden:

x-api-key: TWOJ_KLUCZ
// albo
Authorization: Bearer TWOJ_KLUCZ

Klucz wydajemy ręcznie — napisz po niego na [e-mail]. Dostaniesz dwa: osobny do sandboxa i osobny do produkcji.

Klucz musi pasować do środowiska

Klucz produkcyjny nie zadziała na sandboxie i odwrotnie — dostaniesz 401. To najczęstsza pomyłka przy podpinaniu: ktoś podmienia klucz, ale zostawia stary URL.

Klucz jest sekretem serwerowym. Nie wołaj tego API z przeglądarki — nagłówki CORS są celowo niewystawione, żeby klucz nie trafił do frontendu. Integracja idzie z Twojego backendu.

Konwencje

ZasadaSzczegół
KwotyWszystko w groszach (1099 = 10,99 zł). Pola *_pln są tylko do wyświetlania — licz na *_grosze.
CenyLiczy je nasz serwer z katalogu. W żądaniu mówisz co i ile, nigdy za ile.
DostawaDoliczana automatycznie: 9,99 zł. Nie przekazujesz jej w pozycjach.
FormatJSON w obie strony. Przy POST ustaw Content-Type: application/json.

Katalog

Trzy endpointy tylko do odczytu — nic nie zmieniają, możesz cache'ować.

GET/api/shopklucz opcjonalny

Manifest ze spisem endpointów. Jedyny, który odpowiada 200 także bez klucza — użyj go jako health-checku. Pole authenticated mówi, czy Twój klucz jest poprawny.

{
  "ok": true,
  "shop": "Chrupkie",
  "api_version": 1,
  "currency": "PLN",
  "authenticated": true,        // false = zły albo brak klucza
  "counts": { "products": 12, "categories": 3, "deliveries": 1 },
  "endpoints": { "products": { "method": "GET", "path": "/api/shop/products" }, ... }
}
GET/api/shop/products

Nasze produkty. Każdy ma gotowe pole order_item — wklej je wprost do items przy zamówieniu, najwyżej zmieniając qty.

{
  "currency": "PLN",
  "products": [
    {
      "id": "flavor-pistacjowe",
      "category": "smaki",
      "type": "single",
      "name": "Pistacjowe",
      "unit_price_grosze": 1200,
      "quantity_tiers": [                        // rabat ilościowy
        { "min_qty": 1,  "unit_grosze": 1200 },
        { "min_qty": 12, "unit_grosze": 1100 },
        { "min_qty": 24, "unit_grosze": 1000 }
      ],
      "order_item": { "kind": "single", "flavor": "Pistacjowe", "qty": 1 }
    },
    {
      "id": "box-6", "category": "pudelka", "type": "box",
      "name": "Szóstka (6 ciastek)", "count": 6, "price_grosze": 6200,
      "order_item": { "kind": "box", "box": "6", "flavors": { "Pistacjowe": 6 } }
    },
    {
      "id": "set-prezent6", "category": "zestawy", "type": "set",
      "name": "Pudełko prezentowe", "count": 6, "price_grosze": 7400,
      "order_item": { "kind": "set", "id": "prezent6" }
    }
  ]
}
Nie hardkoduj smaków

Smaki zmieniamy co tydzień — do zamówienia dostępne są tylko te, które akurat zwraca /products. Produkt flavor-test ma flagę "test": true i stałą cenę 3 zł; służy do sprawdzania płatności, więc odfiltruj go z listy dla klientów.

GET/api/shop/categories
{ "categories": [
  { "id": "smaki",   "name": "Smaki (pojedyncze ciastka)",  "product_count": 4 },
  { "id": "pudelka", "name": "Pudełka (własna kompozycja)", "product_count": 3 },
  { "id": "zestawy", "name": "Zestawy gotowe",              "product_count": 5 }
] }
GET/api/shop/deliveries

Na razie wozimy jedną metodą. Opłatę doliczamy sami do każdego zamówienia.

{ "currency": "PLN", "deliveries": [
  { "id": "standard", "name": "Dostawa pod drzwi", "fee_grosze": 999, "fee_pln": 9.99 }
] }

Pozycje zamówienia

Trzy kształty pozycji. Bierz je z order_item zamiast składać ręcznie.

RodzajKształtJak wyceniamy
single {"kind":"single","flavor":"Pistacjowe","qty":12} Cena za sztukę wg progów: 12 zł, od 12 szt. 11 zł, od 24 szt. 10 zł. Smak "Test" zawsze 3 zł.
box {"kind":"box","box":"6","flavors":{"Pistacjowe":4,"Matcha z białą czekoladą":2}} Stała cena pudełka (4 → 44 zł, 6 → 62 zł, 12 → 116 zł). flavors to sam skład — nie wpływa na cenę.
set {"kind":"set","id":"prezent6"} Stała cena zestawu. Id: prezent6, tydzien6, impreza24, degustacja12, firmowy50.

Limity: 1–50 pozycji w koszyku, qty od 1 do 200. Nazwy smaków i id zestawów muszą zgadzać się z /products — inaczej 400.

Złożenie zamówienia

POST/api/shop/orders

Wyceniamy koszyk, tworzymy zamówienie i zwracamy checkout_url — link do płatności, pod który przekierowujesz klienta.

curl -X POST https://chrupkie.vercel.app/api/shop/orders \
  -H "x-api-key: TWOJ_KLUCZ" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [{ "kind": "single", "flavor": "Pistacjowe", "qty": 6 }],
    "delivery": {
      "name":   "Jan Kowalski",       // te 5 pól jest wymagane
      "phone":  "600700800",
      "street": "Piotrkowska 100/5",
      "zip":    "90-001",
      "city":   "Łódź",
      "notes":  "kod do bramy 1234"    // opcjonalne
    },
    "code": "FRIENDSANDFAMILY",        // opcjonalne
    "dry_run": false,                  // opcjonalne
    "return_url": "https://twoj-system.pl/koszyk"  // opcjonalne
  }'

return_url — powrót kupującego do Ciebie

Domyślnie po płatności odsyłamy klienta na naszą stronę. Podaj return_url, a wrócimy go do Ciebie — doklejamy do niego parametry:

// po udanej płatności
https://twoj-system.pl/koszyk?status=sukces&session_id=cs_live_...

// po anulowaniu
https://twoj-system.pl/koszyk?status=anulowano&order_ref=ord_...
Twój host musimy najpierw dopuścić

Przyjmujemy tylko https i tylko hosty z naszej allowlisty — inaczej byłby to open redirect na ścieżce płatniczej. Podaj nam swój host przy wydaniu klucza, dopiszemy go. Niedopuszczony adres zwraca 400 z return_url_host_not_allowed i listą dozwolonych hostów w polu allowed_hosts.

Odpowiedź 201

{
  "order_ref": "ord_d3830dbd996922b6",
  "order_id": null,
  "status": "oczekuje_na_platnosc",
  "currency": "PLN",
  "items_total_grosze": 7200,
  "delivery_grosze": 999,
  "subtotal_grosze": 8199,          // przed rabatem
  "promo_code": "FRIENDSANDFAMILY",
  "promo_label": "Darmowa dostawa",
  "discount_grosze": 999,
  "total_grosze": 7200,             // do zapłaty
  "total_pln": 72,
  "line_items": [
    { "name": "Pistacjowe", "qty": 6, "unit_grosze": 1200 },
    { "name": "Dostawa",    "qty": 1, "unit_grosze": 999 }
  ],
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_...",
  "stripe_session_id": "cs_live_..."
}
Zapisz stripe_session_id

To jedyny identyfikator, po którym odpytasz status. order_ref pokazuj człowiekowi, ale nie da się po nim szukać. order_id jest na razie zawsze null — nie buduj na nim niczego.

dry_run — wycena bez zobowiązań

Z "dry_run": true dostajesz te same sumy i rabat, ale bez tworzenia płatności. Odpowiedź 200 z {"dry_run": true, ...}, bez checkout_url. Używaj do pokazania koszyka i sprawdzenia kodu rabatowego — nie zaśmiecasz wtedy Stripe'a sesjami, których nikt nie opłaci. Działa też na sandboxie, zanim wpniemy tam płatności.

Status zamówienia

GET/api/shop/orders?session=cs_live_...

Status bierzemy prosto ze Stripe'a, więc jest zawsze aktualny.

{
  "order_ref": "ord_fd2240feb5566f5a",
  "stripe_session_id": "cs_live_...",
  "status": "oczekuje_na_platnosc",
  "payment_status": "unpaid",
  "amount_total_grosze": 1099,
  "amount_total_pln": 10.99,
  "currency": "PLN",
  "delivery": { "name": "Jan Kowalski", "phone": "600700800", ... },
  "created": "2026-07-10T17:41:48.000Z"
}
statusZnaczy
oczekuje_na_platnoscLink wygenerowany, klient jeszcze nie zapłacił.
oplaconeZapłacone — pieczemy.
wygasloSesja płatności wygasła (po 24 h). Złóż zamówienie jeszcze raz.

Do wykrywania płatności lepszy jest webhook niż odpytywanie w pętli — ale ten endpoint zawsze zadziała jako zapasowy.

Kody rabatowe

Przekazujesz code przy zamówieniu. Wysokość rabatu wyliczamy my — Twoja strona jej nie ustala.

KodEfektRabat
FRIENDSANDFAMILYDarmowa dostawa−9,99 zł
KRUCHESLODKOSCIProcent od całości−10%

Kody nie mają limitu użyć ani historii wykorzystań — nie buduj na nich logiki „jednorazowego kuponu".

Przepływ zamówienia

  1. Pokaż katalogPobierz /products i zbuduj koszyk z pól order_item.
  2. Wyceń koszykPOST /orders z dry_run: true — pokaż klientowi sumy i rabat.
  3. Złóż zamówienieTen sam POST bez dry_run. Zapisz u siebie stripe_session_id.
  4. Przekieruj do płatnościWyślij klienta pod checkout_url. Kartę obsługuje Stripe — nie dotykasz danych płatniczych.
  5. Odbierz potwierdzenieWebhook order.paid albo GET /orders?session=…. Maila do klienta wysyłamy sami.

Webhook — potwierdzenie płatności

Po opłaceniu wysyłamy POST na Twój adres. Odbiornik implementujesz u siebie, adres podajesz nam przy wydaniu klucza.

POST https://twoj-system.pl/webhooks/chrupkie
Content-Type: application/json
x-chrupkie-signature: sha256=<HMAC>

{
  "event": "order.paid",
  "order_ref": "ord_d3830dbd996922b6",
  "stripe_session_id": "cs_live_...",
  "status": "oplacone",
  "amount_total_grosze": 1099,
  "currency": "PLN",
  "delivery": { "name": "...", "phone": "...", "street": "...", "zip": "...", "city": "...", "notes": "..." },
  "paid_at": "2026-07-10T18:02:11.000Z"
}

Zweryfikuj podpis

Podpis to HMAC-SHA256 z surowego body, kluczem jest Twój klucz API. Licz go przed parsowaniem JSON-a — przeparsowanie i ponowne złożenie zmienia bajty, więc podpis się nie zgodzi.

const crypto = require("crypto");

app.post("/webhooks/chrupkie",
  express.raw({ type: "application/json" }),   // surowe bajty, nie express.json()
  (req, res) => {
    const expected = "sha256=" + crypto
      .createHmac("sha256", process.env.CHRUPKIE_API_KEY)
      .update(req.body)
      .digest("hex");

    const a = Buffer.from(req.headers["x-chrupkie-signature"] || "");
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end();            // nie ufaj payloadowi
    }

    const order = JSON.parse(req.body);
    // ...zapisz u siebie...
    res.status(200).end();                     // 2xx = przyjęte
  });
Odpowiadaj 2xx

Każda inna odpowiedź (albo timeout) uruchomi ponowienie — dzięki temu potwierdzenie dojdzie mimo chwilowej awarii u Ciebie. Obsłuż powtórki: ten sam stripe_session_id może przyjść więcej niż raz, więc zapisuj idempotentnie.

Błędy

Zawsze JSON w formacie {"error": "kod", ...}. Kody są stabilne — możesz na nich budować logikę.

HTTPerrorCo zrobić
400invalid_cartKoszyk pusty albo ponad 50 pozycji.
400invalid_qtyqty poza zakresem 1–200.
400invalid_box · invalid_set · invalid_kindNieznane id albo kind. Weź kształt z /products.
400missing_delivery_fieldsBrakujące pola wypisujemy w fields.
400invalid_promoPowód w reason: not_found, expired, min_order, no_discount, empty.
400missing_or_invalid_sessionsession musi mieć postać cs_live_… / cs_test_….
401unauthorizedZły albo brakujący klucz. Sprawdź, czy pasuje do środowiska.
404order_not_foundNie znamy takiej sesji.
405method_not_allowedZła metoda HTTP.
502stripe_errorOperator płatności odmówił. Powód jest w message — przeczytaj go, zanim zaczniesz zgadywać.
503api_not_configuredNie mamy ustawionego klucza po naszej stronie. Napisz do nas.
503stripe_not_configuredPłatności nieskonfigurowane. Na sandboxie to stan normalny — patrz niżej.

Pułapki

Rzeczy, na których stracisz godzinę, jeśli ich tu nie przeczytasz.

Minimum 2,00 zł

Stripe odrzuca płatności w PLN poniżej 2,00 zł: „The Checkout Session's total amount due must add up to at least 2.00 zł PLN". Dostaniesz wtedy 502 z tym komunikatem w polu message.

Normalnie nieosiągalne (sama dostawa to 9,99 zł), ale łatwo w to wpaść, gdy kod rabatowy zbije kwotę prawie do zera. Najtańsze zamówienie, jakie przejdzie: 1× Test (3 zł) + FRIENDSANDFAMILY = równo 3,00 zł. Przy dry_run tego limitu nie zobaczysz, bo tam Stripe w ogóle nie jest wołany.

Sandbox nie przyjmuje jeszcze płatności

POST /orders na sandboxie zwraca 503 stripe_not_configured, dopóki nie wepniemy tam klucza testowego. Katalog, kody rabatowe i dry_run działają już teraz — możesz spokojnie zbudować całą logikę koszyka.

Czego tu nie ma

Listy zamówień ani szukania po order_ref — trzymaj stripe_session_id u siebie. Anulowania i zwrotów przez API — na razie robimy je ręcznie. Stanów magazynowych — katalog nie mówi o dostępności.