Architecture Deployment API

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
Production https://api.findmyflowers.pl

All REST endpoints are prefixed with /api/v1 (the API is versioned — controllers route on api/v{version:apiVersion}/... with the current version 1.0). 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
Production https://auth.findmyflowers.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):

  1. Unregister deviceDELETE /api/v1/notifications/devices/{deviceToken} with the current FCM/APNs token in the path.
  2. End Keycloak session — redirect to Keycloak End Session endpoint with id_token_hint and post_logout_redirect_uri.
  3. 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/v1/customers/register

Creates a new customer account atomically (Keycloak user + local Customer/User record) and returns a JWT so the app can proceed without a separate login. The account can be used immediately; email verification is handled by Keycloak out-of-band (see below).

Auth: None

Request Body (CustomerRegisterRequest):

{
  "username": "jan.kowalski",
  "email": "jan@example.com",
  "password": "SecureP@ss1",
  "customerName": "Jan Kowalski",
  "preferredVendorType": "shop",
  "phoneNumber": "+48123456789"
}

Validation Rules:

Field Rules
username Required, unique
email Valid email, unique, max 255 chars
password Min 8 chars, at least 1 uppercase, 1 lowercase, 1 digit, 1 special char
customerName Required — customer display name
preferredVendorType Optional — shop or freelance
phoneNumber Optional

Response 201 (AuthResponse):

{
  "token": "eyJhbGciOi...",
  "tokenType": "Bearer",
  "expiresIn": 86400,
  "username": "jan.kowalski"
}

Response 400: Registration failed (e.g. username/email already registered) — returned as a Problem Details object.

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/v1/auth/resend-verification

Triggers Keycloak to resend the email verification link. Rate-limited (policy auth-email) to prevent abuse.

Auth: Required (user must be logged in but unverified)

Response 202: Verification email queued by Keycloak. Response 204: No content — the user is already verified (treated as an idempotent success). Response 429: Rate limited — max 1 request per 60 seconds.


POST /api/v1/auth/password-reset/request

Initiates a password reset flow. Keycloak sends the reset email; the user completes the reset on Keycloak’s hosted page (there is no backend confirm endpoint). Rate-limited (policy auth-email).

Auth: None

Request Body:

{
  "email": "jan@example.com"
}

Response 202: Always returned (does not reveal whether email exists).


POST /api/v1/auth/logout

Revokes the caller’s current JWT so it cannot be reused after logout. The token’s jti claim is added to a server-side blacklist until the token’s natural expiry.

Auth: Required

Response 204: Token revoked. Response 400: Token has no jti claim.


2. App Configuration Endpoint

GET /api/v1/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 (AppConfigDto):

{
  "stripePublishableKey": "pk_live_...",
  "supportEmail": "support@bloomplatform.pl",
  "termsUrl": "https://bloomplatform.pl/terms",
  "privacyPolicyUrl": "https://bloomplatform.pl/privacy",
  "minAppVersion": "1.0.0",
  "forceUpdate": false
}
Field Description
stripePublishableKey Stripe publishable key for PaymentSheet initialization
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 an update prompt if current version is lower
forceUpdate When true, the app must block usage until updated (hard force-update); when false, the update prompt is advisory

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/v1/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",
      "initialFreshnessDays": 3,
      "currentFreshnessDays": 2,
      "maxFreshnessScale": 3,
      "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:

  • isFavourite is always false for unauthenticated requests.
  • exactAddressHidden is true for freelance florists; coordinates are approximate within ~500 m (SEC-06).
  • Freshness visibility (EP-25): initialFreshnessDays is the vendor-declared rating (1–maxFreshnessScale, where maxFreshnessScale is always 3), declared when the vendor sets the price. currentFreshnessDays is the value after daily decay — the initial rating minus one per full day since the rating was declared (not since the bouquet was created; the two usually differ), floored at 0. Render it as maxFreshnessScale flower pictograms with currentFreshnessDays coloured and the rest greyed (e.g. 2 of 3 → two coloured + one grey). All three fields are null/absent when the vendor did not declare a rating. This is a display-only signal and is independent of freshnessScore (the 0–100 sensor reading) and of price — freshness-based discounts are handled separately by the vendor pricing strategy and never driven by these fields.
  • freshnessDays semantics changed (EP-25): on the vendor-facing catalog read models (AvailableBouquetReadModel, BouquetDetails) the pre-existing freshnessDays field now carries the vendor-declared rating (0 when unset), replacing its former meaning of estimated age/shelf-life in days. Customer-facing endpoints are unaffected — use initialFreshnessDays/currentFreshnessDays there.

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/v1/bouquets/{id}/detail

