FMF — API Contracts
Document: API-CONTRACTS.md App: Find My Flowers (FMF) Version: 1.2 (MVP)
This document defines the REST API endpoints, WebSocket events, and request/response schemas that the FMF mobile app consumes from the Flower Shop IoT Platform backend.
Base URL
| Environment | URL |
|---|---|
| Development | http://localhost:8080 |
| Staging | https://api-staging.bloomplatform.pl |
| Production | https://api.bloomplatform.pl |
All REST endpoints are prefixed with /api. Dates use ISO 8601 UTC format. Monetary amounts are decimals with 2 fractional digits.
Authentication
All authenticated endpoints require a Bearer token in the Authorization header:
Authorization: Bearer <access_token>
Tokens are issued by Keycloak. FMF uses OIDC Authorization Code + PKCE flow.
Keycloak OIDC Endpoints
| Purpose | URL |
|---|---|
| Discovery | {keycloakBase}/realms/flowershop/.well-known/openid-configuration |
| Authorization | {keycloakBase}/realms/flowershop/protocol/openid-connect/auth |
| Token | {keycloakBase}/realms/flowershop/protocol/openid-connect/token |
| End Session | {keycloakBase}/realms/flowershop/protocol/openid-connect/logout |
| UserInfo | {keycloakBase}/realms/flowershop/protocol/openid-connect/userinfo |
| Environment | Keycloak Base URL |
|---|---|
| Development | http://localhost:8090 |
| Staging | https://auth-staging.bloomplatform.pl |
| Production | https://auth.bloomplatform.pl |
OIDC Client Configuration
| Parameter | Value |
|---|---|
client_id |
fmf-mobile |
response_type |
code |
scope |
openid profile email offline_access |
code_challenge_method |
S256 |
redirect_uri |
pl.bloomplatform.fmf://auth/callback |
post_logout_redirect_uri |
pl.bloomplatform.fmf://auth/logout-callback |
Google OAuth (AUTH-03): Google social login is live as an Identity Provider in the flowershop Keycloak realm (committed export: docker/keycloak/flowershop-realm.json). The mobile app uses the standard OIDC Authorization Code + PKCE flow — Keycloak renders the Google login button on its hosted login page, so no additional client-side Google SDK integration or app code change is required. Google emails are trusted as verified, so no separate email-verification step applies, and a Google login is auto-linked to any existing account with the same verified email. On the first authenticated API call, the backend provisions the local Customer and the customer_id claim just-in-time (CustomerProvisioningService), so all CustomerAccess endpoints work immediately without a re-login.
Token Lifecycle
| Token | Lifetime | Storage |
|---|---|---|
| Access token | 5 minutes | In-memory only |
| Refresh token | 30 days | Platform secure storage (iOS Keychain / Android Keystore) |
| ID token | 5 minutes | In-memory only |
Silent Renewal: Use the refresh token to obtain a new access token before expiry. The mobile app should proactively refresh when the access token has < 30 seconds remaining.
Token Refresh Request:
POST {keycloakBase}/realms/flowershop/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&client_id=fmf-mobile
&refresh_token=<refresh_token>
JWT Access Token Claims
{
"sub": "a1b2c3d4-...",
"preferred_username": "jan.kowalski",
"email": "jan@example.com",
"email_verified": true,
"given_name": "Jan",
"family_name": "Kowalski",
"realm_access": {
"roles": ["User"]
},
"customer_id": "cust-123",
"iss": "{keycloakBase}/realms/flowershop",
"exp": 1712745900,
"iat": 1712745600
}
Sign-Out Sequence
The mobile app must perform the following steps on sign-out (PRF-07):
- Unregister device —
DELETE /api/notifications/devicewith the current FCM/APNs token. - End Keycloak session — redirect to Keycloak End Session endpoint with
id_token_hintandpost_logout_redirect_uri. - Clear local state — remove access token, refresh token, and ID token from memory and secure storage. Clear any cached data.
GET {keycloakBase}/realms/flowershop/protocol/openid-connect/logout
?id_token_hint=<id_token>
&post_logout_redirect_uri=pl.bloomplatform.fmf://auth/logout-callback
1. Auth & Registration Endpoints
POST /api/auth/register
Creates a new customer account. The account is created in Keycloak, which sends a verification email. The user can log in immediately but will see a reminder banner until email is verified.
Auth: None
Request Body:
{
"email": "jan@example.com",
"password": "SecureP@ss1",
"firstName": "Jan",
"lastName": "Kowalski",
"acceptedTermsVersion": "1.0",
"acceptedPrivacyPolicyVersion": "1.0"
}
Validation Rules:
| Field | Rules |
|---|---|
email |
Valid email, unique, max 255 chars |
password |
Min 8 chars, at least 1 uppercase, 1 lowercase, 1 digit, 1 special char |
firstName |
Required, 1–100 chars |
lastName |
Required, 1–100 chars |
acceptedTermsVersion |
Required |
acceptedPrivacyPolicyVersion |
Required |
Response 201:
{
"userId": "a1b2c3d4-...",
"email": "jan@example.com",
"emailVerified": false,
"message": "Account created. A verification email has been sent."
}
Response 409: Email already registered.
Email Verification: Handled entirely by Keycloak. The verification link in the email points to Keycloak, which marks the account as verified. The mobile app checks email_verified in the JWT claims and displays a reminder banner if false. No custom verify endpoint is needed.
POST /api/auth/resend-verification
Triggers Keycloak to resend the email verification link. Rate-limited to prevent abuse.
Auth: Required (user must be logged in but unverified)
Response 202: Verification email sent (always returned; does not reveal internal state). Response 429: Rate limited — max 1 request per 60 seconds.
POST /api/auth/password-reset/request
Initiates a password reset flow. Keycloak sends the reset email.
Auth: None
Request Body:
{
"email": "jan@example.com"
}
Response 202: Always returned (does not reveal whether email exists).
POST /api/auth/password-reset/confirm
Completes the password reset using the token from the email link.
Auth: None
Request Body:
{
"token": "reset-token-from-email",
"newPassword": "NewSecureP@ss2"
}
Response 200: Password updated. Response 400: Token invalid or expired.
2. App Configuration Endpoint
GET /api/config
Returns environment-specific configuration the mobile app needs at startup. Called once on app launch and cached for the session.
Auth: None
Response 200:
{
"stripePublishableKey": "pk_live_...",
"mapTileProvider": "google",
"supportEmail": "support@bloomplatform.pl",
"termsUrl": "https://bloomplatform.pl/terms",
"privacyPolicyUrl": "https://bloomplatform.pl/privacy",
"minAppVersion": "1.0.0"
}
| Field | Description |
|---|---|
stripePublishableKey |
Stripe publishable key for PaymentSheet initialization |
mapTileProvider |
Map provider hint (reserved for future use) |
supportEmail |
Customer support contact |
termsUrl |
Link to current Terms of Service |
privacyPolicyUrl |
Link to current Privacy Policy |
minAppVersion |
Minimum supported app version; app should show a force-update prompt if current version is lower |
3. Bouquet Endpoints
Bouquet Status Values
All bouquet-related endpoints use the following status field values:
| Status | Display Label | Description |
|---|---|---|
available |
Available | Bouquet is available for purchase |
reserved |
Reserved | Bouquet is reserved during an active checkout (up to 10 minutes) |
sold |
Sold | Bouquet has been purchased |
expired |
No Longer Available | Bouquet was removed by vendor or freshness dropped below threshold |
vase_offline |
Temporarily Unavailable | The smart vase went offline; bouquet may return |
GET /api/bouquets/nearby
Returns bouquets available within a radius of the given location.
Auth: Optional (unauthenticated users can browse)
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
lat |
double | Yes | Customer latitude |
lng |
double | Yes | Customer longitude |
radiusKm |
int | Yes | Search radius: 5, 10, or 15 |
vendorType |
string | No | Filter: shop, freelance, or omit for all |
search |
string | No | Filter by vendor name or neighbourhood |
page |
int | No | Page number (default: 1) |
pageSize |
int | No | Items per page (default: 20, max: 50) |
Response 200:
{
"items": [
{
"bouquetId": "b3f1a2c4-...",
"vaseId": "v-001",
"vendorId": "vend-42",
"vendorName": "Flora Elegance",
"vendorType": "shop",
"price": 89.00,
"currency": "PLN",
"status": "available",
"freshnessScore": 84,
"freshnessLabel": "Very Fresh",
"thumbnailUrl": "https://cdn.bloomplatform.pl/bouquets/b3f1a2c4/thumb.jpg",
"location": {
"latitude": 52.2297,
"longitude": 21.0122,
"displayAddress": "Śródmieście, Warsaw",
"exactAddressHidden": false
},
"distanceKm": 0.8,
"availableFrom": "2025-04-10T09:15:00Z",
"isFavourite": false
}
],
"totalCount": 42,
"page": 1,
"pageSize": 20
}
Notes:
isFavouriteis alwaysfalsefor unauthenticated requests.exactAddressHiddenistruefor freelance florists; coordinates are approximate within ~500 m (SEC-06).
Response 400: Invalid coordinates or radius value.
Geocoding Note (DISC-09): This endpoint requires numeric lat/lng coordinates. When the user denies location permission and enters an address manually, the mobile app must geocode the address client-side using the Google Places SDK (the same SDK used for delivery address autocomplete in CHK-04) before calling this endpoint. The backend does not provide a geocoding endpoint.
GET /api/bouquets/{id}
Returns full detail for a single bouquet.
Auth: Optional
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
lat |
double | No | Customer latitude (used to compute distanceKm) |
lng |
double | No | Customer longitude (used to compute distanceKm) |
Response 200:
{
"bouquetId": "b3f1a2c4-...",
"vaseId": "v-001",
"vendorId": "vend-42",
"vendorName": "Flora Elegance",
"vendorType": "shop",
"vendorOpeningHours": "Mon-Sat 09:00-20:00, Sun 10:00-16:00",
"isVendorCurrentlyOpen": true,
"price": 89.00,
"currency": "PLN",
"status": "available",
"freshnessScore": 84,
"freshnessLabel": "Very Fresh",
"imageUrl": "https://cdn.bloomplatform.pl/bouquets/b3f1a2c4/full.jpg",
"aiTags": [
{ "type": "rose", "label": "Roses", "count": 8 },
{ "type": "tulip", "label": "Tulips", "count": 5 },
{ "type": "eucalyptus", "label": "Eucalyptus", "count": null }
],
"location": {
"latitude": 52.2297,
"longitude": 21.0122,
"displayAddress": "Śródmieście, Warsaw",
"exactAddressHidden": false
},
"distanceKm": 0.8,
"availableFrom": "2025-04-10T09:15:00Z",
"isFavourite": false
}
Notes:
exactAddressHidden: truefor freelance florists — location is approximate within ~500 m (SEC-06).isFavouriteisfalsefor unauthenticated requests.distanceKmis included only whenlatandlngquery params are provided; omitted otherwise.isVendorCurrentlyOpenis computed server-side fromvendorOpeningHoursand the server’s current time. Returnsnullif opening hours are not configured.
Response 404: Bouquet not found or no longer available.
GET /api/bouquets/qr/{code}
Resolves a bouquet from a scanned QR code.
Auth: Optional (unauthenticated users can scan, prompted to login before purchase)
Path Parameters: code — 8-character alphanumeric short code (e.g., A3F8K2M1) or full UUID. Both formats are accepted.
QR codes displayed on smart vases encode the URL https://fmf.bloomplatform.pl/b/{code}. The mobile app intercepts this deep link and extracts {code} to call this endpoint.
Response 200: Same schema as GET /api/bouquets/{id}
Response 404: Code not found or expired.
Response 410: Bouquet was available but is now sold/reserved.
Image Dimensions
| Image field | Resolution | Aspect ratio | Typical size |
|---|---|---|---|
thumbnailUrl |
400 × 400 px | 1:1 (square) | 15–30 KB |
imageUrl |
1200 × 900 px | 4:3 | 80–200 KB |
All images are served via CDN with Cache-Control: public, max-age=604800 (7 days). The mobile app should use these dimensions for placeholder/skeleton sizing before the image loads.
4. Favourites Endpoints
GET /api/favourites
Returns all favourited bouquets for the authenticated customer.
Auth: Required
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
lat |
double | No | Customer latitude (used to compute distanceKm) |
lng |
double | No | Customer longitude (used to compute distanceKm) |
page |
int | No | Page number (default: 1) |
pageSize |
int | No | Items per page (default: 20, max: 50) |
Response 200:
{
"items": [
{
"bouquetId": "b3f1a2c4-...",
"vendorId": "vend-42",
"vendorName": "Flora Elegance",
"vendorType": "shop",
"price": 89.00,
"currency": "PLN",
"status": "available",
"freshnessScore": 84,
"freshnessLabel": "Very Fresh",
"thumbnailUrl": "https://cdn.bloomplatform.pl/bouquets/b3f1a2c4/thumb.jpg",
"location": {
"latitude": 52.2297,
"longitude": 21.0122,
"displayAddress": "Śródmieście, Warsaw",
"exactAddressHidden": false
},
"distanceKm": 0.8,
"availableFrom": "2025-04-10T09:15:00Z",
"savedAt": "2025-04-09T14:00:00Z"
}
],
"totalCount": 3,
"page": 1,
"pageSize": 20
}
Notes:
distanceKmis included only whenlatandlngquery params are provided.- Bouquets that are no longer available still appear in favourites with their last known
status(e.g.,sold,expired). The app should dim or badge unavailable favourites.
POST /api/favourites/{bouquetId}
Adds a bouquet to the customer’s favourites.
Auth: Required Response 201: No content. Response 409: Already favourited.
DELETE /api/favourites/{bouquetId}
Removes a bouquet from the customer’s favourites.
Auth: Required Response 204: No content.
5. Order Endpoints
POST /api/orders/preview
Returns cost breakdown for a potential order without creating the order or reserving the bouquet. Used to display the order summary screen (CHK-06) before the customer commits.
Auth: Required Idempotent: Yes — safe to call multiple times.
Request Body (delivery):
{
"bouquetIds": ["b3f1a2c4-..."],
"deliveryMethod": "delivery",
"street": "ul. Marszałkowska 10",
"city": "Warsaw",
"postalCode": "00-001",
"notes": "3rd floor, flat 12"
}
Request Body (pickup):
{
"bouquetIds": ["b3f1a2c4-..."],
"deliveryMethod": "pickup"
}
Response 200 (delivery):
{
"lines": [
{
"bouquetId": "b3f1a2c4-...",
"bouquetName": "Spring Roses Bouquet",
"bouquetImageUrl": "https://cdn.bloomplatform.pl/bouquets/b3f1a2c4/full.jpg",
"unitPrice": 89.00
}
],
"linesSubtotal": 89.00,
"deliveryFee": 25.00,
"totalAmount": 114.00,
"currency": "PLN",
"vendorName": "Flora Elegance",
"deliveryMethod": "delivery",
"deliveryDistanceKm": 4.82,
"resolvedDeliveryAddress": {
"street": "ul. Marszałkowska 10",
"city": "Warsaw",
"postalCode": "00-001",
"notes": "3rd floor, flat 12",
"latitude": 52.2321,
"longitude": 21.0151,
"displayName": "Marszałkowska 10, 00-001 Warszawa, Poland"
}
}
Response 200 (pickup):
{
"lines": [
{
"bouquetId": "b3f1a2c4-...",
"bouquetName": "Spring Roses Bouquet",
"bouquetImageUrl": "https://cdn.bloomplatform.pl/bouquets/b3f1a2c4/full.jpg",
"unitPrice": 89.00
}
],
"linesSubtotal": 89.00,
"deliveryFee": 0.00,
"totalAmount": 89.00,
"currency": "PLN",
"vendorName": "Flora Elegance",
"deliveryMethod": "pickup"
}
Response 400/422: See the delivery error table at the end of this section.
Notes:
- This endpoint does NOT reserve the bouquet. Reservation happens only on
POST /api/orders. deliveryFeeis0.00whendeliveryMethodispickup; flat25.00 PLNwhen delivery falls within the vendor’s 10 km service radius.deliveryDistanceKmandresolvedDeliveryAddressare present only on delivery previews. The server geocodes the address via Nominatim (cached for 10 minutes per address) and returns the resolved latitude/longitude so the client can surface a map preview.
POST /api/orders
Creates a new order and reserves the bouquet. The bouquet is locked for 10 minutes. If payment is not confirmed within that window, the reservation is released automatically.
Auth: Required
Idempotent: No — use Idempotency-Key header to prevent duplicate orders on retry (see section 18).
Request Headers:
| Header | Required | Description |
|---|---|---|
Idempotency-Key |
Required | Client-generated UUID v4, unique per order attempt |
Request Body:
{
"bouquetIds": ["b3f1a2c4-..."],
"deliveryMethod": "delivery",
"street": "ul. Marszałkowska 10",
"city": "Warsaw",
"postalCode": "00-001",
"notes": "3rd floor, flat 12",
"expectedTotalAmount": 114.00
}
| Field | Rules |
|---|---|
bouquetIds |
One or more bouquet UUIDs; all must belong to the same vendor |
deliveryMethod |
delivery or pickup |
street, city, postalCode, notes |
Required when deliveryMethod is delivery; ignored for pickup. The server re-geocodes the address and stores the resolved latitude/longitude on the order so couriers can route without re-geocoding. |
expectedTotalAmount |
Server validates the computed total matches. Returns 409 with price-changed type if it doesn’t. |
Response 201:
{
"orderId": "ord-8821",
"status": "created",
"bouquetReservedUntil": "2025-04-10T10:25:00Z",
"stripeClientSecret": "pi_stripe_..._secret_...",
"stripeEphemeralKey": "ek_live_...",
"stripeCustomerId": "cus_...",
"bouquetPrice": 89.00,
"deliveryFee": 12.00,
"totalAmount": 101.00,
"currency": "PLN"
}
Stripe Integration Notes:
- Use
stripeClientSecretwith the Stripe Mobile SDK PaymentSheet. stripeEphemeralKeyandstripeCustomerIdare needed for the PaymentSheet configuration.- The Stripe publishable key is obtained from
GET /api/config(stripePublishableKeyfield). - The app never handles raw card data (SEC-05).
- Supported payment methods: card, Apple Pay, Google Pay.
Response 409 (already reserved):
{
"type": "https://bloomplatform.pl/errors/conflict",
"title": "Bouquet Already Reserved",
"status": 409,
"detail": "This bouquet has been reserved by another customer."
}
Response 409 (price changed):
{
"type": "https://bloomplatform.pl/errors/price-changed",
"title": "Price Changed",
"status": 409,
"detail": "The bouquet price has changed since your preview.",
"currentTotalAmount": 105.00,
"currency": "PLN"
}
Response 400/422: See the delivery error table below.
Delivery error codes (preview + create)
| Error code | HTTP | When |
|---|---|---|
delivery-address-required |
400 | deliveryMethod=delivery but street/city/postalCode missing |
geocoding-failed |
422 | The geocoder returned no match or errored |
delivery-out-of-range |
422 | Resolved address is more than 10 km from the vendor location |
vendor-location-unknown |
422 | Vendor has no configured location (can’t compute distance) |
All error responses use the shape { "error": "<code>" }.
Side effects during the order lifecycle:
- On
POST /orders/{id}/confirm-paymentfor a delivery order: the order advances frompaid→preparing, and the smart vase hosting the bouquet is sent an MQTTprepare_deliverycard carryingdisplay_mode=delivery,delivery_status=PREPARING,delivery_qr=geo:<lat>,<lon>?q=<url-encoded-address>, anddelivery_address=<street, postal city>. - When the vendor transitions the order to
in_delivery(vendor portalPUT /api/v1/vendor/orders/{id}/status): the bouquet is unassigned from its vase (so the vase can host a new bouquet), and the vase receives an MQTTdelivery_startedcard resetting it todisplay_mode=idlewith banner “Waiting for bouquet”.
POST /api/orders/{id}/confirm-payment
Confirms that the Stripe payment succeeded. Called after Stripe SDK reports success on the client. The server also receives a Stripe webhook independently; this endpoint ensures the order transitions promptly without waiting for the webhook.
Auth: Required Idempotent: Yes — safe to retry. Duplicate confirmations for an already-paid order return 200 with the same response.
Request Body:
{
"paymentIntentId": "pi_stripe_..."
}
Response 200:
{
"orderId": "ord-8821",
"status": "paid",
"paidAt": "2025-04-10T10:11:00Z"
}
Response 402: Payment failed or was declined. Response 404: Order not found. Response 409: Order already cancelled.
POST /api/orders/{id}/cancel
Cancels an order. Only allowed while the order is in a cancellable state.
Auth: Required
Response 200:
{
"orderId": "ord-8821",
"status": "cancelled",
"cancelledAt": "2025-04-10T10:15:00Z",
"refundStatus": "not_applicable"
}
refundStatus value |
Meaning |
|---|---|
not_applicable |
Order was not yet paid — no refund needed |
refund_initiated |
Payment was made — Stripe refund has been initiated |
refund_completed |
Refund processed (may appear on subsequent GET) |
Cancellable statuses:
| Order status | Can cancel? | Notes |
|---|---|---|
created |
Yes | Reservation released immediately |
paid |
Yes | Automatic Stripe refund initiated |
preparing |
No | Contact support: support@bloomplatform.pl |
in_delivery |
No | Contact support |
delivered |
No | Use report-issue endpoint (post-MVP) |
cancelled |
No | Already cancelled (returns 409) |
Response 409: Order is not in a cancellable state. Response 404: Order not found or does not belong to the authenticated customer.
GET /api/orders
Returns all orders for the authenticated customer.
Auth: Required Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
status |
string | No | Filter: active, completed, cancelled |
page |
int | No | Page number (default: 1) |
pageSize |
int | No | Items per page (default: 20, max: 50) |
Status filter mapping:
| Filter value | Includes order statuses |
|---|---|
active |
created, paid, preparing, in_delivery |
completed |
delivered |
cancelled |
cancelled |
| (omitted) | All statuses |
Response 200:
{
"items": [
{
"orderId": "ord-8821",
"bouquetThumbnailUrl": "https://cdn.bloomplatform.pl/...",
"vendorName": "Flora Elegance",
"totalAmount": 101.00,
"currency": "PLN",
"status": "in_delivery",
"deliveryMethod": "delivery",
"createdAt": "2025-04-10T10:10:00Z"
}
],
"totalCount": 5,
"page": 1,
"pageSize": 20
}
GET /api/orders/{id}
Returns full detail of a single order.
Auth: Required
Response 200 (delivery order):
{
"orderId": "ord-8821",
"bouquetId": "b3f1a2c4-...",
"bouquetImageUrl": "https://cdn.bloomplatform.pl/bouquets/b3f1a2c4/full.jpg",
"vendorId": "vend-42",
"vendorName": "Flora Elegance",
"bouquetPrice": 89.00,
"deliveryFee": 12.00,
"totalAmount": 101.00,
"currency": "PLN",
"status": "in_delivery",
"deliveryMethod": "delivery",
"deliveryAddress": {
"street": "ul. Marszałkowska 10",
"city": "Warsaw",
"postalCode": "00-001",
"notes": "3rd floor, flat 12"
},
"pickupAddress": null,
"estimatedDeliveryAt": "2025-04-10T11:00:00Z",
"deliveryQrCode": "data:image/png;base64,...",
"timeline": [
{ "status": "created", "timestamp": "2025-04-10T10:10:00Z", "label": "Order Placed" },
{ "status": "paid", "timestamp": "2025-04-10T10:11:00Z", "label": "Payment Confirmed" },
{ "status": "preparing", "timestamp": "2025-04-10T10:20:00Z", "label": "Preparing" },
{ "status": "in_delivery", "timestamp": "2025-04-10T10:23:00Z", "label": "Out for Delivery" },
{ "status": "delivered", "timestamp": null, "label": "Delivered" }
],
"createdAt": "2025-04-10T10:10:00Z"
}
Response 200 (pickup order):
{
"orderId": "ord-8822",
"bouquetId": "b3f1a2c4-...",
"bouquetImageUrl": "https://cdn.bloomplatform.pl/bouquets/b3f1a2c4/full.jpg",
"vendorId": "vend-42",
"vendorName": "Flora Elegance",
"bouquetPrice": 89.00,
"deliveryFee": 0.00,
"totalAmount": 89.00,
"currency": "PLN",
"status": "paid",
"deliveryMethod": "pickup",
"deliveryAddress": null,
"pickupAddress": {
"street": "ul. Nowy Świat 15",
"city": "Warsaw",
"postalCode": "00-029",
"vendorName": "Flora Elegance",
"openingHours": "Mon-Sat 09:00-20:00"
},
"estimatedDeliveryAt": null,
"deliveryQrCode": "data:image/png;base64,...",
"timeline": [
{ "status": "created", "timestamp": "2025-04-10T10:10:00Z", "label": "Order Placed" },
{ "status": "paid", "timestamp": "2025-04-10T10:11:00Z", "label": "Payment Confirmed" },
{ "status": "ready_for_pickup", "timestamp": "2025-04-10T10:20:00Z", "label": "Ready for Pickup" },
{ "status": "delivered", "timestamp": null, "label": "Picked Up" }
],
"createdAt": "2025-04-10T10:10:00Z"
}
Order Status Values:
| Status | Label (delivery) | Label (pickup) | Description |
|---|---|---|---|
created |
Order Placed | Order Placed | Order created, awaiting payment |
paid |
Payment Confirmed | Payment Confirmed | Stripe payment succeeded |
preparing |
Preparing | Preparing | Vendor is preparing the bouquet |
ready_for_pickup |
(not used) | Ready for Pickup | Pickup only: bouquet ready at vendor location |
in_delivery |
Out for Delivery | (not used) | Delivery only: courier has picked up the bouquet |
delivered |
Delivered | Picked Up | Customer has received the bouquet |
cancelled |
Cancelled | Cancelled | Order was cancelled (payment failed, timeout, or manual) |
deliveryQrCode availability:
| Order status | deliveryQrCode value |
|---|---|
created |
null — not yet generated |
paid and later |
Base64 PNG data URI — available for both delivery (courier verification) and pickup (shop verification) |
cancelled |
null |
Notes:
bouquetPriceis always present as a separate field so the app can display the itemised cost breakdown.- For pickup orders,
deliveryAddressisnullandpickupAddresscontains the vendor’s location. For delivery orders,pickupAddressisnull. deliveryFeeis0.00for pickup orders.estimatedDeliveryAtisnullfor pickup orders and for delivery orders beforein_deliverystatus.
Response 404: Order not found or does not belong to the authenticated customer.
POST /api/orders/{id}/report-issue
Reports an issue with a delivered order (TRK-08). Reserved for post-MVP.
Auth: Required
Request Body:
{
"issueType": "damaged",
"description": "Several stems were broken on arrival."
}
issueType values: damaged, wrong_bouquet, late_delivery, other
Response 201:
{
"issueId": "iss-001",
"orderId": "ord-8821",
"status": "submitted",
"createdAt": "2025-04-10T14:00:00Z"
}
Response 400: Order is not in delivered status.
Response 404: Order not found.
Note: This endpoint is planned for post-MVP. The mobile app should hide the “Report Issue” UI element until this endpoint is available.
6. Subscription Endpoints
GET /api/subscriptions
Returns all subscriptions for the authenticated customer.
Auth: Required
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
page |
int | No | Page number (default: 1) |
pageSize |
int | No | Items per page (default: 20, max: 50) |
Response 200:
{
"items": [
{
"subscriptionId": "sub-001",
"type": "shop",
"isActive": true,
"shopId": "vend-42",
"shopName": "Flora Elegance",
"notificationFrequency": "immediate",
"createdAt": "2025-03-01T10:00:00Z"
},
{
"subscriptionId": "sub-002",
"type": "flower_type",
"isActive": true,
"flowerType": "rose",
"flowerTypeLabel": "Roses",
"radiusKm": 5,
"notificationFrequency": "daily",
"createdAt": "2025-03-15T14:30:00Z"
},
{
"subscriptionId": "sub-003",
"type": "price_range",
"isActive": true,
"maxPrice": 50.00,
"currency": "PLN",
"radiusKm": 10,
"notificationFrequency": "weekly",
"createdAt": "2025-03-20T08:00:00Z"
},
{
"subscriptionId": "sub-004",
"type": "combined",
"isActive": true,
"flowerType": "rose",
"flowerTypeLabel": "Roses",
"maxPrice": 60.00,
"currency": "PLN",
"radiusKm": 5,
"notificationFrequency": "immediate",
"createdAt": "2025-03-25T10:00:00Z"
}
],
"totalCount": 4,
"page": 1,
"pageSize": 20
}
GET /api/subscriptions/{id}
Returns a single subscription by ID.
Auth: Required
Response 200: Single subscription object (same schema as items in GET /api/subscriptions).
Response 404: Subscription not found or does not belong to the customer.
POST /api/subscriptions
Creates a new subscription.
Auth: Required Request Body (by type):
Shop subscription:
{
"type": "shop",
"shopId": "vend-42",
"notificationFrequency": "immediate"
}
Flower type subscription:
{
"type": "flower_type",
"flowerType": "rose",
"radiusKm": 5,
"notificationFrequency": "immediate"
}
Price range subscription:
{
"type": "price_range",
"maxPrice": 50.00,
"radiusKm": 10,
"notificationFrequency": "weekly"
}
Combined subscription (flower type + price range):
{
"type": "combined",
"flowerType": "rose",
"maxPrice": 60.00,
"radiusKm": 5,
"notificationFrequency": "immediate"
}
| Field | Rules |
|---|---|
type |
shop, flower_type, price_range, combined |
notificationFrequency |
immediate, daily, weekly |
radiusKm |
5, 10, or 15 (required for flower_type, price_range, combined) |
flowerType |
Required for flower_type and combined — use values from GET /api/reference/flower-types |
shopId |
Required for shop |
maxPrice |
Required for price_range and combined |
Response 201: Full subscription object (same as GET schema) Response 400: Invalid parameters or missing required fields.
PUT /api/subscriptions/{id}
Updates an existing subscription (toggle active, change frequency).
Auth: Required Request Body: Partial subscription object (only changed fields required)
{
"isActive": false,
"notificationFrequency": "daily"
}
Response 200: Updated subscription object Response 404: Subscription not found or does not belong to the customer.
PUT /api/subscriptions/disable-all
Deactivates all subscriptions for the authenticated customer. Sets isActive: false on every subscription without deleting them (SUB-09).
Auth: Required
Response 200:
{
"disabledCount": 4,
"message": "All subscriptions have been deactivated."
}
DELETE /api/subscriptions/{id}
Deletes a subscription.
Auth: Required Response 204: No content Response 404: Subscription not found or does not belong to the customer.
7. Vendor Endpoints
GET /api/vendors/{id}
Returns public vendor profile.
Auth: Optional
Response 200:
{
"vendorId": "vend-42",
"vendorType": "shop",
"name": "Flora Elegance",
"description": "Warsaw's finest fresh bouquets since 2012.",
"openingHours": "Mon-Sat 09:00-20:00",
"isCurrentlyOpen": true,
"location": {
"latitude": 52.2297,
"longitude": 21.0122,
"displayAddress": "ul. Nowy Świat 15, Warsaw",
"exactAddressHidden": false
},
"activeBouquetCount": 3,
"rating": 4.8
}
Notes:
- For freelance florists,
exactAddressHiddenistrueand coordinates are approximate (within ~500 m). isCurrentlyOpenis computed server-side fromopeningHours. Returnsnullif opening hours are not configured.
Response 404: Vendor not found.
GET /api/vendors/{id}/bouquets
Returns all currently available bouquets for a specific vendor.
Auth: Optional
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
page |
int | No | Page number (default: 1) |
pageSize |
int | No | Items per page (default: 20, max: 50) |
Response 200: Same paginated schema as GET /api/bouquets/nearby items.
GET /api/v1/vendor/settings/pricing-strategy
Returns the vendor’s current freshness-based pricing strategy, or the platform default if none is configured.
Auth: Required (VendorAccess)
Response 200:
{
"isDefault": false,
"autoAdjustPrice": true,
"tiers": [
{
"afterHours": 24,
"cumulativeDiscountPercent": 20.0,
"description": "After 24h in vase: -20% off original price"
},
{
"afterHours": 48,
"cumulativeDiscountPercent": 40.0,
"description": "After 48h in vase: -40% off original price"
},
{
"afterHours": 72,
"cumulativeDiscountPercent": 70.0,
"description": "After 72h in vase: -70% off original price"
}
]
}
Notes:
isDefault: truemeans no custom strategy is stored — the platform Standard preset is being used.- Discount percentages are cumulative off the
BasePriceset when the vendor last calledSetPriceorUpdatePriceon the bouquet.
Response 403: No VendorId in token.
PUT /api/v1/vendor/settings/pricing-strategy
Sets a custom freshness pricing strategy for the vendor. Replaces any previously stored strategy.
Auth: Required (VendorAccess)
Request Body:
{
"autoAdjustPrice": true,
"tiers": [
{ "afterHours": 24, "cumulativeDiscountPercent": 15 },
{ "afterHours": 48, "cumulativeDiscountPercent": 35 },
{ "afterHours": 72, "cumulativeDiscountPercent": 60 }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
autoAdjustPrice |
bool | No | Default true. Set false to store tiers without activating auto-adjustment. |
tiers |
array | Yes | At least one tier. null or empty resets to platform default. |
tiers[].afterHours |
int | Yes | Hours since bouquet placement at which this tier activates. Must be > 0 and strictly ascending. |
tiers[].cumulativeDiscountPercent |
decimal | Yes | Total discount off the original base price (not compounded). Must be in (0, 100]. Must be strictly ascending across tiers. |
Response 204: Strategy saved.
Response 400: Validation error (e.g., hours not ascending, discount out of range).
Response 403: No VendorId in token.
DELETE /api/v1/vendor/settings/pricing-strategy
Resets the vendor’s pricing strategy to the platform default (Standard 3-day schedule: −20%/−40%/−70% at 24h/48h/72h).
Auth: Required (VendorAccess)
Response 204: Strategy reset.
Response 403: No VendorId in token.
8. Customer Profile Endpoints
GET /api/profile
Returns the authenticated customer’s profile.
Auth: Required
Response 200:
{
"customerId": "cust-123",
"firstName": "Jan",
"lastName": "Kowalski",
"email": "jan@example.com",
"emailVerified": true,
"memberSince": "2025-01-15T00:00:00Z",
"stats": {
"totalOrders": 12,
"activeSubscriptions": 3,
"totalSpent": 1240.00,
"currency": "PLN"
},
"preferredFlowerTypes": ["rose", "tulip"],
"notificationSettings": {
"pushEnabled": true,
"emailEnabled": true,
"subscriptionDigestEnabled": true
}
}
PUT /api/profile
Updates the customer’s profile.
Auth: Required
Request Body: Partial object — only changed fields required.
{
"firstName": "Jan",
"lastName": "Kowalski",
"preferredFlowerTypes": ["rose", "tulip", "peony"]
}
Response 200: Updated profile object.
PUT /api/profile/notification-settings
Updates notification preferences.
Auth: Required
Request Body:
{
"pushEnabled": true,
"emailEnabled": false,
"subscriptionDigestEnabled": true
}
Response 200: Updated notification settings object.
Notes:
- Setting
pushEnabled: falsestops all push notifications from being delivered to the device but does not deactivate individual subscriptions. To deactivate all subscriptions, usePUT /api/subscriptions/disable-all. - Setting
subscriptionDigestEnabled: falsestops daily/weekly digest emails for subscriptions withnotificationFrequencyset todailyorweekly.
DELETE /api/profile
Requests account deletion (GDPR). Marks the account for deletion; completed within 30 days.
Auth: Required
Request Body:
{
"confirmationText": "DELETE"
}
Response 202:
{
"message": "Account deletion requested. Your data will be removed within 30 days.",
"scheduledDeletionDate": "2025-05-10T00:00:00Z"
}
9. Saved Addresses Endpoints
Saved addresses are a small, bounded collection (practical limit: 10 per customer). They return a plain array, not the paginated envelope.
GET /api/profile/addresses
Returns the customer’s saved delivery addresses.
Auth: Required
Response 200:
[
{
"addressId": "addr-001",
"label": "Home",
"street": "ul. Marszałkowska 10",
"city": "Warsaw",
"postalCode": "00-001",
"notes": "3rd floor, flat 12",
"isDefault": true
}
]
POST /api/profile/addresses
Creates a new saved address. Maximum 10 addresses per customer.
Auth: Required
Request Body:
{
"label": "Work",
"street": "ul. Złota 59",
"city": "Warsaw",
"postalCode": "00-120",
"notes": "Reception desk",
"isDefault": false
}
Response 201: Full address object. Response 409: Maximum address limit (10) reached.
PUT /api/profile/addresses/{id}
Updates a saved address.
Auth: Required Request Body: Partial address object. Response 200: Updated address object.
DELETE /api/profile/addresses/{id}
Deletes a saved address.
Auth: Required Response 204: No content.
10. Push Notifications Endpoints
POST /api/notifications/device
Registers a device for push notifications (Firebase Cloud Messaging for Android, APNs for iOS).
Auth: Required
Request Body:
{
"deviceToken": "firebase-or-apns-token-...",
"platform": "android",
"appVersion": "1.0.0"
}
| Field | Rules |
|---|---|
platform |
android or ios |
deviceToken |
Required, FCM registration token or APNs device token |
Response 200: Device registered / updated.
DELETE /api/notifications/device
Unregisters the current device from push notifications (e.g., on sign-out).
Auth: Required
Request Body:
{
"deviceToken": "firebase-or-apns-token-..."
}
Response 204: No content.
GET /api/notifications
Returns the in-app notification feed for the authenticated customer.
Auth: Required
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
page |
int | No | Page number (default: 1) |
pageSize |
int | No | Items per page (default: 20, max: 50) |
unreadOnly |
bool | No | Filter to unread only (default: false) |
Response 200:
{
"items": [
{
"notificationId": "notif-001",
"type": "subscription_match",
"title": "New roses available!",
"body": "Flora Elegance has fresh roses for 89.00 PLN",
"bouquetId": "b3f1a2c4-...",
"orderId": null,
"subscriptionId": "sub-002",
"newStatus": null,
"isRead": false,
"createdAt": "2025-04-10T09:20:00Z"
},
{
"notificationId": "notif-002",
"type": "order_update",
"title": "Order out for delivery",
"body": "Your order ord-8821 is on its way!",
"bouquetId": null,
"orderId": "ord-8821",
"subscriptionId": null,
"newStatus": "in_delivery",
"isRead": true,
"createdAt": "2025-04-10T10:23:00Z"
}
],
"totalCount": 15,
"unreadCount": 3,
"page": 1,
"pageSize": 20
}
Notification fields by type:
| Field | subscription_match |
order_update |
promotion |
system |
|---|---|---|---|---|
bouquetId |
present | null |
present or null |
null |
orderId |
null |
present | null |
null |
subscriptionId |
present | null |
null |
null |
newStatus |
null |
present | null |
null |
Push Notification Payload (FCM / APNs)
Push notifications sent to the device include a data payload for deep linking (SUB-07). The mobile app should parse data to navigate to the correct screen.
Subscription match (new bouquet):
{
"notification": {
"title": "New roses available!",
"body": "Flora Elegance has fresh roses for 89.00 PLN"
},
"data": {
"type": "subscription_match",
"bouquetId": "b3f1a2c4-...",
"subscriptionId": "sub-002"
}
}
Order status update:
{
"notification": {
"title": "Order out for delivery",
"body": "Your order ord-8821 is on its way!"
},
"data": {
"type": "order_update",
"orderId": "ord-8821",
"newStatus": "in_delivery"
}
}
Deep link mapping:
data.type |
Navigate to | Key field |
|---|---|---|
subscription_match |
Bouquet detail screen | data.bouquetId |
order_update |
Order tracking screen | data.orderId |
promotion |
Promotions screen or bouquet detail | data.bouquetId (optional) |
system |
Notifications list | — |
PUT /api/notifications/{id}/read
Marks a notification as read.
Auth: Required Response 204: No content.
PUT /api/notifications/read-all
Marks all notifications as read.
Auth: Required Response 204: No content.
11. Reference Data Endpoints
GET /api/reference/flower-types
Returns the list of supported flower types for subscriptions and filtering.
Auth: None Cacheable: Yes — cache client-side for up to 24 hours (see section 19).
Response 200:
[
{ "type": "rose", "label": "Roses" },
{ "type": "tulip", "label": "Tulips" },
{ "type": "peony", "label": "Peonies" },
{ "type": "lily", "label": "Lilies" },
{ "type": "sunflower", "label": "Sunflowers" },
{ "type": "eucalyptus", "label": "Eucalyptus" },
{ "type": "orchid", "label": "Orchids" },
{ "type": "hydrangea", "label": "Hydrangeas" },
{ "type": "chrysanthemum", "label": "Chrysanthemums" },
{ "type": "gerbera", "label": "Gerberas" }
]
GET /api/reference/freshness-labels
Returns freshness score ranges and their display labels.
Auth: None Cacheable: Yes — cache client-side for up to 24 hours (see section 19).
Response 200:
[
{ "min": 80, "max": 100, "label": "Very Fresh", "color": "#22C55E" },
{ "min": 60, "max": 79, "label": "Fresh", "color": "#84CC16" },
{ "min": 40, "max": 59, "label": "Moderate", "color": "#EAB308" },
{ "min": 20, "max": 39, "label": "Declining", "color": "#F97316" },
{ "min": 0, "max": 19, "label": "Poor", "color": "#EF4444" }
]
12. Health Check Endpoint
GET /api/health
Returns backend health status. Useful for mobile app to detect connectivity issues.
Auth: None
Response 200:
{
"status": "healthy",
"version": "1.0.0",
"timestamp": "2025-04-10T09:00:00Z"
}
Response 503: Service unavailable.
13. SignalR Hub
Hub URL: {baseUrl}/hubs/bouquet
Authentication
The SignalR hub supports both authenticated and anonymous connections:
| Connection type | How to connect | Available groups |
|---|---|---|
| Authenticated | Pass access_token query string param |
Radius groups, order groups |
| Anonymous | Connect without token | Radius groups only |
Anonymous connections can subscribe to JoinRadiusGroup to receive real-time bouquet availability updates (AUTH-04, DISC-04). Order-related groups (JoinOrderGroup) require authentication.
Connection Setup (.NET MAUI)
// Authenticated connection
var connection = new HubConnectionBuilder()
.WithUrl($"{baseUrl}/hubs/bouquet", options =>
{
options.AccessTokenProvider = () => Task.FromResult(accessToken);
})
.WithAutomaticReconnect(new[] { TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30) })
.Build();
// Anonymous connection (unauthenticated user browsing the map)
var connection = new HubConnectionBuilder()
.WithUrl($"{baseUrl}/hubs/bouquet")
.WithAutomaticReconnect(new[] { TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30) })
.Build();
Reconnection Behaviour
The hub automatically reconnects on network interruption (REL-02). The client must:
- Re-join radius/order groups after reconnection via the
Reconnectedevent handler. - Fetch latest data via REST to fill any events missed during disconnection.
- Show a “Reconnecting…” indicator during the reconnection window.
connection.Reconnected += async (connectionId) =>
{
// Re-join groups after reconnect
await connection.InvokeAsync("JoinRadiusGroup", lastLatitude, lastLongitude, lastRadiusKm);
// Refresh bouquet data via REST to catch missed events
await RefreshNearbyBouquets();
};
Client → Server
JoinRadiusGroup
Subscribe to real-time updates for a location and radius. Available to both authenticated and anonymous connections.
await connection.InvokeAsync("JoinRadiusGroup", latitude, longitude, radiusKm);
LeaveRadiusGroup
Unsubscribe from radius updates.
await connection.InvokeAsync("LeaveRadiusGroup");
JoinOrderGroup
Subscribe to updates for a specific order. Requires authentication.
await connection.InvokeAsync("JoinOrderGroup", orderId);
LeaveOrderGroup
Unsubscribe from order updates.
await connection.InvokeAsync("LeaveOrderGroup", orderId);
Server → Client
BouquetBecameAvailable
A new bouquet is available within the subscribed radius. Payload matches the nearby endpoint item schema so the app can add a map pin without an extra REST call.
{
"bouquetId": "b3f1a2c4-...",
"vaseId": "v-001",
"vendorId": "vend-42",
"vendorName": "Flora Elegance",
"vendorType": "shop",
"price": 89.00,
"currency": "PLN",
"status": "available",
"freshnessScore": 84,
"freshnessLabel": "Very Fresh",
"thumbnailUrl": "https://cdn.bloomplatform.pl/...",
"location": {
"latitude": 52.2297,
"longitude": 21.0122,
"displayAddress": "Śródmieście, Warsaw",
"exactAddressHidden": false
},
"distanceKm": 0.8,
"availableFrom": "2025-04-10T09:15:00Z"
}
Notes:
distanceKmis computed using the coordinates the client sent inJoinRadiusGroup.- The event uses the same nested
locationstructure as the REST response.
BouquetBecameUnavailable
A bouquet is no longer available (sold, reserved, vase offline).
{
"bouquetId": "b3f1a2c4-...",
"vendorType": "shop",
"reason": "reserved"
}
reason values: sold, reserved, vase_offline, removed
Notes:
vendorTypeis included so the app can correctly update filtered views (DISC-11).
FreshnessUpdated
A bouquet’s freshness score has changed (pushed periodically by the vase sensors).
{
"bouquetId": "b3f1a2c4-...",
"freshnessScore": 78,
"freshnessLabel": "Fresh"
}
OrderStatusChanged
An order’s status has changed.
{
"orderId": "ord-8821",
"newStatus": "in_delivery",
"label": "Out for Delivery",
"estimatedDeliveryAt": "2025-04-10T11:00:00Z"
}
DeliveryLocationUpdated
Real-time delivery courier location update (sent while order status is in_delivery).
{
"orderId": "ord-8821",
"courierLatitude": 52.2310,
"courierLongitude": 21.0145,
"estimatedMinutesRemaining": 8
}
14. Error Schema
All API errors follow the RFC 7807 Problem Details format:
{
"type": "https://bloomplatform.pl/errors/bouquet-not-found",
"title": "Bouquet Not Found",
"status": 404,
"detail": "Bouquet 'b3f1a2c4-...' does not exist or is no longer available.",
"traceId": "00-abc123-def456-00"
}
Validation Error Extension
For 400 validation errors, the response includes field-level details:
{
"type": "https://bloomplatform.pl/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "One or more validation errors occurred.",
"traceId": "00-abc123-def456-00",
"errors": {
"email": ["Email is required.", "Email format is invalid."],
"password": ["Password must be at least 8 characters."]
}
}
Price Changed Error Extension
For 409 price-changed errors (on POST /api/orders), the response includes the current price:
{
"type": "https://bloomplatform.pl/errors/price-changed",
"title": "Price Changed",
"status": 409,
"detail": "The bouquet price has changed since your preview.",
"traceId": "00-abc123-def456-00",
"currentTotalAmount": 105.00,
"currency": "PLN"
}
Common Error Codes
| HTTP Status | type | Meaning |
|---|---|---|
| 400 | validation-error |
Request body or query params invalid |
| 401 | unauthorized |
Missing or invalid access token |
| 402 | payment-failed |
Payment was declined or failed |
| 403 | forbidden |
Token valid but insufficient permissions |
| 404 | not-found |
Resource does not exist |
| 409 | conflict |
Bouquet already reserved; concurrent order conflict; duplicate resource |
| 409 | price-changed |
Price changed between preview and order creation |
| 410 | gone |
QR code bouquet no longer available |
| 429 | rate-limited |
Too many requests — see Retry-After header |
| 500 | internal-error |
Unexpected server error |
15. Rate Limiting
| Scope | Limit | Window |
|---|---|---|
| Anonymous endpoints | 60 requests | per minute per IP |
| Authenticated endpoints | 120 requests | per minute per user |
| Order creation | 5 requests | per minute per user |
| Auth registration | 3 requests | per minute per IP |
| Resend verification | 1 request | per 60 seconds per user |
When rate-limited, the response includes:
| Header | Description |
|---|---|
Retry-After |
Seconds until the limit resets |
X-RateLimit-Limit |
Maximum requests in the window |
X-RateLimit-Remaining |
Remaining requests in the window |
16. Pagination Convention
List endpoints use one of two response formats:
Paginated envelope
Used for potentially large or growing collections:
{
"items": [ ... ],
"totalCount": 42,
"page": 1,
"pageSize": 20
}
| Param | Default | Max |
|---|---|---|
page |
1 | — |
pageSize |
20 | 50 |
Endpoints using paginated envelope: bouquets/nearby, favourites, orders, subscriptions, notifications.
Plain array
Used for small, bounded collections where pagination is unnecessary:
Endpoints using plain array: profile/addresses (max 10), reference/flower-types, reference/freshness-labels.
17. HTTP Headers Convention
Request Headers
| Header | Required | Description |
|---|---|---|
Authorization |
For auth endpoints | Bearer <access_token> |
Content-Type |
For POST/PUT | application/json |
Accept-Language |
Optional | pl-PL (default) or en-US |
X-App-Version |
Recommended | Mobile app version, e.g. 1.0.0 |
X-Platform |
Recommended | android or ios |
Idempotency-Key |
For POST /api/orders | Client-generated UUID v4 |
Response Headers
| Header | Description |
|---|---|
X-Request-Id |
Unique request identifier for debugging |
X-RateLimit-Limit |
Rate limit ceiling |
X-RateLimit-Remaining |
Remaining requests in window |
Retry-After |
Seconds to wait (only on 429) |
Cache-Control |
Caching directive (see section 19) |
ETag |
Entity tag for conditional requests (see section 19) |
18. Idempotency
Non-idempotent POST endpoints that create resources or have side effects require an Idempotency-Key header to safely support retries (REL-04).
| Endpoint | Idempotent | Retry Safe | Notes |
|---|---|---|---|
POST /api/auth/register |
No | No | Duplicate returns 409 naturally |
POST /api/auth/resend-verification |
Yes | Yes | Rate-limited; no side effects on repeat |
POST /api/orders/preview |
Yes | Yes | Read-only, no side effects |
POST /api/orders |
No | Yes with Idempotency-Key |
Same key returns cached 201 response |
POST /api/orders/{id}/confirm-payment |
Yes | Yes | Duplicate confirmation returns same 200 |
POST /api/orders/{id}/cancel |
Yes | Yes | Duplicate cancellation returns 409 harmlessly |
POST /api/subscriptions |
No | No | Duplicate returns 409 if identical |
POST /api/favourites/{id} |
Yes | Yes | Duplicate returns 409 harmlessly |
POST /api/profile/addresses |
No | No | May create duplicates — client should check |
POST /api/notifications/device |
Yes | Yes | Upserts by device token |
All GET endpoints |
Yes | Yes | Always safe |
All PUT endpoints |
Yes | Yes | Same payload produces same result |
All DELETE endpoints |
Yes | Yes | Deleting already-deleted returns 204/404 |
Usage:
POST /api/orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
The server caches the response for a given Idempotency-Key for 24 hours. If the same key is sent again, the server returns the cached response without creating a new order.
19. Caching & Offline Support
The backend includes caching headers to support offline mode (REL-01) and performance targets (PERF-02, PERF-03).
Cache-Control Policy
| Endpoint | Cache-Control |
ETag |
Offline Strategy |
|---|---|---|---|
GET /api/config |
public, max-age=3600 |
No | Cache for 1h; serve stale if offline |
GET /api/reference/flower-types |
public, max-age=86400 |
Yes | Cache for 24h; serve stale if offline |
GET /api/reference/freshness-labels |
public, max-age=86400 |
Yes | Cache for 24h; serve stale if offline |
GET /api/vendors/{id} |
public, max-age=3600 |
Yes | Cache for 1h; serve stale if offline |
GET /api/bouquets/{id} |
private, max-age=60 |
Yes | Short cache; show stale with banner if offline |
GET /api/bouquets/nearby |
private, no-cache |
No | Cache last result; show with “May be outdated” banner |
GET /api/orders |
private, no-cache |
No | Cache last result for offline viewing |
GET /api/orders/{id} |
private, max-age=30 |
Yes | Short cache; show stale with banner |
GET /api/profile |
private, max-age=300 |
Yes | Cache for 5 min |
GET /api/health |
no-store |
No | Never cache |
CDN images (thumbnailUrl, imageUrl) |
public, max-age=604800 |
No | Cache for 7 days |
Conditional Requests
For endpoints that return ETag, the mobile app should send If-None-Match on subsequent requests:
GET /api/bouquets/b3f1a2c4-...
If-None-Match: "abc123"
Response 304: Not Modified — use cached version (saves bandwidth).
Offline Mode Behaviour
When the device is offline, the app should:
- Serve cached data for all previously visited screens.
- Display a persistent “You are offline” banner at the top of the screen.
- Mark all cached bouquet data with “May be outdated” indicators.
- Disable actions that require network: checkout, favourites toggle, subscription changes.
- Queue notification read/mark operations and sync when back online.
20. Localisation
The backend supports content localisation via the Accept-Language request header (I18N-01, I18N-04).
Supported Locales
| Locale | Status |
|---|---|
pl-PL |
Default — returned if no Accept-Language header is sent |
en-US |
Secondary language for MVP |
Localised Fields
The following response fields are returned in the requested language:
| Field | Endpoints | Example (pl-PL) | Example (en-US) |
|---|---|---|---|
freshnessLabel |
bouquets/nearby, bouquets/{id}, favourites | “Bardzo Świeży” | “Very Fresh” |
Timeline label |
orders/{id} | “Zamówienie Złożone” | “Order Placed” |
Error title |
all errors | “Bukiet Nie Znaleziony” | “Bouquet Not Found” |
Error detail |
all errors | (Polish message) | (English message) |
Flower type label |
reference/flower-types | “Róże” | “Roses” |
Freshness label |
reference/freshness-labels | “Bardzo Świeży” | “Very Fresh” |
Notification title and body |
notifications, push payloads | (Polish) | (English) |
| Bouquet status display label | (client-side mapping) | “Dostępny” | “Available” |
Non-Localised Fields
The following fields are always returned as-is, regardless of locale:
- All identifiers (
bouquetId,orderId, etc.) - Status values (
available,in_delivery, etc.) — these are enum keys, not display text - Enum/type values (
shop,freelance,rose, etc.) - Vendor-authored content (
vendorName,description,vendorOpeningHours) — stored in the vendor’s language - Monetary values and currency codes
- Coordinates, dates, URLs