
Revenuecat Webhooks
- 1 installs
- 3 repo stars
- Updated March 17, 2026
- erganestudio/agentic-skills
Guides building server-side RevenueCat webhook listeners in .NET/C#, covering payloads, idempotency by event id, retries, and subscription events.
About
Provides expert knowledge for building server-side RevenueCat webhook listeners, focused on ASP.NET Core / C#, covering payloads, idempotency, retries, and subscription lifecycle events. A developer uses it when implementing or maintaining a webhook endpoint that syncs in-app purchase and subscription status.
- Server-side RevenueCat webhook listeners with a .NET/C# focus
- Covers idempotency by event.id, retry behavior, and payload structure
Revenuecat Webhooks by the numbers
- 1 all-time installs (skills.sh)
- Ranked #3,836 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 22, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erganestudio/agentic-skills --skill revenuecat-webhooksAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 3 |
| Last updated | March 17, 2026 |
| Repository | erganestudio/agentic-skills ↗ |
What it does
Guides building server-side RevenueCat webhook listeners in .NET/C#, covering payloads, idempotency by event id, retries, and subscription events.
Files
RevenueCat Webhooks Skill
This skill covers everything needed to understand, implement, and maintain a RevenueCat webhook listener — with a focus on ASP.NET Core / C# (.NET) backends.
Quick Reference
- RevenueCat sends
POSTrequests with a JSON body to your HTTPS endpoint - Your server must return HTTP 200 within 60 seconds
- RevenueCat retries up to 5 times (delays: 5, 10, 20, 40, 80 minutes)
- Always make your handler idempotent — deduplication by
event.id - Available on the Pro plan only
---
1. Setup (Dashboard)
1. Go to Project → Integrations → Webhooks → Add new configuration 2. Name the webhook, enter your HTTPS URL 3. (Optional) Set an Authorization header — RevenueCat sends this with every request; validate it in your handler 4. Choose environment: Production, Sandbox, or Both 5. Optionally filter to specific apps or event types
---
2. Payload Structure
Every webhook is a POST with Content-Type: application/json:
{
"api_version": "1.0",
"event": {
"type": "INITIAL_PURCHASE",
"id": "UniqueEventID",
"app_id": "yourAppID",
"event_timestamp_ms": 1591121855319,
"app_user_id": "yourCustomerAppUserID",
"original_app_user_id": "OriginalAppUserID",
"aliases": ["alias1", "alias2"],
"product_id": "onemonth_no_trial",
"entitlement_ids": ["pro_cat"],
"period_type": "NORMAL",
"purchased_at_ms": 1591121853000,
"expiration_at_ms": 1591726653000,
"store": "APP_STORE",
"environment": "PRODUCTION",
"currency": "USD",
"price": 2.49,
"price_in_purchased_currency": 2.49,
"transaction_id": "170000869511114",
"original_transaction_id": "1530648507000",
"subscriber_attributes": {}
}
}For the full field reference, see → references/fields.md For sample payloads per event type, see → references/sample-events.md
---
3. All Event Types
| Event | When it fires |
|---|---|
TEST | Manual test from dashboard |
INITIAL_PURCHASE | First subscription purchase |
RENEWAL | Subscription renewed / lapsed user resubscribed |
CANCELLATION | Subscription or non-renewing purchase cancelled/refunded |
UNCANCELLATION | Cancelled subscription re-enabled before expiry |
NON_RENEWING_PURCHASE | One-time purchase (no auto-renew) |
SUBSCRIPTION_PAUSED | Subscription set to pause at period end (Android only) |
EXPIRATION | Subscription expired → revoke access now |
BILLING_ISSUE | Payment charge failed |
PRODUCT_CHANGE | Subscriber changed product/tier |
TRANSFER | Entitlements transferred between user IDs |
SUBSCRIPTION_EXTENDED | Expiration date pushed further into future |
TEMPORARY_ENTITLEMENT_GRANT | Short-term access granted during store outage |
REFUND_REVERSED | A previous refund was reversed (App Store only) |
INVOICE_ISSUANCE | Invoice created (Web Billing only) |
VIRTUAL_CURRENCY_TRANSACTION | Virtual currency balance adjusted |
EXPERIMENT_ENROLLMENT | Customer enrolled in an A/B experiment |
Key access-control rules:
- Grant access on:
INITIAL_PURCHASE,RENEWAL,UNCANCELLATION,SUBSCRIPTION_EXTENDED,TEMPORARY_ENTITLEMENT_GRANT - Revoke access on:
EXPIRATIONonly (NOT onCANCELLATIONorSUBSCRIPTION_PAUSED) - Use
expiration_at_msto determine if a subscription is still active
---
4. .NET Implementation
4a. Models (C#)
public class RevenueCatWebhookPayload
{
[JsonPropertyName("api_version")]
public string ApiVersion { get; set; } = string.Empty;
[JsonPropertyName("event")]
public RevenueCatEvent Event { get; set; } = new();
}
public class RevenueCatEvent
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("app_id")]
public string? AppId { get; set; }
[JsonPropertyName("event_timestamp_ms")]
public long EventTimestampMs { get; set; }
[JsonPropertyName("app_user_id")]
public string? AppUserId { get; set; }
[JsonPropertyName("original_app_user_id")]
public string? OriginalAppUserId { get; set; }
[JsonPropertyName("aliases")]
public List<string> Aliases { get; set; } = new();
[JsonPropertyName("product_id")]
public string? ProductId { get; set; }
[JsonPropertyName("entitlement_ids")]
public List<string>? EntitlementIds { get; set; }
[JsonPropertyName("period_type")]
public string? PeriodType { get; set; }
[JsonPropertyName("purchased_at_ms")]
public long? PurchasedAtMs { get; set; }
[JsonPropertyName("expiration_at_ms")]
public long? ExpirationAtMs { get; set; }
[JsonPropertyName("store")]
public string? Store { get; set; }
[JsonPropertyName("environment")]
public string? Environment { get; set; }
[JsonPropertyName("currency")]
public string? Currency { get; set; }
[JsonPropertyName("price")]
public double? Price { get; set; }
[JsonPropertyName("price_in_purchased_currency")]
public double? PriceInPurchasedCurrency { get; set; }
[JsonPropertyName("transaction_id")]
public string? TransactionId { get; set; }
[JsonPropertyName("original_transaction_id")]
public string? OriginalTransactionId { get; set; }
[JsonPropertyName("cancel_reason")]
public string? CancelReason { get; set; }
[JsonPropertyName("expiration_reason")]
public string? ExpirationReason { get; set; }
[JsonPropertyName("is_trial_conversion")]
public bool? IsTrialConversion { get; set; }
[JsonPropertyName("new_product_id")]
public string? NewProductId { get; set; }
[JsonPropertyName("subscriber_attributes")]
public Dictionary<string, SubscriberAttribute>? SubscriberAttributes { get; set; }
[JsonPropertyName("transferred_from")]
public List<string>? TransferredFrom { get; set; }
[JsonPropertyName("transferred_to")]
public List<string>? TransferredTo { get; set; }
[JsonPropertyName("is_family_share")]
public bool? IsFamilyShare { get; set; }
[JsonPropertyName("country_code")]
public string? CountryCode { get; set; }
[JsonPropertyName("renewal_number")]
public int? RenewalNumber { get; set; }
}
public class SubscriberAttribute
{
[JsonPropertyName("value")]
public string? Value { get; set; }
[JsonPropertyName("updated_at_ms")]
public long UpdatedAtMs { get; set; }
}
// Strongly-typed event type constants
public static class RevenueCatEventType
{
public const string Test = "TEST";
public const string InitialPurchase = "INITIAL_PURCHASE";
public const string Renewal = "RENEWAL";
public const string Cancellation = "CANCELLATION";
public const string Uncancellation = "UNCANCELLATION";
public const string NonRenewingPurchase = "NON_RENEWING_PURCHASE";
public const string SubscriptionPaused = "SUBSCRIPTION_PAUSED";
public const string Expiration = "EXPIRATION";
public const string BillingIssue = "BILLING_ISSUE";
public const string ProductChange = "PRODUCT_CHANGE";
public const string Transfer = "TRANSFER";
public const string SubscriptionExtended = "SUBSCRIPTION_EXTENDED";
public const string TemporaryEntitlementGrant = "TEMPORARY_ENTITLEMENT_GRANT";
public const string RefundReversed = "REFUND_REVERSED";
public const string InvoiceIssuance = "INVOICE_ISSUANCE";
public const string VirtualCurrencyTransaction = "VIRTUAL_CURRENCY_TRANSACTION";
public const string ExperimentEnrollment = "EXPERIMENT_ENROLLMENT";
}4b. Controller / Endpoint
[ApiController]
[Route("webhooks")]
public class RevenueCatWebhookController : ControllerBase
{
private readonly IRevenueCatWebhookService _webhookService;
private readonly IConfiguration _configuration;
private readonly ILogger<RevenueCatWebhookController> _logger;
public RevenueCatWebhookController(
IRevenueCatWebhookService webhookService,
IConfiguration configuration,
ILogger<RevenueCatWebhookController> logger)
{
_webhookService = webhookService;
_configuration = configuration;
_logger = logger;
}
[HttpPost("revenuecat")]
public async Task<IActionResult> HandleWebhook(
[FromBody] RevenueCatWebhookPayload payload,
CancellationToken cancellationToken)
{
// 1. Validate authorization header
if (!ValidateAuthorization())
return Unauthorized();
// 2. Respond fast — queue for background processing
_ = Task.Run(() => _webhookService.ProcessAsync(payload, cancellationToken), cancellationToken);
// 3. Always return 200 quickly
return Ok();
}
private bool ValidateAuthorization()
{
var expectedToken = _configuration["RevenueCat:WebhookAuthorizationHeader"];
if (string.IsNullOrEmpty(expectedToken))
return true; // No auth configured — skip validation
if (!Request.Headers.TryGetValue("Authorization", out var receivedToken))
return false;
return receivedToken == expectedToken;
}
}4c. Service with Idempotency + Event Routing
public interface IRevenueCatWebhookService
{
Task ProcessAsync(RevenueCatWebhookPayload payload, CancellationToken ct);
}
public class RevenueCatWebhookService : IRevenueCatWebhookService
{
private readonly ISubscriptionRepository _subscriptions;
private readonly IProcessedEventRepository _processedEvents;
private readonly ILogger<RevenueCatWebhookService> _logger;
public RevenueCatWebhookService(
ISubscriptionRepository subscriptions,
IProcessedEventRepository processedEvents,
ILogger<RevenueCatWebhookService> logger)
{
_subscriptions = subscriptions;
_processedEvents = processedEvents;
_logger = logger;
}
public async Task ProcessAsync(RevenueCatWebhookPayload payload, CancellationToken ct)
{
var evt = payload.Event;
// Idempotency check — skip duplicate events
if (await _processedEvents.HasBeenProcessedAsync(evt.Id, ct))
{
_logger.LogInformation("Duplicate webhook event {EventId} — skipped", evt.Id);
return;
}
_logger.LogInformation("Processing RevenueCat event {Type} for user {UserId}",
evt.Type, evt.AppUserId);
try
{
await (evt.Type switch
{
RevenueCatEventType.InitialPurchase => HandleInitialPurchaseAsync(evt, ct),
RevenueCatEventType.Renewal => HandleRenewalAsync(evt, ct),
RevenueCatEventType.Cancellation => HandleCancellationAsync(evt, ct),
RevenueCatEventType.Uncancellation => HandleUncancellationAsync(evt, ct),
RevenueCatEventType.Expiration => HandleExpirationAsync(evt, ct),
RevenueCatEventType.BillingIssue => HandleBillingIssueAsync(evt, ct),
RevenueCatEventType.ProductChange => HandleProductChangeAsync(evt, ct),
RevenueCatEventType.NonRenewingPurchase => HandleNonRenewingPurchaseAsync(evt, ct),
RevenueCatEventType.Transfer => HandleTransferAsync(evt, ct),
RevenueCatEventType.SubscriptionExtended => HandleSubscriptionExtendedAsync(evt, ct),
RevenueCatEventType.TemporaryEntitlementGrant => HandleTemporaryEntitlementAsync(evt, ct),
RevenueCatEventType.Test => HandleTestAsync(evt, ct),
_ => HandleUnknownAsync(evt, ct)
});
// Mark as processed only after successful handling
await _processedEvents.MarkProcessedAsync(evt.Id, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process event {EventId} of type {Type}", evt.Id, evt.Type);
throw;
}
}
private async Task HandleInitialPurchaseAsync(RevenueCatEvent evt, CancellationToken ct)
{
// Grant subscription/entitlements
var expiresAt = evt.ExpirationAtMs.HasValue
? DateTimeOffset.FromUnixTimeMilliseconds(evt.ExpirationAtMs.Value)
: (DateTimeOffset?)null;
await _subscriptions.GrantAccessAsync(
userId: evt.OriginalAppUserId ?? evt.AppUserId!,
productId: evt.ProductId!,
entitlementIds: evt.EntitlementIds ?? new(),
expiresAt: expiresAt,
transactionId: evt.TransactionId,
cancellationToken: ct);
}
private async Task HandleRenewalAsync(RevenueCatEvent evt, CancellationToken ct)
{
// Extend/refresh subscription expiry
var expiresAt = evt.ExpirationAtMs.HasValue
? DateTimeOffset.FromUnixTimeMilliseconds(evt.ExpirationAtMs.Value)
: (DateTimeOffset?)null;
await _subscriptions.RenewAccessAsync(
userId: evt.OriginalAppUserId ?? evt.AppUserId!,
productId: evt.ProductId!,
entitlementIds: evt.EntitlementIds ?? new(),
expiresAt: expiresAt,
isTrialConversion: evt.IsTrialConversion ?? false,
cancellationToken: ct);
}
private async Task HandleCancellationAsync(RevenueCatEvent evt, CancellationToken ct)
{
// Mark as cancelled — do NOT revoke access yet!
// Access is revoked on EXPIRATION event.
await _subscriptions.MarkCancelledAsync(
userId: evt.OriginalAppUserId ?? evt.AppUserId!,
reason: evt.CancelReason,
cancellationToken: ct);
}
private async Task HandleUncancellationAsync(RevenueCatEvent evt, CancellationToken ct)
{
// Re-enable — user resubscribed before expiry
await _subscriptions.MarkActiveAsync(
userId: evt.OriginalAppUserId ?? evt.AppUserId!,
cancellationToken: ct);
}
private async Task HandleExpirationAsync(RevenueCatEvent evt, CancellationToken ct)
{
// REVOKE ACCESS HERE — this is the definitive "access ends" event
await _subscriptions.RevokeAccessAsync(
userId: evt.OriginalAppUserId ?? evt.AppUserId!,
reason: evt.ExpirationReason,
cancellationToken: ct);
}
private async Task HandleBillingIssueAsync(RevenueCatEvent evt, CancellationToken ct)
{
// Notify user about payment failure — do NOT revoke access
await _subscriptions.RecordBillingIssueAsync(
userId: evt.OriginalAppUserId ?? evt.AppUserId!,
gracePeriodEndsAt: evt.ExpirationAtMs.HasValue
? DateTimeOffset.FromUnixTimeMilliseconds(evt.ExpirationAtMs.Value)
: null,
cancellationToken: ct);
}
private async Task HandleProductChangeAsync(RevenueCatEvent evt, CancellationToken ct)
{
await _subscriptions.UpdateProductAsync(
userId: evt.OriginalAppUserId ?? evt.AppUserId!,
oldProductId: evt.ProductId!,
newProductId: evt.NewProductId,
cancellationToken: ct);
}
private async Task HandleNonRenewingPurchaseAsync(RevenueCatEvent evt, CancellationToken ct)
{
await _subscriptions.RecordOneTimePurchaseAsync(
userId: evt.OriginalAppUserId ?? evt.AppUserId!,
productId: evt.ProductId!,
entitlementIds: evt.EntitlementIds ?? new(),
cancellationToken: ct);
}
private async Task HandleTransferAsync(RevenueCatEvent evt, CancellationToken ct)
{
if (evt.TransferredFrom != null && evt.TransferredTo != null)
{
await _subscriptions.TransferEntitlementsAsync(
fromUserIds: evt.TransferredFrom,
toUserIds: evt.TransferredTo,
cancellationToken: ct);
}
}
private async Task HandleSubscriptionExtendedAsync(RevenueCatEvent evt, CancellationToken ct)
{
var newExpiry = evt.ExpirationAtMs.HasValue
? DateTimeOffset.FromUnixTimeMilliseconds(evt.ExpirationAtMs.Value)
: (DateTimeOffset?)null;
await _subscriptions.ExtendSubscriptionAsync(
userId: evt.OriginalAppUserId ?? evt.AppUserId!,
newExpiresAt: newExpiry,
cancellationToken: ct);
}
private async Task HandleTemporaryEntitlementAsync(RevenueCatEvent evt, CancellationToken ct)
{
// Short-term access during store outage — max 24 hours
var expiresAt = evt.ExpirationAtMs.HasValue
? DateTimeOffset.FromUnixTimeMilliseconds(evt.ExpirationAtMs.Value)
: DateTimeOffset.UtcNow.AddHours(24);
await _subscriptions.GrantTemporaryAccessAsync(
userId: evt.AppUserId!,
expiresAt: expiresAt,
cancellationToken: ct);
}
private Task HandleTestAsync(RevenueCatEvent evt, CancellationToken ct)
{
_logger.LogInformation("RevenueCat TEST webhook received successfully");
return Task.CompletedTask;
}
private Task HandleUnknownAsync(RevenueCatEvent evt, CancellationToken ct)
{
_logger.LogWarning("Unhandled RevenueCat event type: {Type}", evt.Type);
return Task.CompletedTask;
}
}4d. Program.cs / DI Registration
builder.Services.AddScoped<IRevenueCatWebhookService, RevenueCatWebhookService>();
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
builder.Services.AddScoped<IProcessedEventRepository, ProcessedEventRepository>();4e. appsettings.json
{
"RevenueCat": {
"WebhookAuthorizationHeader": "YOUR_SECRET_HEADER_VALUE_FROM_DASHBOARD"
}
}---
5. Best Practices Checklist
| Practice | Details |
|---|---|
| Respond in < 60s | Return 200 immediately; process in background |
| Idempotency | Store processed event.id values; skip duplicates |
| Validate auth header | Check Authorization header matches dashboard secret |
| Use `EXPIRATION` to revoke | Never revoke on CANCELLATION or SUBSCRIPTION_PAUSED |
| Look up by `original_app_user_id` | Also check aliases array for alias matching |
| Handle new fields gracefully | RevenueCat may add fields — use flexible deserialization |
| Handle new event types | Log and ignore unknown type values |
| Sandbox vs Production | Filter by environment field; use separate webhook configs for each |
| Delivery delays | Most events: 5–60 seconds. Cancellation events: up to 2 hours |
| At-least-once delivery | RevenueCat may send the same event more than once |
---
6. User Identification
When looking up a user from a webhook, always search by both: 1. original_app_user_id — the canonical user ID 2. aliases array — all historical user IDs ever used
// Example lookup
var userId = evt.OriginalAppUserId;
var allIds = new List<string> { userId ?? "" }
.Concat(evt.Aliases ?? new())
.Where(id => !string.IsNullOrEmpty(id))
.Distinct()
.ToList();---
7. Syncing Subscription Status (Recommended Pattern)
Rather than writing complex logic for each event type, the recommended approach is:
After receiving any webhook, call the GET /v1/subscribers/{app_user_id} REST API endpoint to get the full, canonical subscription state.This avoids edge cases and keeps your database always consistent with RevenueCat's source of truth.
---
8. Testing
- Use RevenueCat Dashboard → Webhooks → Send Test Event to verify your endpoint
- Use sandbox purchases on device (events have
environment: "SANDBOX") - Failed/retrying events can be manually retried from the dashboard
- When testing locally, use a tunnel like
ngrokto expose your endpoint
---
Reference Files
references/fields.md— Complete field-by-field reference tablereferences/sample-events.md— Full JSON sample payloads for all event types
RevenueCat Webhook Fields Reference
Common Fields (all events)
| Field | Type | Description |
|---|---|---|
type | String | Event type (see event types table in SKILL.md) |
id | String | Unique event ID — use for idempotency |
app_id | String | App identifier within the project. Not present when store is PROMOTIONAL |
event_timestamp_ms | Integer | When the event was generated (Unix ms). Same on retries |
app_user_id | String | Last seen user ID. Not present on TRANSFER events |
original_app_user_id | String | First ever user ID for this subscriber |
aliases | Array[String] | All user IDs ever used by this subscriber |
subscriber_attributes | Map | Key → { value, updated_at_ms } map of custom attributes |
experiments | Array | Experiments subscriber is enrolled in: experiment_id, experiment_variant, enrolled_at_ms |
Subscription Lifecycle Fields
| Field | Type | Description | Nullable |
|---|---|---|---|
product_id | String | Product identifier. For Google Play post-Feb 2023: <sub_id>:<base_plan_id> | No |
entitlement_ids | Array[String] | Entitlement identifiers granted | Yes (if product not mapped) |
entitlement_id | String | Deprecated — use entitlement_ids | Deprecated |
period_type | String | TRIAL, INTRO, NORMAL, PROMOTIONAL, PREPAID | No |
purchased_at_ms | Integer | Transaction purchase time (Unix ms) | No |
expiration_at_ms | Integer | Transaction expiration (Unix ms). Use to check if active | Yes (non-subscription) |
grace_period_expiration_at_ms | Integer | Grace period end for billing issues (Unix ms) | Yes |
auto_resume_at_ms | Integer | When paused Android sub resumes (Unix ms) | Yes |
store | String | AMAZON, APP_STORE, MAC_APP_STORE, PADDLE, PLAY_STORE, PROMOTIONAL, RC_BILLING, ROKU, STRIPE, TEST_STORE | No |
environment | String | SANDBOX or PRODUCTION | No |
is_trial_conversion | Boolean | Only on RENEWAL — whether prior was a free trial | RENEWAL only |
cancel_reason | String | Only on CANCELLATION. See cancellation reasons | CANCELLATION only |
expiration_reason | String | Only on EXPIRATION. See cancellation reasons | EXPIRATION only |
new_product_id | String | New product ID on plan change (Play Store DEFERRED mode / App Store) | PRODUCT_CHANGE only |
presented_offering_id | String | Offering shown to user at purchase. Can be null for old purchases | Yes |
price | Double | USD price. 0 for trials. Negative for refunds | Yes |
currency | String | ISO 4217 currency code of purchase (e.g. USD, EUR) | Yes |
price_in_purchased_currency | Double | Price in local currency | Yes |
tax_percentage | Double | Estimated tax % deducted | Yes |
commission_percentage | Double | Estimated store commission % deducted | Yes |
takehome_percentage | Double | DEPRECATED — use tax_percentage + commission_percentage instead | Deprecated |
transaction_id | String | Store transaction ID | No |
original_transaction_id | String | Original transaction ID in subscription chain | No |
is_family_share | Boolean | True if shared via Apple Family Sharing | No |
transferred_from | Array[String] | Only on TRANSFER — source user IDs | TRANSFER only |
transferred_to | Array[String] | Only on TRANSFER — destination user IDs | TRANSFER only |
country_code | String | ISO 3166 2-letter code (e.g. US, DE) | Yes |
offer_code | String | Offer/promo code redeemed. Null if none | Yes |
renewal_number | Integer | Number of renewals so far. Starts at 1 | Yes |
Cancellation and Expiration Reasons
| Reason | Description | Stores |
|---|---|---|
UNSUBSCRIBE | User cancelled voluntarily | App Store, Play Store, Amazon, Web |
BILLING_ERROR | Payment failed | App Store, Play Store, Amazon |
DEVELOPER_INITIATED | Developer cancelled | App Store, Play Store, Promo |
PRICE_INCREASE | User rejected price increase | App Store, Play Store |
CUSTOMER_SUPPORT | Refund via support | App Store, Play Store, Amazon, Web |
UNKNOWN | Apple didn't provide reason | App Store |
SUBSCRIPTION_PAUSED | Expired due to pause (EXPIRATION only) | Play Store |
Virtual Currency Transaction Fields
| Field | Type | Description |
|---|---|---|
adjustments | Array | Array of currency adjustments |
adjustments[].amount | Integer | Positive = added, Negative = removed |
adjustments[].currency.code | String | Virtual currency identifier |
adjustments[].currency.name | String | Display name |
adjustments[].currency.description | String | Description |
product_display_name | String | Display name of triggering product |
purchase_environment | String | SANDBOX or PRODUCTION |
source | String | in_app_purchase or admin_api |
virtual_currency_transaction_id | String | Unique ID for this transaction |
Experiment Enrollment Fields
| Field | Type | Description |
|---|---|---|
experiment_id | String | ID of the experiment |
experiment_variant | String | Variant the customer is in |
offering_id | String | Offering ID of the variant |
enrolled_at_ms | Integer | Enrollment time (Unix ms) |
Tips
- Trial detection:
period_type == "TRIAL" - Trial duration:
expiration_at_ms - purchased_at_ms(in ms) - Subscription active:
expiration_at_ms > now - Refund:
CANCELLATION+cancel_reason == "CUSTOMER_SUPPORT"+price < 0 - Sandbox:
environment == "SANDBOX"
RevenueCat Sample Webhook Payloads
INITIAL_PURCHASE
{
"event": {
"event_timestamp_ms": 1658726378679,
"product_id": "com.subscription.weekly",
"period_type": "NORMAL",
"purchased_at_ms": 1658726374000,
"expiration_at_ms": 1659331174000,
"environment": "PRODUCTION",
"entitlement_ids": ["pro"],
"transaction_id": "123456789012345",
"original_transaction_id": "123456789012345",
"is_family_share": false,
"country_code": "US",
"app_user_id": "1234567890",
"aliases": ["$RCAnonymousID:8069238d6049ce87cc529853916d624c"],
"original_app_user_id": "$RCAnonymousID:87c6049c58069238dce29853916d624c",
"currency": "USD",
"price": 4.99,
"price_in_purchased_currency": 4.99,
"store": "APP_STORE",
"tax_percentage": 0.0,
"commission_percentage": 0.3,
"type": "INITIAL_PURCHASE",
"id": "12345678-1234-1234-1234-123456789012",
"app_id": "1234567890"
},
"api_version": "1.0"
}RENEWAL
{
"event": {
"event_timestamp_ms": 1658726405017,
"product_id": "com.subscription.weekly",
"period_type": "NORMAL",
"purchased_at_ms": 1658755132000,
"expiration_at_ms": 1659359932000,
"environment": "PRODUCTION",
"entitlement_ids": ["pro"],
"transaction_id": "123456789012345",
"original_transaction_id": "123456789012345",
"is_family_share": false,
"country_code": "DE",
"app_user_id": "1234567890",
"original_app_user_id": "$RCAnonymousID:87c6049c58069238dce29853916d624c",
"currency": "EUR",
"is_trial_conversion": false,
"price": 8.14,
"price_in_purchased_currency": 7.99,
"store": "APP_STORE",
"tax_percentage": 0.0,
"commission_percentage": 0.3,
"type": "RENEWAL",
"id": "12345678-1234-1234-1234-123456789012",
"app_id": "1234567890"
},
"api_version": "1.0"
}CANCELLATION (user unsubscribed)
{
"event": {
"event_timestamp_ms": 1601337615995,
"product_id": "com.revenuecat.myapp.weekly",
"period_type": "NORMAL",
"purchased_at_ms": 1601417766000,
"expiration_at_ms": 1602022566000,
"environment": "PRODUCTION",
"entitlement_ids": ["pro"],
"transaction_id": "100000000000002",
"original_transaction_id": "100000000000000",
"app_user_id": "$RCAnonymousID:12345678-1234-1234-1234-123456789123",
"aliases": [
"$RCAnonymousID:12345678-1234-ABCD-1234-123456789123",
"user_1234"
],
"original_app_user_id": "$RCAnonymousID:12345678-1234-ABCD-1234-123456789123",
"cancel_reason": "UNSUBSCRIBE",
"currency": "USD",
"price": 0.0,
"store": "APP_STORE",
"tax_percentage": 0.0,
"commission_percentage": 0.3,
"type": "CANCELLATION",
"id": "12345678-ABCD-1234-ABCD-12345678912"
},
"api_version": "1.0"
}CANCELLATION (refund — negative price)
{
"event": {
"event_timestamp_ms": 1601337615995,
"product_id": "com.revenuecat.myapp.monthly",
"period_type": "NORMAL",
"cancel_reason": "CUSTOMER_SUPPORT",
"currency": "USD",
"price": -9.99,
"price_in_purchased_currency": -9.99,
"store": "APP_STORE",
"type": "CANCELLATION",
"id": "12345678-1234-1234-1234-12345678912"
},
"api_version": "1.0"
}EXPIRATION
{
"event": {
"event_timestamp_ms": 1697451462232,
"product_id": "com.subscription.weekly",
"period_type": "NORMAL",
"expiration_at_ms": 1697451423000,
"environment": "PRODUCTION",
"entitlement_ids": ["pro"],
"app_user_id": "1234567890",
"expiration_reason": "UNSUBSCRIBE",
"currency": "USD",
"price": 0.0,
"store": "APP_STORE",
"type": "EXPIRATION",
"id": "12345678-1234-1234-1234-123456789012"
},
"api_version": "1.0"
}UNCANCELLATION
{
"event": {
"event_timestamp_ms": 1663982135337,
"product_id": "com.subscription.monthly",
"expiration_at_ms": 1665235092000,
"environment": "PRODUCTION",
"entitlement_ids": ["plus"],
"app_user_id": "1234567890",
"store": "APP_STORE",
"type": "UNCANCELLATION",
"id": "12345678-1234-1234-1234-123456789012"
},
"api_version": "1.0"
}BILLING_ISSUE
{
"event": {
"event_timestamp_ms": 1601337601013,
"product_id": "com.revenuecat.myapp.monthly",
"period_type": "NORMAL",
"entitlement_ids": ["pro"],
"app_user_id": "$RCAnonymousID:12345678-1234-1234-1234-123456789123",
"original_app_user_id": "$RCAnonymousID:12345678-1234-1234-1234-123456789123",
"store": "APP_STORE",
"type": "BILLING_ISSUE",
"id": "12345678-1234-1234-1234-12345678912"
},
"api_version": "1.0"
}NON_RENEWING_PURCHASE (one-time)
{
"event": {
"event_timestamp_ms": 1658726522314,
"product_id": "2100_tokens",
"period_type": "NORMAL",
"purchased_at_ms": 1658726519000,
"expiration_at_ms": null,
"environment": "PRODUCTION",
"entitlement_ids": ["pro"],
"country_code": "CA",
"app_user_id": "1234567890",
"currency": "CAD",
"price": 25.487,
"price_in_purchased_currency": 32.99,
"store": "APP_STORE",
"commission_percentage": 0.15,
"type": "NON_RENEWING_PURCHASE",
"id": "12345678-1234-1234-1234-123456789012"
},
"api_version": "1.0"
}SUBSCRIPTION_PAUSED (Android only)
{
"event": {
"event_timestamp_ms": 1652796516000,
"product_id": "premium",
"auto_resume_at_ms": 1657951448845,
"environment": "PRODUCTION",
"entitlement_ids": ["Premium1"],
"app_user_id": "1234567890",
"store": "PLAY_STORE",
"type": "SUBSCRIPTION_PAUSED",
"id": "12345678-1234-1234-1234-123456789012"
},
"api_version": "1.0"
}TRANSFER
{
"event": {
"app_id": "1234567890",
"event_timestamp_ms": 78789789798798,
"id": "CD489E0E-5D52-4E03-966B-A7F17788E432",
"store": "APP_STORE",
"transferred_from": ["00005A1C-6091-4F81-BE77-F0A83A271AB6"],
"transferred_to": ["4BEDB450-8EF2-11E9-B475-0800200C9A66"],
"type": "TRANSFER",
"environment": "PRODUCTION"
},
"api_version": "1.0"
}PRODUCT_CHANGE
{
"event": {
"event_timestamp_ms": 1601338594769,
"product_id": "com.revenuecat.myapp.monthly",
"new_product_id": "com.revenuecat.myapp.yearly",
"environment": "PRODUCTION",
"entitlement_ids": ["subscription"],
"app_user_id": "$RCAnonymousID:12345678-1234-1234-1234-123456789123",
"original_app_user_id": "$RCAnonymousID:12345678-1234-1234-1234-123456789123",
"store": "PLAY_STORE",
"type": "PRODUCT_CHANGE",
"id": "12345678-1234-1234-1234-12345678912"
},
"api_version": "1.0"
}SUBSCRIPTION_EXTENDED
{
"event": {
"event_timestamp_ms": 1697451462232,
"product_id": "com.subscription.weekly",
"expiration_at_ms": 1697451423000,
"environment": "PRODUCTION",
"entitlement_ids": ["pro"],
"app_user_id": "1234567890",
"store": "APP_STORE",
"type": "SUBSCRIPTION_EXTENDED",
"id": "12345678-1234-1234-1234-123456789012"
},
"api_version": "1.0"
}REFUND_REVERSED
{
"event": {
"product_id": "com.subscription.weekly",
"app_user_id": "1234567890",
"price": 5.0,
"renewal_number": 3,
"store": "APP_STORE",
"type": "REFUND_REVERSED",
"id": "12345678-1234-1234-1234-123456789012"
},
"api_version": "1.0"
}TEMPORARY_ENTITLEMENT_GRANT
{
"event": {
"event_timestamp_ms": 1744824815307,
"app_user_id": "41234567890",
"store": "APP_STORE",
"type": "TEMPORARY_ENTITLEMENT_GRANT",
"id": "12345678-1234-1234-1234-123456789012",
"app_id": "1234567890"
},
"api_version": "1.0"
}Trial Started (INITIAL_PURCHASE with period_type TRIAL)
{
"event": {
"product_id": "com.subscription.yearly",
"period_type": "TRIAL",
"purchased_at_ms": 1658726358573,
"expiration_at_ms": 1658992117958,
"environment": "PRODUCTION",
"entitlement_ids": ["pro"],
"price": 0,
"store": "PLAY_STORE",
"type": "INITIAL_PURCHASE",
"id": "12345678-1234-1234-1234-123456789012"
},
"api_version": "1.0"
}INVOICE_ISSUANCE (Web Billing only)
{
"event": {
"event_timestamp_ms": 1745004447300,
"product_id": "com.subscription.monthly",
"environment": "PRODUCTION",
"app_user_id": "41234567890",
"currency": "USD",
"price_in_purchased_currency": 9.0,
"store": "RC_BILLING",
"type": "INVOICE_ISSUANCE",
"id": "12345678-1234-1234-1234-123456789012"
},
"api_version": "1.0"
}VIRTUAL_CURRENCY_TRANSACTION
{
"event": {
"adjustments": [
{
"amount": 100,
"currency": {
"code": "CRD",
"description": "The main currency unit",
"name": "Credits"
}
}
],
"app_user_id": "1234567890",
"product_id": "1M_100credits",
"source": "in_app_purchase",
"store": "APP_STORE",
"virtual_currency_transaction_id": "vatx123456789012345",
"type": "VIRTUAL_CURRENCY_TRANSACTION",
"id": "12345678-1234-1234-1234-123456789012"
},
"api_version": "1.0"
}EXPERIMENT_ENROLLMENT
{
"event": {
"event_timestamp_ms": 1658726378679,
"app_user_id": "$RCAnonymousID:12345678-1234-1234-1234-123456789123",
"experiment_id": "prexpca1234abcd",
"experiment_variant": "b",
"offering_id": "experiment_offering_b",
"experiment_enrolled_at_ms": 1658726378679,
"type": "EXPERIMENT_ENROLLMENT",
"id": "12345678-1234-1234-1234-123456789012"
},
"api_version": "1.0"
}