Returns full detail for a single bouquet (customer-facing BouquetDetailDto). Note the /detail suffix — the plain GET /api/v1/bouquets/{id} route is the vendor endpoint and returns a different DTO; the mobile client must use /detail.

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",
  "initialFreshnessDays": 3,
  "currentFreshnessDays": 2,
  "maxFreshnessScale": 3,
  "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: true for freelance florists — location is approximate within ~500 m (SEC-06).
  • isFavourite is false for unauthenticated requests.
  • distanceKm is included only when lat and lng query params are provided; omitted otherwise.
  • isVendorCurrentlyOpen is computed server-side from vendorOpeningHours and the server’s current time. Returns null if opening hours are not configured.

Response 404: Bouquet not found or no longer available.


GET /api/v1/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 payload format: Smart vases now display a full URL built by the backend (VaseQrUrl.ForBouquet), not a short /b/{code} link:

{CustomerApp:BaseUrl}/Bouquets/Details/{bouquetId}?addToCart=true&vaseId={deviceId}

Scanning it opens the customer web app bouquet Details page (image + price + Add-to-Cart), which auto-adds the bouquet to the cart because of addToCart=true. The {CustomerApp:BaseUrl} comes from backend config so the domain can change without reflashing devices. The mobile app can still resolve a bouquet programmatically by extracting the {bouquetId} (or a short code) and calling this endpoint, which returns BouquetDetailDto.

Response 200 (BouquetDetailDto): Same schema as GET /api/v1/bouquets/{id}/detail 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/v1/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",
      "initialFreshnessDays": 3,
      "currentFreshnessDays": 2,
      "maxFreshnessScale": 3,
      "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:

  • distanceKm is included only when lat and lng query 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/v1/favourites/{bouquetId}

Adds a bouquet to the customer’s favourites. Idempotent — favouriting an already-favourited bouquet succeeds.

Auth: Required Response 204: No content (added, or already present). Response 404: Bouquet does not exist.


DELETE /api/v1/favourites/{bouquetId}

Removes a bouquet from the customer’s favourites. Idempotent — removing a non-existent favourite succeeds.

Auth: Required Response 204: No content.


5. Order Endpoints

