Documentation
API v1.0

Axelo API

The backend service powering the Axelo transport platform.

REST JSON HTTPS Node.js

Overview

The Axelo API provides endpoints for managing transport orders, generating PDF reports, configuring vehicle classes and goods types, sending push notifications, and providing calendar feeds.

Base URL

All API requests should be made to:

https://api.axelo.app

Available Modules

ModuleBase PathEndpointsDescription
Cancellation/reports/cancellation1 + FirestoreStorno system with form types F1–F5, photo upload, PDF generation
Reports/reports4Generate, download, list, and delete delivery PDF reports
Vehicle Classes/vehicle-classes8CRUD, pricing calculation, and recommendations
Goods Types/goods-types6CRUD for shipment categories
Notifications/notifications9Push notifications, broadcasts, maintenance alerts
Calendar/calendar1iCal feed for order schedules
Mail APImailapi.axelo.app/v13Email verification codes and password-reset mails via Microsoft Graph (separate service)
Admin/api/admin1Admin token verification
Payments (Stripe)https://payments.axelo.app10Stripe Connect onboarding, PaymentSheet, webhooks & 24-h driver payouts

Authentication

Admin-protected endpoints require a Firebase Auth ID token in the Authorization header. The user must have isAdmin: true set in their Firestore users document.

Authorization Header
Authorization: Bearer <firebase-id-token>
Note

Endpoints marked with Admin require this header. Public endpoints have no authentication requirement.

Getting a Token

import FirebaseAuth

let token = try await Auth.auth().currentUser?.getIDToken()
var request = URLRequest(url: url)
request.setValue("Bearer \(token ?? "")", forHTTPHeaderField: "Authorization")
import com.google.firebase.auth.FirebaseAuth

val user = FirebaseAuth.getInstance().currentUser
user?.getIdToken(true)?.addOnSuccessListener { result ->
    val token = result.token
    val request = Request.Builder()
        .url(url)
        .header("Authorization", "Bearer $token")
        .build()
}
import { getAuth } from "firebase/auth";

const token = await getAuth().currentUser.getIdToken();
const res = await fetch(url, {
  headers: { "Authorization": `Bearer ${token}` }
});

Error Handling

The API uses standard HTTP status codes. Error responses include a JSON body:

Error Response Format
{
  "error": "Missing required field: reason",
  "details": "Additional context about the error"
}
CodeMeaning
200Success
201Created
400Bad Request — missing or invalid parameters
401Unauthorized — missing or invalid token
403Forbidden — not an admin
404Not Found
409Conflict — resource already exists
429Too Many Requests — rate limited
500Internal Server Error

Rate Limiting

Rate limits are enforced per IP address using draft-7 standard headers.

ScopeLimitWindow
Global (all endpoints)100 requests1 minute
Auth endpoints5 requests1 minute
Action endpoints10 requests1 minute

Cancellation (Storno)

Complete order cancellation system with form types, photo evidence, and PDF generation

Overview

The Axelo cancellation system (Storno) handles order cancellations based on the current order status and the user's role (driver or client). Depending on timing and circumstances, the system routes the user to one of five form types, each with different requirements, reasons, and cost implications.

Important

The form type is determined automatically based on order status, user role, and time until scheduled pickup. The client application should implement the same routing logic described in the Form Routing section.

Form Types

FormNameConditionPaid
F1Late CancellationLess than 60 minutes before pickupYes
F2Cancellation En RouteDriver is en route to pickup locationYes
F3Cancellation at PickupDriver is at the pickup locationYes
F4Wait Time ReportWait time exceeding 30 minutesNo
F5General IssueComplaint or general issue (client-only edge case)No

Cancellation Actions

Not all cancellations require a form. The system defines four possible actions:

ActionDescriptionForm Required
directCancelFree cancellation — executed immediately after confirmation dialogNo
form(F1–F5)A cancellation form must be filled out with reason, description, and optional photosYes
supportOnlyCancellation only possible through support (goods already in transit)No
notPossibleCancellation is not possible in this state (delivered, already cancelled)No

Cost Implications

FormDriverClient
F1Negative review (waivable with valid reason + proof)Flat fee of €20.00 (waivable with valid reason + proof)
F2Negative review + possible suspension on repeat (waivable)Partial compensation fee (minimum rate for vehicle class)
F3–F5Fees may apply depending on circumstances

Form Routing

The cancellation form type is determined by the order status, user role, and time until scheduled pickup. The client app must implement this decision matrix to display the correct UI.

Driver Decision Matrix

Order StatusConditionAction
pending / requestedDrivernotPossible
accepted> 60 min until pickup (non-ASAP)directCancel
accepted≤ 60 min until pickup OR ASAP orderform(F1)
drivingToPickupform(F2)
arrivedAtPickupform(F3)
pickedUp / drivingToDeliverysupportOnly
arrivedAtDelivery / deliverednotPossible
cancelled / cancellationRequestednotPossible

Client Decision Matrix

Order StatusConditionAction
pending / requestedDriverdirectCancel
accepted> 60 min until pickup (non-ASAP)directCancel
accepted≤ 60 min until pickup OR ASAP orderform(F1)
drivingToPickupform(F2)
arrivedAtPickupform(F3)
pickedUp / drivingToDeliveryform(F5)
arrivedAtDelivery / deliverednotPossible
cancelled / cancellationRequestednotPossible

Implementation Example

func determineAction(status: OrderStatus, minutesUntilPickup: Int?, isASAP: Bool, role: UserRole) -> StornoAction {
    switch (role, status) {
    case (.driver, .pending), (.driver, .requestedDriver):
        return .notPossible
    case (.client, .pending), (.client, .requestedDriver):
        return .directCancel
    case (_, .accepted):
        if !isASAP, let minutes = minutesUntilPickup, minutes > 60 {
            return .directCancel
        }
        return .form(.f1)
    case (_, .drivingToPickup):
        return .form(.f2)
    case (_, .arrivedAtPickup):
        return .form(.f3)
    case (.driver, .pickedUp), (.driver, .drivingToDelivery):
        return .supportOnly
    case (.client, .pickedUp), (.client, .drivingToDelivery):
        return .form(.f5)
    default:
        return .notPossible
    }
}
fun determineAction(status: OrderStatus, minutesUntilPickup: Int?, isASAP: Boolean, role: UserRole): StornoAction {
    if (status == OrderStatus.CANCELLED || status == OrderStatus.CANCELLATION_REQUESTED) {
        return StornoAction.NotPossible
    }
    return when (role) {
        UserRole.DRIVER -> when (status) {
            OrderStatus.PENDING, OrderStatus.REQUESTED_DRIVER -> StornoAction.NotPossible
            OrderStatus.ACCEPTED -> {
                if (!isASAP && minutesUntilPickup != null && minutesUntilPickup > 60)
                    StornoAction.DirectCancel
                else StornoAction.Form(FormType.F1)
            }
            OrderStatus.DRIVING_TO_PICKUP -> StornoAction.Form(FormType.F2)
            OrderStatus.ARRIVED_AT_PICKUP -> StornoAction.Form(FormType.F3)
            OrderStatus.PICKED_UP, OrderStatus.DRIVING_TO_DELIVERY -> StornoAction.SupportOnly
            else -> StornoAction.NotPossible
        }
        UserRole.CLIENT -> when (status) {
            OrderStatus.PENDING, OrderStatus.REQUESTED_DRIVER -> StornoAction.DirectCancel
            OrderStatus.ACCEPTED -> {
                if (!isASAP && minutesUntilPickup != null && minutesUntilPickup > 60)
                    StornoAction.DirectCancel
                else StornoAction.Form(FormType.F1)
            }
            OrderStatus.DRIVING_TO_PICKUP -> StornoAction.Form(FormType.F2)
            OrderStatus.ARRIVED_AT_PICKUP -> StornoAction.Form(FormType.F3)
            OrderStatus.PICKED_UP, OrderStatus.DRIVING_TO_DELIVERY -> StornoAction.Form(FormType.F5)
            else -> StornoAction.NotPossible
        }
    }
}
function determineAction(status, minutesUntilPickup, isASAP, role) {
  if (["cancelled", "cancellationRequested"].includes(status)) return "notPossible";

  if (role === "driver") {
    switch (status) {
      case "pending": case "requestedDriver": return "notPossible";
      case "accepted":
        return (!isASAP && minutesUntilPickup > 60) ? "directCancel" : "form:F1";
      case "drivingToPickup": return "form:F2";
      case "arrivedAtPickup": return "form:F3";
      case "pickedUp": case "drivingToDelivery": return "supportOnly";
      default: return "notPossible";
    }
  } else {
    switch (status) {
      case "pending": case "requestedDriver": return "directCancel";
      case "accepted":
        return (!isASAP && minutesUntilPickup > 60) ? "directCancel" : "form:F1";
      case "drivingToPickup": return "form:F2";
      case "arrivedAtPickup": return "form:F3";
      case "pickedUp": case "drivingToDelivery": return "form:F5";
      default: return "notPossible";
    }
  }
}

