Skip to main content
Version: 3.1 (Current)

Expo Plugin

expo-iap comes with a config plugin to automatically configure your project for both iOS and Android. This guide explains how to use it and what it does.

Usage

To use the plugin, add it to the plugins array in your app.json or app.config.js:

{
"expo": {
"plugins": ["expo-iap"]
}
}

This is the most basic configuration. The plugin also accepts options for local development.

Local Development

If you are developing the native OpenIAP modules locally, you can use the enableLocalDev and localPath options to link them to your project.

Set enableLocalDev to true to enable this feature. localPath should point to the root of your local openiap-apple and openiap-google repositories.

{
"expo": {
"plugins": [
[
"expo-iap",
{
"enableLocalDev": true,
"localPath": {
"ios": "../path/to/your/openiap-apple",
"android": "../path/to/your/openiap-google"
}
}
]
]
}
}

Note: Setting enableLocalDev to true allows you to test with native open-iap modules from a local path.

What it does

Android

  • Adds the com.android.vending.BILLING permission to AndroidManifest.xml.
  • Adds the io.github.hyochan.openiap:openiap-google dependency to your app's build.gradle.

iOS

  • Ensures the CocoaPods CDN source is present in your Podfile.
  • Removes any stale local OpenIAP pod entries to avoid conflicts.

Plugin Source Code

app.plugin.js
import {
ConfigPlugin,
createRunOncePlugin,
WarningAggregator,
withAndroidManifest,
withAppBuildGradle,
withDangerousMod,
} from 'expo/config-plugins';
import * as fs from 'fs';
import * as path from 'path';
import withLocalOpenIAP from './withLocalOpenIAP';

const pkg = require('../../package.json');
const openiapVersions = JSON.parse(
fs.readFileSync(
path.resolve(__dirname, '../../openiap-versions.json'),
'utf8',
),
);
const OPENIAP_ANDROID_VERSION = openiapVersions.google;

// Log a message only once per Node process
const logOnce = (() => {
const printed = new Set<string>();
return (msg: string) => {
if (!printed.has(msg)) {
console.log(msg);
printed.add(msg);
}
};
})();

const addLineToGradle = (
content: string,
anchor: RegExp | string,
lineToAdd: string,
offset: number = 1,
): string => {
const lines = content.split('\n');
const index = lines.findIndex((line) => line.match(anchor));
if (index === -1) {
WarningAggregator.addWarningAndroid(
'expo-iap',
`dependencies { ... } block not found; skipping injection: ${lineToAdd.trim()}`,
);
return content;
} else {
lines.splice(index + offset, 0, lineToAdd);
}
return lines.join('\n');
};

