Shop api: products, orders and storefront

Shop API v1

Manage products (with photos and lifetime), categories, delivery cities, orders and customers from your own software — and run white-label shop apps on top of the public storefront.

Resumen

Base URL: https://smsgateway24.com/api/v1 Two kinds of access: • Company API token (full access to your shop) — send `Authorization: Bearer <token>` and, to be safe behind proxies that strip that header, also `X-Api-Token: <token>` (a `token` parameter works too). Tokens: Profile → Tokens. Use it on servers only (your website, flower_selector, scripts). • Storefront (white-label client apps) — the public shop key in the URL `/api/v1/storefront/{key}/…` (Profile → Shop). Catalogue is open; customer endpoints need `Authorization: Bearer <access_token>` from the SMS login (or `X-Customer-Token: <access_token>`). Bodies are JSON (`Content-Type: application/json`); file uploads are multipart. Money is a decimal string ("39.90") plus `currency`. Dates are ISO 8601; a date without offset is read in the company timezone. Success: `{"error": 0, "data": …, "meta": {…}}`. Lists carry `meta.total`, `meta.limit`, `meta.offset` (query `limit` ≤ 200, `offset`). Failure: HTTP 4xx/5xx with `{"error": 1, "code": "validation_failed", "message": "…", "fields": {"price": "must be a number ≥ 0"}}`. Codes: unauthorized (401), not_found (404), validation_failed (422), not_available (409, + unavailable_product_ids), reservation_consumed (409), too_many_requests (429), sms_unavailable (503).

Products

A product is something you sell — for a florist usually one unique bouquet (quantity 1). Status: draft | active | sold | withdrawn | expired. `availability` is what a shop window should show: available | reserved | scheduled | sold | withdrawn | expired | draft. Lifetime: a product is on sale between `available_from` (empty = now) and `expires_at`. Without `expires_at` a new product gets the shop's default lifetime (Profile → Shop, 72 h by default); `lifetime_hours: 0` = never expires. Expired products switch to status `expired` automatically. `external_id` is your own id (e.g. the bouquet id in your app): upsert by it to push the same product again without duplicates.
GET /products API token
List products.
Parameters: Query: status (comma list), available=1, category_id, q, external_id, updated_since (ISO date, for incremental sync), sort (sort|newest|price_asc|price_desc|updated), limit, offset.
Response data
{
  "id": 412,
  "title": "Pink peonies with eucalyptus",
  "description": "Hand-tied, 40 cm",
  "price": "45.50",
  "currency": "EUR",
  "photos": ["https://smsgateway24.s3.eu-central-1.amazonaws.com/products/7/20261107_a1b2c3d4.jpg"],
  "cover": "https://smsgateway24.s3.eu-central-1.amazonaws.com/products/7/20261107_a1b2c3d4.jpg",
  "categories": [{"id": 3, "name": "Bouquets"}],
  "attributes": {"flowers": ["peony", "eucalyptus"], "palette": ["pink"]},
  "availability": "available",
  "is_available": true,
  "available_quantity": 1,
  "expires_at": "2026-11-10T09:00:00+01:00",
  "status": "active",
  "external_id": "fs-7f3a",
  "vat_rate": "7.00",
  "quantity": 1,
  "reserved_quantity": 0,
  "available_from": null,
  "sort_order": 0,
  "sold_at": null,
  "withdrawn_at": null,
  "created_at": "2026-11-07T09:00:00+01:00",
  "updated_at": "2026-11-07T09:00:00+01:00"
}
POST /products API token
Create a product. New products are `active` unless you send another status.
Parameters: title*, price*, description, currency, vat_rate, quantity (default 1), status, category_ids[], photos[] (already hosted URLs), attributes{}, external_id, available_from, expires_at | lifetime_hours, sort_order
Request
{"title": "Pink peonies", "price": "45.50", "vat_rate": 7, "category_ids": [3], "external_id": "fs-7f3a", "lifetime_hours": 48}
GET /products/{id} API token
Get one product.
PATCH /products/{id} API token
Update — only the fields you send are changed (PUT works the same).
Parameters: Same fields as create.
Request
{"price": "39.90", "status": "active"}
DELETE /products/{id} API token
Delete. Orders that contain it keep their lines.
PUT /products/external/{external_id} API token
Upsert by your id: creates (201) or updates (200, meta.result).
Parameters: Same fields as create.
Request
{"title": "Pink peonies", "price": 45.5}
GET /products/external/{external_id} API token
Get a product by your id.
POST /products/batch API token
Upsert up to 100 products by external_id. Each row succeeds or fails on its own.
Parameters: products[] — objects with external_id.
Request
{"products": [{"external_id": "fs-1", "title": "Tulips", "price": 25}]}
Response data
[{"index": 0, "external_id": "fs-1", "result": "created", "product": {…}}]
POST /products/{id}/withdraw API token
Take off sale (e.g. sold in the shop).
POST /products/{id}/activate API token
Put back on sale (an expired product gets a fresh default lifetime).
POST /products/{id}/photos API token
Upload a photo to S3 and attach it (multipart). Max 10 photos, 15 MB, JPEG/PNG/WebP.
Parameters: photo* (file), position (0 = make it the cover)
Request
curl -H "Authorization: Bearer $TOKEN" -F photo=@bouquet.jpg https://smsgateway24.com/api/v1/products/412/photos
Response data
{"url": "https://…jpg", "product": {…}}
PUT /products/{id}/photos API token
Reorder / drop photos: send the complete new list of the product's URLs. The first one is the cover.
Parameters: photos[]
Request
{"photos": ["https://…/b.jpg", "https://…/a.jpg"]}
DELETE /products/{id}/photos?url=… API token
Remove one photo.
Parameters: url*

