Axelo API
The backend service powering the Axelo transport platform.
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.
All API requests should be made to:
https://api.axelo.app
Available Modules
| Module | Base Path | Endpoints | Description |
|---|---|---|---|
| Cancellation | /reports/cancellation | 1 + Firestore | Storno system with form types F1–F5, photo upload, PDF generation |
| Reports | /reports | 4 | Generate, download, list, and delete delivery PDF reports |
| Vehicle Classes | /vehicle-classes | 8 | CRUD, pricing calculation, and recommendations |
| Goods Types | /goods-types | 6 | CRUD for shipment categories |
| Notifications | /notifications | 9 | Push notifications, broadcasts, maintenance alerts |
| Calendar | /calendar | 1 | iCal feed for order schedules |
| Mail API | mailapi.axelo.app/v1 | 3 | Email verification codes and password-reset mails via Microsoft Graph (separate service) |
| Admin | /api/admin | 1 | Admin token verification |
| Payments (Stripe) | https://payments.axelo.app | 10 | Stripe 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: Bearer <firebase-id-token>
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": "Missing required field: reason",
"details": "Additional context about the error"
}
| Code | Meaning |
|---|---|
200 | Success |
201 | Created |
400 | Bad Request — missing or invalid parameters |
401 | Unauthorized — missing or invalid token |
403 | Forbidden — not an admin |
404 | Not Found |
409 | Conflict — resource already exists |
429 | Too Many Requests — rate limited |
500 | Internal Server Error |
Rate Limiting
Rate limits are enforced per IP address using draft-7 standard headers.
| Scope | Limit | Window |
|---|---|---|
| Global (all endpoints) | 100 requests | 1 minute |
| Auth endpoints | 5 requests | 1 minute |
| Action endpoints | 10 requests | 1 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.
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
| Form | Name | Condition | Paid |
|---|---|---|---|
F1 | Late Cancellation | Less than 60 minutes before pickup | Yes |
F2 | Cancellation En Route | Driver is en route to pickup location | Yes |
F3 | Cancellation at Pickup | Driver is at the pickup location | Yes |
F4 | Wait Time Report | Wait time exceeding 30 minutes | No |
F5 | General Issue | Complaint or general issue (client-only edge case) | No |
Cancellation Actions
Not all cancellations require a form. The system defines four possible actions:
| Action | Description | Form Required |
|---|---|---|
directCancel | Free cancellation — executed immediately after confirmation dialog | No |
form(F1–F5) | A cancellation form must be filled out with reason, description, and optional photos | Yes |
supportOnly | Cancellation only possible through support (goods already in transit) | No |
notPossible | Cancellation is not possible in this state (delivered, already cancelled) | No |
Cost Implications
| Form | Driver | Client |
|---|---|---|
F1 | Negative review (waivable with valid reason + proof) | Flat fee of €20.00 (waivable with valid reason + proof) |
F2 | Negative review + possible suspension on repeat (waivable) | Partial compensation fee (minimum rate for vehicle class) |
F3–F5 | Fees 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 Status | Condition | Action |
|---|---|---|
pending / requestedDriver | — | notPossible |
accepted | > 60 min until pickup (non-ASAP) | directCancel |
accepted | ≤ 60 min until pickup OR ASAP order | form(F1) |
drivingToPickup | — | form(F2) |
arrivedAtPickup | — | form(F3) |
pickedUp / drivingToDelivery | — | supportOnly |
arrivedAtDelivery / delivered | — | notPossible |
cancelled / cancellationRequested | — | notPossible |
Client Decision Matrix
| Order Status | Condition | Action |
|---|---|---|
pending / requestedDriver | — | directCancel |
accepted | > 60 min until pickup (non-ASAP) | directCancel |
accepted | ≤ 60 min until pickup OR ASAP order | form(F1) |
drivingToPickup | — | form(F2) |
arrivedAtPickup | — | form(F3) |
pickedUp / drivingToDelivery | — | form(F5) |
arrivedAtDelivery / delivered | — | notPossible |
cancelled / cancellationRequested | — | notPossible |
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)
| ID | Label | Photo Required |
|---|---|---|
vehicle_breakdown | Vehicle Breakdown | Yes |
accident | Accident | Yes |
medical_emergency | Medical Emergency | No |
extreme_weather | Extreme Weather | No |
police_check | Police Check | No |
private_emergency | Private Emergency | No |
business_reason | Business Reason | No |
other | Other Reason | No |
F2 Driver Reasons
| ID | Label | Photo Required |
|---|---|---|
vehicle_breakdown | Vehicle Breakdown | Yes |
accident | Accident | Yes |
medical_emergency | Medical Emergency | No |
road_closure | Road Closure | No |
extreme_weather | Extreme Weather | No |
police_check | Police Check | No |
other | Other Reason | No |
F2 Client Reasons
| ID | Label | Photo Required |
|---|---|---|
no_longer_needed | Order No Longer Needed | No |
wrong_order | Wrong Order | No |
goods_unavailable | Goods Not Available | No |
other_carrier | Found Another Carrier | No |
other | Other Reason | No |
F3–F5 Generic Reasons
| ID | Label | Photo Required |
|---|---|---|
general_issue | General Issue | No |
other | Other Reason | No |
Validation Rules
| Rule | F1 | F2 | F3–F5 |
|---|---|---|---|
| Min. description length | 20 chars | 30 chars | 20 chars |
| Max photos | 3 | 5 | 3 |
| Photo required if reason demands it | Yes | Yes | No |
confirmedTruthfulness | Required | Required | Required |
confirmedConditions | Required | Required | Required |
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
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
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/.*');
}
/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:
| Field | Type | Required | Description |
|---|---|---|---|
request.orderId | String | Yes | Firestore order document ID |
request.formType | String | Yes | "F1", "F2", "F3", "F4", or "F5" |
request.reason | String | Yes | Cancellation reason label (from the reason tables) |
request.detailedDescription | String | Yes | Detailed description (min 20–30 chars depending on form) |
request.confirmedTruthfulness | Boolean | Yes | Must be true |
request.confirmedConditions | Boolean | Yes | Must be true |
request.orderNumber | String | No | Human-readable order number (e.g. "AXL-2026-001234") |
request.requestedBy | String | No | "driver" or "client" |
request.requestedAt | ISO 8601 | No | Timestamp of the request |
request.orderStatus | String | No | Order status at time of request |
request.pickupAddress | String | No | Pickup address |
request.deliveryAddress | String | No | Delivery address |
request.scheduledPickupTime | ISO 8601 | No | Scheduled pickup time |
request.minutesUntilPickup | Number | No | Minutes until scheduled pickup |
request.vehicleClass | String | No | Vehicle class ID (e.g. "pkwCaddy") |
request.price | Number | No | Order price in EUR |
request.distanceToPickupKm | Number | No | F2: driver's distance to pickup in km |
request.estimatedArrivalMinutes | Number | No | F2: estimated arrival time in minutes |
request.photoURLs | String[] | No | Firebase Storage URLs of evidence photos |
Response
| Header | Description |
|---|---|
Content-Type | application/pdf |
X-Download-URL | Firebase Storage download URL of the uploaded PDF |
X-Storage-Path | Storage 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
cancellationRequests/{requestId}
Document Schema
| Field | Type | Description |
|---|---|---|
id | String | UUID — document ID |
requesterId | String | Firebase Auth UID of the requesting user |
clientId | String | Firebase Auth UID of the order's client |
driverId | String? | Firebase Auth UID of the assigned driver (null if unassigned) |
orderId | String | Referenced order document ID |
orderNumber | String | Human-readable order number |
formType | String | "F1"–"F5" |
requestedBy | String | "driver" or "client" |
requestedAt | Timestamp | ISO 8601 date of the request |
orderStatus | String | Order status at time of cancellation |
pickupAddress | String | Pickup address |
deliveryAddress | String | Delivery address |
scheduledPickupTime | Timestamp | Scheduled pickup time |
minutesUntilPickup | Number? | Minutes until pickup at time of request |
vehicleClass | String? | Vehicle class ID |
price | Number | Order price in EUR |
distanceToPickupKm | Number? | F2 only: distance to pickup |
estimatedArrivalMinutes | Number? | F2 only: ETA |
reason | String | Selected cancellation reason label |
detailedDescription | String | User-written detailed description |
photoURLs | String[] | Firebase Storage URLs of evidence photos |
confirmedTruthfulness | Boolean | User confirmed truthfulness |
confirmedConditions | Boolean | User accepted cancellation conditions |
status | String | "pending", "approved", or "rejected" |
pdfURL | String? | Firebase Storage URL of the generated PDF |
costAmount | Number? | Cancellation cost (set by support) |
costDescription | String? | Cost justification (set by support) |
reviewedBy | String? | UID of the support agent who reviewed |
reviewedAt | Timestamp? | Review timestamp |
reviewNotes | String? | 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.
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.
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
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
/reports/delivery
Generates a professional delivery report PDF. Supports single-delivery and multi-stop orders. Optionally uploads the PDF to Firebase Storage.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
order | Object | Yes | The full transport order object |
clientName | String | No | Name of the client |
driverName | String | No | Name of the driver |
userId | String | No | If provided, uploads PDF to reports/{userId}/{orderId}.pdf |
pickupImageBase64 | String | No | Base64-encoded pickup photo |
pickupImageURL | String | No | Storage URL (fallback if no base64) |
deliveryImageBase64 | String | No | Base64-encoded delivery photo |
deliveryImageURL | String | No | Storage URL (fallback) |
signatureImageBase64 | String | No | Base64-encoded recipient signature |
signatureImageURL | String | No | Storage URL (fallback) |
Response Headers
| Header | Description |
|---|---|
Content-Type | application/pdf |
X-Download-URL | Firebase Storage download URL (if userId provided) |
X-Storage-Path | Storage 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();
/reports/download/:userId/:orderIdDownloads a previously generated report PDF from Firebase Storage. Returns application/pdf stream or 404.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
userId | String | Firebase UID |
orderId | String | Order document ID |
curl https://api.axelo.app/reports/download/USER_ID/ORDER_ID --output report.pdf/reports/list/:userIdLists all generated reports for a user.
{
"reports": [
{ "name": "abc123.pdf", "path": "reports/uid/abc123.pdf",
"created": "2026-02-21T14:30:00.000Z", "size": "125432" }
]
}/reports/delete/:userId/:orderIdDeletes a report PDF from Firebase Storage.
{ "success": true, "message": "Report deleted" }Vehicle Classes
CRUD, pricing calculation, and vehicle recommendations
/vehicle-classesReturns all vehicle classes sorted by sortOrder. Use ?includeInactive=true to include inactive classes.
[{
"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" }
}]/vehicle-classes/:idReturns a single vehicle class by document ID (e.g. pkw_caddy, klein_lkw, klein_lkw_lbw).
/vehicle-classesAdminCreates a new vehicle class. Returns 201 on success, 409 if ID exists.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
id | String | Yes | Unique ID (e.g. "lkw_7t") |
name | String | Yes | Display name |
pricing | Object | Yes | Pricing config (see GET response) |
icon | String | No | SF Symbol (default: "truck.box.fill") |
sortOrder | Number | No | Sort position (default: 99) |
maxPayload | Number | No | Max payload in kg |
maxVolume | Number | No | Max volume in m³ |
palletCapacity | Number | No | Euro pallet count |
hasLiftgate | Boolean | No | Has liftgate |
/vehicle-classes/:idAdminPartial update. Only provided fields are changed. Accepts the same fields as the create endpoint.
/vehicle-classes/:id/toggleAdminToggles active status. No request body needed.
{ "id": "pkw_caddy", "active": false }/vehicle-classes/:idAdminPermanently deletes a vehicle class.
{ "deleted": true, "id": "pkw_caddy" }/vehicle-classes/calculate-priceCalculates delivery price based on vehicle class, distance, and surcharges.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
vehicleClassId | String | Yes | Vehicle class ID |
distance | Number | Yes | Distance in km |
isWithinVienna | Boolean | No | Use Vienna rate |
isNightOrWeekend | Boolean | No | Night/weekend surcharge |
isRushHour | Boolean | No | Rush hour surcharge |
{
"vehicleClass": "PKW/Caddy", "distance": 25, "price": 46.25,
"breakdown": {
"basePrice": 25.0, "kmRate": 0.85, "distanceCost": 21.25,
"surcharges": { "nightWeekend": 0, "rushHour": 0 }
}
}/vehicle-classes/recommendRecommends the smallest vehicle class that fits all criteria. At least one of weight (kg), volume (m³), or pallets is required.
{
"recommended": { "id": "klein_lkw", "name": "Klein-LKW" },
"details": [
{ "criteria": "weight", "vehicleClass": "klein_lkw", "name": "Klein-LKW" }
]
}Goods Types
CRUD for shipment and goods categories
/goods-typesReturns all goods types sorted by sortOrder. Use ?includeInactive=true to include inactive.
[{
"id": "europalette", "name": "Europalette", "dimensions": "120×80 cm",
"length": 120, "width": 80, "height": null,
"icon": "shippingbox.fill", "isCustom": false, "sortOrder": 0, "active": true
}]/goods-types/:idReturns a single goods type by ID.
/goods-typesAdminCreates a new goods type. Required: id, name. Returns 201 / 409.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
id | String | Yes | Unique identifier |
name | String | Yes | Display name |
dimensions | String | No | Human-readable dimensions |
length / width / height | Number | No | Dimensions in cm |
icon | String | No | SF Symbol |
isCustom | Boolean | No | Custom dimension input |
sortOrder | Number | No | Sort position |
/goods-types/:idAdminPartial update. Only provided fields are changed.
/goods-types/:id/toggleAdminToggles active status.
/goods-types/:idAdminPermanently deletes a goods type.
Notifications
Push notifications, broadcasts, and maintenance alerts
/notifications/sendAdminSends a push notification to a single user via FCM.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
userId | String | Yes | Target Firebase UID |
title | String | Yes | Notification title |
body | String | Yes | Body text |
data | Object | No | Custom payload |
{ "success": true, "messageId": "projects/.../messages/..." }/notifications/broadcastAdminSends a notification to a list of users. Requires userIds (array), title, body.
{ "success": true, "sent": 15, "total": 20 }/notifications/broadcast-driversAdminBroadcasts 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.
{ "success": true, "sent": 8, "totalDrivers": 12, "region": "Wien" }/notifications/maintenanceAdminCreates a maintenance alert and pushes it to the target audience.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
title | String | Yes | Alert title |
message | String | Yes | Alert message |
type | String | No | "info" | "warning" | "critical" |
targetAudience | String | No | "all" | "drivers" | "clients" |
endsAt | ISO String | No | End time |
{ "success": true, "id": "firestore-doc-id", "sent": 42 }/notifications/maintenanceAdminLists maintenance alerts. Use ?all=true to include deactivated. Returns up to 50 sorted by creation date.
/notifications/maintenance/:idAdminDeactivates a maintenance alert (sets active: false).
/notifications/logsAdminReturns recent notification logs from the in-memory buffer. Use ?limit=100 to control count (default: 200).
/notifications/statsAdminReturns aggregated notification statistics.
/notifications/variantsAdminReturns all predefined notification template variants used by the system.
Calendar
iCal feed for order schedules
/calendar/:userId.icsReturns 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).
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
/api/admin/verifyAdminVerifies the provided Firebase Auth token belongs to an admin user. Used by the admin dashboard to validate sessions.
{ "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.
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
| Group | Endpoint | Auth | Purpose |
|---|---|---|---|
| Health | GET /health | None | Liveness probe |
| Connect | POST /connect/account/create | Driver | Create Stripe Express account |
POST /connect/account/onboarding-link | Driver | Get hosted onboarding URL | |
GET /connect/account/status | Driver | Re-fetch & mirror Stripe status | |
POST /connect/account/dashboard-link | Driver | Login link to Stripe Express Dashboard | |
| Intents | POST /intents/create-for-order | Client | Create PaymentIntent + Ephemeral Key |
POST /intents/sync | Client | Force a state mirror after PaymentSheet returns | |
| Webhook | POST /webhook | Stripe signature | Server-to-server lifecycle events |
| Transfers | POST /transfers/run-sweep | Admin | Manually trigger the 24-h payout sweep |
GET /transfers/health | Admin | Cron 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:
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
| Example | Total | Driver (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:
| # | Actor | Action | Effect on order doc |
|---|---|---|---|
| 1 | Client (WebApp/iOS) | Creates the order and submits payment via PaymentSheet (POST /intents/create-for-order). | paymentStatus: pending, stripePaymentIntentId |
| 2 | Stripe | 3-D-Secure flow (if needed), captures the card. | — |
| 3 | Stripe → Axelo | payment_intent.succeeded webhook fires. | paymentStatus: held, paidAt, stripeChargeId, releaseScheduledFor = paidAt + 24h |
| 4 | Driver | Accepts & delivers the order. | status: delivered |
| 5 | Axelo cron | Every 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 |
| 6 | Stripe | Drives daily/weekly payout to the driver's IBAN according to the schedule the driver picked during onboarding. | — |
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
| Case | Effect |
|---|---|
| Client cancels in PaymentSheet | Order is deleted (FirestoreManagerOrders.deleteOrder), no Stripe charge ever happens. |
| Card declined | payment_intent.payment_failed webhook flips order to paymentStatus: failed. |
| Driver onboarding incomplete at payout time | Sweep skips the order and re-tries on every following tick. No transfer happens until stripeOnboardingCompleted == true. |
| Refund issued from Stripe Dashboard | charge.refunded webhook mirrors paymentStatus to refunded or partially_refunded. |
| Chargeback opened | charge.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)
| Field | Type | Description |
|---|---|---|
stripeConnectAccountId | String | Stripe Connect account id, e.g. acct_1Q…. |
stripeChargesEnabled | Boolean | Driver may receive charges via Stripe. |
stripePayoutsEnabled | Boolean | Driver may receive payouts to their bank. |
stripeOnboardingCompleted | Boolean | true only when charges & payouts are enabled and details are submitted. |
stripeRequirementsCurrentlyDue | String[] | Outstanding requirements (Stripe field names). |
stripeRequirementsPastDue | String[] | Overdue requirements (block payouts). |
stripeBankLast4 | String? | Last 4 digits of the linked bank account. |
stripeBankName | String? | Bank name (as Stripe reports it). |
stripeUpdatedAt | Timestamp | Last time the mirror was refreshed. |
orders/{orderId} — Stripe payment fields
| Field | Type | Description |
|---|---|---|
paymentStatus | String | One of pending, held, paid_out, failed, refunded, partially_refunded, disputed. |
stripePaymentIntentId | String | Set when /intents/create-for-order succeeds. |
stripeChargeId | String | Set when payment succeeds (used as source_transaction for the transfer). |
stripeTransferId | String | Set when the 24-h sweep transfers funds to the driver. |
paidAt | Timestamp | Server timestamp at which the charge succeeded. |
releaseScheduledFor | Timestamp | paidAt + 24h; the cron filters against this. |
releasedAt | Timestamp | Server timestamp at which the transfer to the driver succeeded. |
driverPayoutAmount | Number | EUR amount sent to the driver (90 % of price). |
platformFee | Number | EUR amount kept by Axelo (10 % of price). |
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.
/health
Public liveness probe for the Stripe subdomain. Used by uptime monitors and the iOS app to verify connectivity before showing the PaymentSheet.
Response
{
"status": "healthy",
"service": "axelo-payments",
"platformFeePercent": 10,
"timestamp": "2026-05-01T14:39:55.436Z"
}
Example
curl https://payments.axelo.app/health
/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
{
"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)
/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
{
"url": "https://connect.stripe.com/setup/e/acct_1Q…/Z3Z…",
"expiresAt": 1746118800
}
Example
curl -X POST https://payments.axelo.app/connect/account/onboarding-link \
-H "Authorization: Bearer $TOKEN" \
-H "X-Firestore-Database: axelo-production-database"
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.
/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 https://payments.axelo.app/connect/account/status \
-H "Authorization: Bearer $TOKEN" \
-H "X-Firestore-Database: axelo-production-database"
/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
{ "url": "https://connect.stripe.com/express/acct_1Q…/J5K…" }
Errors
| Code | Reason |
|---|---|
409 | Driver has not finished onboarding yet. |
404 | Driver has no Connect account. |
/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
| Field | Type | Required | Description |
|---|---|---|---|
orderId | String | Yes | Firestore order document id (must belong to the caller). |
amountEur | Number | Yes | Total to charge in EUR (e.g. 131.88). Must match order.price. |
Response
{
"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) }
}
/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
| Field | Type | Required |
|---|---|---|
orderId | String | Yes |
paymentIntentId | String | Yes |
Response
{
"paymentStatus": "held",
"stripePaymentIntentId": "pi_3Q…",
"stripeChargeId": "ch_3Q…"
}
/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.
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 Event | Effect on order doc |
|---|---|
payment_intent.succeeded | Sets paymentStatus: held, paidAt, stripeChargeId, releaseScheduledFor = paidAt + 24h (idempotent merge). |
payment_intent.payment_failed | Sets paymentStatus: failed. |
payment_intent.canceled | Sets paymentStatus: failed. |
account.updated | Re-fetches the Connect account (with external_accounts expanded) and mirrors the stripe* fields onto the driver's user doc. |
charge.refunded | Sets paymentStatus to refunded (full) or partially_refunded based on amount_refunded / amount. |
charge.dispute.createdcharge.dispute.funds_withdrawn | Sets paymentStatus: disputed. |
| any other event | Acknowledged 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
# 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
{ "received": true, "eventId": "evt_1Q…" }
/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
{
"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
stripeConnectAccountIdon 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 ifpaymentStatus == "held"). - Order already carries a
stripeTransferId— already paid.
Example
curl -X POST https://payments.axelo.app/transfers/run-sweep \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "X-Firestore-Database: axelo-production-database"
/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
{
"ok": true,
"platformFeePercent": 10,
"cronEnabled": true,
"cronSchedule": "*/5 * * * *"
}
Environment Variables
| Variable | Default | Description |
|---|---|---|
STRIPE_SECRET_KEY | — | Stripe API secret key (sk_test_… or sk_live_…). |
STRIPE_WEBHOOK_SECRET | — | Signing secret of the configured webhook endpoint. |
STRIPE_PLATFORM_FEE_PERCENT | 10 | Axelo's commission in percent. |
STRIPE_CONNECT_COUNTRY | AT | ISO country code used for new Connect accounts. |
STRIPE_CONNECT_RETURN_URL | — | Stripe redirects here after onboarding finishes. |
STRIPE_CONNECT_REFRESH_URL | — | Stripe redirects here when the onboarding link expires. |
STRIPE_PAYOUT_CRON_ENABLED | true | Set 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_VERSION | — | Stripe API version reported back to the iOS PaymentSheet (must match the embedded SDK). |
ALLOWED_FIRESTORE_DATABASES | see source | Comma-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).
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.
All Mail API requests go to a separate host:
https://mailapi.axelo.app
Endpoints
| Method | Path | Description |
|---|---|---|
/health | Liveness check — no auth required | |
/v1/verification-code/send | Issue + send a 6-digit verification code | |
/v1/verification-code/verify | Validate a submitted code against the stored hash | |
/v1/password-reset/send | Generate 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/sendalways returns200 OKwhether or not the address is registered. - App Check enforcement. All
/v1/*endpoints require a validX-Firebase-AppChecktoken (disable only in dev viaAPP_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.
| Header | Required | Description |
|---|---|---|
X-Firebase-AppCheck | Yes* | 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-Database | Yes | Target Firestore database ID (e.g. axelo-production-database). Must be in the server-side allowlist. |
Content-Type | Yes | application/json |
Rate Limiting
Two independent rate-limit layers protect the send endpoints:
| Layer | Limit | Window | Scope |
|---|---|---|---|
| IP rate limit | 60 requests | 60 seconds | Per client IP (applied before App Check) |
| Email rate limit | 5 sends | 1 hour | Per recipient address (stored in Firestore) |
| Resend cooldown | 1 send | 30 seconds | Per recipient address |
When a limit is exceeded the server responds with 429 Too Many Requests and a Retry-After header (seconds).
/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.
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
| Header | Value |
|---|---|
X-Firebase-AppCheck | App Check JWT |
X-Firestore-Database | Database ID (e.g. axelo-production-database) |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Recipient email address (max 254 chars). Normalised server-side (lowercased, trimmed). |
{ "email": "user@example.com" }
Response
{
"ok": true,
"expiresAt": "2026-05-04T11:30:00.000Z",
"ttlSeconds": 600
}
Error Responses
| Status | error | Cause |
|---|---|---|
400 | invalid_request | Missing or malformed email field |
401 | app_check_failed | Missing or invalid App Check token |
429 | rate_limited | IP or per-email limit exceeded; includes Retry-After header and retryAfter field |
502 | send_failed | Microsoft Graph call failed |
Example
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"}'
let expiresAt = try await MailAPIService.shared.sendVerificationCode(to: email)
/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
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | The address the code was sent to. |
code | string | Yes | Exactly 6 digits (e.g. "483920"). Non-digit characters are rejected. |
{ "email": "user@example.com", "code": "483920" }
Response — Success
{ "verified": true }
Response — Failure
{ "verified": false, "reason": "invalid" }
Failure Reasons
| reason | Meaning |
|---|---|
invalid | Code does not match the stored hash (attempts counter incremented) |
expired | Code TTL (10 minutes) has elapsed |
too_many_attempts | 5 failed attempts reached; request a new code |
not_found | No pending verification document for this address |
Example
let result = try await MailAPIService.shared.verifyCode(code, for: email)
if result.verified {
// proceed
} else {
print(result.reason?.rawValue ?? "unknown")
}
/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.
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
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Address associated with the account to reset. |
{ "email": "user@example.com" }
Response
{ "ok": true, "ttlSeconds": 3600 }
Flow
- iOS app calls this endpoint after the user taps "Forgot Password".
- Server calls
firebase-admin.auth().generatePasswordResetLink(email). - Server extracts the
oobCodequery parameter and buildshttps://account.axelo.app/reset?oobCode=…. - Server sends the branded HTML email via Microsoft Graph (
noreply@axelo.app). - User taps the button in the email → browser opens
account.axelo.app/reset. - Page uses the Firebase Web SDK to verify the
oobCodeand callconfirmPasswordReset().
Example
try await authManager.sendPasswordReset(email: email)
// Always show generic success message — server never reveals
// whether the address is registered.
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"}'
/health
Liveness check. No authentication required. Used by monitoring and deploy scripts.
Response
{ "ok": true, "service": "mail-api" }
curl https://mailapi.axelo.app/health
Firestore Model
Collection: email_verifications — document ID equals the normalised (lowercase) email address.
| Field | Type | Description |
|---|---|---|
email | string | Normalised recipient address |
codeHash | string | bcrypt hash of the 6-digit code (cost factor 10). The plaintext is never stored. |
createdAt | Timestamp | When this code was generated |
expiresAt | Timestamp | Code expiry (createdAt + CODE_TTL_SECONDS) |
verified | boolean | true once /verify confirms a matching code |
verifiedAt | Timestamp | null | Timestamp of successful verification |
attempts | number | Failed verification attempts (max 5) |
lastSentAt | Timestamp | Timestamp of the most recent /send call |
windowStartedAt | Timestamp | Start of the current hourly rate-limit window |
sendsInWindow | number | Number of sends in the current window (max 5) |
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.
| Property | Value |
|---|---|
| URL | https://account.axelo.app/reset?oobCode=<code> |
| Served from | mail-api container, /public/reset/index.html |
| Auth SDK | Firebase Web SDK 10 (ESM from gstatic.com) |
| No backend call needed | The Firebase Web SDK talks directly to Identity Toolkit |
Page States
| State | Trigger |
|---|---|
| Loading | Initial — while verifyPasswordResetCode() is in-flight |
| Form | Valid oobCode — shows new-password input and confirm field |
| Success | After confirmPasswordReset() succeeds |
| Invalid | Missing / expired / already-used oobCode |