Cancellation Reasons

Each form type has a predefined set of selectable reasons. Some reasons require the user to provide photo evidence.

F1 Reasons (Driver & Client)

IDLabelPhoto Required
vehicle_breakdownVehicle BreakdownYes
accidentAccidentYes
medical_emergencyMedical EmergencyNo
extreme_weatherExtreme WeatherNo
police_checkPolice CheckNo
private_emergencyPrivate EmergencyNo
business_reasonBusiness ReasonNo
otherOther ReasonNo

F2 Driver Reasons

IDLabelPhoto Required
vehicle_breakdownVehicle BreakdownYes
accidentAccidentYes
medical_emergencyMedical EmergencyNo
road_closureRoad ClosureNo
extreme_weatherExtreme WeatherNo
police_checkPolice CheckNo
otherOther ReasonNo

F2 Client Reasons

IDLabelPhoto Required
no_longer_neededOrder No Longer NeededNo
wrong_orderWrong OrderNo
goods_unavailableGoods Not AvailableNo
other_carrierFound Another CarrierNo
otherOther ReasonNo

F3–F5 Generic Reasons

IDLabelPhoto Required
general_issueGeneral IssueNo
otherOther ReasonNo

Validation Rules

RuleF1F2F3–F5
Min. description length20 chars30 chars20 chars
Max photos353
Photo required if reason demands itYesYesNo
confirmedTruthfulnessRequiredRequiredRequired
confirmedConditionsRequiredRequiredRequired

Photo Upload

Evidence photos are uploaded to Firebase Storage before submitting the cancellation request. The resulting download URLs are then included in the API request body.

Storage Path

Storage Path Pattern
cancellation_photos/{orderId}/{index}.jpg

Where {orderId} is the Firestore order document ID and {index} is the 0-based photo index.

Upload Example

import FirebaseStorage

let ref = Storage.storage().reference()
    .child("cancellation_photos/\(orderId)/\(index).jpg")

guard let data = image.jpegData(compressionQuality: 0.7) else { return }

let metadata = StorageMetadata()
metadata.contentType = "image/jpeg"

let _ = try await ref.putDataAsync(data, metadata: metadata)
let url = try await ref.downloadURL()
// Store url.absoluteString in photoURLs array
val ref = FirebaseStorage.getInstance().reference
    .child("cancellation_photos/$orderId/$index.jpg")

val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, baos)
val data = baos.toByteArray()

ref.putBytes(data)
    .addOnSuccessListener {
        ref.downloadUrl.addOnSuccessListener { uri ->
            photoURLs.add(uri.toString())
        }
    }
import { getStorage, ref, uploadBytes, getDownloadURL } from "firebase/storage";

const storage = getStorage();
const storageRef = ref(storage, `cancellation_photos/${orderId}/${index}.jpg`);

const blob = await fetch(imageUri).then(r => r.blob());
await uploadBytes(storageRef, blob, { contentType: "image/jpeg" });
const url = await getDownloadURL(storageRef);
photoURLs.push(url);

Firebase Storage Security Rules

Storage Rules
match /cancellation_photos/{orderId}/{fileName} {
  allow read: if request.auth != null;
  allow write: if request.auth != null
    && fileName.matches('[0-9]+\\.jpg')
    && request.resource.size < 10 * 1024 * 1024
    && request.resource.contentType.matches('image/.*');
}
POST /reports/cancellation

Generates a cancellation report PDF (F1–F5). The server downloads evidence photos from their Firebase Storage URLs, embeds them into the PDF, uploads the result to Storage, and returns the PDF bytes.

Request Body

Wrap all fields inside a request object:

FieldTypeRequiredDescription
request.orderIdStringYesFirestore order document ID
request.formTypeStringYes"F1", "F2", "F3", "F4", or "F5"
request.reasonStringYesCancellation reason label (from the reason tables)
request.detailedDescriptionStringYesDetailed description (min 20–30 chars depending on form)
request.confirmedTruthfulnessBooleanYesMust be true
request.confirmedConditionsBooleanYesMust be true
request.orderNumberStringNoHuman-readable order number (e.g. "AXL-2026-001234")
request.requestedByStringNo"driver" or "client"
request.requestedAtISO 8601NoTimestamp of the request
request.orderStatusStringNoOrder status at time of request
request.pickupAddressStringNoPickup address
request.deliveryAddressStringNoDelivery address
request.scheduledPickupTimeISO 8601NoScheduled pickup time
request.minutesUntilPickupNumberNoMinutes until scheduled pickup
request.vehicleClassStringNoVehicle class ID (e.g. "pkwCaddy")
request.priceNumberNoOrder price in EUR
request.distanceToPickupKmNumberNoF2: driver's distance to pickup in km
request.estimatedArrivalMinutesNumberNoF2: estimated arrival time in minutes
request.photoURLsString[]NoFirebase Storage URLs of evidence photos

Response

HeaderDescription
Content-Typeapplication/pdf
X-Download-URLFirebase Storage download URL of the uploaded PDF
X-Storage-PathStorage path: cancellation_reports/{orderId}_{formType}.pdf

Example