Reservations

Hold products while your customer pays (e.g. during Stripe Checkout on your website), so nobody else can buy the same bouquet. All-or-nothing; a hold lasts the shop's reservation time (15 min by default) or `minutes`. Pass the token to POST /orders — the hold becomes the order. Unused holds are released automatically.
POST /products/reserve API token
Reserve. 409 not_available lists `unavailable_product_ids`.
Parameters: items*[] {product_id, quantity}, minutes (1–120)
Request
{"items": [{"product_id": 412, "quantity": 1}]}
Response data
{"reservation_token": "rsv_…", "expires_at": "2026-11-07T10:30:00+01:00", "items": [{"product_id": 412, "quantity": 1, "price": "45.50", "title": "Pink peonies"}]}
DELETE /reservations/{token} API token
Release a hold (customer abandoned the payment).
Response data
{"released": 1}

Categorías

Shelves of your shop. A product can be in several categories; deleting a category keeps its products.
GET /categories API token
List (with product_count).
Parameters: include_archived=1
Response data
[{"id": 3, "name": "Bouquets", "color": "danger", "sort_order": 0, "is_archived": false, "product_count": 12}]
POST /categories API token
Create.
Parameters: name*, color (primary|info|success|warning|danger|secondary|dark), sort_order
Request
{"name": "Bouquets"}
GET /categories/{id} API token
Get one.
PATCH /categories/{id} API token
Update.
Parameters: name, color, sort_order, is_archived
DELETE /categories/{id} API token
Delete.

Delivery & shop settings

Website webhook: set `webhook_url` (PATCH /shop or Profile → Shop) and we POST `{"event": "products.changed", "product_ids": [412], "new_products": false, "sent_at": "…"}` whenever products are sold, reserved, withdrawn, expired or edited. Verify `X-Shop-Signature: sha256=<HMAC-SHA256 of the raw body with webhook_secret>`, then re-read the products (GET /products/{id}).\n\nDelivery cities with their price (e.g. Wiesbaden 10 €, Mainz 20 €), optional postcode lists and a free-delivery threshold. Orders take the delivery price from here. Slots, lead time and the default product lifetime live in the shop settings.
GET /delivery/cities API token
List cities.
Parameters: only_active=1
Response data
[{"id": 1, "name": "Wiesbaden", "price": "10.00", "free_from": null, "zip_codes": [], "is_active": true, "sort_order": 0}]
POST /delivery/cities API token
Add a city.
Parameters: name*, price*, free_from, zip_codes[] , is_active, sort_order
Request
{"name": "Mainz", "price": 20, "zip_codes": ["55116", "55118"]}
PATCH /delivery/cities/{id} API token
Update a city.
Parameters: Same fields.
DELETE /delivery/cities/{id} API token
Delete a city.
GET /delivery/options API token
Cities + slots + the bookable dates with the slots still open on each.
Response data
{"cities": […], "slots": ["09:00-12:00", "12:00-15:00"], "dates": [{"date": "2026-11-07", "slots": ["15:00-18:00", "18:00-21:00"]}], "pickup_enabled": true, "pickup_address": "…", "min_lead_hours": 2}
GET /shop API token
Shop settings (secrets are never returned).
Response data
{"is_enabled": true, "public_key": "pk_shop_…", "default_lifetime_hours": 72, "reservation_minutes": 15, "delivery_slots": […], "min_lead_hours": 2, "max_days_ahead": 14, "pickup_enabled": true, "cash_enabled": false, "stripe_ready": true, "currency": "EUR", "timezone": "Europe/Berlin", …}
PATCH /shop API token
Update shop settings.
Parameters: is_enabled, shop_name, default_lifetime_hours, reservation_minutes, delivery_slots[], min_lead_hours, max_days_ahead, pickup_enabled, pickup_address, contact_phone, contact_email, brand_color, logo_url, terms_url, privacy_url, otp_device_id, otp_sim, notify_phones[], cash_enabled, stripe_publishable_key, stripe_secret_key, stripe_webhook_secret
Request
{"default_lifetime_hours": 24, "delivery_slots": ["10:00-14:00", "14:00-18:00"]}