POST /api/v1/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/v1/orders.
  • deliveryFee is 0.00 when deliveryMethod is pickup; flat 25.00 PLN when delivery falls within the vendor’s 10 km service radius.
  • deliveryDistanceKm and resolvedDeliveryAddress are 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/v1/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 stripeClientSecret with the Stripe Mobile SDK PaymentSheet.
  • stripeEphemeralKey and stripeCustomerId are needed for the PaymentSheet configuration.
  • The Stripe publishable key is obtained from GET /api/v1/config (stripePublishableKey field).
  • 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-payment for a delivery order: the order advances from paidpreparing, and the smart vase hosting the bouquet is sent an MQTT prepare_delivery card carrying display_mode=delivery, delivery_status=PREPARING, delivery_qr=geo:<lat>,<lon>?q=<url-encoded-address>, and delivery_address=<street, postal city>.
  • When the vendor transitions the order to in_delivery (vendor portal PUT /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 MQTT delivery_started card resetting it to display_mode=idle with banner “Waiting for bouquet”.

POST /api/v1/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 204.

Request Body (ConfirmPaymentRequest):

{
  "paymentIntentId": "pi_stripe_...",
  "stripeCustomerId": "cus_..."
}
Field Rules
paymentIntentId Required — the Stripe PaymentIntent id
stripeCustomerId Optional — the Stripe customer id, used to persist the saved card / customer association

Response 204: Payment confirmed (order transitions to paid). No response body. Response 400: Payment failed/declined, order not found, or order not in a confirmable state.


POST /api/v1/orders/{id}/cancel

Cancels an order. Only allowed while the order is in a cancellable state.

Auth: Required

Request Body (CancelOrderRequest):

{
  "reason": "Changed my mind"
}
Field Rules
reason Required — free-text cancellation reason

Response 200 (CancelOrderResponseDto):

{
  "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.


POST /api/v1/orders/{id}/confirm-delivery

Customer confirms they received the delivery, completing the order (transitions to delivered).

Auth: Required Request Body: None.

Response 204: Delivery confirmed. Response 400: Order not found, not owned by the customer, or not in a state that can be confirmed.


POST /api/v1/orders/{orderId}/lines/{bouquetId}/feedback

Submits feedback (1–5 star rating + optional comment) for a single bouquet line in a delivered order.

Auth: Required

Request Body (SubmitFeedbackRequest):

{
  "rating": 5,
  "comment": "Beautiful and fresh!",
  "isAnonymous": false
}
Field Rules
rating Required — integer 1–5
comment Optional free text
isAnonymous If true, the vendor does not see the customer identity

Response 201: Feedback recorded — body { "id": "<feedbackId>" }. Response 404: order-not-found or bouquet-not-in-order. Response 409: order-not-delivered or feedback-already-exists. Response 400: Validation error (e.g. rating out of range).


GET /api/v1/orders/me/pending-feedback

Returns delivered order lines the customer has not yet reviewed, so the app can prompt for ratings.

Auth: Required

Response 200: Array of PendingFeedbackDto.


GET /api/v1/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/v1/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:

  • bouquetPrice is always present as a separate field so the app can display the itemised cost breakdown.
  • For pickup orders, deliveryAddress is null and pickupAddress contains the vendor’s location. For delivery orders, pickupAddress is null.
  • deliveryFee is 0.00 for pickup orders.
  • estimatedDeliveryAt is null for pickup orders and for delivery orders before in_delivery status.

Response 404: Order not found or does not belong to the authenticated customer.


POST /api/v1/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/v1/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",
      "frequency": "instant",
      "createdAt": "2025-03-01T10:00:00Z"
    },
    {
      "subscriptionId": "sub-002",
      "type": "flower_type",
      "isActive": true,
      "flowerType": "rose",
      "flowerTypeLabel": "Roses",
      "centerLatitude": 52.2297,
      "centerLongitude": 21.0122,
      "radiusKm": 5,
      "frequency": "daily",
      "createdAt": "2025-03-15T14:30:00Z"
    },
    {
      "subscriptionId": "sub-003",
      "type": "price_range",
      "isActive": true,
      "maxPrice": 50.00,
      "currency": "PLN",
      "centerLatitude": 52.2297,
      "centerLongitude": 21.0122,
      "radiusKm": 10,
      "frequency": "weekly",
      "createdAt": "2025-03-20T08:00:00Z"
    },
    {
      "subscriptionId": "sub-004",
      "type": "combined",
      "isActive": true,
      "flowerType": "rose",
      "flowerTypeLabel": "Roses",
      "maxPrice": 60.00,
      "currency": "PLN",
      "centerLatitude": 52.2297,
      "centerLongitude": 21.0122,
      "radiusKm": 5,
      "frequency": "instant",
      "createdAt": "2025-03-25T10:00:00Z"
    }
  ],
  "totalCount": 4,
  "page": 1,
  "pageSize": 20
}

Notes:

  • The notification cadence field is frequency (values instant, daily, weekly).
  • Geo-scoped subscription types carry centerLatitude + centerLongitude (the search origin) plus radiusKm.

GET /api/v1/subscriptions/{id}

Returns a single subscription by ID.

