iOS应用内购买,第4部分:服务器端购买验证
Updated: March 20, 2023
什么是服务器购买验证?
服务器验证,即服务器端收据验证(receipt validation)是一种验证购买真实性的方法。与基于设备的验证不同,服务器验证(请注意)发生在服务器端。验证表示设备或服务器向苹果服务器发出请求,以查明购买是否已实际发生,以及购买是否有效。
为什么需要验证购买?
需要注意的是,服务器验证不是强制性的——没有它,应用内购买仍然有效。不过,它也带来了一些优势:
- 高级支付分析,这对订阅(subscription)尤其重要,因为激活后发生的一切都不会被设备处理。如果没有服务器购买处理,您将无法检索当前的订阅状态,也无法了解用户是续订了还是取消了订阅,是否存在任何账单问题(billing issue),等等。
- 能够验证购买的真实性。您将确保交易不是欺诈,用户确实已经为您的产品付费。
- 跨平台订阅。如果能够实时查看用户的订阅状态,则可以与其他平台进行同步。例如,从iOS设备上购买订阅的用户将能够在安卓、网络和其他平台上使用。
- 能够从服务器端控制内容访问,通过简单地执行对服务器的请求来保护您不受那些未订阅却试图访问数据的用户的影响。
从我们的经验来看,第一个优势本身就足够建立服务器购买处理。
购买验证
一般来说,iOS上的收据验证过程是这样的:
生成共享密钥(shared secret)
如需发送支付验证请求,必须包含共享密钥来对请求进行授权。您可以在App Store Connect中生成一个。
可以为特定应用程序(应用特定密钥)或账户中的所有应用程序(主密钥)创建一个共享密钥。
要生成一个特定于应用程序的密钥,请在App Store Connect中打开应用程序页面,进入“In-App Purchases”→“Manage”,然后点击“App-Specific Shared Secre”。在打开的窗口中,您能够生成新的令牌或复制现有令牌。
如需接收您账户中所有应用程序的密钥,请打开Users and Access页面,选择Shared Secret选项卡。
请求支付验证
一旦您收到共享密钥,您就可以发送收据到苹果服务器上进行验证。这是通过verifyReceipt请求完成的。您必须向https://buy.itunes.apple.com/verifyReceipt发送一个POST请求。在请求的JSON体中,在password字段中传递共享密钥,在receipt-data字段中传递收据。还有一个可选的exclude-old-transactions参数。如果它具有true值,那么对于每个自动续订,您都会只接收最后一个交易,而不是完整的续订历史。
以下是购买验证请求的有效负载:
{ "password": "f4d35830e3...52aae", "receipt-data": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==", "exclude-old-transactions": false }
如果您在Sandbox环境中工作——也就是说,您正在测试购买——那么将验证请求发送到https://sandbox.itunes.apple.com/verifyReceipt。共享密钥、有效负载和响应格式保持不变。
需要注意的是,您不能在成品服务器(Production server)上验证在沙盒环境中创建的收据,反之亦然。这就是为什么在实际系统中,最佳实践是将第一个请求定向到成品服务器,并将其重定向到沙盒服务器,以防状态键返回21007错误代码。这种行为在应用审查过程中是必须的,因为它允许苹果员工测试购买,同时也允许您的应用的真正用户进行购买。
在其他需要注意的错误中,有一个21004错误代码,这意味着我们使用了错误密钥。这一点很重要,因为它会影响用户体验和分析准确性。在最糟糕的情况下,如果用户在付费后无法获得高级功能,该应用可能会被从应用商店下架。
如果验证成功(status=0),那么响应将包含用户交易的详细信息。
以下是支付验证请求响应:
{ "environment": "Production", "receipt": { "receipt_type": "Production", "adam_id": 123, "app_item_id": 123, "bundle_id": "com.adapty.sample_app", "application_version": "1", "download_id": 123, "version_external_identifier": 123, "receipt_creation_date": "2021-04-28 19:42:01 Etc/GMT", "receipt_creation_date_ms": "1619638921000", "receipt_creation_date_pst": "2021-04-28 12:42:01 America/Los_Angeles", "request_date": "2021-08-09 18:26:02 Etc/GMT", "request_date_ms": "1628533562696", "request_date_pst": "2021-08-09 11:26:02 America/Los_Angeles", "original_purchase_date": "2017-04-09 21:18:41 Etc/GMT", "original_purchase_date_ms": "1491772721000", "original_purchase_date_pst": "2017-04-09 14:18:41 America/Los_Angeles", "original_application_version": "1", "in_app": [ { "quantity": "1", "product_id": "basic_subscription_1_month", "transaction_id": "1000000831360853", "original_transaction_id": "1000000831360853", "purchase_date": "2021-04-28 19:41:58 Etc/GMT", "purchase_date_ms": "1619638918000", "purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT", "original_purchase_date_ms": "1619638918000", "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "expires_date": "2021-05-05 19:41:58 Etc/GMT", "expires_date_ms": "1620243718000", "expires_date_pst": "2021-05-05 12:41:58 America/Los_Angeles", "web_order_line_item_id": "230000397200750", "is_trial_period": "true", "is_in_intro_offer_period": "false", "in_app_ownership_type": "PURCHASED" } ] }, "latest_receipt_info": [ { "quantity": "1", "product_id": "basic_subscription_1_month", "transaction_id": "230001020690335", "original_transaction_id": "1000000831360853", "purchase_date": "2021-08-04 19:41:58 Etc/GMT", "purchase_date_ms": "1628106118000", "purchase_date_pst": "2021-08-04 12:41:58 America/Los_Angeles", "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT", "original_purchase_date_ms": "1619638918000", "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "expires_date": "2021-08-11 19:41:58 Etc/GMT", "expires_date_ms": "1628710918000", "expires_date_pst": "2021-08-11 12:41:58 America/Los_Angeles", "web_order_line_item_id": "230000438372383", "is_trial_period": "false", "is_in_intro_offer_period": "false", "in_app_ownership_type": "PURCHASED", "subscription_group_identifier": "272394410" }, { "quantity": "1", "product_id": "basic_subscription_1_month", "transaction_id": "230001017218955", "original_transaction_id": "1000000831360853", "purchase_date": "2021-07-28 19:41:58 Etc/GMT", "purchase_date_ms": "1627501318000", "purchase_date_pst": "2021-07-28 12:41:58 America/Los_Angeles", "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT", "original_purchase_date_ms": "1619638918000", "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "expires_date": "2021-08-04 19:41:58 Etc/GMT", "expires_date_ms": "1628106118000", "expires_date_pst": "2021-08-04 12:41:58 America/Los_Angeles", "web_order_line_item_id": "230000849023623", "is_trial_period": "false", "is_in_intro_offer_period": "false", "in_app_ownership_type": "PURCHASED", "subscription_group_identifier": "272394410" } ], "latest_receipt": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==", "pending_renewal_info": [ { "auto_renew_product_id": "basic_subscription_1_month", "product_id": "basic_subscription_1_month", "original_transaction_id": "1000000831360853", "auto_renew_status": "1" } ], "status": 0 }
这个响应相当繁琐,在苹果应用商店服务器API的新版本中被简化了,但是当前的实现并不难掌握。
检索订阅状态和交易历史记录
为了查明用户是否有权限使用应用程序的高级功能,您需要一种方法来确定其订阅状态。在当前版本的API中没有用于检索订阅状态的专用请求,因此在任何情况下都需要使用交易历史记录。
默认情况下,latest_receipt_info数组包含特定用户的所有应用内购买交易,除了在应用端完成的可消费产品。通过这种方式,您可以检索用户的整个购买历史。这对于分析和确定当前订阅状态非常有用。
似乎交易总是以最新的优先排序。当然,我仍然建议为交易实现您自己的按日期排序。
交易有效负载:
{ "quantity": "1", "product_id": "basic_subscription_1_month", "transaction_id": "1000000831360853", "original_transaction_id": "1000000831360853", "purchase_date": "2021-04-28 19:41:58 Etc/GMT", "purchase_date_ms": "1619638918000", "purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT", "original_purchase_date_ms": "1619638918000", "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "expires_date": "2021-05-05 19:41:58 Etc/GMT", "expires_date_ms": "1620243718000", "expires_date_pst": "2021-05-05 12:41:58 America/Los_Angeles", "web_order_line_item_id": "230000397200750", "is_trial_period": "true", "is_in_intro_offer_period": "false", "in_app_ownership_type": "PURCHASED", "subscription_group_identifier": "272394410" }
如需检查当前的订阅状态,检索链的最新交易并查看expires_date属性就足够了。例外是宽限期(grace period),我们稍后会讨论这个问题。
出于分析的目的,我建议保存以下属性:
- product_id:购买产品的文本标识符。
- transaction_id:交易的唯一数字标识符。每次购买或续订都有自己的标识符,可以用来了解该交易以前是否被处理过。
- original_transaction_id:交易链的唯一数字标识符。它将在订阅或试用(trial)激活时匹配transaction_id。然而,在进一步订阅续订(subscription renewal)时,transaction_id将会改变,而original_transaction_id将保持不变。这便于跟踪订阅的续订次数。
- purchase_date和original_purchase_date:交易日期和原始交易日期。这与前面的属性在相同的逻辑下工作。
- expires_date:订阅到期日。
- cancellation_date:退款日期,而不是像名字所暗示的那样取消订阅(subscription cancellation)。如果响应有这个字段,这意味着您可以终止用户对他们的订阅的访问,并在分析中说明取消的原因——您不会从这笔交易中收到任何付款。
- is_in_intro_offer_period:显示在激活订阅时是否使用了试销优惠价(intro offer)的标志。
- is_trial_period:显示订阅激活时是否有试用期的标志。
- offer_code_ref_name:激活订阅时使用的优惠代码。
- promotional_offer_id:进入当前计费周期时使用的优惠推广的文本标识符。
- in_app_ownership_type:订阅所有权类型。它可以显示用户是自己购买了产品,还是作为家庭订阅的一部分收到的产品。一些可能的值包括:
- PURCHASED:用户自己购买产品。
- FAMILY_SHARED:用户收到的产品是家庭订阅的一部分。
苹果公司在WWDC 2021上宣布,他们计划在交易信息中添加appAccountToken字段。它将包含系统中的用户标识符。这个标识符必须是UUID格式的,并且在初始化购买时在应用程序端定义。如果定义了,它将在该链的所有交易中返回(订阅续订、账单问题等),这意味着您将很容易理解是哪个用户进行了购买。
您还应该跟踪subscription_group_identifier参数。如果用户之前曾与活跃试用或试销优惠价进行过任何交易,那么他们就不应该在同一订阅组中访问这些内容。这应该在服务器端进行跟踪。
订阅更新信息、宽限期和账单问题
pending_renewal_info数组存储订阅续订数据。它允许理解在下一个计费周期订阅会发生什么。例如,如果您发现用户不选择自动续订,您可以建议他们换一个不同的计划,或向他们提供优惠推广。通过服务器通知(server notification)可以很方便地跟踪这些事件(event),我很快会讨论这一点。
订阅续订数据:
{ "auto_renew_product_id": "basic_subscription_1_month", "product_id": "basic_subscription_1_month", "original_transaction_id": "1000000831360853", "auto_renew_status": "1" }
- product_id:购买的产品的文本标识符。
- auto_renew_product_id:在下一个计费周期将被激活的产品的文本标识符。如果它不同于当前的订阅类型(product_id),则意味着用户更改了其订阅类型。
- auto_renew_status:显示订阅是否继续到下一个计费周期的标志。
- expiration_intent:订阅到期的原因。一些可能的值包括:
- 1 —— 用户自己取消了订阅。
- 2 ——由于账单问题,订阅被取消了。
- 3 —— 用户不同意涨价。
- 4 —— 订阅产品无法续订,例如该产品已从App Store Connect中移除。
- 5 —— 未知原因。
- grace_period_expires_date:如果您的应用程序存在宽限期,那么就是宽限期的有效期。如果是这种情况,用户应该有权访问这里指定的日期之前的高级功能,而不是交易本身的日期。如果您有这个密钥,您可以通知用户他们需要更新他们的卡信息或充值余额。
- is_in_billing_retry_period:显示订阅是否具有计费重试状态的标志。这意味着订阅并没有被取消,但苹果不能收取续订费,并将在60天内继续尝试。
- offer_code_ref_name:将在下一个计费周期使用的优惠代码。
- promotional_offer_id:将在下一个计费期间使用的优惠推广的文本标识符。
- price_consent_status:显示用户是否同意即将上涨的订阅价格的标志。如果它的值为0,您应该向用户提供一些不同的产品或优惠推广,这样他们就不会取消订阅。
消耗性、非消耗性订阅和不续订
如果用户没有自动续订产品(auto-renewable product),则不会返回latest_receipt_info和pending_renewal_info键。在这种情况下,可以在receipt → in_app中找到交易。该交易格式类似于自动续订交易,但没有专门用于自动续订交易的到期、续期、优惠和其他属性的字段。
需要注意的是,receipt → in_app也会自动更新交易,但更好的实践是使用latest_receipt_info,因为它包含的订阅数据是最新的。
服务器交易通知
不久前,您还必须设计一个复杂的系统才能跟踪订阅状态的变化。例如,要了解订阅是否已续订,您必须在订阅到期前24小时,每小时向苹果服务器发送一次订阅状态请求。随着时间的推移,苹果添加了越来越多的服务器通知,现在这些通知几乎涵盖了所有与订阅有关的重要事件。这是非常有用的:一旦苹果方面有任何变化,您就会在自己的服务器上收到通知。也就是说,您会收到关于新购买、订阅续订、账单问题等的通知。这让您可以收集更准确的分析,并让订阅状态管理更加容易。
您可以在App Store Connect中启用服务器通知。打开应用程序页面,进入General -> App information。接下来,将链接放入苹果应用商店服务器通知字段的链接中,并保存更改。
服务器通知:
{ "notification_type": "DID_RENEW", "password": "f4d35830e3...52aae", "environment": "PROD", "auto_renew_product_id": "basic_subscription_1_month", "auto_renew_status": "true", "unified_receipt": { "status": 0, "environment": "Production", "latest_receipt_info": [ { "quantity": "1", "product_id": "basic_subscription_1_month", "transaction_id": "230001020690335", "original_transaction_id": "1000000831360853", "purchase_date": "2021-08-04 19:41:58 Etc/GMT", "purchase_date_ms": "1628106118000", "purchase_date_pst": "2021-08-04 12:41:58 America/Los_Angeles", "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT", "original_purchase_date_ms": "1619638918000", "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "expires_date": "2021-08-11 19:41:58 Etc/GMT", "expires_date_ms": "1628710918000", "expires_date_pst": "2021-08-11 12:41:58 America/Los_Angeles", "web_order_line_item_id": "230000438372383", "is_trial_period": "false", "is_in_intro_offer_period": "false", "in_app_ownership_type": "PURCHASED", "subscription_group_identifier": "272394410" }, { "quantity": "1", "product_id": "basic_subscription_1_month", "transaction_id": "230001017218955", "original_transaction_id": "1000000831360853", "purchase_date": "2021-07-28 19:41:58 Etc/GMT", "purchase_date_ms": "1627501318000", "purchase_date_pst": "2021-07-28 12:41:58 America/Los_Angeles", "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT", "original_purchase_date_ms": "1619638918000", "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "expires_date": "2021-08-04 19:41:58 Etc/GMT", "expires_date_ms": "1628106118000", "expires_date_pst": "2021-08-04 12:41:58 America/Los_Angeles", "web_order_line_item_id": "230000849023623", "is_trial_period": "false", "is_in_intro_offer_period": "false", "in_app_ownership_type": "PURCHASED", "subscription_group_identifier": "272394410" } ], "latest_receipt": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==", "pending_renewal_info": [ { "auto_renew_status": "1", "auto_renew_product_id": "basic_subscription_1_month", "product_id": "basic_subscription_1_month", "original_transaction_id": "1000000831360853" } ] }, "bid": "com.adapty.sample_app", "bvrs": "0" }
服务器通知格式类似于支付验证响应。交易详细信息存储在unified_receipt → latest_receipt_info中。密码密钥存储了应用程序的共享密钥,允许您验证请求的真实性。notification_type键存储事件类型。在我看来,最有用的是:
- DID_CHANGE_RENEWAL_STATUS:这意味着用户关闭或打开了订阅自动续订功能(后者更少见)。如果用户选择退出自动续订,您可能想要鼓励他们重新成为您的活跃订阅者。
- DID_FAIL_TO_RENEW:由于账单问题,订阅无法续订。您应该通知用户,这样他们的订阅才不会被自动取消。
- DID_RENEW:订阅续订成功。
- REFUND:订阅费已退回。您应该限制用户访问该购买所授予的高级功能,并在您的分析中解释退款(即损失这笔钱)的原因。
结论
服务器验证可以加强您对从应用程序收集的数据的分析。这也导致骗子更难访问您的优质内容,并且让您可以跨平台订阅。同时,实现服务器验证可能需要花费大量时间,尤其是在需要高度准确的数据时。这将需要考虑许多次要情况:订阅升级、订阅跨级、试用期、优惠推广/试销优惠价、宽限期、退款、家庭订阅等。您还必须了解并考虑到各种细微差别,例如苹果有一项政策,即对定期续订超过一年的订阅将佣金从30%降至15%。
Further reading