curl -X POST https://api.axelo.app/reports/cancellation \
  -H "Content-Type: application/json" \
  -d '{
    "request": {
      "orderId": "abc123",
      "formType": "F1",
      "reason": "Vehicle Breakdown",
      "detailedDescription": "Engine failure on highway A1 near km 42",
      "confirmedTruthfulness": true,
      "confirmedConditions": true,
      "orderNumber": "AXL-2026-001234",
      "requestedBy": "driver",
      "requestedAt": "2026-02-21T14:30:00.000Z",
      "pickupAddress": "Musterstr. 1, 1010 Wien",
      "deliveryAddress": "Beispielweg 5, 4020 Linz",
      "vehicleClass": "pkwCaddy",
      "price": 45.90,
      "photoURLs": [
        "https://firebasestorage.googleapis.com/..."
      ]
    }
  }' --output cancellation.pdf
let requestBody: [String: Any] = [
    "request": [
        "orderId": order.id,
        "formType": formType.rawValue,
        "reason": selectedReason.label,
        "detailedDescription": description,
        "confirmedTruthfulness": true,
        "confirmedConditions": true,
        "orderNumber": order.deliveryOrderNumber ?? "",
        "requestedBy": userRole == .driver ? "driver" : "client",
        "requestedAt": ISO8601DateFormatter().string(from: Date()),
        "pickupAddress": order.pickupLocation.address,
        "deliveryAddress": order.deliveryLocation.address,
        "vehicleClass": order.vehicleClass.rawValue,
        "price": order.price,
        "photoURLs": uploadedPhotoURLs
    ]
]
var urlRequest = URLRequest(url: URL(string: "https://api.axelo.app/reports/cancellation")!)
urlRequest.httpMethod = "POST"
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.timeoutInterval = 60
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: requestBody)

let (data, response) = try await URLSession.shared.data(for: urlRequest)
let storageURL = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "X-Download-URL")
val requestJson = JSONObject().apply {
    put("request", JSONObject().apply {
        put("orderId", order.id)
        put("formType", "F1")
        put("reason", "Vehicle Breakdown")
        put("detailedDescription", "Engine failure on highway A1")
        put("confirmedTruthfulness", true)
        put("confirmedConditions", true)
        put("orderNumber", order.orderNumber)
        put("requestedBy", "driver")
        put("pickupAddress", order.pickupAddress)
        put("deliveryAddress", order.deliveryAddress)
        put("vehicleClass", order.vehicleClass)
        put("price", order.price)
        put("photoURLs", JSONArray(uploadedPhotoURLs))
    })
}
val body = requestJson.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
    .url("https://api.axelo.app/reports/cancellation")
    .post(body)
    .build()

val client = OkHttpClient.Builder()
    .callTimeout(60, TimeUnit.SECONDS)
    .build()
val response = client.newCall(request).execute()
val pdfBytes = response.body?.bytes()
val storageURL = response.header("X-Download-URL")
const res = await fetch("https://api.axelo.app/reports/cancellation", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    request: {
      orderId: "abc123",
      formType: "F1",
      reason: "Vehicle Breakdown",
      detailedDescription: "Engine failure on highway A1 near km 42",
      confirmedTruthfulness: true,
      confirmedConditions: true,
      orderNumber: "AXL-2026-001234",
      requestedBy: "driver",
      pickupAddress: "Musterstr. 1, 1010 Wien",
      deliveryAddress: "Beispielweg 5, 4020 Linz",
      vehicleClass: "pkwCaddy",
      price: 45.90,
      photoURLs: ["https://firebasestorage.googleapis.com/..."]
    }
  })
});
const pdfBlob = await res.blob();
const storageURL = res.headers.get("X-Download-URL");

Firestore Data Model

After the PDF is generated, the complete cancellation request is saved to the cancellationRequests Firestore collection. This document serves as the permanent record for support review.

Collection Path

Firestore Path
cancellationRequests/{requestId}

Document Schema

FieldTypeDescription
idStringUUID — document ID
requesterIdStringFirebase Auth UID of the requesting user
clientIdStringFirebase Auth UID of the order's client
driverIdString?Firebase Auth UID of the assigned driver (null if unassigned)
orderIdStringReferenced order document ID
orderNumberStringHuman-readable order number
formTypeString"F1""F5"
requestedByString"driver" or "client"
requestedAtTimestampISO 8601 date of the request
orderStatusStringOrder status at time of cancellation
pickupAddressStringPickup address
deliveryAddressStringDelivery address
scheduledPickupTimeTimestampScheduled pickup time
minutesUntilPickupNumber?Minutes until pickup at time of request
vehicleClassString?Vehicle class ID
priceNumberOrder price in EUR
distanceToPickupKmNumber?F2 only: distance to pickup
estimatedArrivalMinutesNumber?F2 only: ETA
reasonStringSelected cancellation reason label
detailedDescriptionStringUser-written detailed description
photoURLsString[]Firebase Storage URLs of evidence photos
confirmedTruthfulnessBooleanUser confirmed truthfulness
confirmedConditionsBooleanUser accepted cancellation conditions
statusString"pending", "approved", or "rejected"
pdfURLString?Firebase Storage URL of the generated PDF
costAmountNumber?Cancellation cost (set by support)
costDescriptionString?Cost justification (set by support)
reviewedByString?UID of the support agent who reviewed
reviewedAtTimestamp?Review timestamp
reviewNotesString?Internal review notes

Security Rules

Both the requesting user and the other order party (client or driver) can read a cancellation request. This ensures the affected party can view the cancellation PDF.

Firestore Rules
match /cancellationRequests/{requestId} {
  allow read: if request.auth != null
    && (resource.data.requesterId == request.auth.uid
        || resource.data.clientId == request.auth.uid
        || resource.data.driverId == request.auth.uid);

  allow create: if request.auth != null
    && request.resource.data.requesterId == request.auth.uid
    && request.resource.data.keys().hasAll([
      'orderId', 'formType', 'reason', 'detailedDescription',
      'confirmedTruthfulness', 'confirmedConditions',
      'clientId', 'driverId'
    ]);

  allow update: if false;  // Only via Admin SDK
  allow delete: if false;
}

Integration Flow

The complete cancellation flow consists of four sequential steps. Implement them in order for a full integration.

Step 1: Determine Action

Use the Form Routing logic to determine which cancellation action applies. If the result is directCancel, skip to updating the order status. If it's form(F1–F5), proceed with the wizard flow.

Step 2: Upload Evidence Photos

If the user selected photos, upload them to Firebase Storage at cancellation_photos/{orderId}/{index}.jpg. Collect the resulting download URLs. See Photo Upload.

Step 3: Generate Cancellation PDF

Call POST /reports/cancellation with the full request object including photoURLs. The server downloads the photos, embeds them into the PDF, uploads it to cancellation_reports/{orderId}_{formType}.pdf, and returns the PDF bytes. Save the X-Download-URL header value as the permanent PDF URL.

Step 4: Save to Firestore & Update Order

Save the complete StornoRequest (including pdfURL from the previous step) to the cancellationRequests collection. Then update the order status to cancellationRequested.

Order Status Update

The order status should be updated after showing a success screen to the user. If updated immediately, the parent view may re-render and dismiss the cancellation UI before the user sees the confirmation.

Sequence Diagram