Auth: Required

Response 200: Single subscription object (same schema as items in GET /api/v1/subscriptions).

Response 404: Subscription not found or does not belong to the customer.


POST /api/v1/subscriptions

Creates a new subscription.

Auth: Required Request Body (by type):

Shop subscription:

{
  "type": "shop",
  "shopId": "vend-42",
  "frequency": "instant"
}

Flower type subscription:

{
  "type": "flower_type",
  "flowerType": "rose",
  "centerLatitude": 52.2297,
  "centerLongitude": 21.0122,
  "radiusKm": 5,
  "frequency": "instant"
}

Price range subscription:

{
  "type": "price_range",
  "maxPrice": 50.00,
  "centerLatitude": 52.2297,
  "centerLongitude": 21.0122,
  "radiusKm": 10,
  "frequency": "weekly"
}

Combined subscription (flower type + price range):

{
  "type": "combined",
  "flowerType": "rose",
  "maxPrice": 60.00,
  "centerLatitude": 52.2297,
  "centerLongitude": 21.0122,
  "radiusKm": 5,
  "frequency": "instant"
}
Field Rules
type shop, flower_type, price_range, combined
frequency instant (default), daily, weekly
centerLatitude / centerLongitude Search origin for geo-scoped types (flower_type, price_range, combined)
radiusKm Search radius (required for flower_type, price_range, combined)
flowerType Required for flower_type and combined — use values from GET /api/v1/reference/flower-types
shopId Required for shop
maxPrice Required for price_range and combined
currency Optional currency for maxPrice

Response 201: Full subscription object (same as GET schema) Response 400: Invalid parameters or missing required fields (e.g. unknown frequency).


PUT /api/v1/subscriptions/{id}

Updates an existing subscription (toggle active, change frequency or criteria).

Auth: Required Request Body: Partial subscription object (only changed fields required)

{
  "isActive": false,
  "frequency": "daily"
}

Response 204: Subscription updated. Response 400: Invalid parameters.


PUT /api/v1/subscriptions/disable-all

Deactivates all subscriptions for the authenticated customer. Sets isActive: false on every subscription without deleting them (SUB-09).

Auth: Required

Response 204: All active subscriptions deactivated. No response body.


DELETE /api/v1/subscriptions/{id}

Deletes a subscription.

Auth: Required Response 204: No content Response 400: Subscription not found or does not belong to the customer.


7. Vendor Endpoints

GET /api/v1/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, exactAddressHidden is true and coordinates are approximate (within ~500 m).
  • isCurrentlyOpen is computed server-side from openingHours. Returns null if opening hours are not configured.

Response 404: Vendor not found.


GET /api/v1/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/v1/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: true means no custom strategy is stored — the platform Standard preset is being used.
  • Discount percentages are cumulative off the BasePrice set when the vendor last called SetPrice or UpdatePrice on 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/v1/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/v1/profile

Updates the customer’s profile.

Auth: Required

Request Body (UpdateProfileRequest): Partial object — only changed fields required.

{
  "name": "Jan Kowalski",
  "phoneNumber": "+48123456789",
  "preferredFlowerTypes": ["rose", "tulip", "peony"]
}
Field Rules
name Optional — single display name (there is no separate first/last name)
phoneNumber Optional
preferredFlowerTypes Optional — array of flower type keys

Response 204: Profile updated. No response body.


PUT /api/v1/profile/notification-settings

Updates notification preferences.

Auth: Required

Request Body:

{
  "pushEnabled": true,
  "emailEnabled": false,
  "subscriptionDigestEnabled": true
}

Response 204: Notification settings updated. No response body.

Notes:

  • Setting pushEnabled: false stops all push notifications from being delivered to the device but does not deactivate individual subscriptions. To deactivate all subscriptions, use PUT /api/v1/subscriptions/disable-all.
  • Setting subscriptionDigestEnabled: false stops daily/weekly digest emails for subscriptions with frequency set to daily or weekly.

POST /api/v1/profile/phone/send-code