Pedidos

Orders land in the same order book as orders typed in the cabinet (accounting, label printer queue, calendar, couriers). Fulfilment status: new → accepted → assembling → ready → delivering → delivered → completed, or cancelled. Payment is separate (`is_paid`). Item prices come from the products, the delivery price from the delivery city — never from the request (except `delivery.price` for a city outside your list). Cancelling an order puts its products back on sale. Idempotency: send your own order id as `external_id` (or an `Idempotency-Key` header) — repeating the call returns the existing order (200, meta.result = existing).
GET /orders API token
List orders, newest first.
Parameters: status (comma list), is_paid, source, customer_id, external_id, deliver_from, deliver_to, created_since, only_shop=1 (only orders with product lines), limit, offset
POST /orders API token
Create an order.
Parameters: reservation_token OR items[] {product_id, quantity} (also free lines {title, price, quantity, vat_rate}), customer {phone*, name} OR contact_id, delivery {type: delivery|pickup, city_id | city, street, zip, address_id, date (Y-m-d), slot, deliver_at, recipient_name, recipient_phone, price}, card_text, comment, notes, is_paid, payment_reference, payment_method_id, category_id, status, source, external_id, subject
Request
{
  "external_id": "pion-web-1001",
  "reservation_token": "rsv_…",
  "customer": {"phone": "+49 176 1234567", "name": "Max"},
  "delivery": {"type": "delivery", "city": "Wiesbaden", "street": "Oranienstraße 52", "zip": "65185",
               "date": "2026-11-08", "slot": "12:00-15:00", "recipient_name": "Anna"},
  "card_text": "Happy birthday!",
  "is_paid": true,
  "payment_reference": "pi_3Q…",
  "source": "web"
}
Response data
{
  "id": 9031,
  "number": "9031",
  "status": "new",
  "is_paid": true,
  "source": "web",
  "external_id": "pion-web-1001",
  "items": [{"id": 1, "product_id": 412, "title": "Pink peonies", "price": "45.50", "quantity": 1, "total": "45.50", "vat_rate": "7.00", "photo": "https://…"}],
  "subtotal": "45.50",
  "delivery_price": "10.00",
  "total": "55.50",
  "currency": "EUR",
  "delivery": {"type": "delivery", "street": "Oranienstraße 52", "city": "Wiesbaden", "zip": "65185",
               "date": "2026-11-08", "slot": "12:00-15:00", "deliver_at": "2026-11-08T12:00:00+01:00",
               "recipient_name": "Anna", "recipient_phone": "+49176…"},
  "card_text": "Happy birthday!",
  "comment": null,
  "customer": {"id": 77, "name": "Max", "phone": "+491761234567"},
  "history": [{"status": "new", "comment": "created via web", "actor": "api", "created_at": "2026-11-07T10:15:00+01:00"}],
  "created_at": "2026-11-07T10:15:00+01:00"
}
GET /orders/{id} API token
Get one order with its status history.
PATCH /orders/{id} API token
Edit descriptive fields (lines are not changed). May also carry status / is_paid.
Parameters: card_text, comment, notes, subject, delivery{…}, category_id, payment_method_id, status, status_comment, is_paid, payment_reference
POST /orders/{id}/status API token
Change the status (adds a history entry).
Parameters: status*, comment
Request
{"status": "delivering"}
POST /orders/{id}/cancel API token
Cancel and return the products to sale.
Parameters: reason
POST /orders/{id}/paid API token
Mark paid / unpaid (books the income).
Parameters: paid (default true), payment_reference
Request
{"paid": true, "payment_reference": "pi_3Q…"}
POST /orders/{id}/photos API token
Attach a photo (multipart, e.g. the assembled bouquet).
Parameters: photo* (file)
Response data
{"url": "https://…", "photos": [...]}
GET /orders/statuses API token
All statuses.
Response data
["new", "accepted", "assembling", "ready", "delivering", "delivered", "completed", "cancelled"]

