●FREETRY — Rork announced on X that Rork Max is free to try for a limited time, a chance to touch the $200/month tier without paying up front●ONESHOT — Rork Max claims to one-shot almost any app for iPhone, Watch, iPad, TV, and Vision Pro, including builds that lean on AR and 3D●XCODE — Positioned as a website that replaces Xcode: one click to install on device, two clicks to publish to the App Store, powered by Swift, Claude Code, and Opus●PAPERLINE — Rork acquired the app builder Paperline and says it will stay acquisitive to bring in engineering talent●SKILLS — Rork's app-store-connect-cli-skills repo was updated on July 9, a sign the submission tooling keeps getting attention●MARKET — Gartner expects 75% of new applications to be built with low-code or no-code tools by the end of 2026, up from under 25% in 2020●FREETRY — Rork announced on X that Rork Max is free to try for a limited time, a chance to touch the $200/month tier without paying up front●ONESHOT — Rork Max claims to one-shot almost any app for iPhone, Watch, iPad, TV, and Vision Pro, including builds that lean on AR and 3D●XCODE — Positioned as a website that replaces Xcode: one click to install on device, two clicks to publish to the App Store, powered by Swift, Claude Code, and Opus●PAPERLINE — Rork acquired the app builder Paperline and says it will stay acquisitive to bring in engineering talent●SKILLS — Rork's app-store-connect-cli-skills repo was updated on July 9, a sign the submission tooling keeps getting attention●MARKET — Gartner expects 75% of new applications to be built with low-code or no-code tools by the end of 2026, up from under 25% in 2020
Shipping Notifications Without Asking First — Provisional Authorization in Rork Apps, and the Expo Snippet That Quietly Undoes It
iOS lets you start delivering notifications with no permission dialog at all, via provisional authorization. The catch: expo-notifications reports granted as false for provisional devices, so the registration snippet in Expo's own docs re-requests permission and fires the very dialog you were avoiding. Here's why granted lies, a hook that models authorization as five states, how to write notifications for quiet delivery, when to ask for the upgrade, and how to keep provisional out of your CTR.
The opt-in rate on my wallpaper app's new drop notifications came in lower than I expected. The soft-ask screen was there. I was waiting for a moment when the value was obvious before asking. Structurally it was fine. And yet I was still asking people to commit to notifications they had never seen.
Which is when I remembered provisional authorization. It has been in iOS since 12, and it lets you start delivering notifications with no permission dialog whatsoever. Users see an actual notification first, then decide whether to keep it. Instead of getting cleverer about how you ask, you flip the order of the conversation. That felt more honest to me.
Then I started implementing, and the first thing I hit was that the registration code in Expo's own documentation is a trap.
The third option nobody reaches for
Push opt-in has had two plays. Fire the OS dialog on launch, or slide a soft-ask screen in front of it first. The second is clearly better, but both ask users to imagine the value of a notification that hasn't arrived yet.
Provisional authorization starts somewhere else. Add .provisional to UNAuthorizationOptions and the system shows no dialog. Notifications go out quietly and appear only in Notification Center, where they carry Keep and Turn Off buttons. The user decides with the real thing in front of them.
Apple's reasoning is clean: it is not actually possible for someone to make an informed choice about your notifications until they have seen what you send.
No dialog. It resolves without user interaction and you can start sending. So far, so documented.
What provisional grants, and what it quietly withholds
Leave this fuzzy and you will eventually be staring at notifications that deliver successfully and that nobody sees, with no idea why. Requesting an option is not the same as receiving it.
Surface
Provisional
Full (AUTHORIZED)
Notification Center
Yes
Yes
Banner
No
Yes
Lock screen
No
Yes
Sound
No
Yes
App icon badge
No — not granted even if requested
Yes
Permission dialog
Never shown
Shown
timeSensitive breakthrough
No
Yes
The badge row deserves your attention. The snippet above requests allowBadge: true, and on a provisional device the badge is still not granted. Open the notification settings on a real device and you'll see Notification Center delivery allowed while the badge toggle sits off. The request went through; the permission did not.
So if your unread count lives on the badge, that path is dead on arrival for your entire provisional cohort. setBadgeCountAsync throws nothing. It simply does nothing. I lost half a day to that. Bugs that don't raise are worse than bugs that do.
timeSensitive doesn't apply either. If you need to break through, that capability lives outside provisional.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why expo-notifications returns granted: false under provisional authorization, how Expo's own registration snippet turns that into a surprise permission dialog that drops deliverable devices, and the drop-in fix
✦A concrete map of what provisional actually grants versus what it silently withholds — badges are never granted even when you request them, and timeSensitive interruption does not apply
✦Working implementation: a five-state authorization hook, notification copy rewritten for Notification Center-only delivery, upgrade timing rules, and cohort separation so provisional doesn't wreck your CTR
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The expo-notifications docs state plainly that on iOS you should rely on ios.status, not the root status field. And the getPermissionsAsync() example reads:
It checks settings.granted and then ORs in PROVISIONAL. That second clause is only meaningful if granted is false under provisional authorization. The docs never say so outright. This line admits it.
The problem is the registration snippet near the top of the same page:
const { status: existingStatus } = await Notifications.getPermissionsAsync();let finalStatus = existingStatus;if (existingStatus !== 'granted') { const { status } = await Notifications.requestPermissionsAsync(); finalStatus = status;}if (finalStatus !== 'granted') { alert('Failed to get push token for push notification!'); return;}
That compares the root status against 'granted' — the exact field the same document tells you not to trust.
Put the two together. A provisional device satisfies status !== 'granted', so requestPermissionsAsync() gets called with no options, and without allowProvisional it fires the real permission dialog. Then finalStatus !== 'granted' bails out before fetching a token, dropping devices you could actually have delivered to.
Practically every Expo push implementation copies this snippet. Mine did too, back when I was sorting out token lifecycle. It was fine right up until the day provisional entered the picture.
Modeling authorization as five states
Stop leaning on one boolean. IosAuthorizationStatus has five members.
Value
Meaning
Will notifications arrive?
NOT_DETERMINED
No choice made yet
No
DENIED
Declined
No
AUTHORIZED
Full authorization
Yes
PROVISIONAL
Quiet delivery only
Yes, quietly
EPHEMERAL
Time-limited (App Clips and similar)
Yes, for a while
"Will it arrive" and "how loud can it be" are two separate axes. Here's a hook that keeps both.
// hooks/useNotificationAuthorization.tsimport { useCallback, useEffect, useState } from 'react';import { Platform } from 'react-native';import * as Notifications from 'expo-notifications';export type DeliveryTier = 'none' | 'quiet' | 'prominent';export type AuthState = { /** Will a send reach the device at all (quiet delivery counts) */ deliverable: boolean; /** How prominent we're allowed to be */ tier: DeliveryTier; /** Is this device a candidate for a provisional-to-full upgrade prompt */ upgradable: boolean; raw: Notifications.NotificationPermissionsStatus | null;};const IDLE: AuthState = { deliverable: false, tier: 'none', upgradable: false, raw: null };function interpret(s: Notifications.NotificationPermissionsStatus): AuthState { if (Platform.OS !== 'ios') { // Android's plain boolean is enough here return { deliverable: s.granted, tier: s.granted ? 'prominent' : 'none', upgradable: false, raw: s }; } const status = s.ios?.status; const A = Notifications.IosAuthorizationStatus; if (status === A.PROVISIONAL) { return { deliverable: true, tier: 'quiet', upgradable: true, raw: s }; } if (status === A.AUTHORIZED || status === A.EPHEMERAL) { // Even full auth can have the lock screen switched off — measure the surfaces const prominent = s.ios?.allowsDisplayOnLockScreen === true || s.ios?.allowsAlert === true; return { deliverable: true, tier: prominent ? 'prominent' : 'quiet', upgradable: false, raw: s }; } return { deliverable: false, tier: 'none', upgradable: false, raw: s };}export function useNotificationAuthorization() { const [state, setState] = useState<AuthState>(IDLE); const refresh = useCallback(async () => { const s = await Notifications.getPermissionsAsync(); setState(interpret(s)); return interpret(s); }, []); useEffect(() => { refresh(); }, [refresh]); return { ...state, refresh };}
After interpret(), callers only ever look at deliverable and tier. The point is to delete every direct read of granted from the codebase.
Why check allowsDisplayOnLockScreen even for AUTHORIZED? Because users grant full permission and then switch off lock screen delivery in Settings. Those devices report granted === true while behaving a lot like provisional ones. Catching that here is what later explains your "fully authorized but never opens anything" segment.
Registration that doesn't step on the trap
// lib/registerForPushAsync.tsimport * as Notifications from 'expo-notifications';import { Platform } from 'react-native';type Mode = 'provisional' | 'full';export async function registerForPushAsync(mode: Mode) { const current = await Notifications.getPermissionsAsync(); const A = Notifications.IosAuthorizationStatus; const status = current.ios?.status; // Already deliverable? Don't re-request. This branch is the whole point. const deliverable = current.granted || status === A.PROVISIONAL || status === A.EPHEMERAL; // For provisional intent, never touch a device we can already reach if (deliverable && mode === 'provisional') { return getToken(); } // For full intent, don't touch a device that's already full if (current.granted && mode === 'full') { return getToken(); } // Once DENIED, no dialog will ever appear again if (status === A.DENIED) { return null; } const requested = await Notifications.requestPermissionsAsync({ ios: { allowAlert: true, allowSound: true, // Badges are never granted provisionally — only request when going full allowBadge: mode === 'full', allowProvisional: mode === 'provisional', }, }); const rs = requested.ios?.status; const ok = requested.granted || rs === A.PROVISIONAL || rs === A.EPHEMERAL; return ok ? getToken() : null;}async function getToken() { if (Platform.OS === 'android') { await Notifications.setNotificationChannelAsync('default', { name: 'default', importance: Notifications.AndroidImportance.DEFAULT, }); } const { data } = await Notifications.getExpoPushTokenAsync({ projectId: 'YOUR_EAS_PROJECT_ID', }); return data;}
Three things carry the weight.
The deliverable check ORs PROVISIONAL and EPHEMERAL alongside granted, which is what stops provisional devices from falling into the re-request path.
mode splits allowProvisional and allowBadge. Mixing allowBadge: true into a provisional request grants nothing, so dropping it keeps the intent legible in the code.
The DENIED early return matters because iOS gives you no way to re-prompt a device that declined. Calling requestPermissionsAsync() there just hands DENIED straight back. It's a wasted call, and while it stays in the code someone will keep misreading the result as a bug.
A provisional notification is only seen by someone who deliberately pulls down Notification Center. No banner catches their eye. Nothing waits on the lock screen.
Under those conditions, a terse context-dependent nudge like "3 more to go" is wasted. By the time they're in Notification Center they're scanning a stack from a dozen apps. Only self-contained value survives that.
Here's the direction I took the wallpaper app.
Written for full auth
Written for provisional
Title
New content available
12 new wallpapers built around morning light
Body
Tap to see
Quiet whites, matched tones. They read well on a lock screen.
Goal
Get noticed, get opened
Land the value without an open — then earn the Keep
A provisional notification's job is not to get your app opened. Its job is to get Keep tapped. Opens follow later. Skip that step and provisional degenerates into a machine for sending notifications nobody reads.
Cut the frequency too. Five quiet notifications from one app stacked in Notification Center is the shortest path to Turn Off. I capped my provisional cohort at three sends over the first two weeks. The first shows the value, the second shows why it continues, the third proposes the upgrade. Those three decide whether you get a channel to that person at all.
While you're in there, update setNotificationHandler to current field names. shouldShowAlert is deprecated in favor of shouldShowBanner and shouldShowList.
That governs foreground behavior and is independent of provisional. Foreground notifications going missing usually has a different cause, so don't conflate the two.
When to ask for the upgrade
Stay provisional forever and you never get banners, lock screen, sound, or badges. At some point you ask.
That's what upgradable is for. For provisional devices only, call requestPermissionsAsync({ ios: { allowAlert: true, allowBadge: true, allowSound: true } }) with no allowProvisional. That one shows the real dialog.
My timing rules:
Only ask people who opened at least one notification. Catch the launch with addNotificationResponseReceivedListener and record it. Asking someone who never opened one throws away the reason you chose provisional.
Wait until the third send. Someone who has seen three and kept them knows what you send. That's the informed decision Apple built provisional for.
Say what changes, in one line, right before the dialog. "Also show these on your lock screen" or "Play a sound for new drops." Upgrading from provisional isn't asking a stranger for permission — it's asking someone you already have a relationship with to change the terms.
The asking mechanics can mirror your soft-ask design. What differs is the audience: not people who've received nothing, but people who received something and kept it. Same screen, different population, different outcome.
One caution. A decline doesn't return you to neutral. Devices that decline full go to DENIED — and you need to verify on real hardware whether provisional authorization survives that or dies with it. On iOS 26.x devices I tested, tapping Don't Allow on the full dialog dropped the device to DENIED and quiet delivery stopped with it. Don't take that on faith; measure it on your own target OS. Asking for the upgrade risks the channel you already have.
Rolling this into an existing app
A brand-new app can just start provisional on first launch. The hard case is an existing app where some users already granted full and others declined long ago. Get this wrong and you quietly degrade delivery for the people you're currently reaching.
Step 1: Don't touch existing users
Protect your AUTHORIZED devices first. That's why registerForPushAsync above returns immediately on current.granted && mode === 'full'. There is no reason to send a request carrying allowProvisional: true to someone who already granted full. Existing authorization won't get downgraded to provisional, but designing so the call never happens is safer than relying on that.
Leave DENIED devices alone for the same reason. A provisional request won't go through on a device that already declined. Provisional only works on people you have never asked. Miss that and you'll carry a false expectation that provisional can resurrect your declined users. It can't.
Step 2: Route only NOT_DETERMINED through provisional
Your addressable set is NOT_DETERMINED devices only. In an existing app that's people who saw the soft-ask and didn't accept, plus people who never hit the trigger conditions. That group never had a notification channel, so provisional costs them nothing.
In my wallpaper app that group was roughly 40% of iOS users. Far more people were churning out before ever reaching the soft-ask than I'd assumed. I thought I'd been designing notifications as a retention lever — but with 40% unreachable, the quality of the lever was beside the point.
Step 3: Branch delivery logic on tier
Stop sending everyone the same notification. Split on the hook's tier.
const { deliverable, tier } = useNotificationAuthorization();// Decide whether to send at allif (!deliverable) return;const payload = tier === 'quiet' ? buildSelfContainedPayload(item) // assumes no open — value lands on its own : buildActionPayload(item); // assumes an open — short, action-oriented
Separating buildSelfContainedPayload from buildActionPayload lets you iterate on provisional copy independently. Pipe one string into both and you get notifications optimized for neither cohort. Until I split these, I couldn't tell whether a CTR dip came from the copy or from the delivery surface.
Separate the cohorts or misread the data
The first thing provisional breaks is your dashboard.
iOS push opt-in averages around 56% and CTR lands somewhere in the 3–4% range (per Pushwoosh's 2025 benchmarks). Fold a provisional cohort in and your addressable audience jumps, obviously — you stopped asking. But opens only come from people who pull down Notification Center.
Net result: sends go up, CTR goes down. Read that number alone and you'll conclude the notification program regressed, and kill the right move.
Split three ways.
Cohort
Detection
Metric that matters
Full
ios.status === AUTHORIZED with allowsDisplayOnLockScreen
CTR, downstream session depth
Full (quiet)
AUTHORIZED with surfaces switched off
CTR — this is what drags your full average down
Provisional
ios.status === PROVISIONAL
Keep rate, upgrade rate
CTR cannot tell you whether provisional worked. What matters is whether the person still has your channel open. Trouble is, you can't observe a Keep tap directly from the app. My proxy is the ios.status transition 72 hours after a send. Still PROVISIONAL means Turn Off wasn't pressed. Flipped to DENIED means you were cut.
Honestly, that proxy is coarse. You can't call getPermissionsAsync() unless the user launches the app, so anyone who turns you off and never returns falls out of the data entirely. Provisional structurally increases that unobservable churn. The price of not showing a dialog is lower resolution on user intent. That was a trade I decided to accept.
Being able to distinguish genuine delivery failures from notifications that arrived perfectly and quietly will save you real investigation time.
Choosing between them
Provisional doesn't fit everything. My rules:
App type
Choice
Why
Notifications are the product (reminders, alarms)
Ask for full, directly
Quiet delivery breaks the feature. Sound and badges are load-bearing
Content drops (wallpapers, reading)
Provisional
Showing the real thing first is more honest, and the audience is larger
Transaction and payment confirmations
Full
A miss causes real harm, and losing timeSensitive is disqualifying
Social and messaging
Full
Immediacy is the value. Notification Center-only defeats the purpose
Weekly summaries and recaps
Provisional
Nothing is urgent, value accrues, and quiet is arguably preferred
The question underneath is simply: does it hurt if this gets missed? If yes, ask for full. If no, provisional lets you start delivering while keeping your one dialog in reserve. There's nothing to lose.
What actually changed
Switching the wallpaper app to provisional did not improve my opt-in rate. What it gave me was the ability to reach people who haven't decided yet.
Before, the moment someone declined the soft-ask, that channel was gone permanently. Now I get one chance to show the real thing before the decline. If that one notification is good, the relationship continues; if it isn't, I get cut. Quality of the notification becomes the outcome, directly. Running that as a solo developer felt good — it's decided by what I send, not by how cleverly I ask.
The first thing to touch in your own code is every direct read of granted. Run grep -rn "\.granted" src/ and check each site for the provisional hole. If you copied Expo's snippet, it's almost certainly there.
I'm still early in operating this and I haven't found the right upgrade timing yet. But showing before asking still feels like the correct order. Thanks for reading.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.