Sends an SMS verification code to the supplied phone number. Stores the phone on the customer, resets its verified flag, then dispatches the code via the SMS provider. Rate-limited (policy phone-verify).

Auth: Required

Request Body (SendPhoneCodeRequest):

{
  "phoneNumber": "+48123456789"
}

Response 202: Code dispatched. Response 400: invalid-phone-format (or other validation error). Response 502: sms-send-failed — the SMS provider rejected the send.


POST /api/v1/profile/phone/verify-code

Verifies the 6-digit code sent by send-code. On success the customer’s PhoneVerified flag is set.

Auth: Required

Request Body (VerifyPhoneCodeRequest):

{
  "code": "123456"
}

Response 204: Phone verified. Response 400: Code invalid or expired.


DELETE /api/v1/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/v1/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/v1/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/v1/profile/addresses/{id}

Updates a saved address.

Auth: Required Request Body: Partial address object. Response 204: Address updated. No response body.


DELETE /api/v1/profile/addresses/{id}

Deletes a saved address.

Auth: Required Response 204: No content.


10. Push Notifications Endpoints

POST /api/v1/notifications/devices

Registers a device for push notifications (Firebase Cloud Messaging for Android, APNs for iOS). Upserts by device token.

Auth: Required

Request Body (RegisterDeviceRequest):

{
  "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
appVersion Optional

Response 204: Device registered / updated. No response body.


DELETE /api/v1/notifications/devices/{deviceToken}

Unregisters the current device from push notifications (e.g., on sign-out). The token is passed in the path — there is no request body.

Auth: Required

Response 204: No content.


GET /api/v1/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/v1/notifications/{id}/read

Marks a notification as read.

Auth: Required Response 204: No content.


PUT /api/v1/notifications/read-all

Marks all notifications as read.

Auth: Required Response 204: No content.


11. Reference Data Endpoints

GET /api/v1/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/v1/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 /health

Returns backend health status. Useful for mobile app to detect connectivity issues. (Health checks are mapped at the unversioned root: /health, plus /health/ready and /health/live — they are not under the /api/v1 prefix.)

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 Customers group, radius groups, order groups
Anonymous Connect without token Customers group, radius groups

Anonymous connections can subscribe to JoinCustomersGroup (or JoinRadiusGroup) to receive real-time bouquet availability updates (AUTH-04, DISC-04). Note that bouquet availability broadcasts (BouquetBecameAvailable / BouquetBecameUnavailable / FreshnessUpdated) are currently emitted to the flat customers group; radius groups still exist for geo-scoped subscriptions. 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:

  1. Re-join radius/order groups after reconnection via the Reconnected event handler.
  2. Fetch latest data via REST to fill any events missed during disconnection.
  3. 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. Takes the same (lat, lng, radiusKm) arguments used to join — the group name is derived from them, so they must match.

await connection.InvokeAsync("LeaveRadiusGroup", latitude, longitude, radiusKm);

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);

JoinCustomersGroup / LeaveCustomersGroup

Join (or leave) the flat customers broadcast group used by the web map and mobile clients to receive BouquetBecameAvailable / BouquetBecameUnavailable / FreshnessUpdated / price / view-count broadcasts. No arguments. On joining, the server also pushes the current offline-vase state so stale markers can be hidden immediately.

await connection.InvokeAsync("JoinCustomersGroup");
await connection.InvokeAsync("LeaveCustomersGroup");

JoinCustomerGroup

Join a per-customer group (customer-{customerId}) and the flat customers group.

await connection.InvokeAsync("JoinCustomerGroup", customerId);

JoinVendorGroup

Vendor-portal subscription. Joins vendor-{vendorId} and vendortype-{vendorType}. If the JWT carries a vendor_id claim it must match vendorId.

await connection.InvokeAsync("JoinVendorGroup", vendorId, vendorType);

StartViewingBouquet / StopViewingBouquet

Increment / decrement the real-time view counter for a bouquet (drives BouquetViewCountChanged).