Customers & addresses

Customers are your company contacts (phone is the key; numbers are normalised with the company phone prefix). Important dates are the contact Events of the cabinet; with Profile → Shop → "Send reminder SMS" the customer gets an SMS from your device remind_days_before days ahead (cron zapp:shop:event-reminders, daily). 29 February is celebrated on 28 February in non-leap years.
GET /customers API token
Search.
Parameters: phone (exact) or q (name / phone fragment), limit, offset
Response data
[{"id": 77, "name": "Max", "phone": "+491761234567"}]
POST /customers API token
Find-or-create by phone (201 created / 200 existing).
Parameters: phone*, name
Request
{"phone": "0176 1234567", "name": "Max"}
GET /customers/{id} API token
Customer with saved addresses.
PATCH /customers/{id} API token
Rename.
Parameters: name
GET /customers/{id}/orders API token
The customer's orders.
Parameters: limit, offset
GET /customers/{id}/addresses API token
Saved addresses.
Response data
[{"id": 5, "customer_id": 77, "label": "Home", "street": "Taunusstr. 1", "city": "Wiesbaden", "zip": "65183", "full": "Taunusstr. 1, 65183 Wiesbaden"}]
POST /customers/{id}/addresses API token
Save an address.
Parameters: street*, city, zip, label
PATCH /addresses/{id} API token
Update an address.
Parameters: street, city, zip, label
DELETE /addresses/{id} API token
Delete an address.
GET /customers/{id}/events API token
Important dates of the customer (the cabinet Events), nearest first.
Response data
[{"id": 8, "title": "Mama — Geburtstag", "person_name": "Mama", "relation": "mother", "occasion": "birthday", "is_own": false, "day": 12, "month": 11, "year": 1965, "next_date": "2026-11-12", "days_left": 5, "turning_age": 61, "remind_days_before": 3, "suggest_bouquets": true, "address": null, "notes": null}]
POST /customers/{id}/events API token
Add an important date.
Parameters: month*, day*, year (null = unknown), person_name* (unless is_own), relation (self|mother|father|spouse|partner|sibling|child|grandparent|friend|colleague|other), occasion (birthday|anniversary|wedding|name_day|other), is_own, remind_days_before (null = no reminder, default 3), suggest_bouquets, address_id, notes, title
Request
{"person_name": "Mama", "relation": "mother", "occasion": "birthday", "day": 12, "month": 11, "remind_days_before": 3}
PATCH /events/{id} API token
Update an important date.
Parameters: Same fields.
DELETE /events/{id} API token
Delete an important date.
GET /events/upcoming API token
Dates of all customers in the next N days (with customer).
Parameters: days (default 14, ≤ 366)

Storefront (client apps)

