Sending email from a mobile app
Your app can call the MiniMailer API directly. It has an HTTP client, the API is public, and nothing on our side stops a request that carries a valid token. Ship that and you have published your API key.
This article covers what to do instead. The worked example is a NativePHP Mobile app, because it is a Laravel app and the code reads like the rest of these docs, but nothing here is specific to it. React Native, Flutter, Expo and a Tauri desktop build all hit the same wall.
Why an API key cannot live in your app
An installed app is a file on a device you do not control. An APK or an IPA is an archive: anyone can open one and read what is inside. Obfuscation buys nothing, because a key the app can use is a key the app can read, and so can the person holding the phone.
NativePHP says this plainly in its own security documentation. Your PHP code, views and assets ship inside the package where anyone who opens it can read them, and encrypting the bundle would not change that, since the decryption key would have to travel in the same package. Its advice is to keep secrets on a server, behind authenticated APIs. That advice is correct and it is not framework-specific.
Email keys are a worse thing to leak than most. A token carrying the emails:send scope sends from your verified domain. Someone who lifts it out of your app is not running up your bill so much as mailing the world as you, and the bounces and spam complaints that follow land on your domain's reputation, which takes months to build and much longer to repair. What reputation costs you →
Assume any key you ship will eventually be found. Design as though it already has been.
The shape that works
Put your own server between the app and us. There is one secret and it never leaves the server:
Your app ──user's session token──▶ Your backend ──MiniMailer key──▶ MiniMailer
- Your app authenticates its user against your own API, the way it already does for everything else. It asks for an outcome — send this receipt — and never composes the email.
- Your backend holds the MiniMailer key in its environment, decides whether this user is allowed to trigger this message, and builds the message itself.
- MiniMailer sees a single caller: your server, from an address you control.
The payoff is not only secrecy. Rotating a leaked key becomes a deploy instead of an app-store release, which on iOS is the difference between an afternoon and a week of review.
A NativePHP example
On the device, the call goes to your own API with the user's token from secure storage. No MiniMailer key appears anywhere in this file:
use Illuminate\Support\Facades\Http; use Native\Mobile\Facades\SecureStorage; Http::withToken(SecureStorage::get('api_token')) ->post('https://api.your-app.com/orders/'.$order->id.'/receipt');
SecureStorage keeps the user's token in the device keychain or keystore rather than in your bundle, which is where a per-user credential belongs. It is not a place to hide a shared API key: a shared key put there is still a shared key, just harder to spot.
On the server, the route authenticates the user, decides what they are allowed to trigger, and builds the message:
Route::post('/orders/{order}/receipt', function (Request $request, Order $order) { abort_unless($order->user_id === $request->user()->id, 403); $document = [ 'data' => [ 'type' => 'outbound-emails', 'attributes' => [ 'from' => 'receipts@acme.com', 'to' => $request->user()->email, 'subject' => 'Your receipt for order '.$order->reference, 'html' => view('mail.receipt', ['order' => $order])->render(), 'idempotency_key' => 'receipt-'.$order->id, ], 'relationships' => [ 'domain' => [ 'data' => ['type' => 'domains', 'id' => config('services.minimailer.domain')], ], ], ], ]; Http::withToken(config('services.minimailer.key')) ->withHeaders(['Accept' => 'application/vnd.api+json']) ->withBody(json_encode($document, JSON_THROW_ON_ERROR), 'application/vnd.api+json') ->throw() ->post('https://api.minimailer.app/outbound-emails'); return response()->noContent(); })->middleware('auth:sanctum');
Read the client call again and notice what is missing from it. It names no sender, no recipient, no subject and no body. It names an order and asks for a receipt. Everything that could be abused is decided on the server.
What your backend should enforce
Pin the sender. from and the domain relationship come from your config, never from the request. A client that can choose its own from can mail anyone as anyone, which is the same problem you just solved, moved one hop.
Derive the recipient. Take the address from the authenticated user, not the payload. If a request has to name a recipient — an invitation, say — check it against something the user already owns.
Send an idempotency_key. Mobile networks drop requests mid-flight and clients retry, so the same tap can easily reach you twice. Pass any stable string up to 255 characters, an order ID or a job UUID, and the retry cannot produce a second email. On this API it is a field in the request body; there is no Idempotency-Key header. More on idempotency →
Add your own rate limit. Ours applies per account across the whole API, so one user in a retry loop spends the same budget as your entire user base. A per-user throttle on your route stops that before it reaches us. Tier limits →
Reduce the blast radius of the server key
The key on your server is safer, not safe. Three things make a bad day smaller:
- Grant only
emails:send. Tokens start with no scopes at all, so a key gets exactly the permissions you tick. A sending key that cannot read your logs, manage domains or mint further tokens is a much smaller prize. - Let it expire. Tokens are issued for 1, 6 or 12 months. An expiring key means a copy taken today stops working on its own.
- Make it findable. Keys are prefixed
mm_sk_, so secret scanners recognise one on sight if it ever reaches a repository or a log. If you suspect a key is loose, revoke that single token from the tokens page and issue another; nothing else you own is affected.
If you have no backend
Then you need one, and it can be very small. A single authenticated route that accepts an identifier and returns 204 is enough to hold the key out of your bundle, and it can run anywhere that serves HTTP.
The alternative people ask for — a restricted key that is safe to embed — is not something we offer, and you should be sceptical of any sending API that claims to. A credential that can send mail from your domain is dangerous wherever it sits. The only real fix is to not put it where strangers can read it.
Where to go next
- Sending — the full transactional surface: attributes, idempotency, tracking, rate limits.
- Getting started — verify a domain and create a scoped token.
- Sender reputation — what a leaked key actually costs you.