みなさん、こんにちは。
iOSDC Japan 2024でも発表したforteeが発行するAppleウォレットのパスについて、パス情報の更新に対応しました。
参加者名の変更と、チケットのキャンセルが反映されます。

実装できたら記事にまとめたいと宣言していたので、この記事ではAppleウォレットのパスの更新方法について解説します。
(「Appleウォレットとは?」「そもそもどうやれば保存できるの?」と疑問に思われた方は1つ目のリンクから遷移できるスライドをご覧ください)
概要
まず、パス更新の大まかな流れや更新するために何を対応すれば良いのか、概要をお伝えします。
ウォレットのパスが更新されるまでの流れは、ほとんど上記スライド(から数スライド)に記載した通りです。
ざっくりまとめると、
- まずパス保存時に端末情報を保存しておき
- パスに更新があったらその端末にパスの更新をPush通知
- そして端末が新しいパスをリクエスト
という流れでパスの更新が行われます。
(それに加え、端末からパスが削除された場合は端末情報を削除して上記フローの対象から外す必要があります)
その内、サーバーで対応が必要な内容を抜き出すと以下のようになります。
- 生成するパスへの情報埋め込み
パス・端末情報の保存
- APIの実装
- MUST
- SHOULD (とまでは言わないが、実装しておいたほうが良い)
- APNs (Apple Push Notification service)へのPush通知実装
詳しくはこの後説明していきます。
実装
開発環境の要件
詳細の説明に移る前に、Appleウォレットのパスの更新を開発環境で動かすには以下の要件を満たす必要があります。
パスの新規追加・表示だけであればSimulatorでも動くのですが、パスの更新は実機でしか動きません。
Test updates on a device; the iOS Simulator app doesn’t register for push notifications.
(このドキュメントはURLを見るに古いドキュメントのアーカイブのようですが、最新のドキュメントでは同様の記述は見当たりませんでした。ただ、Simulatorからパスを追加しても更新用の端末登録リクエストは飛んでこなかったのでこの仕様は現在も生きているものと思われます)
そして、実機に追加したパスのwebServiceURLにhttpのURLが指定されていると以下のエラーが発生することをConsole.appから確認できました。
scheme of webServiceURL 'http://XXX' needs to be an https rather than http.
自己署名した証明書を使ったhttpsのサイトの場合、Safariでは警告を無視すれば接続可能ですが、パスを受け取ったウォレット側でサーバーとの接続に失敗してしまいます。
デフォルト 11:05:08.728411+0900 passd Enabled Notification Services Push Topics: <private> デフォルト 11:05:08.789829+0900 passd Generating POST request with URL <<private>> デフォルト 11:05:08.791635+0900 passd Connection 0: creating secure tcp or quic connection デフォルト 11:05:08.850603+0900 passd Trust evaluate failure: [leaf AnchorTrusted OtherTrustValidityPeriod SSLHostname ServerAuthEKU] エラー 11:05:08.852628+0900 passd Connection 58: TLS Trust encountered error 3:-9807
そのため、何らかの方法で「自己署名ではない」証明書が設定されたhttpsのサイトで開発環境を動かす必要があります。
(自分の場合ははngrokを使いました)
パスの更新を開発する際は、この注意事項を踏まえた上で開発環境を用意して頂ければと思います。
それでは、次からはパスが更新されるまでの詳しい流れを説明していきます。
パスの保存
まず最初、パスが端末に新規保存される時の流れは以下のようになります。
sequenceDiagram
actor iPhone
alt パス新規登録
iPhone ->>+ fortee: パスを要求
fortee -->>- iPhone: pkpassファイル
Note over fortee,iPhone: webServiceURL, authenticationTokenを埋め込む
iPhone ->>+ fortee: 更新通知用に端末を登録
Note right of iPhone: POST /v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}/{serialNumber}<br>Authorization: ApplePass {authenticationToken}<br>リクエストボディ: pushToken
fortee ->> DB: deviceLibraryIdentifier, pushToken, serialNumberを保存
fortee ->>- iPhone: 登録結果
Note right of iPhone: - 200: serialNumber登録済<br>- 201: 登録成功<br>- 401: 認証失敗
iPhone ->>+ fortee: 更新パス一覧取得
Note right of iPhone: GET /v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}
fortee -->>- iPhone: 更新があったパスのserialNumber
end
POST /v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}/{serialNumber}のAPIにパスが保存された端末からリクエストが送られてくるため、serialNumberとauthenticationTokenを照合した上でdeviceLibraryIdentifierとpushTokenを保存します。
URLに含まれるpassTypeIdentifierはパスの発行主体を識別するIDで、forteeの場合は全カンファレンスで共通です。
万が一、他のシステムによって発行されたパスを元にしたリクエストが送られてきた場合を考慮してpassTypeIdentifierもチェックしておくのが良いでしょう。
(serialNumberとauthenticationTokenによる認証で弾かれるとは思いますが、念の為)
また、スライド (やAppleのドキュメント)には記載がありませんが、この時点で更新可能なパスの一覧を取得するリクエストも飛んできます。(詳細は後ほど)
パスの更新
sequenceDiagram
actor iPhone
fortee ->> fortee: 参加者情報更新
Note over fortee,fortee: キャンセルなど
alt パス更新
fortee ->>+ APNs: Push通知リクエスト
APNs -->>- iPhone: 更新通知
iPhone ->>+ fortee: 更新パス一覧取得
Note right of iPhone: GET /v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}<br>?passesUpdatedSince={previousLastUpdated}
fortee -->>- iPhone: 更新があったパスのserialNumber
iPhone ->>+ fortee: 更新されたパスを要求
Note over fortee,iPhone: GET /v1/passes/{passTypeIdentifier}/{serialNumber}<br>Authorization: ApplePass {authenticationToken}
fortee -->>- iPhone: pkpassファイル
end
チケットがキャンセルされたなど、パスの情報を更新したい場合は登録されたpushTokenの端末に対してPush通知を送ってパスに更新があることを知らせ、端末が新しいパスを取得することでパスが更新されます。
パスの更新通知
Push通知は画面上にポップアップ通知としては表示されず、OSに対してサイレントにパス更新を知らせるトリガーとしての役割を果たしています。
Push通知を送るにはAPNs(Apple Push Notification service)にリクエストを送る必要があり、パス更新のドキュメントには
Create and send a push notification for each registered device. The notification uses the same certificate and private key that the creator of the pass used to sign the original, the push token registered by the device, and an empty JSON dictionary for the payload.
中略
For more information on sending push notifications, see Sending notification requests to APNs.
中略
A push notification for a pass update works only in the production environment.
と記載されています。
APNsへはHTTP/2でPOSTしないといけないなど縛りがあるのですが、リンク先のAPNsのドキュメントも合わせて読むと
- APNsにリクエストする際の認証にはパス作成時に使用する証明書と秘密鍵が使えること
- 通知先の端末の指定にはpushTokenを使用すること
- ペイロードは空のJSON dictionary
{}を送信すること - Appleウォレットの更新通知にはAPNsのDevelopment server (
api.sandbox.push.apple.com:443)は使えず、Production server (api.push.apple.com:443)しか使用できないこと
が分かります。
コマンドラインでPush通知を送る方法について記載されたドキュメントにcurlコマンドでPOSTするサンプルがあるので、一度curlコマンドでpushTokenや証明書・秘密鍵を正しく指定できているか確認しておくのが良いかと思います。
上記、パス更新の場合の条件を合わせるとコマンドは以下のようになります。
curl \
--cert 証明書.pem \
--cert-type PEM --key 秘密鍵.pem \
--key-type PEM --data '{}'\
--http2 https://api.push.apple.com/3/device/{pushToken}
問題なく指定できていれば、APNsのサーバーから200レスポンスが返ってきてサーバーの更新可能パス一覧APIに端末からGETリクエストが送られてくるはずです。
動作確認が取れたら、あとはそれを実装に落とし込めば動きます。
forteeの場合はCakePHPのHTTPクライアントを使って
<?php use Cake\Http\Client; $client = new Client([ 'protocolVersion' => '2', 'curl' => [ CURLOPT_SSLCERT => '証明書のパス' CURLOPT_SSLKEY => '秘密鍵のパス', ], ]); $client->post("https://api.push.apple.com/3/device/{通知先のpushToken}", '{}');
のように実装しています。
CakePHPの(デフォルトのCurlアダプターを使った)HTTPクライアントでcurlのオプションをどのように指定すれば良いのだろう、と少し戸惑ったのですがコードに答えがありました。
更新パスの送信
更新通知を受け取った端末は、サーバーに対して更新可能なパスの一覧を要求し、サーバーはクエリパラメータで指定されたpreviousLastUpdated < パスの更新日時がtrueになるパスを更新日時と合わせて返却します。
レスポンスボディの例)
{ "serialNumbers": ["更新可能なパスのserialNumberの配列"], "lastUpdated": "最終更新日時" }
lastUpdatedに記載する日時のフォーマットは「A developer-defined string」とドキュメントに記載されており、lastUpdatedの値に応じてpreviousLastUpdatedのフォーマットが決まります。
実際に試してみた所
- UNIXタイムスタンプ
- Y-m-dTH:i:s+09:00
- Y年m月d日TH時i分s秒+09:00
- 令和6年m月d日TH時i分s秒+09:00
- 令和6年m月d日TH時i分s秒
のどのフォーマットでも問題なく動いたので日時として解釈可能なフォーマットならある程度は対応していそうです。
(previousLastUpdatedの形式はlastUpdatedのフォーマットから微妙に変わってはいましたが、ちゃんとJSTの日時として認識されていました)
(フォーマットの指定方法はDateTimeInterface::formatに準じます)
ただ、previousLastUpdatedで受け取った値をパスの更新日時と比較しないといけないことを考えるとUNIXタイムスタンプにしておくのが一番無難・楽かなと思います。
そして、更新可能なパスがあった場合、端末からそのパスへのリクエストが飛んでくるので新しいpkpassファイルをレスポンスすれば端末にあるパスが更新されます。
GET /v1/passes/{passTypeIdentifier}/{serialNumber}
この際、レスポンスヘッダにLast-Modifiedヘッダを指定しないとエラーが発生しました。
新規保存時は未設定でもエラーは発生せず、更新時のみ必要になるようです。
ご注意ください。
新規保存時もリクエストされる更新可能パス一覧API
実は、更新可能パス一覧APIは端末へのパスの新規保存直後も呼び出されます。
(これがスライドとの差異です)
ドキュメントではpreviousLastUpdatedがRequiredと記載されているのですが、新規保存時はクエリパラメータ未指定でリクエストが送られてきました。
(Requiredとは・・・)
そのため、クエリパラメータ未指定の場合は指定されたdeviceLibraryIdentifierに紐づくパスを全て返す実装にしています。
パスを保持している端末情報の削除
このようにして端末に保存されたパスが更新されるのですが、もう1つ実装が必要なAPIがあります。
端末情報の削除APIです。
端末がパスを更新した際、その端末をパス更新対象から外す必要があります。
sequenceDiagram
actor iPhone
iPhone ->> iPhone: パス削除
alt パス更新通知の対象から端末を削除
iPhone ->>+ fortee: 端末削除リクエスト
Note right of iPhone: DELETE v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}/{serialNumber}<br>Authorization: ApplePass {authenticationToken}
fortee ->> DB: 端末情報を削除
fortee -->>- iPhone: 200
end
ここは送信されたパラメータに応じて認証・DBからの削除を行うだけです。
まとめ
このようにしてAppleウォレットのパスは更新されます。
実装してみるとそれなりに面倒で、PUT/PATCHリクエスト一発で更新できるGoogleウォレットが神に思えました。
(まあ、あちらはあちらで審査があったりと別の面倒さはあるんですけどね)
この記事がAppleウォレットのパスを実装しようとされている方の参考になれば幸いです。
お読みいただきありがとうございました。
補足
Apple Watchにパスを保存していた場合は?
forteeのようなWebサイト上からパスを保存する場合、Apple WatchにはiPhone経由でパスを保存します。
なので
ような気がしなくもないのですが、Apple Watchを持っていないので未確認です。
複数のチケットを更新する場合
パスを更新したい場合は対象の端末ごとにAPNsへのPush通知送信・その後の更新処理が動きます。
そのため、パスにイベント情報を埋め込んでいてそのイベント情報を一括で変更したい場合は端末の数だけ処理が走ることになり、バッチ処理などに切り出す必要が出てくるかと思います。
(この辺りはイベント情報と参加者情報が別れていてイベント情報の更新だけなら1回のPUT/PATCHで済むGoogleウォレットの方が楽ですね)