For the white-label customer apps and public shop pages. Prefix: /api/v1/storefront/{key} where {key} is the public shop key (Profile → Shop; the storefront must be enabled). Only products that are available right now are listed. Customers log in with an SMS code sent from your own device. Card payments use your Stripe account: POST /orders returns a PaymentIntent `client_secret` for Stripe PaymentSheet; point a Stripe webhook (payment_intent.succeeded, payment_intent.canceled) to /api/v1/storefront/{key}/stripe/webhook. Unpaid card orders are cancelled after the reservation time + 30 minutes.
GET /storefront/{key} public
Shop info for branding and checkout.
Response data
{"name": "Pion Blumen", "currency": "EUR", "timezone": "Europe/Berlin", "brand_color": "#A61E4D", "logo_url": "…", "pickup_enabled": true, "pickup_address": "…", "payment_methods": ["card", "cash"], "stripe_publishable_key": "pk_live_…"}
GET /storefront/{key}/categories public
Categories that have products on sale.
GET /storefront/{key}/products public
Products on sale.
Parameters: category_id, q, sort, limit (≤ 100), offset
GET /storefront/{key}/products/{id} public
One product (sold ones stay readable, see `availability`).
GET /storefront/{key}/delivery public
Cities, slots and bookable dates.
POST /storefront/{key}/quote public
Price a cart (no stock is taken).
Parameters: items[] {product_id, quantity}, delivery {type, city_id, zip}
Response data
{"items": […], "unavailable_product_ids": [], "subtotal": "45.50", "delivery_price": "10.00", "total": "55.50", "currency": "EUR", "delivery_error": null}
POST /storefront/{key}/auth/request-code public
Send an SMS login code (1/min, 5/hour per phone).
Parameters: phone*
Request
{"phone": "+49 176 1234567"}
Response data
{"phone": "+491761234567", "expires_in": 600}
POST /storefront/{key}/auth/verify public
Exchange the code for an access token (valid a year, renewed on use).
Parameters: phone*, code*, name, device_name
Request
{"phone": "+491761234567", "code": "4821", "name": "Max"}
Response data
{"access_token": "sct_…", "token_type": "Bearer", "customer": {"id": 77, "name": "Max", "phone": "+491761234567"}}
POST /storefront/{key}/auth/logout customer token
End this session.
GET /storefront/{key}/me customer token
The customer with saved addresses.
PATCH /storefront/{key}/me customer token
Update name / register the push token of this app installation.
Parameters: name, push_token, platform (ios|android)
DELETE /storefront/{key}/me customer token
Delete the account. Sessions, login codes and important dates are removed; a customer without orders is deleted with addresses and profile, a customer with orders is anonymised instead (name "deleted", phone "+XXXXXXXXX", addresses masked) because the orders stay in the books.
Response data
{"deleted": true, "anonymized": false}
GET /storefront/{key}/me/addresses customer token
Saved addresses.
POST /storefront/{key}/me/addresses customer token
Save an address.
Parameters: street*, city, zip, label
PATCH /storefront/{key}/me/addresses/{id} customer token
Update an address.
DELETE /storefront/{key}/me/addresses/{id} customer token
Delete an address.
GET /storefront/{key}/me/events customer token
Important dates of the customer, nearest first (next_date, days_left, turning_age).
POST /storefront/{key}/me/events customer token
Add an important date (same fields as POST /customers/{id}/events).
Request
{"person_name": "Mama", "relation": "mother", "occasion": "birthday", "day": 12, "month": 11, "remind_days_before": 3, "suggest_bouquets": true}
PATCH /storefront/{key}/me/events/{id} customer token
Update an important date.
DELETE /storefront/{key}/me/events/{id} customer token
Delete an important date.
POST /storefront/{key}/orders customer token
Place an order. Stock is taken immediately; card orders return the Stripe PaymentIntent.
Parameters: items*[] {product_id, quantity}, payment_method (card|cash), delivery {type, city_id, street, zip, address_id, date*, slot, recipient_name, recipient_phone}, card_text, comment, external_id (client-side idempotency key)
Request
{"items": [{"product_id": 412}], "payment_method": "card",
 "delivery": {"type": "delivery", "city_id": 1, "street": "Kirchgasse 1", "zip": "65185", "date": "2026-11-08", "slot": "15:00-18:00"}}
Response data
{"order": {…}, "payment": {"method": "card", "client_secret": "pi_…_secret_…", "payment_intent_id": "pi_…", "publishable_key": "pk_live_…", "amount": 5550, "currency": "eur"}}
GET /storefront/{key}/me/orders customer token
Order history.
Parameters: limit, offset
GET /storefront/{key}/me/orders/{id} customer token
One order with the status timeline.
POST /storefront/{key}/me/orders/{id}/pay customer token
New PaymentIntent for an unpaid order.
POST /storefront/{key}/me/orders/{id}/cancel customer token
Cancel while still new and unpaid.
POST /storefront/{key}/stripe/webhook public
Stripe webhook endpoint (signature checked with the shop's webhook secret).

Typical flows

1. Florist app → shop

PUT  /products/external/{bouquet-id}   {"title": "…", "price": 45.5, "category_ids": [3]}
POST /products/{id}/photos              (multipart photo)
POST /products/{id}/withdraw            sold at the counter
GET  /products?updated_since=…          pick up sales made online

2. Website with Stripe Checkout

GET  /products?available=1             show today's bouquets
GET  /delivery/options                  cities, prices, dates and slots for the checkout form
POST /products/reserve                  hold the cart while the customer pays → reservation_token
     … Stripe Checkout …
POST /orders                            {"reservation_token": "…", "external_id": "web-1001", "is_paid": true, …}
DELETE /reservations/{token}            payment abandoned

3. Customer app

GET  /storefront/{key}                  branding, payment methods
GET  /storefront/{key}/products         catalogue
POST /storefront/{key}/auth/request-code → /auth/verify → access_token
POST /storefront/{key}/orders           → Stripe PaymentSheet with payment.client_secret
GET  /storefront/{key}/me/orders/{id}   status timeline