Flow Overview
User          Client App       Firebase Storage      API Server         Firestore
 |                |                    |                   |                  |
 | tap cancel     |                    |                   |                  |
 |───────────────>| determineAction()  |                   |                  |
 |                |────────┐           |                   |                  |
 |                |<───────┘ form(F2)  |                   |                  |
 |                |                    |                   |                  |
 | fill form      |                    |                   |                  |
 | select photos  |                    |                   |                  |
 | confirm        |                    |                   |                  |
 |───────────────>|                    |                   |                  |
 |                | upload photos      |                   |                  |
 |                |───────────────────>| save to Storage   |                  |
 |                |<───────────────────| return URLs       |                  |
 |                |                    |                   |                  |
 |                | POST /reports/cancellation             |                  |
 |                |───────────────────────────────────────>|                  |
 |                |                    | fetch photos      |                  |
 |                |                    |<──────────────────|                  |
 |                |                    |──────────────────>|                  |
 |                |                    |                   | generate PDF     |
 |                |                    |    upload PDF     |                  |
 |                |                    |<──────────────────|                  |
 |                |<─────────────────────────────── PDF bytes + X-Download-URL|
 |                |                    |                   |                  |
 |                | save StornoRequest |                   |                  |
 |                |──────────────────────────────────────────────────────────>|
 |                |                    |                   |                  |
 | show success   |                    |                   |                  |
 |<───────────────|                    |                   |                  |
 |                |                    |                   |                  |
 | tap "Done"     |                    |                   |                  |
 |───────────────>| update order status: cancellationRequested               |
 |                |──────────────────────────────────────────────────────────>|

Reports

Generate, download, list, and delete delivery PDF reports

POST /reports/delivery

Generates a professional delivery report PDF. Supports single-delivery and multi-stop orders. Optionally uploads the PDF to Firebase Storage.

Request Body

FieldTypeRequiredDescription
orderObjectYesThe full transport order object
clientNameStringNoName of the client
driverNameStringNoName of the driver
userIdStringNoIf provided, uploads PDF to reports/{userId}/{orderId}.pdf
pickupImageBase64StringNoBase64-encoded pickup photo
pickupImageURLStringNoStorage URL (fallback if no base64)
deliveryImageBase64StringNoBase64-encoded delivery photo
deliveryImageURLStringNoStorage URL (fallback)
signatureImageBase64StringNoBase64-encoded recipient signature
signatureImageURLStringNoStorage URL (fallback)

Response Headers

HeaderDescription
Content-Typeapplication/pdf
X-Download-URLFirebase Storage download URL (if userId provided)
X-Storage-PathStorage path of the uploaded PDF

Example

curl -X POST https://api.axelo.app/reports/delivery \
  -H "Content-Type: application/json" \
  -d '{
    "order": {
      "id": "abc123",
      "deliveryOrderNumber": "AXL-2026-001",
      "status": "delivered",
      "pickupLocation": { "address": "Musterstr. 1, 1010 Wien" },
      "deliveryLocation": { "address": "Beispielweg 5, 4020 Linz" },
      "price": 45.90
    },
    "clientName": "Max Mustermann",
    "driverName": "Hans Fahrer",
    "userId": "firebase-uid"
  }' --output report.pdf
var request = URLRequest(url: URL(string: "https://api.axelo.app/reports/delivery")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

let body: [String: Any] = [
    "order": orderDict,
    "clientName": "Max Mustermann",
    "driverName": "Hans Fahrer",
    "userId": Auth.auth().currentUser?.uid ?? ""
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
// data contains the PDF bytes
val client = OkHttpClient()
val json = JSONObject().apply {
    put("order", orderJson)
    put("clientName", "Max Mustermann")
    put("driverName", "Hans Fahrer")
    put("userId", FirebaseAuth.getInstance().currentUser?.uid)
}
val body = json.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
    .url("https://api.axelo.app/reports/delivery")
    .post(body)
    .build()
val response = client.newCall(request).execute()
val pdfBytes = response.body?.bytes()
const res = await fetch("https://api.axelo.app/reports/delivery", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    order: { id: "abc123", deliveryOrderNumber: "AXL-2026-001",
             status: "delivered", price: 45.90,
             pickupLocation: { address: "Musterstr. 1, 1010 Wien" },
             deliveryLocation: { address: "Beispielweg 5, 4020 Linz" } },
    clientName: "Max Mustermann",
    driverName: "Hans Fahrer",
    userId: "firebase-uid"
  })
});
const pdfBlob = await res.blob();
GET/reports/download/:userId/:orderId

Downloads a previously generated report PDF from Firebase Storage. Returns application/pdf stream or 404.

Path Parameters

ParameterTypeDescription
userIdStringFirebase UID
orderIdStringOrder document ID
cURL
curl https://api.axelo.app/reports/download/USER_ID/ORDER_ID --output report.pdf
GET/reports/list/:userId

Lists all generated reports for a user.

Response
{
  "reports": [
    { "name": "abc123.pdf", "path": "reports/uid/abc123.pdf",
      "created": "2026-02-21T14:30:00.000Z", "size": "125432" }
  ]
}
DELETE/reports/delete/:userId/:orderId

Deletes a report PDF from Firebase Storage.

Response
{ "success": true, "message": "Report deleted" }

Vehicle Classes

CRUD, pricing calculation, and vehicle recommendations

GET/vehicle-classes

Returns all vehicle classes sorted by sortOrder. Use ?includeInactive=true to include inactive classes.

Response
[{
  "id": "pkw_caddy",
  "name": "PKW/Caddy",
  "icon": "car.fill",
  "sortOrder": 0,
  "active": true,
  "maxPayload": 500,
  "maxVolume": 1,
  "interiorLength": 100,
  "interiorWidth": 100,
  "interiorHeight": 70,
  "palletCapacity": 0,
  "hasLiftgate": false,
  "pricing": {
    "basePrice": 25.0, "hourlyRate": 30.0,
    "pricePerKmOutside": 0.85, "pricePerKmVienna": 0.72,
    "nightWeekendSurcharge": 0.50, "rushHourSurcharge": 0.10
  },
  "formatted": { "payload": "500 kg", "volume": "Bis 1 m³", "dimensions": "~100×100×70 cm" }
}]
GET/vehicle-classes/:id

Returns a single vehicle class by document ID (e.g. pkw_caddy, klein_lkw, klein_lkw_lbw).

POST/vehicle-classesAdmin

Creates a new vehicle class. Returns 201 on success, 409 if ID exists.

Request Body

FieldTypeRequiredDescription
idStringYesUnique ID (e.g. "lkw_7t")
nameStringYesDisplay name
pricingObjectYesPricing config (see GET response)
iconStringNoSF Symbol (default: "truck.box.fill")
sortOrderNumberNoSort position (default: 99)
maxPayloadNumberNoMax payload in kg
maxVolumeNumberNoMax volume in m³
palletCapacityNumberNoEuro pallet count
hasLiftgateBooleanNoHas liftgate
PUT/vehicle-classes/:idAdmin

Partial update. Only provided fields are changed. Accepts the same fields as the create endpoint.

PATCH/vehicle-classes/:id/toggleAdmin

Toggles active status. No request body needed.

Response
{ "id": "pkw_caddy", "active": false }
DELETE/vehicle-classes/:idAdmin

Permanently deletes a vehicle class.

Response
{ "deleted": true, "id": "pkw_caddy" }
POST/vehicle-classes/calculate-price