const modifyAppBuildGradle = (
gradle: string,
language: 'groovy' | 'kotlin',
): string => {
let modified = gradle;

// Ensure OpenIAP dependency exists at desired version in app-level build.gradle(.kts)
const impl = (ga: string, v: string) =>
language === 'kotlin'
? ` implementation("${ga}:${v}")`
: ` implementation "${ga}:${v}"`;
const openiapDep = impl(
'io.github.hyochan.openiap:openiap-google',
OPENIAP_ANDROID_VERSION,
);

// Remove any existing openiap-google lines (any version, groovy/kotlin, implementation/api)
const openiapAnyLine =
/^\\s*(?:implementation|api)\\s*\(?\\s*["']io\.github\.hyochan\.openiap:openiap-google:[^"']+\\s*["']\\)?\\s*$/gm;
const hadExisting = openiapAnyLine.test(modified);
if (hadExisting) {
modified = modified.replace(openiapAnyLine, '').replace(/\n{3,}/g, '\n\n');
}

// Ensure the desired dependency line is present
if (
!new RegExp(
String.raw`io\.github\.hyochan\.openiap:openiap-google:${OPENIAP_ANDROID_VERSION}`,
).test(modified)
) {
// Insert just after the opening `dependencies {` line
modified = addLineToGradle(modified, /dependencies\s*{/, openiapDep, 1);
logOnce(
hadExisting
? `🛠️ expo-iap: Replaced OpenIAP dependency with ${OPENIAP_ANDROID_VERSION}`
: `🛠️ expo-iap: Added OpenIAP dependency (${OPENIAP_ANDROID_VERSION}) to build.gradle`,
);
}

return modified;
};

const withIapAndroid: ConfigPlugin<{addDeps?: boolean} | void> = (
config,
props,
) => {
const addDeps = props?.addDeps ?? true;

if (addDeps) {
config = withAppBuildGradle(config, (config) => {
// language provided by config-plugins: 'groovy' | 'kotlin'
const language = (config.modResults as any).language || 'groovy';
config.modResults.contents = modifyAppBuildGradle(
config.modResults.contents,
language,
);
return config;
});
}

config = withAndroidManifest(config, (config) => {
const manifest = config.modResults;
if (!manifest.manifest['uses-permission']) {
manifest.manifest['uses-permission'] = [];
}

const permissions = manifest.manifest['uses-permission'];
const billingPerm = {$: {'android:name': 'com.android.vending.BILLING'}};

const alreadyExists = permissions.some(
(p) => p.$['android:name'] === 'com.android.vending.BILLING',
);
if (!alreadyExists) {
permissions.push(billingPerm);
logOnce('✅ Added com.android.vending.BILLING to AndroidManifest.xml');
} else {
logOnce(
'ℹ️ com.android.vending.BILLING already exists in AndroidManifest.xml',
);
}

return config;
});

return config;
};

/** Ensure Podfile uses CocoaPods CDN and no stale local OpenIAP entry remains. */
const withIapIOS: ConfigPlugin = (config) => {
return withDangerousMod(config, [
'ios',
async (config) => {
const {platformProjectRoot} = config.modRequest;
const podfilePath = path.join(platformProjectRoot, 'Podfile');

if (!fs.existsSync(podfilePath)) {
return config;
}

let content = fs.readFileSync(podfilePath, 'utf8');

// 1) Ensure CocoaPods CDN source is present at the very top
const cdnLine = `source 'https://cdn.cocoapods.org/'`;
if (!content.includes(cdnLine)) {
content = `${cdnLine}\n\n${content}`;
logOnce('📦 expo-iap: Added CocoaPods CDN source to Podfile');
}

// 2) Remove any lingering local OpenIAP pod injection
const localPodRegex =
/^\s*pod\s+'openiap'\s*,\s*:path\s*=>\s*['"][^'"]+['"][^\n]*$/gm;
if (localPodRegex.test(content)) {
content = content.replace(localPodRegex, '').replace(/\n{3,}/g, '\n\n');
logOnce('🧹 expo-iap: Removed local OpenIAP pod from Podfile');
}

fs.writeFileSync(podfilePath, content);
return config;
},
]);
};

export interface ExpoIapPluginOptions {
/** Local development path for OpenIAP library */
localPath?:
| string
| {
ios?: string;
android?: string;
};
/** Enable local development mode */
enableLocalDev?: boolean;
}

const withIap: ConfigPlugin<ExpoIapPluginOptions | void> = (
config,
options,
) => {
try {
// Respect explicit flag; fall back to presence of localPath only when flag is unset
const isLocalDev = options?.enableLocalDev ?? !!options?.localPath;
// Apply Android modifications (skip adding deps when linking local module)
let result = withIapAndroid(config, {addDeps: !isLocalDev});

// iOS: choose one path to avoid overlap
if (isLocalDev) {
if (!options?.localPath) {
WarningAggregator.addWarningIOS(
'expo-iap',
'enableLocalDev is true but no localPath provided. Skipping local OpenIAP integration.',
);
} else {
const raw = options.localPath;
const resolved =
typeof raw === 'string'
? path.resolve(raw)
: {
ios: raw.ios ? path.resolve(raw.ios) : undefined,
android: raw.android ? path.resolve(raw.android) : undefined,
};

const preview =
typeof resolved === 'string'
? resolved
: `ios=${resolved.ios ?? 'auto'}, android=${ resolved.android ?? 'auto' }`;
logOnce(`🔧 [expo-iap] Enabling local OpenIAP: ${preview}`);
result = withLocalOpenIAP(result, {localPath: resolved});
}
} else {
// Ensure iOS Podfile is set up to resolve public CocoaPods specs
result = withIapIOS(result);
logOnce('📦 [expo-iap] Using OpenIAP from CocoaPods');
}

return result;
} catch (error) {
WarningAggregator.addWarningAndroid(
'expo-iap',
`expo-iap plugin encountered an error: ${error}`,
);
console.error('expo-iap plugin error:', error);
return config;
}
};

export default createRunOncePlugin(withIap, pkg.name, pkg.version);