PurchaseKit normalizes Apple and Google billing notifications into a unified webhook format.
When your app receives a billing notification from Apple or Google, PurchaseKit processes it and forwards a normalized payload to your configured webhook URL.
{
"event_id": "unique-event-identifier",
"type": "subscription.created",
"customer_id": "your-customer-id",
"subscription_id": "store-subscription-id",
"store": "apple",
"store_product_id": "com.example.premium_monthly",
"subscription_name": "default",
"status": "active",
"current_period_start": "2025-01-23T12:00:00Z",
"current_period_end": "2025-02-23T12:00:00Z",
"success_path": "/welcome"
}
| Field | Type | Description |
|---|---|---|
event_id |
string | Unique identifier for this event. Apple: notification UUID. Google: message ID. |
type |
string | Normalized event type. See Event types below. |
customer_id |
string | Your customer identifier, passed when creating the purchase intent. |
subscription_id |
string | Store's subscription identifier. Stable across renewals. |
store |
string | Either "apple" or "google". |
store_product_id |
string | The product ID as configured in App Store Connect or Google Play Console. |
subscription_name |
string | Always "default" (reserved for future multi-subscription support). |
status |
string | Current subscription status: "active", "canceled", or "expired". |
current_period_start |
string | ISO 8601 timestamp of the current billing period start. |
current_period_end |
string | ISO 8601 timestamp when the current period ends. null if unknown. |
success_path |
string | The redirect path passed when creating the purchase intent. null if not set. |
PurchaseKit normalizes store-specific notification types into four unified event types:
| Event type | Description | Status |
|---|---|---|
subscription.created |
New subscription purchased | active |
subscription.updated |
Subscription renewed, recovered, or modified | active |
subscription.canceled |
Auto-renewal turned off (still active until period end) | canceled |
subscription.expired |
Subscription ended, revoked, or refunded | expired |
The purchasekit gem handles verification automatically when you mount the engine.
Each webhook request includes a signature header for verification:
X-PurchaseKit-Signature: sha256=abc123...
Verify the signature by computing HMAC-SHA256 of the raw request body using your webhook secret:
expected = "sha256=" + OpenSSL::HMAC.hexdigest(
"SHA256",
webhook_secret,
request.raw_post
)
valid = ActiveSupport::SecurityUtils.secure_compare(expected, request.headers["X-PurchaseKit-Signature"])
If your webhook endpoint returns a non-2xx response, PurchaseKit retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | 5 seconds |
| 2 | 5 minutes |
| 3 | 1 hour |
| 4 | 1 day |
| 5 | 3 days |
After 5 failed attempts, the delivery is marked as failed and the account owner receives an email notification.
Webhooks are delivered to different URLs based on environment:
| Environment | URL used |
|---|---|
production |
Webhook URL |
sandbox |
Sandbox Webhook URL (falls back to Webhook URL if not set) |
xcode |
Sandbox Webhook URL |
Configure both URLs in your app's webhook settings to test sandbox purchases separately.
The gem automatically verifies signatures and processes webhooks:
# config/routes.rb
mount PurchaseKit::Engine, at: "/purchasekit"
Subscribe to events in your app:
# config/initializers/purchasekit.rb
PurchaseKit.configure do |config|
config.on_subscription_created do |payload|
user = User.find_by(id: payload["customer_id"])
user&.activate_subscription!
end
config.on_subscription_canceled do |payload|
user = User.find_by(id: payload["customer_id"])
user&.mark_subscription_canceled!
end
config.on_subscription_expired do |payload|
user = User.find_by(id: payload["customer_id"])
user&.expire_subscription!
end
end