Calculates delivery price based on vehicle class, distance, and surcharges.

Request Body

FieldTypeRequiredDescription
vehicleClassIdStringYesVehicle class ID
distanceNumberYesDistance in km
isWithinViennaBooleanNoUse Vienna rate
isNightOrWeekendBooleanNoNight/weekend surcharge
isRushHourBooleanNoRush hour surcharge
Response
{
  "vehicleClass": "PKW/Caddy", "distance": 25, "price": 46.25,
  "breakdown": {
    "basePrice": 25.0, "kmRate": 0.85, "distanceCost": 21.25,
    "surcharges": { "nightWeekend": 0, "rushHour": 0 }
  }
}
POST/vehicle-classes/recommend

Recommends the smallest vehicle class that fits all criteria. At least one of weight (kg), volume (m³), or pallets is required.

Response
{
  "recommended": { "id": "klein_lkw", "name": "Klein-LKW" },
  "details": [
    { "criteria": "weight", "vehicleClass": "klein_lkw", "name": "Klein-LKW" }
  ]
}

Goods Types

CRUD for shipment and goods categories

GET/goods-types

Returns all goods types sorted by sortOrder. Use ?includeInactive=true to include inactive.

Response
[{
  "id": "europalette", "name": "Europalette", "dimensions": "120×80 cm",
  "length": 120, "width": 80, "height": null,
  "icon": "shippingbox.fill", "isCustom": false, "sortOrder": 0, "active": true
}]
GET/goods-types/:id

Returns a single goods type by ID.

POST/goods-typesAdmin

Creates a new goods type. Required: id, name. Returns 201 / 409.

Request Body

FieldTypeRequiredDescription
idStringYesUnique identifier
nameStringYesDisplay name
dimensionsStringNoHuman-readable dimensions
length / width / heightNumberNoDimensions in cm
iconStringNoSF Symbol
isCustomBooleanNoCustom dimension input
sortOrderNumberNoSort position
PUT/goods-types/:idAdmin

Partial update. Only provided fields are changed.

PATCH/goods-types/:id/toggleAdmin

Toggles active status.

DELETE/goods-types/:idAdmin

Permanently deletes a goods type.

Notifications

Push notifications, broadcasts, and maintenance alerts

POST/notifications/sendAdmin

Sends a push notification to a single user via FCM.

Request Body

FieldTypeRequiredDescription
userIdStringYesTarget Firebase UID
titleStringYesNotification title
bodyStringYesBody text
dataObjectNoCustom payload
Response
{ "success": true, "messageId": "projects/.../messages/..." }
POST/notifications/broadcastAdmin

Sends a notification to a list of users. Requires userIds (array), title, body.

Response
{ "success": true, "sent": 15, "total": 20 }
POST/notifications/broadcast-driversAdmin

Broadcasts to all available drivers. Filters by region (matches city, postal code, or delivery areas). If orderId is provided, triggers the automatic driver notification flow instead.

Response
{ "success": true, "sent": 8, "totalDrivers": 12, "region": "Wien" }
POST/notifications/maintenanceAdmin

Creates a maintenance alert and pushes it to the target audience.

Request Body

FieldTypeRequiredDescription
titleStringYesAlert title
messageStringYesAlert message
typeStringNo"info" | "warning" | "critical"
targetAudienceStringNo"all" | "drivers" | "clients"
endsAtISO StringNoEnd time
Response
{ "success": true, "id": "firestore-doc-id", "sent": 42 }
GET/notifications/maintenanceAdmin

Lists maintenance alerts. Use ?all=true to include deactivated. Returns up to 50 sorted by creation date.

DELETE/notifications/maintenance/:idAdmin

Deactivates a maintenance alert (sets active: false).

GET/notifications/logsAdmin

Returns recent notification logs from the in-memory buffer. Use ?limit=100 to control count (default: 200).

GET/notifications/statsAdmin

Returns aggregated notification statistics.

GET/notifications/variantsAdmin

Returns all predefined notification template variants used by the system.

Calendar

iCal feed for order schedules

GET/calendar/:userId.ics

Returns an iCal (.ics) feed of a user's orders for Apple Calendar, Google Calendar, or Outlook. Use ?filter=all for all orders (default: active only).

Calendar Subscription

Subscribe via URL in your calendar app. The feed auto-refreshes every 15 minutes.

https://api.axelo.app/calendar/FIREBASE_UID.ics

Admin

Admin token verification

GET/api/admin/verifyAdmin

Verifies the provided Firebase Auth token belongs to an admin user. Used by the admin dashboard to validate sessions.

Response
{ "ok": true, "email": "admin@axelo.app" }

Payments (Stripe)

Stripe Connect onboarding, PaymentSheet, webhooks & the 24-hour driver-payout sweep

Overview

The Payments module handles the full money flow on Axelo using Stripe Connect with the Separate Charges & Transfers pattern. Clients pay the full order amount to the platform, the funds are held for 24 hours after delivery, and 90 % are then transferred to the driver's Connect account. Axelo retains a 10 % platform fee.

Separate Subdomain

Unlike the rest of the API, Payments lives on its own subdomain:

https://payments.axelo.app

