Dokumentacja API
REST API v1 · wszystkie kwoty w groszach · waluta PLN
Podłącz nasze ciastka do swojego sklepu, aplikacji albo agenta AI: pobierasz katalog, składasz zamówienie i dostajesz gotowy link do płatności. Ceny liczy nasz serwer — Ty nigdy nie podajesz kwoty.
https://chrupkie.vercel.app/api/shop
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 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
| Zasada | Szczegół |
|---|---|
| Kwoty | Wszystko w groszach (1099 = 10,99 zł). Pola *_pln są tylko do wyświetlania — licz na *_grosze. |
| Ceny | Liczy je nasz serwer z katalogu. W żądaniu mówisz co i ile, nigdy za ile. |
| Dostawa | Doliczana automatycznie: 9,99 zł. Nie przekazujesz jej w pozycjach. |
| Format | JSON w obie strony. Przy POST ustaw Content-Type: application/json. |
Katalog
Trzy endpointy tylko do odczytu — nic nie zmieniają, możesz cache'ować.
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" }, ... }
}
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" }
}
]
}
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.
{ "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 }
] }
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.
| Rodzaj | Kształt | Jak 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
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_...
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_..."
}
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
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"
}
| status | Znaczy |
|---|---|
oczekuje_na_platnosc | Link wygenerowany, klient jeszcze nie zapłacił. |
oplacone | Zapłacone — pieczemy. |
wygaslo | Sesja 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.
| Kod | Efekt | Rabat |
|---|---|---|
FRIENDSANDFAMILY | Darmowa dostawa | −9,99 zł |
KRUCHESLODKOSCI | Procent od całości | −10% |
- Wielkość liter i spacje nie mają znaczenia —
" friendsandfamily "przejdzie. - Podstawa rabatu to ciastka + dostawa, nie same ciastka.
- Rabat nigdy nie przekroczy kwoty zamówienia.
- Zły kod =
400z{"error":"invalid_promo","reason":"not_found"}. Sprawdzisz go wcześniej przezdry_run.
Kody nie mają limitu użyć ani historii wykorzystań — nie buduj na nich logiki „jednorazowego kuponu".
Przepływ zamówienia
- Pokaż katalogPobierz
/productsi zbuduj koszyk z pólorder_item. - Wyceń koszyk
POST /orderszdry_run: true— pokaż klientowi sumy i rabat. - Złóż zamówienieTen sam
POSTbezdry_run. Zapisz u siebiestripe_session_id. - Przekieruj do płatnościWyślij klienta pod
checkout_url. Kartę obsługuje Stripe — nie dotykasz danych płatniczych. - Odbierz potwierdzenieWebhook
order.paidalboGET /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
});
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ę.
| HTTP | error | Co zrobić |
|---|---|---|
| 400 | invalid_cart | Koszyk pusty albo ponad 50 pozycji. |
| 400 | invalid_qty | qty poza zakresem 1–200. |
| 400 | invalid_box · invalid_set · invalid_kind | Nieznane id albo kind. Weź kształt z /products. |
| 400 | missing_delivery_fields | Brakujące pola wypisujemy w fields. |
| 400 | invalid_promo | Powód w reason: not_found, expired, min_order, no_discount, empty. |
| 400 | missing_or_invalid_session | session musi mieć postać cs_live_… / cs_test_…. |
| 401 | unauthorized | Zły albo brakujący klucz. Sprawdź, czy pasuje do środowiska. |
| 404 | order_not_found | Nie znamy takiej sesji. |
| 405 | method_not_allowed | Zła metoda HTTP. |
| 502 | stripe_error | Operator płatności odmówił. Powód jest w message — przeczytaj go, zanim zaczniesz zgadywać. |
| 503 | api_not_configured | Nie mamy ustawionego klucza po naszej stronie. Napisz do nas. |
| 503 | stripe_not_configured | Pł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.
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.
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.
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.