diff --git a/README.md b/README.md index 88807db..b6666aa 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ https://skyhookapi.com/api/webhooks/firstPartOfWebhook/secondPartOfWebhook/provi `/newrelic` - [Patreon](https://www.patreon.com/platform/documentation/webhooks) - `/patreon` - [Pingdom](https://www.pingdom.com/resources/webhooks) - `/pingdom` +- [RevenueCat](https://www.revenuecat.com/docs/integrations/webhooks) - `/revenuecat` - [Rollbar](https://docs.rollbar.com/docs/webhooks) - `/rollbar` - [Shopify](https://shopify.dev/docs/api/webhooks/latest) - `/shopify` - [Square](https://developer.squareup.com/docs/webhooks/overview) - `/square` diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 11b4381..e47141e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,6 +12,18 @@ Buildkite is a trademark of Buildkite Pty Ltd. The icon is used only to identify the supported provider. +## Simple Icons — RevenueCat icon + +- File: `web/public/providers/revenuecat.svg` +- Project: Simple Icons +- Source: https://github.com/simple-icons/simple-icons/blob/25d6e5b39bc55bc446e147700294628af1734f7e/icons/revenuecat.svg +- Revision: `25d6e5b39bc55bc446e147700294628af1734f7e` +- Imported size: 1497 bytes +- Imported SHA-256: `1e224d9067e9b9b2e7366d4f38c1670d5c6131baf9b408a1a6039a8c8f285156` +- License: CC0 1.0 Universal; a copy is included at `web/public/providers/LICENSE.simple-icons.md` + +RevenueCat is a trademark of RevenueCat, Inc. The icon is used only to identify the supported provider. + ## Simple Icons — Stripe icon - File: `web/public/providers/stripe.svg` diff --git a/examples/revenuecat/revenuecat.json b/examples/revenuecat/revenuecat.json new file mode 100644 index 0000000..1af0573 --- /dev/null +++ b/examples/revenuecat/revenuecat.json @@ -0,0 +1,49 @@ +{ + "event": { + "event_timestamp_ms": 1658726378679, + "product_id": "com.subscription.weekly", + "period_type": "NORMAL", + "purchased_at_ms": 1658726374000, + "expiration_at_ms": 1659331174000, + "environment": "PRODUCTION", + "entitlement_id": null, + "entitlement_ids": [ + "pro" + ], + "presented_offering_id": null, + "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, + "subscriber_attributes": { + "$email": { + "updated_at_ms": 1662955084635, + "value": "firstlast@gmail.com" + } + }, + "store": "APP_STORE", + "takehome_percentage": 0.7, + "tax_percentage": 0, + "commission_percentage": 0.3, + "offer_code": null, + "type": "INITIAL_PURCHASE", + "id": "12345678-1234-1234-1234-123456789012", + "app_id": "1234567890", + "experiments": [ + { + "experiment_id": "prexp123", + "experiment_variant": "b", + "enrolled_at_ms": 1658726378679 + } + ] + }, + "api_version": "1.0" +} diff --git a/src/provider/ProviderRegistry.ts b/src/provider/ProviderRegistry.ts index e5b6f3b..00cc2c1 100644 --- a/src/provider/ProviderRegistry.ts +++ b/src/provider/ProviderRegistry.ts @@ -20,6 +20,7 @@ import { NewRelic } from './NewRelic.ts' import { Patreon } from './Patreon.ts' import { Pingdom } from './Pingdom.ts' import type { ProviderDefinition } from './Provider.ts' +import { RevenueCat } from './RevenueCat.ts' import { Rollbar } from './Rollbar.ts' import { Shopify } from './Shopify.ts' import { Square } from './Square.ts' @@ -89,6 +90,7 @@ const providerDefinitions: readonly ProviderDefinition[] = [ NewRelic, Patreon, Pingdom, + RevenueCat, Rollbar, Shopify, Square, diff --git a/src/provider/RevenueCat.ts b/src/provider/RevenueCat.ts new file mode 100644 index 0000000..4dbf104 --- /dev/null +++ b/src/provider/RevenueCat.ts @@ -0,0 +1,265 @@ +import type { Embed, EmbedField } from '../model/DiscordApi.ts' +import { DISCORD_EMBED_LIMITS, fitLiteralEmbedFields } from '../util/DiscordEmbed.ts' +import { cleanText, escapeDiscordMarkdownLiteral, humanizeWords, truncateText } from '../util/DiscordText.ts' +import { isRecord, scalarText } from '../util/WebhookValue.ts' +import { defineProvider } from './Provider.ts' + +const MAX_API_VERSION_CHARACTERS = 32 +const MAX_EVENT_TYPE_CHARACTERS = 200 +const MAX_EVENT_ID_CHARACTERS = 512 +const MAX_FIELD_SOURCE_CHARACTERS = 2_048 +const MAX_LIST_ITEMS = 10 +const MAX_ADJUSTMENTS = 8 +const MAX_UNIX_TIMESTAMP_MILLISECONDS = 253_402_300_799_999 + +const ENUM_LABELS: Readonly> = { + AMAZON: 'Amazon', + ANDROID: 'Android', + APP_STORE: 'App Store', + ADMIN_API: 'Admin API', + CLIENT_SDK: 'Client SDK', + CUSTOMER_SUPPORT: 'Customer Support', + IN_APP_PURCHASE: 'In App Purchase', + IOS: 'iOS', + MACOS: 'macOS', + MAC_APP_STORE: 'Mac App Store', + PLAY_STORE: 'Google Play', + RC_BILLING: 'RevenueCat Billing', + ROKU: 'Roku', + SERVER_API: 'Server API', + TEST_STORE: 'Test Store', + WEB: 'Web', +} + +/** + * Converts RevenueCat webhook events into bounded Discord embeds. + * + * @see https://www.revenuecat.com/docs/integrations/webhooks + * @see https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields + */ +export const RevenueCat = defineProvider({ + path: 'revenuecat', + name: 'RevenueCat', + example: { body: 'revenuecat/revenuecat.json' }, + defaults: { + username: 'RevenueCat', + embedColor: 0xf2545b, + }, + map({ body }, output) { + const apiVersion = requiredText(body.api_version, MAX_API_VERSION_CHARACTERS) + const event = isRecord(body.event) ? body.event : null + const eventId = requiredText(event?.id, MAX_EVENT_ID_CHARACTERS) + const eventType = requiredText(event?.type, MAX_EVENT_TYPE_CHARACTERS) + const timestamp = revenueCatTimestamp(event?.event_timestamp_ms) + const title = + eventType == null ? '' : boundedLiteral(humanizeWords(eventType), DISCORD_EMBED_LIMITS.title, true) + if ( + apiVersion == null || + event == null || + eventId == null || + eventType == null || + timestamp == null || + title.length === 0 + ) { + output.ignore() + return + } + + const embed: Embed = { + title, + timestamp, + } + embed.fields = fitLiteralEmbedFields(embed, createFields(event, eventType, eventId)) + output.addEmbed(embed) + }, +}) + +function createFields(event: Record, eventType: string, eventId: string): EmbedField[] { + const fields: EmbedField[] = [] + + if (isPaywallEvent(eventType, event)) { + addTextField(fields, 'Paywall', event.paywall_name) + addTextField(fields, 'Paywall ID', event.paywall_id) + addTextField(fields, 'Offering', event.offering_id) + addEnumField(fields, 'Component', event.component_type) + addTextField(fields, 'Component value', event.component_value, false) + addEnumField(fields, 'Platform', event.platform) + addTextField(fields, 'Paywall event ID', event.event_id) + addTextField(fields, 'Session ID', event.session_id) + } else if (eventType === 'TRANSFER') { + addListField(fields, 'Transferred from', event.transferred_from) + addListField(fields, 'Transferred to', event.transferred_to) + } else if (eventType === 'VIRTUAL_CURRENCY_TRANSACTION') { + addTextField(fields, 'Adjustment', adjustmentSummary(event.adjustments), false) + addEnumField(fields, 'Source', event.source) + addTextField(fields, 'Virtual currency transaction ID', event.virtual_currency_transaction_id) + addTextField(fields, 'Product', event.product_id) + } else if (eventType === 'EXPERIMENT_ENROLLMENT') { + addTextField(fields, 'Experiment ID', event.experiment_id) + addTextField(fields, 'Variant', event.experiment_variant) + addTextField(fields, 'Offering', event.offering_id) + addTimestampField(fields, 'Enrolled at', event.experiment_enrolled_at_ms) + } else if (eventType === 'PURCHASE_REDEEMED') { + addEnumField(fields, 'Outcome', event.redemption_outcome) + addListField(fields, 'Redeemed from', event.redeemed_from) + addListField(fields, 'Redeemed by', event.redeemed_by) + addEnumField(fields, 'Platform', event.redemption_platform) + addTextField(fields, 'Product', event.product_id) + addListField(fields, 'Entitlements', event.entitlement_ids) + } else { + addPurchaseFields(fields, event) + } + + addTextField(fields, 'App user ID', event.app_user_id) + addEnumField(fields, 'Store', event.store) + addEnumField(fields, 'Environment', event.environment ?? event.purchase_environment) + addTextField(fields, 'Event ID', eventId) + return fields +} + +function addPurchaseFields(fields: EmbedField[], event: Record): void { + addTextField(fields, 'Product', event.product_id) + addTextField(fields, 'Price', revenueCatPrice(event)) + addTextField(fields, 'New product', event.new_product_id) + addEnumField(fields, 'Period', event.period_type) + addListField(fields, 'Entitlements', event.entitlement_ids) + addEnumField(fields, 'Reason', event.cancel_reason ?? event.expiration_reason) + addTimestampField(fields, 'Expires', event.expiration_at_ms) + addTimestampField(fields, 'Grace period ends', event.grace_period_expiration_at_ms) + addTimestampField(fields, 'Auto resumes', event.auto_resume_at_ms) + addTextField(fields, 'Transaction ID', event.transaction_id) +} + +function addTextField(fields: EmbedField[], name: string, value: unknown, inline = true): void { + const text = boundedFieldText(value) + if (text != null) { + fields.push({ name, value: text, inline }) + } +} + +function addEnumField(fields: EmbedField[], name: string, value: unknown, inline = true): void { + const text = boundedFieldText(value) + if (text != null) { + fields.push({ name, value: enumLabel(text), inline }) + } +} + +function addTimestampField(fields: EmbedField[], name: string, value: unknown): void { + const timestamp = revenueCatTimestamp(value) + if (timestamp != null) { + fields.push({ name, value: timestamp, inline: true }) + } +} + +function addListField(fields: EmbedField[], name: string, value: unknown): void { + const list = stringList(value) + if (list != null) { + fields.push({ name, value: list, inline: false }) + } +} + +function stringList(value: unknown): string | null { + if (!Array.isArray(value)) { + return null + } + const items = value.map(boundedFieldText).filter((item): item is string => item != null) + if (items.length === 0) { + return null + } + const visible = items.slice(0, MAX_LIST_ITEMS) + const suffix = items.length > visible.length ? ` (+${items.length - visible.length} more)` : '' + return `${visible.join(', ')}${suffix}` +} + +function adjustmentSummary(value: unknown): string | null { + if (!Array.isArray(value)) { + return null + } + const adjustments: string[] = [] + for (const candidate of value.slice(0, MAX_ADJUSTMENTS)) { + if (!isRecord(candidate) || !Number.isSafeInteger(candidate.amount) || !isRecord(candidate.currency)) { + continue + } + const code = requiredText(candidate.currency.code, 64) + const name = requiredText(candidate.currency.name, 128) ?? code + if (code == null || name == null) { + continue + } + adjustments.push(`${candidate.amount} ${name}${name === code ? '' : ` (${code})`}`) + } + if (adjustments.length === 0) { + return null + } + const omitted = value.length - adjustments.length + return `${adjustments.join(', ')}${omitted > 0 ? ` (+${omitted} more)` : ''}` +} + +function revenueCatPrice(event: Record): string | null { + const currency = currencyCode(event.currency) + const purchasedPrice = finitePrice(event.price_in_purchased_currency) + if (currency != null && purchasedPrice != null) { + return formatPrice(purchasedPrice, currency) + } + + const usdPrice = finitePrice(event.price) + return usdPrice == null ? null : formatPrice(usdPrice, 'USD') +} + +function formatPrice(value: number, currency: string): string | null { + try { + const fractionDigits = + new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + }).resolvedOptions().maximumFractionDigits ?? 2 + return `${value.toFixed(fractionDigits)} ${currency}` + } catch { + return null + } +} + +function finitePrice(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER + ? value + : null +} + +function currencyCode(value: unknown): string | null { + return typeof value === 'string' && /^[A-Za-z]{3}$/.test(value) ? value.toUpperCase() : null +} + +function isPaywallEvent(eventType: string, event: Record): boolean { + return eventType.startsWith('PAYWALL_') || event.paywall_id != null || event.paywall_name != null +} + +function requiredText(value: unknown, maxLength: number): string | null { + if (typeof value !== 'string') { + return null + } + const text = cleanText(value, true) + return text.length > 0 && text.length <= maxLength ? text : null +} + +function boundedFieldText(value: unknown): string | null { + const text = scalarText(value) + return text == null ? null : truncateText(text, MAX_FIELD_SOURCE_CHARACTERS, false) +} + +function revenueCatTimestamp(value: unknown): string | null { + if (!Number.isSafeInteger(value) || Number(value) < 0 || Number(value) > MAX_UNIX_TIMESTAMP_MILLISECONDS) { + return null + } + try { + return new Date(Number(value)).toISOString() + } catch { + return null + } +} + +function enumLabel(value: string): string { + return ENUM_LABELS[value.toUpperCase()] ?? humanizeWords(value) +} + +function boundedLiteral(value: string, maxLength: number, singleLine: boolean): string { + return truncateText(escapeDiscordMarkdownLiteral(value), maxLength, singleLine) +} diff --git a/test/examples/examples-spec.ts b/test/examples/examples-spec.ts index c197cc3..92790f0 100644 --- a/test/examples/examples-spec.ts +++ b/test/examples/examples-spec.ts @@ -34,6 +34,7 @@ const expectedProviderPaths = [ 'newrelic', 'patreon', 'pingdom', + 'revenuecat', 'rollbar', 'shopify', 'square', diff --git a/test/provider/provider-registry-spec.ts b/test/provider/provider-registry-spec.ts index bf54825..af49509 100644 --- a/test/provider/provider-registry-spec.ts +++ b/test/provider/provider-registry-spec.ts @@ -57,6 +57,7 @@ const expectedMetadata = [ example: { body: 'patreon/patreon-member-create.json', headers: 'patreon/patreon.headers.json' }, }, { path: 'pingdom', name: 'Pingdom', example: { body: 'pingdom/pingdom.json' } }, + { path: 'revenuecat', name: 'RevenueCat', example: { body: 'revenuecat/revenuecat.json' } }, { path: 'rollbar', name: 'Rollbar', example: { body: 'rollbar/rollbar.json' } }, { path: 'shopify', diff --git a/test/revenuecat/revenuecat-spec.ts b/test/revenuecat/revenuecat-spec.ts new file mode 100644 index 0000000..6dc3dc1 --- /dev/null +++ b/test/revenuecat/revenuecat-spec.ts @@ -0,0 +1,398 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { app } from '../../src/index.ts' +import { loadProviderExample } from '../../src/ProviderExamples.ts' +import { RevenueCat } from '../../src/provider/RevenueCat.ts' +import { validateDiscordPayload } from '../../src/util/DiscordPayloadValidator.ts' +import { Tester } from '../Tester.ts' + +const eventEnvelope = (event: Record, apiVersion: unknown = '1.0') => ({ + event: { + id: 'evt_123', + event_timestamp_ms: 1785175200000, + ...event, + }, + api_version: apiVersion, +}) + +describe('/POST revenuecat', () => { + it('advertises and accepts the RevenueCat webhook endpoint', async () => { + const providersResponse = await app.request('/api/providers') + assert.equal(providersResponse.status, 200) + const providers = (await providersResponse.json()) as { name: string; path: string }[] + + assert.deepEqual( + providers.find((provider) => provider.path === 'revenuecat'), + { name: 'RevenueCat', path: 'revenuecat' }, + ) + assert.equal((await app.request('/api/webhooks/example-id/example-secret/revenuecat')).status, 200) + }) + + it('formats the packaged RevenueCat initial-purchase example', async () => { + assert.equal(RevenueCat.name, 'RevenueCat') + assert.equal(RevenueCat.path, 'revenuecat') + + const example = loadProviderExample('revenuecat') + const result = await Tester.testWithBody(RevenueCat, example.body, example.headers, example.query) + + assert.notEqual(result, null) + assert.equal(result!.username, 'RevenueCat') + assert.deepEqual(result!.allowed_mentions, { parse: [] }) + assert.equal(result!.embeds?.length, 1) + assert.equal(result!.embeds![0].title, 'Initial purchase') + assert.equal(result!.embeds![0].timestamp, '2022-07-25T05:19:38.679Z') + assert.equal(result!.embeds![0].color, 0xf2545b) + assert.deepEqual(result!.embeds![0].fields, [ + { name: 'Product', value: 'com.subscription.weekly', inline: true }, + { name: 'Price', value: '4.99 USD', inline: true }, + { name: 'Period', value: 'Normal', inline: true }, + { name: 'Entitlements', value: 'pro', inline: false }, + { name: 'Expires', value: '2022-08-01T05:19:34.000Z', inline: true }, + { name: 'Transaction ID', value: '123456789012345', inline: true }, + { name: 'App user ID', value: '1234567890', inline: true }, + { name: 'Store', value: 'App Store', inline: true }, + { name: 'Environment', value: 'Production', inline: true }, + { name: 'Event ID', value: '12345678-1234-1234-1234-123456789012', inline: true }, + ]) + assert.doesNotMatch(JSON.stringify(result), /firstlast@gmail\.com|subscriber_attributes|aliases/) + assert.deepEqual(validateDiscordPayload(result!), []) + }) + + it('summarizes lifecycle reasons, product changes, and RevenueCat major-unit prices', async () => { + const cancellation = await Tester.testWithBody( + RevenueCat, + eventEnvelope({ + type: 'CANCELLATION', + app_user_id: 'user_123', + product_id: 'monthly_pro', + entitlement_ids: ['pro_access'], + period_type: 'TRIAL', + price_in_purchased_currency: -9.99, + price: -9.99, + currency: 'EUR', + cancel_reason: 'CUSTOMER_SUPPORT', + expiration_at_ms: 1785261600000, + transaction_id: 'txn_123', + store: 'RC_BILLING', + environment: 'SANDBOX', + }), + ) + + assert.notEqual(cancellation, null) + assert.equal(cancellation!.embeds![0].title, 'Cancellation') + assert.equal(cancellation!.embeds![0].timestamp, '2026-07-27T18:00:00.000Z') + assert.deepEqual(cancellation!.embeds![0].fields, [ + { name: 'Product', value: 'monthly\\_pro', inline: true }, + { name: 'Price', value: '-9.99 EUR', inline: true }, + { name: 'Period', value: 'Trial', inline: true }, + { name: 'Entitlements', value: 'pro\\_access', inline: false }, + { name: 'Reason', value: 'Customer Support', inline: true }, + { name: 'Expires', value: '2026-07-28T18:00:00.000Z', inline: true }, + { name: 'Transaction ID', value: 'txn\\_123', inline: true }, + { name: 'App user ID', value: 'user\\_123', inline: true }, + { name: 'Store', value: 'RevenueCat Billing', inline: true }, + { name: 'Environment', value: 'Sandbox', inline: true }, + { name: 'Event ID', value: 'evt\\_123', inline: true }, + ]) + + const productChange = await Tester.testWithBody( + RevenueCat, + eventEnvelope({ + type: 'PRODUCT_CHANGE', + product_id: 'monthly', + new_product_id: 'yearly', + app_user_id: 'user', + }), + ) + assert.notEqual(productChange, null) + assert.ok( + productChange!.embeds![0].fields?.some(({ name, value }) => name === 'New product' && value === 'yearly'), + ) + }) + + it('summarizes transfers and purchase redemptions', async () => { + const transfer = await Tester.testWithBody( + RevenueCat, + eventEnvelope({ + type: 'TRANSFER', + transferred_from: ['old_user', '$RCAnonymousID:old'], + transferred_to: ['new_user'], + store: 'APP_STORE', + environment: 'PRODUCTION', + }), + ) + assert.notEqual(transfer, null) + assert.deepEqual(transfer!.embeds![0].fields, [ + { name: 'Transferred from', value: 'old\\_user, $RCAnonymousID:old', inline: false }, + { name: 'Transferred to', value: 'new\\_user', inline: false }, + { name: 'Store', value: 'App Store', inline: true }, + { name: 'Environment', value: 'Production', inline: true }, + { name: 'Event ID', value: 'evt\\_123', inline: true }, + ]) + + const redeemed = await Tester.testWithBody( + RevenueCat, + eventEnvelope({ + type: 'PURCHASE_REDEEMED', + redeemed_from: ['$RCAnonymousID:original'], + redeemed_by: ['user_456'], + redemption_outcome: 'transfer', + redemption_platform: 'ios', + product_id: 'premium_monthly', + entitlement_ids: ['premium'], + store: 'STRIPE', + environment: 'PRODUCTION', + }), + ) + assert.notEqual(redeemed, null) + assert.equal(redeemed!.embeds![0].title, 'Purchase redeemed') + assert.deepEqual(redeemed!.embeds![0].fields, [ + { name: 'Outcome', value: 'Transfer', inline: true }, + { name: 'Redeemed from', value: '$RCAnonymousID:original', inline: false }, + { name: 'Redeemed by', value: 'user\\_456', inline: false }, + { name: 'Platform', value: 'iOS', inline: true }, + { name: 'Product', value: 'premium\\_monthly', inline: true }, + { name: 'Entitlements', value: 'premium', inline: false }, + { name: 'Store', value: 'Stripe', inline: true }, + { name: 'Environment', value: 'Production', inline: true }, + { name: 'Event ID', value: 'evt\\_123', inline: true }, + ]) + }) + + it('summarizes virtual currency transactions and experiment enrollment', async () => { + const currency = await Tester.testWithBody( + RevenueCat, + eventEnvelope({ + type: 'VIRTUAL_CURRENCY_TRANSACTION', + adjustments: [ + { + amount: 100, + currency: { code: 'CRD', name: 'Credits', description: 'Main credits' }, + }, + ], + source: 'in_app_purchase', + virtual_currency_transaction_id: 'vatx_123', + product_id: 'monthly_credits', + app_user_id: 'user_123', + store: 'PLAY_STORE', + purchase_environment: 'PRODUCTION', + }), + ) + assert.notEqual(currency, null) + assert.deepEqual(currency!.embeds![0].fields, [ + { name: 'Adjustment', value: '100 Credits \\(CRD\\)', inline: false }, + { name: 'Source', value: 'In App Purchase', inline: true }, + { name: 'Virtual currency transaction ID', value: 'vatx\\_123', inline: true }, + { name: 'Product', value: 'monthly\\_credits', inline: true }, + { name: 'App user ID', value: 'user\\_123', inline: true }, + { name: 'Store', value: 'Google Play', inline: true }, + { name: 'Environment', value: 'Production', inline: true }, + { name: 'Event ID', value: 'evt\\_123', inline: true }, + ]) + + const experiment = await Tester.testWithBody( + RevenueCat, + eventEnvelope({ + type: 'EXPERIMENT_ENROLLMENT', + experiment_id: 'prexp_123', + experiment_variant: 'b', + offering_id: 'experiment_offering_b', + experiment_enrolled_at_ms: 1785175200000, + app_user_id: 'user_123', + }), + ) + assert.notEqual(experiment, null) + assert.deepEqual(experiment!.embeds![0].fields, [ + { name: 'Experiment ID', value: 'prexp\\_123', inline: true }, + { name: 'Variant', value: 'b', inline: true }, + { name: 'Offering', value: 'experiment\\_offering\\_b', inline: true }, + { name: 'Enrolled at', value: '2026-07-27T18:00:00.000Z', inline: true }, + { name: 'App user ID', value: 'user\\_123', inline: true }, + { name: 'Event ID', value: 'evt\\_123', inline: true }, + ]) + }) + + it('summarizes paywall interactions, including custom event names', async () => { + const result = await Tester.testWithBody( + RevenueCat, + eventEnvelope({ + type: 'CUSTOM_PAYWALL_PACKAGE_CHANGED', + app_user_id: '$RCAnonymousID:user', + environment: 'SANDBOX', + paywall_id: 'pw_123', + paywall_name: 'Pro **Paywall**', + offering_id: 'monthly_offer', + event_id: 'paywall_event_123', + session_id: 'session_123', + platform: 'iOS', + component_type: 'package', + component_value: '@everyone monthly', + }), + ) + + assert.notEqual(result, null) + assert.equal(result!.embeds![0].title, 'Custom paywall package changed') + assert.deepEqual(result!.embeds![0].fields, [ + { name: 'Paywall', value: 'Pro \\*\\*Paywall\\*\\*', inline: true }, + { name: 'Paywall ID', value: 'pw\\_123', inline: true }, + { name: 'Offering', value: 'monthly\\_offer', inline: true }, + { name: 'Component', value: 'Package', inline: true }, + { name: 'Component value', value: '@everyone monthly', inline: false }, + { name: 'Platform', value: 'iOS', inline: true }, + { name: 'Paywall event ID', value: 'paywall\\_event\\_123', inline: true }, + { name: 'Session ID', value: 'session\\_123', inline: true }, + { name: 'App user ID', value: '$RCAnonymousID:user', inline: true }, + { name: 'Environment', value: 'Sandbox', inline: true }, + { name: 'Event ID', value: 'evt\\_123', inline: true }, + ]) + assert.deepEqual(result!.allowed_mentions, { parse: [] }) + + const collidingCustomName = await Tester.testWithBody( + RevenueCat, + eventEnvelope({ + type: 'TRANSFER', + paywall_id: 'paywall_collision', + paywall_name: 'Collision-safe paywall', + event_id: 'paywall_ui_collision', + session_id: 'paywall_session_collision', + transferred_from: ['should_not_render'], + }), + ) + assert.notEqual(collidingCustomName, null) + assert.deepEqual(collidingCustomName!.embeds![0].fields, [ + { name: 'Paywall', value: 'Collision-safe paywall', inline: true }, + { name: 'Paywall ID', value: 'paywall\\_collision', inline: true }, + { name: 'Paywall event ID', value: 'paywall\\_ui\\_collision', inline: true }, + { name: 'Session ID', value: 'paywall\\_session\\_collision', inline: true }, + { name: 'Event ID', value: 'evt\\_123', inline: true }, + ]) + }) + + it('accepts every documented RevenueCat event type', async () => { + const documentedEventTypes = [ + 'TEST', + 'INITIAL_PURCHASE', + 'RENEWAL', + 'CANCELLATION', + 'UNCANCELLATION', + 'NON_RENEWING_PURCHASE', + 'SUBSCRIPTION_PAUSED', + 'EXPIRATION', + 'BILLING_ISSUE', + 'PRODUCT_CHANGE', + 'SUBSCRIPTION_EXTENDED', + 'REFUND_REVERSED', + 'INVOICE_ISSUANCE', + 'TRANSFER', + 'TEMPORARY_ENTITLEMENT_GRANT', + 'VIRTUAL_CURRENCY_TRANSACTION', + 'EXPERIMENT_ENROLLMENT', + 'PURCHASE_REDEEMED', + 'PAYWALL_IMPRESSION', + 'PAYWALL_CLOSE', + 'PAYWALL_CANCEL', + 'PAYWALL_EXIT_OFFER', + 'PAYWALL_COMPONENT_INTERACTED', + 'SUBSCRIBER_ALIAS', + 'PRICE_INCREASE_CONSENT_REQUIRED', + 'PRICE_INCREASE_CONSENT_APPROVED', + ] + + for (const eventType of documentedEventTypes) { + const result = await Tester.testWithBody(RevenueCat, eventEnvelope({ type: eventType })) + assert.notEqual(result, null, eventType) + assert.equal( + result!.embeds![0].title, + eventType + .toLowerCase() + .replaceAll('_', ' ') + .replace(/^./, (c) => c.toUpperCase()), + ) + } + }) + + it('handles temporary grants, price-consent events, and future event types generically', async () => { + const temporaryGrant = await Tester.testWithBody( + RevenueCat, + eventEnvelope({ + type: 'TEMPORARY_ENTITLEMENT_GRANT', + app_user_id: 'user_123', + store: 'APP_STORE', + }), + ) + assert.notEqual(temporaryGrant, null) + assert.equal(temporaryGrant!.embeds![0].title, 'Temporary entitlement grant') + + const consent = await Tester.testWithBody( + RevenueCat, + eventEnvelope({ + type: 'PRICE_INCREASE_CONSENT_REQUIRED', + app_user_id: 'user_123', + product_id: 'premium', + transaction_id: 'txn_123', + store: 'APP_STORE', + environment: 'PRODUCTION', + }), + ) + assert.notEqual(consent, null) + assert.equal(consent!.embeds![0].title, 'Price increase consent required') + assert.ok(consent!.embeds![0].fields?.some(({ name, value }) => name === 'Product' && value === 'premium')) + + const future = await Tester.testWithBody( + RevenueCat, + eventEnvelope({ + type: 'FUTURE_EVENT_FAMILY', + app_user_id: 'user_123', + store: 'TEST_STORE', + environment: 'SANDBOX', + }), + ) + assert.notEqual(future, null) + assert.equal(future!.embeds![0].title, 'Future event family') + assert.ok(future!.embeds![0].fields?.some(({ name, value }) => name === 'Event ID' && value === 'evt\\_123')) + }) + + it('rejects malformed common webhook envelopes', async () => { + const valid = eventEnvelope({ type: 'INITIAL_PURCHASE' }) + for (const body of [ + null, + {}, + { ...valid, api_version: null }, + { ...valid, api_version: 'x'.repeat(33) }, + { ...valid, event: null }, + { ...valid, event: { ...valid.event, id: null } }, + { ...valid, event: { ...valid.event, id: '' } }, + { ...valid, event: { ...valid.event, type: null } }, + { ...valid, event: { ...valid.event, type: '' } }, + { ...valid, event: { ...valid.event, type: '___' } }, + { ...valid, event: { ...valid.event, event_timestamp_ms: -1 } }, + { ...valid, event: { ...valid.event, event_timestamp_ms: 253_402_300_800_000 } }, + { ...valid, event: { ...valid.event, event_timestamp_ms: Number.MAX_SAFE_INTEGER } }, + { ...valid, event: { ...valid.event, event_timestamp_ms: '1785175200000' } }, + ]) { + assert.equal(await Tester.testWithBody(RevenueCat, body), null) + } + }) + + it('stays within Discord limits and escapes long untrusted values', async () => { + const longText = '@everyone [click](https://evil.example) ' + 'x'.repeat(7000) + const result = await Tester.testWithBody( + RevenueCat, + eventEnvelope({ + type: 'CUSTOM_[click](https://evil.example)', + app_user_id: longText, + product_id: longText, + entitlement_ids: Array.from({ length: 100 }, (_, index) => `${longText}_${index}`), + subscriber_attributes: { + private: { value: 'do-not-render', updated_at_ms: 1785175200000 }, + }, + }), + ) + + assert.notEqual(result, null) + assert.deepEqual(result!.allowed_mentions, { parse: [] }) + assert.deepEqual(validateDiscordPayload(result!), []) + assert.doesNotMatch(JSON.stringify(result), /do-not-render|subscriber_attributes/) + assert.doesNotMatch(result!.embeds![0].title ?? '', /\[click\]\(https:\/\/evil\.example\)/) + }) +}) diff --git a/web/public/providers/revenuecat.svg b/web/public/providers/revenuecat.svg new file mode 100644 index 0000000..91811a5 --- /dev/null +++ b/web/public/providers/revenuecat.svg @@ -0,0 +1 @@ +RevenueCat \ No newline at end of file diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 93ad293..925e3d9 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -28,6 +28,7 @@ const providers: Provider[] = [ { name: 'New Relic', slug: 'newrelic', color: '#1ce783' }, { name: 'Patreon', slug: 'patreon', color: '#ff5a45' }, { name: 'Pingdom', slug: 'pingdom', color: '#fce000' }, + { name: 'RevenueCat', slug: 'revenuecat', color: '#f2545b' }, { name: 'Rollbar', slug: 'rollbar', color: '#5c7cf5' }, { name: 'Shopify', slug: 'shopify', color: '#95bf47' }, { name: 'Square', slug: 'square', color: '#006aff', monogram: 'SQ' }, @@ -49,9 +50,9 @@ const year = new Date().getFullYear() const SITE = Astro.site ?? new URL('https://www.skyhookapi.com') const canonical = new URL(base, SITE).href const ogImage = new URL(`${base}og.png`, SITE).href -const pageTitle = 'skyhook — Discord Notifications for Stripe, Shopify, GitLab & 20+ Tools' +const pageTitle = 'skyhook — Discord Notifications for RevenueCat, Stripe, Shopify & 20+ Tools' const pageDescription = - 'skyhook turns webhooks from Stripe, Shopify, GitLab, Hugging Face, Jira, Docker Hub, CI tools and 20+ other services into clean, native Discord notifications — no bot to host, no code to write.' + 'skyhook turns webhooks from RevenueCat, Stripe, Shopify, GitLab, Hugging Face, Jira, Docker Hub, CI tools and 20+ other services into clean, native Discord notifications — no bot to host, no code to write.' const faqs = [ { @@ -60,7 +61,7 @@ const faqs = [ }, { q: 'Which services does skyhook support?', - a: `${providers.length} and counting — including Stripe, Buildkite, Shopify, Square, Zendesk, Linear, GitLab, Hugging Face, Jira, Confluence, Bitbucket, Docker Hub, Travis CI, CircleCI, Jenkins, Heroku, New Relic, Rollbar, Pingdom, Uptime Robot, Trello, Patreon and Azure DevOps.`, + a: `${providers.length} and counting — including RevenueCat, Stripe, Buildkite, Shopify, Square, Zendesk, Linear, GitLab, Hugging Face, Jira, Confluence, Bitbucket, Docker Hub, Travis CI, CircleCI, Jenkins, Heroku, New Relic, Rollbar, Pingdom, Uptime Robot, Trello, Patreon and Azure DevOps.`, }, { q: 'Do I need to host a bot or write any code?', @@ -161,7 +162,7 @@ const structuredData = [