All paths in this section are relative to that base URL (e.g. POST /connect/account/create means POST https://payments.axelo.app/connect/account/create).

Endpoint Summary

GroupEndpointAuthPurpose
HealthGET /healthNoneLiveness probe
ConnectPOST /connect/account/createDriverCreate Stripe Express account
POST /connect/account/onboarding-linkDriverGet hosted onboarding URL
GET /connect/account/statusDriverRe-fetch & mirror Stripe status
POST /connect/account/dashboard-linkDriverLogin link to Stripe Express Dashboard
IntentsPOST /intents/create-for-orderClientCreate PaymentIntent + Ephemeral Key
POST /intents/syncClientForce a state mirror after PaymentSheet returns
WebhookPOST /webhookStripe signatureServer-to-server lifecycle events
TransfersPOST /transfers/run-sweepAdminManually trigger the 24-h payout sweep
GET /transfers/healthAdminCron status & configured platform fee

Authentication

All non-webhook endpoints accept the same Authorization: Bearer <firebase-id-token> header as the rest of the API (see Authentication). In addition, every request must include the X-Firestore-Database header naming the database the user lives in:

Required Headers
Authorization: Bearer <firebase-id-token>
X-Firestore-Database: axelo-production-database
Content-Type: application/json

Allowed values: axelo-production-database, axelo-ios-test-database, axelo-android-test-database, axelo-test-database. The chosen database id is also written as metadata onto every Stripe object the backend creates so that webhooks can mirror status updates back into the correct Firestore.

Money Split

ExampleTotalDriver (90%)Axelo (10%)
Standard order€100.00€90.00€10.00
Night surcharge€131.88€118.69€13.19

Stripe processing fees (1.5 % + €0.25 in the EU) are deducted from Axelo's 10 % share, not from the driver. The percentage is configurable via the STRIPE_PLATFORM_FEE_PERCENT environment variable.

Payment Flow

One full happy-path order from creation to driver payout:

#ActorActionEffect on order doc
1Client (WebApp/iOS)Creates the order and submits payment via PaymentSheet (POST /intents/create-for-order).paymentStatus: pending, stripePaymentIntentId
2Stripe3-D-Secure flow (if needed), captures the card.
3Stripe → Axelopayment_intent.succeeded webhook fires.paymentStatus: held, paidAt, stripeChargeId, releaseScheduledFor = paidAt + 24h
4DriverAccepts & delivers the order.status: delivered
5Axelo cronEvery 5 min the payout sweep checks for orders with paymentStatus = held, status = delivered, releaseScheduledFor <= now. For each match it calls stripe.transfers.create({ source_transaction, destination, amount }).paymentStatus: paid_out, stripeTransferId, releasedAt, driverPayoutAmount
6StripeDrives daily/weekly payout to the driver's IBAN according to the schedule the driver picked during onboarding.
Why "Separate Charges & Transfers"?

The destination charges shortcut would settle the money straight to the driver's Connect account, but Stripe's mandatory 24-hour escrow before the payout can only be enforced when we control the transfer ourselves. Hence we charge to the platform balance and transfer to the driver after the wait period, using source_transaction so each charge can only ever be transferred once.

Edge cases

CaseEffect
Client cancels in PaymentSheetOrder is deleted (FirestoreManagerOrders.deleteOrder), no Stripe charge ever happens.
Card declinedpayment_intent.payment_failed webhook flips order to paymentStatus: failed.
Driver onboarding incomplete at payout timeSweep skips the order and re-tries on every following tick. No transfer happens until stripeOnboardingCompleted == true.
Refund issued from Stripe Dashboardcharge.refunded webhook mirrors paymentStatus to refunded or partially_refunded.
Chargeback openedcharge.dispute.created webhook flips order to paymentStatus: disputed.

Firestore Model

The backend mirrors all relevant Stripe state into Firestore so the iOS & Web clients never need to call Stripe directly. Two collections are touched:

users/{uid} — Stripe Connect mirror (drivers only)

FieldTypeDescription
stripeConnectAccountIdStringStripe Connect account id, e.g. acct_1Q….
stripeChargesEnabledBooleanDriver may receive charges via Stripe.
stripePayoutsEnabledBooleanDriver may receive payouts to their bank.
stripeOnboardingCompletedBooleantrue only when charges & payouts are enabled and details are submitted.
stripeRequirementsCurrentlyDueString[]Outstanding requirements (Stripe field names).
stripeRequirementsPastDueString[]Overdue requirements (block payouts).
stripeBankLast4String?Last 4 digits of the linked bank account.
stripeBankNameString?Bank name (as Stripe reports it).
stripeUpdatedAtTimestampLast time the mirror was refreshed.

orders/{orderId} — Stripe payment fields

FieldTypeDescription
paymentStatusStringOne of pending, held, paid_out, failed, refunded, partially_refunded, disputed.
stripePaymentIntentIdStringSet when /intents/create-for-order succeeds.
stripeChargeIdStringSet when payment succeeds (used as source_transaction for the transfer).
stripeTransferIdStringSet when the 24-h sweep transfers funds to the driver.
paidAtTimestampServer timestamp at which the charge succeeded.
releaseScheduledForTimestamppaidAt + 24h; the cron filters against this.
releasedAtTimestampServer timestamp at which the transfer to the driver succeeded.
driverPayoutAmountNumberEUR amount sent to the driver (90 % of price).
platformFeeNumberEUR amount kept by Axelo (10 % of price).
Security Rules

Clients cannot write any of the stripe* or payment-related fields directly — Firestore rules forbid it. Only the backend (using the Admin SDK) may set them. See firestore.rules in the iOS repo for the exact predicates.

GET /health

Public liveness probe for the Stripe subdomain. Used by uptime monitors and the iOS app to verify connectivity before showing the PaymentSheet.

Response

200 OK
{
  "status": "healthy",
  "service": "axelo-payments",
  "platformFeePercent": 10,
  "timestamp": "2026-05-01T14:39:55.436Z"
}

Example

cURL
curl https://payments.axelo.app/health
POST /connect/account/create Driver

Creates a Stripe Express account for the authenticated driver, or returns the existing one. Mirrors the freshly retrieved Stripe state into users/{uid}. Idempotent — safe to call multiple times.

Request Body

No body required. The driver is identified via the Firebase ID token.

Response

200 OK
{
  "accountId": "acct_1Q9f7s2eZvKYlo2C",
  "chargesEnabled": false,
  "payoutsEnabled": false,
  "detailsSubmitted": false,
  "onboardingCompleted": false,
  "requirementsCurrentlyDue": ["external_account", "tos_acceptance.date"],
  "requirementsPastDue": [],
  "bankLast4": null,
  "bankName": null
}

Example

curl -X POST https://payments.axelo.app/connect/account/create \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Firestore-Database: axelo-production-database"
let token = try await Auth.auth().currentUser?.getIDToken()
var req = URLRequest(url: URL(string: "https://payments.axelo.app/connect/account/create")!)
req.httpMethod = "POST"
req.setValue("Bearer \(token!)", forHTTPHeaderField: "Authorization")
req.setValue("axelo-production-database", forHTTPHeaderField: "X-Firestore-Database")
let (data, _) = try await URLSession.shared.data(for: req)
POST /connect/account/onboarding-link Driver

Returns a one-time URL to Stripe's hosted onboarding flow. The link expires after a few minutes and must be regenerated for each session.

Response

200 OK
{
  "url": "https://connect.stripe.com/setup/e/acct_1Q…/Z3Z…",
  "expiresAt": 1746118800
}

Example

cURL
curl -X POST https://payments.axelo.app/connect/account/onboarding-link \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Firestore-Database: axelo-production-database"
Return / Refresh URLs

After the driver finishes (or aborts) onboarding, Stripe redirects them to https://payments.axelo.app/onboarding/return or /onboarding/refresh respectively. Both pages just close the in-app SFSafariViewController and instruct the iOS app to re-fetch /connect/account/status.

GET /connect/account/status Driver

Re-fetches the driver's Connect account from Stripe (with expanded external_accounts) and mirrors the result into Firestore. Returns the same shape as /account/create. Used by the iOS app on every appear of the Finance settings screen.

Example

cURL
curl https://payments.axelo.app/connect/account/status \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Firestore-Database: axelo-production-database"
POST /connect/account/dashboard-link Driver

Returns a one-time login URL to the Stripe Express Dashboard so the driver can review payouts, edit their bank account or update tax info. Only available once stripeOnboardingCompleted is true.

Response

200 OK
{ "url": "https://connect.stripe.com/express/acct_1Q…/J5K…" }

Errors

CodeReason
409Driver has not finished onboarding yet.
404Driver has no Connect account.
POST /intents/create-for-order Client

Creates (or reuses) a Stripe Customer for the authenticated client, then creates a PaymentIntent + Ephemeral Key for the iOS PaymentSheet. The paymentStatus on the order is flipped to pending and the stripePaymentIntentId is mirrored.

Request Body

FieldTypeRequiredDescription
orderIdStringYesFirestore order document id (must belong to the caller).
amountEurNumberYesTotal to charge in EUR (e.g. 131.88). Must match order.price.

Response

200 OK
{
  "paymentIntentClientSecret": "pi_3Q…_secret_AbCdEf",
  "ephemeralKeySecret": "ek_test_YWNjdF…",
  "customerId": "cus_Q…",
  "publishableKey": "pk_test_…"
}

Example

curl -X POST https://payments.axelo.app/intents/create-for-order \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Firestore-Database: axelo-production-database" \
  -H "Content-Type: application/json" \
  -d '{
    "orderId": "abc123",
    "amountEur": 131.88
  }'