await connection.InvokeAsync("StartViewingBouquet", bouquetId);
await connection.InvokeAsync("StopViewingBouquet", bouquetId);

Server → Client

BouquetBecameAvailable

A bouquet became available. Broadcast to the flat customers group (not per-radius). The payload is flat and minimal — the client should fetch full detail via REST if it needs location, freshness, distance, etc.

{
  "bouquetId": "b3f1a2c4-...",
  "vendorId": "vend-42",
  "vendorType": "shop",
  "status": "available",
  "price": 89.00,
  "currency": "PLN",
  "imageUrl": "https://cdn.bloomplatform.pl/...",
  "timestamp": "2025-04-10T09:15:00Z"
}

Notes:

  • Not all fields are present on every emission — a status-transition emission may carry only bouquetId, vendorId, vendorType, status, timestamp, while a newly-published bouquet also carries price, currency, imageUrl.
  • There is no nested location, distanceKm, freshnessScore, vendorName, vaseId, or availableFrom on this event — fetch GET /api/v1/bouquets/{id}/detail for those.
  • The client subscribes via JoinCustomersGroup (web map) / JoinCustomerGroup (authenticated).

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:

  • vendorType is 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). Broadcast to the customers group and the owning vendor-{vendorId} group.

{
  "bouquetId": "b3f1a2c4-...",
  "freshnessScore": 78,
  "freshnessLabel": "Fresh",
  "timestamp": "2025-04-10T09:15:00Z"
}

OrderStatusChanged

An order’s status has changed.

{
  "orderId": "ord-8821",
  "newStatus": "in_delivery",
  "label": "Out for Delivery",
  "estimatedDeliveryAt": "2025-04-10T11:00:00Z"
}

DeliveryLocationUpdated

Not yet implemented (planned EP-07). Documented here for the mobile client to code against; the backend does not currently emit this event.

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/v1/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/v1/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/v1/customers/register No No Duplicate returns 400 naturally
POST /api/v1/auth/resend-verification Yes Yes Rate-limited; no side effects on repeat
POST /api/v1/orders/preview Yes Yes Read-only, no side effects
POST /api/v1/orders No Yes with Idempotency-Key Same key returns cached 201 response
POST /api/v1/orders/{id}/confirm-payment Yes Yes Duplicate confirmation returns 204
POST /api/v1/orders/{id}/cancel Yes Yes Duplicate cancellation returns 409 harmlessly
POST /api/v1/subscriptions No No Duplicate returns 400 if identical
POST /api/v1/favourites/{id} Yes Yes Idempotent — duplicate returns 204
POST /api/v1/profile/addresses No No May create duplicates — client should check
POST /api/v1/notifications/devices 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/v1/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/v1/config public, max-age=3600 No Cache for 1h; serve stale if offline
GET /api/v1/reference/flower-types public, max-age=86400 Yes Cache for 24h; serve stale if offline
GET /api/v1/reference/freshness-labels public, max-age=86400 Yes Cache for 24h; serve stale if offline
GET /api/v1/vendors/{id} public, max-age=3600 Yes Cache for 1h; serve stale if offline
GET /api/v1/bouquets/{id}/detail private, max-age=60 Yes Short cache; show stale with banner if offline
GET /api/v1/bouquets/nearby private, no-cache No Cache last result; show with “May be outdated” banner
GET /api/v1/orders private, no-cache No Cache last result for offline viewing
GET /api/v1/orders/{id} private, max-age=30 Yes Short cache; show stale with banner
GET /api/v1/profile private, max-age=300 Yes Cache for 5 min
GET /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/v1/bouquets/b3f1a2c4-.../detail
If-None-Match: "abc123"

Response 304: Not Modified — use cached version (saves bandwidth).

Offline Mode Behaviour

When the device is offline, the app should:

  1. Serve cached data for all previously visited screens.
  2. Display a persistent “You are offline” banner at the top of the screen.
  3. Mark all cached bouquet data with “May be outdated” indicators.
  4. Disable actions that require network: checkout, favourites toggle, subscription changes.
  5. 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