壁紙アプリの新しい配信機能を入れたとき、通知の許可率が思ったほど伸びませんでした。ソフトアスク画面は作ってあり、価値が伝わる場面まで待ってから聞いていたので、設計としては悪くないはずでした。それでも「まだこのアプリが何を送ってくるか分からない」段階で判断を迫っている構図は変わっていなかったのです。
そこで思い出したのが provisional 認可でした。iOS 12 から入っている仕組みで、許可ダイアログを一切出さずに通知を配り始められます。ユーザーは通知そのものを見てから、続けるか止めるかを決めます。聞き方を工夫するのではなく、聞く順番をひっくり返す。私はこの発想の方が誠実だと感じました。
ただ、実装に入って最初に踏んだのは、Expo 公式ドキュメントに載っている登録コードそのものが罠になっている、という落とし穴でした。
許可を聞かない、という第三の選択肢
通知の opt-in をめぐる打ち手は、これまで2つでした。起動直後に OS ダイアログを撃つか、自前のソフトアスク画面を一枚挟んでから撃つか。後者が優れているのは間違いありませんが、どちらも「まだ届いていない通知」の価値をユーザーに想像させている点では同じです。
provisional 認可は前提が違います。UNAuthorizationOptions に .provisional を足すと、システムはダイアログを出しません。通知は静かに配信され、通知センターの中だけに現れます。そこに「残す」「オフにする」のボタンが並び、ユーザーは実物を見たうえで判断します。
Apple がこれを用意した理由は明快です。どんな通知が来るのかを見ないまま、通知を受け取るかどうかを情報に基づいて決めることは、そもそもユーザーには不可能だからです。
Expo からは allowProvisional で要求します。
import * as Notifications from 'expo-notifications';
const settings = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowSound: true,
allowBadge: true,
allowProvisional: true,
},
});
このコードはダイアログを出しません。ユーザーの操作を待たずに解決し、通知の送信が始められる状態になります。ここまでは公式ドキュメントの通りです。
provisional で実際に手に入るもの、手に入らないもの
ここを曖昧にしたまま実装すると、あとで通知が「届いているのに誰も見ていない」という状態を、原因不明のまま抱えることになります。要求したオプションが全部通るわけではないからです。
| 配信面 |
provisional |
full(AUTHORIZED) |
| 通知センターへの表示 |
○ |
○ |
| バナー表示 |
× |
○ |
| ロック画面への表示 |
× |
○ |
| サウンド |
× |
○ |
| アプリアイコンのバッジ |
×(要求しても付与されない) |
○ |
| 許可ダイアログ |
出ない |
出る |
| timeSensitive による割り込み |
× |
○ |
バッジの扱いは特に注意が必要です。上のコードでは allowBadge: true を要求していますが、provisional で通った端末ではバッジは付きません。設定アプリの通知詳細を開くと、通知センターへの表示は許可されている一方、バッジの項目だけが許可されていない状態が確認できます。要求は通っているのに権限は下りていない、という食い違いが起きます。
つまり、未読件数をバッジで伝える設計をしているなら、provisional コホートに対してその導線は最初から死んでいます。setBadgeCountAsync は例外を投げずに、ただ何も起きません。私はここで半日を溶かしました。エラーが出ないバグは、出るバグより厄介です。
timeSensitive も効きません。緊急性の高い通知を割り込ませたいなら、それは provisional の外側にある機能です。
Expo の定番コードが provisional を握りつぶす
ここが本題です。
expo-notifications のドキュメントには、iOS の許可状態を判定するとき ios.status を見ろ、ルートの status を見るなと明記されています。そして getPermissionsAsync() の例では、こう書かれています。
export async function allowsNotificationsAsync() {
const settings = await Notifications.getPermissionsAsync();
return (
settings.granted || settings.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL
);
}
settings.granted を見たうえで、さらに || PROVISIONAL を OR しています。この後半が意味を持つということは、granted は provisional のとき false を返すということです。ドキュメントはそう明言していませんが、この一行がそれを認めています。
問題は、同じドキュメントの冒頭にある登録スニペットが、こうなっていることです。
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;
}
ルートの status を 'granted' と比較しています。ドキュメント自身が「ルートの status を見るな」と書いている、まさにその値です。
この2つを組み合わせると何が起きるか。provisional で通った端末は status !== 'granted' を満たします。すると requestPermissionsAsync() がオプション無しで呼ばれ、allowProvisional が付いていないので、今度は本物の許可ダイアログが出ます。
許可を聞かないために provisional を入れたのに、コピーしてきた登録処理が許可を聞きに行く。しかもその後 finalStatus !== 'granted' でトークン取得を諦めて return するので、実際には配信できる端末をプッシュ対象から丸ごと落とします。
この登録スニペットは、Expo のプッシュ通知を実装する人がほぼ全員コピーするものです。私もトークンのライフサイクルを整えたときは、この形をベースにしていました。provisional を入れる日まで、それで何も困りませんでした。
許可状態を5値で扱うフック
granted という真偽値ひとつに寄りかかるのをやめます。iOS の IosAuthorizationStatus は5つの状態を持ちます。
| 値 |
意味 |
通知は届くか |
NOT_DETERMINED |
まだ何も決めていない |
届かない |
DENIED |
拒否された |
届かない |
AUTHORIZED |
full 認可 |
届く |
PROVISIONAL |
静かな配信のみ認可 |
届く(静かに) |
EPHEMERAL |
App Clip 等の一時認可 |
届く(期限付き) |
「届くかどうか」と「目立つかどうか」は別軸です。この2軸を潰さずに持ち回るフックを作ります。
// hooks/useNotificationAuthorization.ts
import { 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 = {
/** 通知を送れば端末に届くか(静かな配信を含む) */
deliverable: boolean;
/** どこまで目立てるか */
tier: DeliveryTier;
/** provisional から full への昇格を提案できる状態か */
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 は granted の単純な真偽値でよい
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) {
// full でもロック画面だけ切られている場合があるため配信面を実測する
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 };
}
interpret() を通したあとは、呼び出し側は deliverable と tier だけを見ればよくなります。granted を直接触る場所をコードベースから消すことが目的です。
なぜ AUTHORIZED のときにまで allowsDisplayOnLockScreen を確認しているかというと、full で許可したあとにユーザーが設定アプリでロック画面表示だけを切っている場合があるからです。その端末は granted === true ですが、実質的な見え方は provisional に近くなります。ここを潰しておくと、後段の計測で「full なのに開封されない層」の正体が分かります。
provisional を要求する登録処理
先ほどの罠を踏まない登録処理に書き換えます。
// lib/registerForPushAsync.ts
import * 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;
// すでに届く状態なら再リクエストしない(ここが分岐の核心)
const deliverable =
current.granted || status === A.PROVISIONAL || status === A.EPHEMERAL;
// provisional 目的の呼び出しなら、届く状態にある端末には二度と触らない
if (deliverable && mode === 'provisional') {
return getToken();
}
// full 目的でも、すでに full なら触らない
if (current.granted && mode === 'full') {
return getToken();
}
// 一度 DENIED になった端末はダイアログを出せない
if (status === A.DENIED) {
return null;
}
const requested = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowSound: true,
// provisional ではバッジが下りないので、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;
}
要点は3つあります。
第一に、deliverable の判定に granted だけでなく PROVISIONAL と EPHEMERAL を OR していること。これで provisional 端末が「未許可」として再リクエストに流れ込むのを止めます。
第二に、mode で allowProvisional と allowBadge を出し分けていること。provisional 要求時に allowBadge: true を混ぜても付与されないので、要求の意図をコードに残すために外しています。
第三に、DENIED で早期 return していること。iOS では一度拒否された端末にダイアログを出す方法はありません。ここで requestPermissionsAsync() を呼んでも、何も起きずに DENIED が返ってくるだけです。呼ぶだけ無駄ですし、呼んでいる限り「なぜか許可されない」という誤読が残ります。
Android 側は provisional という概念自体が存在しないので、POST_NOTIFICATIONS のランタイム権限を通常どおり要求します。mode は iOS 専用の分岐として扱って構いません。
静かな配信を前提に、通知の中身を設計し直す
ここが実装より大事かもしれません。
provisional の通知は、通知センターを能動的に開いた人にしか見られません。画面上部から下にスワイプする、あの操作をした人だけです。バナーで視界に飛び込むことも、ロック画面で待ち構えることもできません。
この条件下で「あと3件で達成です」のような、文脈依存で短い通知を送っても意味がありません。ユーザーは通知センターを開いた時点で、いくつものアプリの通知を並べて眺めています。そこで生き残るのは、単体で完結して価値が伝わるものだけです。
私が壁紙アプリで書き換えたのは、この方向でした。
|
full 前提の文面 |
provisional 前提の文面 |
| タイトル |
新着があります |
朝の光をテーマにした壁紙を12枚追加しました |
| 本文 |
タップして確認 |
白を基調にした静かなトーンで揃えています。ロック画面で試せます |
| 狙い |
気づかせて開かせる |
開かなくても価値が伝わる。そのうえで「残す」を押させる |
provisional の通知の役割は、アプリを開かせることではありません。「残す」を押させることです。開封は、そのあとについてきます。この一段を挟んだ設計に切り替えないと、provisional は「誰にも見られない通知を大量に送る仕組み」に落ちます。
配信頻度も抑えます。通知センターに静かに積み上がった同じアプリの通知が5件並んでいる状態は、「オフにする」への最短距離です。私は provisional コホートには、最初の2週間で3通までと決めました。1通目で価値を見せ、2通目で継続の理由を見せ、3通目で昇格を提案する。この3通が、そのユーザーへの通知チャネルを持てるかどうかを決めます。
setNotificationHandler の設定も、現行の API 名で書き直しておきます。shouldShowAlert は非推奨になり、shouldShowBanner と shouldShowList に分かれました。
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
これはアプリがフォアグラウンドにあるときの挙動を決めるものなので、provisional かどうかとは独立しています。フォアグラウンド通知が出ない件は別の原因で起きることが多いので、混同しないようにしています。
いつ昇格を求めるか
provisional のまま放置しても、バナーもロック画面もサウンドもバッジも手に入りません。どこかで full を求める必要があります。
ここで効くのが、フックに持たせた upgradable です。provisional の端末に対してだけ、requestPermissionsAsync({ ios: { allowAlert: true, allowBadge: true, allowSound: true } }) を allowProvisional 無しで呼びます。このときは本物のダイアログが出ます。
タイミングの判断基準を、私は次のように置いています。
- 通知を1回でも開いた人にだけ聞く。通知センターから起動されたことを
addNotificationResponseReceivedListener で捉えて記録します。開いていない人に聞くのは、provisional を入れた意味を捨てる行為です
- 3通目に到達してから聞く。実物を3回見て残してくれた人は、通知の中身を知っています。Apple が provisional に期待した「情報に基づく判断」が、ここで初めて成立します
- ダイアログの直前に、何が変わるかを一文で伝える。「ロック画面にも表示する」なのか「新着を音でお知らせする」なのか。provisional からの昇格は、ゼロから許可を求めるのとは違い、すでに関係のある相手に条件変更を頼む行為です
聞き方そのものはソフトアスクの設計と同じで構いません。違うのは、聞く相手が「まだ何も受け取っていない人」ではなく「受け取ったうえで残した人」だという点です。母集団が違えば、同じ画面でも結果は変わります。
そして、断られてもゼロには戻りません。full を拒否した端末は DENIED になりますが、provisional 認可自体は残るのか消えるのかを、実機で必ず確認してください。私が iOS 26.x の実機で確認した範囲では、full のダイアログで「許可しない」を押した端末は DENIED に落ち、静かな配信も止まりました。ここは想像で判断せず、自分のターゲット OS で測るべき箇所です。昇格を求めることには、いま持っているチャネルを失うリスクがあるということです。
既存アプリにどう入れるか
新規アプリなら初回起動から provisional で始めれば済みます。難しいのは、すでに full の許可を持つユーザーと、過去に拒否したユーザーが混在している既存アプリです。ここを間違えると、いま届いている人の配信を静かに劣化させます。
手順1: 既存ユーザーには一切触らない
まず AUTHORIZED の端末を保護します。前掲の registerForPushAsync が current.granted && mode === 'full' で即 return しているのは、このためです。すでに full を持つユーザーに allowProvisional: true 付きのリクエストを投げる必要はどこにもありません。既存の許可が provisional に格下げされることはありませんが、そもそも呼ばない設計にしておく方が安全です。
過去に DENIED になった端末も同様に触りません。provisional を要求しても、拒否済みの端末では通りません。provisional は「一度も聞いていない人」にだけ効く手段です。ここを理解しないまま導入すると、「拒否済みユーザーを provisional で復活させられる」という誤った期待を持つことになります。復活はしません。
手順2: NOT_DETERMINED の層だけを provisional に流す
対象は NOT_DETERMINED の端末に限られます。既存アプリなら、ソフトアスクを表示したが同意されなかった層、あるいはまだ表示条件に達していない層です。この層は、これまで通知チャネルを持てていなかった人たちなので、provisional で失うものがありません。
私の壁紙アプリでは、この層が iOS ユーザーの4割ほどを占めていました。ソフトアスクまで到達しないまま離脱していた人が想像以上に多かったのです。リテンション施策として通知を設計してきたつもりでしたが、そもそも通知を届けられない人が4割いる状態では、施策の善し悪し以前の問題でした。
手順3: 配信ロジックを tier で分岐させる
同じ通知を全員に送らないようにします。フックの tier で分けます。
const { deliverable, tier } = useNotificationAuthorization();
// 送信対象の決定
if (!deliverable) return;
const payload =
tier === 'quiet'
? buildSelfContainedPayload(item) // 開かせない前提・単体で価値が伝わる文面
: buildActionPayload(item); // 開かせる前提・短く行動を促す文面
buildSelfContainedPayload と buildActionPayload を分けておくと、provisional コホートの文面だけを独立して改善できます。同じ文面を両方に流している限り、どちらのコホートにも最適化されない中途半端な通知が残り続けます。私はここを分けるまで、CTR の低下が文面のせいなのか配信面のせいなのか、切り分けられずにいました。
計測を分けないと判断を誤る
provisional を入れたあとに最初に壊れるのは、ダッシュボードです。
一般に iOS の通知 opt-in 率は平均56%前後、CTR は3〜4%程度とされています(Pushwoosh の2025年ベンチマーク)。ここに provisional コホートを混ぜると、送信対象は一気に増えます。ダイアログを出していないのですから当然です。一方、開封は通知センターを開いた人からしか発生しません。
結果として、送信数は増え、CTR は下がります。この数字だけを見て「通知施策が悪化した」と判断すると、正しい打ち手を捨てます。
分けるべきは3つです。
| コホート |
判定 |
見るべき指標 |
| full |
ios.status === AUTHORIZED かつ allowsDisplayOnLockScreen |
CTR・開封からの回遊 |
| full(静か) |
AUTHORIZED だが配信面が絞られている |
CTR(full 平均を引き下げる正体) |
| provisional |
ios.status === PROVISIONAL |
「残す」到達率・full への昇格率 |
provisional コホートの成否は CTR では測れません。測るべきは、そのユーザーが通知チャネルを持ち続けてくれたかどうかです。とはいえ、「残す」を押したこと自体はアプリからは直接観測できません。私は代理指標として、送信後72時間の ios.status の遷移を記録しています。PROVISIONAL のまま残っていれば少なくとも「オフにする」は押されていない。DENIED に変わっていれば切られた、という読み方です。
正直に書くと、この代理指標は粗いです。ユーザーがアプリを起動しない限り getPermissionsAsync() を呼べないので、切ったまま二度と開かない人は観測から漏れます。provisional を入れると、この「観測できない離脱」が構造的に増えます。ダイアログを出さない代償として、ユーザーの意思表示の解像度が下がる。これは受け入れるしかないトレードオフでした。
配信の失敗そのものを疑う場面と、静かに届いているだけの場面を切り分けられるようにしておくと、原因調査の時間がかなり減ります。
どちらを選ぶか
全部のアプリに provisional が合うわけではありません。私の判断軸はこうです。
| アプリの性質 |
推奨 |
理由 |
| 通知が主機能(リマインダー・アラーム) |
full を正面から求める |
静かな配信では機能が成立しない。バッジもサウンドも必須 |
| コンテンツの新着通知(壁紙・読み物) |
provisional |
実物を見せてから判断してもらう方が誠実で、母集団も大きくなる |
| 取引・決済の確認通知 |
full |
見落としが実害になる。timeSensitive が使えないのは致命的 |
| ソーシャル・メッセージ |
full |
即時性が価値そのもの。通知センター止まりでは意味がない |
| 週次サマリー・振り返り |
provisional |
急がない。積み重ねで価値が伝わる。むしろ静かな方が好まれる |
判断の中心は「その通知は、見逃されたら困るか」です。困るなら full を求めるべきです。困らないなら、ダイアログを撃つ権利を温存したまま配り始められる provisional の方が、失うものがありません。
この設計で何が変わったか
私が壁紙アプリで provisional に切り替えて得たのは、許可率の改善ではありませんでした。得たのは、「まだ判断していない人」に通知を届けられる状態です。
以前は、ソフトアスクを断られた時点でそのユーザーへの通信手段が永久に消えていました。いまは、断られる前に一度だけ実物を見せられます。その一通が良ければ関係が続き、悪ければ切られる。通知の質が、そのまま結果になる仕組みです。これは個人開発で回すには気持ちのいい構造でした。小手先の聞き方ではなく、送るものの中身で決まるからです。
実装で最初に触るべきは、granted を直接見ている箇所の洗い出しです。grep -rn "\.granted" src/ を一度走らせて、そこに provisional の穴が空いていないかを確かめてください。Expo の定番スニペットをコピーしている限り、ほぼ確実に空いています。
私自身まだ運用の途中で、昇格タイミングの最適解は掴めていません。それでも、聞く前に見せるという順番だけは、間違っていない気がしています。お読みいただきありがとうございました。