let body: [String: Any] = ["orderId": orderId, "amountEur": price]
let data = try await StripePaymentService.shared.post("/intents/create-for-order", body: body)
let response = try JSONDecoder().decode(PaymentIntentResponse.self, from: data)

var config = PaymentSheet.Configuration()
config.merchantDisplayName = "Axelo"
config.customer = .init(id: response.customerId,
                        ephemeralKeySecret: response.ephemeralKeySecret)

let sheet = PaymentSheet(paymentIntentClientSecret: response.paymentIntentClientSecret,
                         configuration: config)
sheet.present(from: viewController) { [weak self] result in
  Task { await self?.handleSheetResult(result, orderId: orderId) }
}
POST /intents/sync Client

Best-effort sync from the iOS PaymentSheet completion handler — the webhook is the authoritative source of truth, but the app calls this endpoint immediately after the sheet returns to give the user instant feedback. Re-validates that the PaymentIntent's metadata (axelo_order_id, axelo_client_uid) matches the caller before writing.

Request Body

FieldTypeRequired
orderIdStringYes
paymentIntentIdStringYes

Response

200 OK
{
  "paymentStatus": "held",
  "stripePaymentIntentId": "pi_3Q…",
  "stripeChargeId": "ch_3Q…"
}
POST /webhook Stripe

Server-to-server endpoint Stripe calls on every state transition. Signature is verified against STRIPE_WEBHOOK_SECRET; requests with an invalid or missing signature get a 400 and are not retried by Stripe.

Raw Body Required

Stripe signs the unparsed request body. The endpoint is mounted before any JSON middleware on its sub-router with express.raw({ type: '*/*' }). Do not POST to it manually with parsed JSON — it will fail signature verification.

Handled Events

Stripe EventEffect on order doc
payment_intent.succeededSets paymentStatus: held, paidAt, stripeChargeId, releaseScheduledFor = paidAt + 24h (idempotent merge).
payment_intent.payment_failedSets paymentStatus: failed.
payment_intent.canceledSets paymentStatus: failed.
account.updatedRe-fetches the Connect account (with external_accounts expanded) and mirrors the stripe* fields onto the driver's user doc.
charge.refundedSets paymentStatus to refunded (full) or partially_refunded based on amount_refunded / amount.
charge.dispute.created
charge.dispute.funds_withdrawn
Sets paymentStatus: disputed.
any other eventAcknowledged with 200 and ignored. Set WEBHOOK_LOG_UNHANDLED=true to log them.

Multi-Database Routing

The webhook does not receive an X-Firestore-Database header. Instead, every PaymentIntent and Connect account is created with metadata.axelo_firestore_database set to the requester's database id. The handler reads that metadata to decide which Firestore database to write back into.

Stripe CLI Setup

Local testing
# Forward live events from Stripe to your local server (or to staging)
stripe listen --forward-to https://payments.axelo.app/webhook

# Trigger a test event manually
stripe trigger payment_intent.succeeded

Response

200 OK
{ "received": true, "eventId": "evt_1Q…" }
POST /transfers/run-sweep Admin

Manually triggers the 24-hour payout sweep. Same code path as the cron job — useful when an admin needs to force-pay a backlog after server downtime, or to verify the flow end-to-end after a Stripe Sandbox reset. Returns a per-database breakdown.

Response

200 OK
{
  "ok": true,
  "summary": {
    "startedAt": "2026-05-01T14:55:00.000Z",
    "durationMs": 412,
    "processedDatabases": 2,
    "transfersAttempted": 3,
    "transfersSucceeded": 2,
    "transfersSkipped": 1,
    "transfersFailed": 0,
    "perDatabase": [
      {
        "databaseId": "axelo-production-database",
        "attempted": 2, "succeeded": 2, "skipped": 0, "failed": 0
      },
      {
        "databaseId": "axelo-ios-test-database",
        "attempted": 1, "succeeded": 0, "skipped": 1, "failed": 0
      }
    ]
  }
}

Skip reasons

An attempted order is "skipped" (not "failed") for any of the following — none of them are fatal, the next sweep tries again:

  • Driver has no stripeConnectAccountId on the user doc.
  • Driver has not finished onboarding (stripeOnboardingCompleted == false).
  • Driver's payouts are not enabled (stripePayoutsEnabled == false).
  • Order has no stripeChargeId (should never happen if paymentStatus == "held").
  • Order already carries a stripeTransferId — already paid.

Example

cURL
curl -X POST https://payments.axelo.app/transfers/run-sweep \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "X-Firestore-Database: axelo-production-database"
GET /transfers/health Admin

Returns the cron configuration so an admin can confirm the sweep is enabled and the platform fee is what they expect.

Response

200 OK
{
  "ok": true,
  "platformFeePercent": 10,
  "cronEnabled": true,
  "cronSchedule": "*/5 * * * *"
}

Environment Variables

VariableDefaultDescription
STRIPE_SECRET_KEYStripe API secret key (sk_test_… or sk_live_…).
STRIPE_WEBHOOK_SECRETSigning secret of the configured webhook endpoint.
STRIPE_PLATFORM_FEE_PERCENT10Axelo's commission in percent.
STRIPE_CONNECT_COUNTRYATISO country code used for new Connect accounts.
STRIPE_CONNECT_RETURN_URLStripe redirects here after onboarding finishes.
STRIPE_CONNECT_REFRESH_URLStripe redirects here when the onboarding link expires.
STRIPE_PAYOUT_CRON_ENABLEDtrueSet to false to disable the sweep at boot.
STRIPE_PAYOUT_CRON_SCHEDULE*/5 * * * *node-cron expression — every 5 min by default.
STRIPE_EPHEMERAL_KEY_API_VERSIONStripe API version reported back to the iOS PaymentSheet (must match the embedded SDK).
ALLOWED_FIRESTORE_DATABASESsee sourceComma-separated allowlist for the X-Firestore-Database header and the sweep loop.

Mail API

A dedicated microservice for sending transactional emails — verification codes and password-reset links — via Microsoft Graph (Office 365).

REST JSON HTTPS Node.js

Overview

The Mail API runs as a separate Docker container on the same Hetzner server as the main API. It was introduced to move sensitive SMTP / Microsoft Graph credentials off the iOS client binary and onto a hardened server-side service.

Base URL

All Mail API requests go to a separate host:

https://mailapi.axelo.app

Endpoints

MethodPathDescription
GET/healthLiveness check — no auth required
POST/v1/verification-code/sendIssue + send a 6-digit verification code
POST/v1/verification-code/verifyValidate a submitted code against the stored hash
POST/v1/password-reset/sendGenerate a Firebase reset link and send a branded email

