Mobile API (/api/v1)
esimScan ships a REST API for mobile clients, mounted at /api/v1. It powers the Flutter mobile app, and you can build any other client against it — a second app, a kiosk, a partner integration.
It is enabled by default and needs no configuration beyond running migrations.
Authentication
Bearer tokens issued by Laravel Sanctum. The token belongs to a CustomerAccount — the same storefront buyer account used at /login, so a customer has one identity across web and mobile.
Create the token table once:
php artisan migrate # adds personal_access_tokens
Get a token, then send it on every protected call:
curl -X POST https://yourstore.com/api/v1/auth/login \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"email":"buyer@example.com","password":"secret","device_name":"Pixel 8"}'
curl https://yourstore.com/api/v1/me \
-H 'Accept: application/json' \
-H 'Authorization: Bearer 3|xxxxxxxxxxxxxxxxxxxx'
Always send Accept: application/json — without it Laravel may answer validation errors with an HTML redirect instead of JSON.
Rate limits
| Scope | Limit |
|---|---|
Everything under /api/v1 | 90 requests/minute, per token (falls back to per IP) |
POST /ai-advisor | 15/minute — each call hits a paid LLM API |
POST /auth/register, POST /auth/login | 10/minute |
POST /auth/forgot-password | 5/minute |
Exceeding a limit returns 429 Too Many Requests.
Endpoints
Public
| Method | Path | Purpose |
|---|---|---|
GET | /health | Liveness probe — {"ok":true,…} |
GET | /config | Bootstrap config: store name, currency, terms/privacy URLs, feature flags, min_supported_version |
GET | /destinations | Catalog: every sellable country/region with a "from" price and flag |
GET | /destinations/{slug} | One destination with all its plans, prices and coverage notes |
GET | /popular-packages | Highlighted best-sellers |
GET | /all-in-one-plans | Data + Voice + SMS bundles |
POST | /ai-advisor | Trip description → plan recommendations from your catalog |
POST /ai-advisor takes {"need": "10 days in Japan, maps and photos"} (3–600 characters) and returns {"ok":true,"summary":…,"recommendations":[…]}. It fails with 422 and a readable message when no AI provider is configured or the store runs in demo mode — see the AI assistant guide.
Auth
| Method | Path | Body |
|---|---|---|
POST | /auth/register | name, email, password, password_confirmation |
POST | /auth/login | email, password, optional device_name |
POST | /auth/forgot-password | email — always returns a generic OK, so it can't be used to probe which emails have accounts |
POST | /auth/logout | (authenticated) revokes the current token |
register and login both return {"token":…,"token_type":"Bearer","user":{…}}.
Profile
| Method | Path | Body |
|---|---|---|
GET | /me | — |
PUT | /me | name, email, optional avatar (image, ≤2 MB) |
PUT | /me/password | current_password, password, password_confirmation |
DELETE | /me | password — permanently deletes the account |
DELETE /me exists because both the App Store and Google Play require in-app account deletion. It is irreversible.
Orders and eSIMs
| Method | Path | Returns |
|---|---|---|
GET | /orders | Paginated order history |
GET | /esims | Purchased eSIMs — destination, data, validity, status |
GET | /esims/{id} | One eSIM plus its top-ups, with ICCID, SM-DP+ address, activation code and a signed QR URL |
The QR image is served from a signed, expiring URL (/esim/qr/{order}), the same mechanism the delivery email uses. qr_url is null until the eSIM is provisioned, so clients should show a "preparing your eSIM" state.
Checkout
| Method | Path | Body |
|---|---|---|
POST | /checkout | plan (e.g. cat-42), optional destination slug |
Returns a checkout_url plus an order summary. The client opens that URL in a system browser — there is no native in-app purchase. It reuses the storefront's Stripe/PayPal flow and provisioning pipeline, and the resulting order links back to the buyer by email so it appears under /orders and /esims. See Payments for why external payment is permitted for eSIM connectivity.
Support
| Method | Path | Body |
|---|---|---|
GET | /support/tickets | — |
POST | /support/tickets | title, description (≤5000), optional priority (low/medium/high/urgent) |
GET | /support/tickets/{id} | — |
POST | /support/tickets/{id}/reply | message (≤5000) |
These are the same tickets your staff answer in Admin → Support, including the AI "suggest reply" helper.
Errors
| Status | Meaning |
|---|---|
401 | Missing, malformed or revoked token |
403 | Authenticated but not allowed (e.g. a blocked customer) |
404 | Not found, or the record belongs to another customer |
422 | Validation failed — {"message":…,"errors":{"field":["…"]}} |
429 | Rate limit exceeded |
503 | A dependency (AI provider, eSIM provider) is temporarily unavailable |
Versioning
The prefix is set once in bootstrap/app.php (apiPrefix: 'api/v1'), and routes live in routes/api.php. Keep /api/v1 stable for released apps — installed clients cannot be updated on your schedule. Add /api/v2 alongside it for breaking changes.
GET /config returns min_supported_version, which lets you force an upgrade prompt in older clients rather than breaking them silently.