Turn webhooks into beautiful Discord notifications.

- skyhook translates webhooks from Stripe, Shopify, GitLab, Hugging Face, Jira, Docker Hub and 20+ other + skyhook translates webhooks from RevenueCat, Stripe, Shopify, GitLab, Hugging Face, Jira, Docker Hub and 20+ other services into clean, native Discord notifications. No bot to host, no code to write — generate a URL, paste it into your provider, and you're done.

@@ -288,9 +289,9 @@ const structuredData = [ Supported providers

Discord notifications for the tools you already use

- From Stripe payments, GitLab pushes, and Jira issues to Docker Hub builds, CI results, and Pingdom or Uptime - Robot alerts — skyhook delivers a clean Discord notification for every event, across {providers.length} - {' '}services and counting. + From RevenueCat subscription events and Stripe payments to GitLab pushes, Jira issues, Docker Hub builds, CI + results, and Pingdom or Uptime Robot alerts — skyhook delivers a clean Discord notification for every event, + across {providers.length} services and counting.

diff --git a/web/test/provider-order.test.mjs b/web/test/provider-order.test.mjs index c028105..cac230f 100644 --- a/web/test/provider-order.test.mjs +++ b/web/test/provider-order.test.mjs @@ -34,6 +34,12 @@ test('supported providers are rendered alphabetically by display name', () => { assert.ok(names.includes('Stripe'), 'Stripe should appear in the supported-provider grid') assert.match(providerSection[1], /title="\/stripe"/, 'Stripe should use the /stripe endpoint') assert.ok(existsSync(new URL('../public/providers/stripe.svg', import.meta.url)), 'Stripe should have a logo asset') + assert.ok(names.includes('RevenueCat'), 'RevenueCat should appear in the supported-provider grid') + assert.match(providerSection[1], /title="\/revenuecat"/, 'RevenueCat should use the /revenuecat endpoint') + assert.ok( + existsSync(new URL('../public/providers/revenuecat.svg', import.meta.url)), + 'RevenueCat should have a logo asset', + ) assert.ok(names.includes('Azure DevOps'), 'Azure DevOps should appear in the supported-provider grid') assert.match(providerSection[1], /title="\/azure"/, 'Azure DevOps should use the /azure endpoint') assert.doesNotMatch(providerSection[1], /title="\/vsts"/, 'the retired /vsts endpoint should not be shown')