Design Principles

  • Credentials stay on the server. The iOS app never sees SMTP passwords or Graph client secrets.
  • Codes are hashed. Only the bcrypt hash of the 6-digit code is stored in Firestore; the plaintext is emailed and never persisted.
  • Anti-enumeration. /v1/password-reset/send always returns 200 OK whether or not the address is registered.
  • App Check enforcement. All /v1/* endpoints require a valid X-Firebase-AppCheck token (disable only in dev via APP_CHECK_ENABLED=false).

Security

Firebase App Check

Every /v1/* request must include an App Check attestation token. The server verifies the token against Firebase before processing the request.

HeaderRequiredDescription
X-Firebase-AppCheckYes*Firebase App Check JWT obtained from AppCheck.appCheck().token(forcingRefresh:). *Can be disabled server-side via APP_CHECK_ENABLED=false for smoke tests.
X-Firestore-DatabaseYesTarget Firestore database ID (e.g. axelo-production-database). Must be in the server-side allowlist.
Content-TypeYesapplication/json

Rate Limiting

Two independent rate-limit layers protect the send endpoints:

LayerLimitWindowScope
IP rate limit60 requests60 secondsPer client IP (applied before App Check)
Email rate limit5 sends1 hourPer recipient address (stored in Firestore)
Resend cooldown1 send30 secondsPer recipient address

When a limit is exceeded the server responds with 429 Too Many Requests and a Retry-After header (seconds).

POST /v1/verification-code/send

Generates a fresh 6-digit verification code, stores its bcrypt hash in Firestore under email_verifications/{email}, and sends an Apple-style HTML email containing the plaintext code to the recipient.

Idempotency

Calling this endpoint again within the 30-second resend cooldown is rejected with 429 rate_limited. After the cooldown, a fresh code replaces the previous one and the 10-minute TTL resets.

Request Headers

HeaderValue
X-Firebase-AppCheckApp Check JWT
X-Firestore-DatabaseDatabase ID (e.g. axelo-production-database)

Request Body

FieldTypeRequiredDescription
emailstringYesRecipient email address (max 254 chars). Normalised server-side (lowercased, trimmed).
JSON
{ "email": "user@example.com" }

Response

200 OK
{
  "ok": true,
  "expiresAt": "2026-05-04T11:30:00.000Z",
  "ttlSeconds": 600
}

Error Responses

StatuserrorCause
400invalid_requestMissing or malformed email field
401app_check_failedMissing or invalid App Check token
429rate_limitedIP or per-email limit exceeded; includes Retry-After header and retryAfter field
502send_failedMicrosoft Graph call failed

Example

cURL
curl -X POST https://mailapi.axelo.app/v1/verification-code/send \
  -H "Content-Type: application/json" \
  -H "X-Firebase-AppCheck: $APP_CHECK_TOKEN" \
  -H "X-Firestore-Database: axelo-production-database" \
  -d '{"email": "user@example.com"}'
Swift (iOS)
let expiresAt = try await MailAPIService.shared.sendVerificationCode(to: email)
POST /v1/verification-code/verify

Validates a 6-digit code submitted by the user against the bcrypt hash stored in Firestore. On success, the document is marked verified: true so the rest of the app's Firestore-backed onboarding logic detects the verification without any additional iOS-side write.

Request Body

FieldTypeRequiredDescription
emailstringYesThe address the code was sent to.
codestringYesExactly 6 digits (e.g. "483920"). Non-digit characters are rejected.
JSON
{ "email": "user@example.com", "code": "483920" }

Response — Success

200 OK
{ "verified": true }

Response — Failure

200 OK
{ "verified": false, "reason": "invalid" }

Failure Reasons

reasonMeaning
invalidCode does not match the stored hash (attempts counter incremented)
expiredCode TTL (10 minutes) has elapsed
too_many_attempts5 failed attempts reached; request a new code
not_foundNo pending verification document for this address

Example

Swift (iOS)
let result = try await MailAPIService.shared.verifyCode(code, for: email)
if result.verified {
    // proceed
} else {
    print(result.reason?.rawValue ?? "unknown")
}
POST /v1/password-reset/send

Uses Firebase Admin SDK's generatePasswordResetLink() to create a Firebase action code, rewrites the destination URL to https://account.axelo.app/reset?oobCode=…, and delivers an Apple-style branded email containing a "Reset Password" button.

Anti-enumeration

This endpoint always returns 200 OK regardless of whether the email address is registered in Firebase Auth. No email is sent for unknown addresses, but the response is identical — callers cannot use it to enumerate accounts.

Request Body

FieldTypeRequiredDescription
emailstringYesAddress associated with the account to reset.
JSON
{ "email": "user@example.com" }

Response

200 OK
{ "ok": true, "ttlSeconds": 3600 }

Flow

  1. iOS app calls this endpoint after the user taps "Forgot Password".
  2. Server calls firebase-admin.auth().generatePasswordResetLink(email).
  3. Server extracts the oobCode query parameter and builds https://account.axelo.app/reset?oobCode=….
  4. Server sends the branded HTML email via Microsoft Graph (noreply@axelo.app).
  5. User taps the button in the email → browser opens account.axelo.app/reset.
  6. Page uses the Firebase Web SDK to verify the oobCode and call confirmPasswordReset().

Example

Swift (iOS)
try await authManager.sendPasswordReset(email: email)
// Always show generic success message — server never reveals
// whether the address is registered.
cURL
curl -X POST https://mailapi.axelo.app/v1/password-reset/send \
  -H "Content-Type: application/json" \
  -H "X-Firebase-AppCheck: $APP_CHECK_TOKEN" \
  -H "X-Firestore-Database: axelo-production-database" \
  -d '{"email": "user@example.com"}'
GET /health

Liveness check. No authentication required. Used by monitoring and deploy scripts.

Response

200 OK
{ "ok": true, "service": "mail-api" }
cURL
curl https://mailapi.axelo.app/health

Firestore Model

Collection: email_verifications — document ID equals the normalised (lowercase) email address.

FieldTypeDescription
emailstringNormalised recipient address
codeHashstringbcrypt hash of the 6-digit code (cost factor 10). The plaintext is never stored.
createdAtTimestampWhen this code was generated
expiresAtTimestampCode expiry (createdAt + CODE_TTL_SECONDS)
verifiedbooleantrue once /verify confirms a matching code
verifiedAtTimestamp | nullTimestamp of successful verification
attemptsnumberFailed verification attempts (max 5)
lastSentAtTimestampTimestamp of the most recent /send call
windowStartedAtTimestampStart of the current hourly rate-limit window
sendsInWindownumberNumber of sends in the current window (max 5)
Database targeting

The Mail API supports multiple Firestore databases via the X-Firestore-Database request header. This ensures Debug builds talking to axelo-ios-test-database don't pollute the production database.

Password Reset Page

A static single-page application served by the mail-api container at https://account.axelo.app/reset. It uses the Firebase Web SDK to validate the action code and set the new password.

PropertyValue
URLhttps://account.axelo.app/reset?oobCode=<code>
Served frommail-api container, /public/reset/index.html
Auth SDKFirebase Web SDK 10 (ESM from gstatic.com)
No backend call neededThe Firebase Web SDK talks directly to Identity Toolkit

Page States

StateTrigger
LoadingInitial — while verifyPasswordResetCode() is in-flight
FormValid oobCode — shows new-password input and confirm field
SuccessAfter confirmPasswordReset() succeeds
InvalidMissing / expired / already-used oobCode