facebook twitter hatena line email

Php/アプリストア連携/返金API/AppStore

提供: 初心者エンジニアの簡易メモ
移動: 案内検索

appleのstorekitの返金のドキュメント

https://developer.apple.com/jp/documentation/storekit/in-app_purchase/handling_refund_notifications/

https://developer.apple.com/documentation/AppStoreServerNotifications/unified_receipt/Latest_receipt_info-data.dictionary

ストアからの通知url設定箇所

appstore管理画面/配信/アプリ情報/appstoreサーバ通知

返金通知のサンプルJSON

  • subtype: "DISPUTE", or "OTHER"
  • signedRenewalInfo: 省略可能(テスト時)
  • signedTransactionInfo: 省略可能(テスト時)
  • cancellation_reason: 1:ユーザー申請, 0:その他
{
  "notificationType": "REFUND",
  "subtype": "DISPUTE", // または "OTHER"
  "notificationUUID": "a1b2c3d4-5678-90ef-1234-567890abcdef",
  "data": {
    "appAppleId": 123456789,
    "bundleId": "com.example.app1",
    "bundleVersion": "1.0",
    "environment": "Sandbox",
    "signedRenewalInfo": "...",
    "signedTransactionInfo": "...", 
    "unified_receipt": {
      "environment": "Sandbox",
      "latest_receipt": "BASE64_ENCODED_RECEIPT_DATA",
      "latest_receipt_info": [
        {
          "cancellation_date_ms": "1625097600000",
          "cancellation_reason": "1",
          "product_id": "premium_subscription",
          "transaction_id": "1000000123456789",
          "original_transaction_id": "1000000123456789",
          "purchase_date_ms": "1625000000000",
          "expires_date_ms": "1627600000000"
        }
      ],
      "status": 0
    }
  },
  "version": "2.0"
}


返金通知の実際のjwtデコードJSON

{
  "notificationType": "SUBSCRIBED",
  "subtype": "INITIAL_BUY"
  "notificationUUID": "a1b2c3d4-5678-90ef-1234-567890abcdef",
  "data": {
    "appAppleId": 123456789,
    "bundleId": "com.example.app1",
    "bundleVersion": "202504201241",
    "environment": "Production",
    "signedRenewalInfo": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdByXjXWPNAT8g~略",
    "signedTransactionInfo": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWL9sbGXT55ZIi7Wt470x3w~略", 
    "status": 1
    }
  },
  "version": "2.0",
  "signedDate": 1749043664833
}

appstore内の返金リクエストurl

ttps://reportaproblem.apple.com/

apple署名検証

jsonはjwtエンコードされてるので、jwtデコードする必要がある。 その際に、apple証明書で、検証する。アプリ別(bundle_id別)ではなく、どのアプリでも同じ証明書で検証する。

opensslでapple署名検証しながらfirebaseのjwtで、jwtデコードする

composerで、firebase/php-jwtのインストール

composer require firebase/php-jwt

apple署名検証付きデコード処理

use \Firebase\JWT\JWT;
use \Firebase\JWT\Key;

function decodeAppleJWS($signedPayload) {
    // 1. JWSのヘッダーを手動で解析(アルゴリズム確認用)
    $parts = explode('.', $signedPayload);
    $headerBase64 = $parts[0];
    $header = json_decode(base64_decode($headerBase64), true);
    
    if ($header['alg'] !== 'ES256') {
        throw new Exception('Invalid algorithm. Expected ES256');
    }

    // 2. Appleの証明書チェーンを準備
    $leafCert = isset($header['x5c'][0]) ? $header['x5c'][0] : null;
    if (!$leafCert) {
        throw new Exception('Missing x5c certificate chain');
    }

    // 3. 中間証明書とルート証明書をダウンロード
    $intermediateCert = file_get_contents('https://www.apple.com/certificateauthority/AppleWWDRCAG6.cer');
    $rootCert = file_get_contents('https://www.apple.com/certificateauthority/AppleRootCA-G3.cer');

    // 4. 証明書チェーンを検証
    $certificateChain = array(
        'leaf' => "-----BEGIN CERTIFICATE-----\n{$leafCert}\n-----END CERTIFICATE-----",
        'intermediate' => $intermediateCert,
        'root' => $rootCert
    );

    if (!verifyCertificateChain($certificateChain)) {
        throw new Exception('Certificate chain validation failed');
    }

    // 5. firebase/php-jwt でデコード
    // PHP 5系では新しい構文が使えないので、適切なJWTライブラリのバージョンを使用する必要あり
    return JWT::decode(
        $signedPayload,
        $certificateChain['leaf'],
        array('ES256')
    );
}

// 証明書チェーン検証(簡略版)
function verifyCertificateChain($chain) {
    // 1. 証明書をメモリ上で読み込み
    $cert = openssl_x509_read($chain['leaf']);
    if ($cert === false) {
        throw new Exception('Memory read failed: ' . openssl_error_string());
    }

    // 2. 一時ファイルを作成
    $tempFile = tempnam(sys_get_temp_dir(), 'cert_');
    file_put_contents($tempFile, $chain['leaf']);
    
    // 3. 証明書検証(PHP 5系用の引数)
    $result = openssl_x509_checkpurpose(
        $tempFile,          // 証明書ファイルパス
        X509_PURPOSE_ANY,   // 目的
        array(),            // CAファイル(空でシステムデフォルトを使用)
        $tempFile    // 検証対象証明書
    );
    
    // 4. リソース解放
    openssl_x509_free($cert);
    unlink($tempFile);
    
    return (bool)$result;
}