Skip to content

Instantly share code, notes, and snippets.

@astashov
Last active June 8, 2025 10:16
Show Gist options
  • Save astashov/79dd4ef4e91ea012710145623bfe0984 to your computer and use it in GitHub Desktop.
Save astashov/79dd4ef4e91ea012710145623bfe0984 to your computer and use it in GitHub Desktop.
const fetch = require("node-fetch");
const fs = require("fs");
const jwt = require("jsonwebtoken");
const util = require("util");
// To get the private key, go to App Store Connect (appstoreconnect.apple.com), then "Users and Access"
// at the top. Then go to "Integrations" -> "App Store Connect API", under "Team Keys" create a new key,
// and you'll be able to download the private key for it.
const PRIVATE_KEY = fs.readFileSync("AuthKey_F2BLAHBLAH.p8", "utf8");
// On the App Store Connect -> Users and Access -> Integrations -> App Store Connect API, you can find "Issuer ID"
const ISSUER_ID = "4b8896ba-0ab0-a7c5-acde-543b969a6c5c";
// Id of your app
const APP_ID = "com.liftosaur.www";
// On the App Store Connect -> Users and Access -> Integrations -> App Store Connect API, you can find Key ID for your
// private key you created following instructions above.
const KEY_ID = "F2BLAHBLAH";
// You can find subscription ids on the subscriptions page under your app in App Store Connect
const SUBSCRIPTION_IDS = {
"6445212345": "monthly",
"6445312346": "yearly",
};
// You can find in-app purchase ids on the in-app purchases page under your app in App Store Connect
const PRODUCT_ID = "645012347";
const API_BASE_URL = "https://api.appstoreconnect.apple.com/v1";
const API_BASE_URL_V2 = "https://api.appstoreconnect.apple.com/v2";
function formatYYYYMMDD(date, separator = "-") {
const d = new Date(date);
let month = `${d.getMonth() + 1}`;
let day = `${d.getDate()}`;
const year = `${d.getFullYear()}`;
if (month.length < 2) {
month = `0${month}`;
}
if (day.length < 2) {
day = `0${day}`;
}
return [year, month, day].join(separator);
}
// Generate JWT token for authentication
function generateJWT() {
const token = jwt.sign(
{
iss: ISSUER_ID,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 20 * 60, // 20 minutes expiration
bid: APP_ID,
aud: "appstoreconnect-v1",
},
PRIVATE_KEY,
{
algorithm: "ES256",
header: {
alg: "ES256",
kid: KEY_ID,
typ: "JWT",
},
}
);
return token;
}
function getNewPrices() {
return fs
.readFileSync("apple_prices.csv", "utf8")
.split("\n")
.slice(1)
.map((line) => {
const [
,
code,
currencyCode,
,
,
,
,
,
,
,
,
,
,
monthlyMarketing,
yearlyMarketing,
lifetimeMarketing,
] = line.split(",").map((v) => v.trim());
return {
countryCode: code.trim(),
currencyCode: currencyCode.trim(),
monthly: parseFloat(monthlyMarketing.trim()),
yearly: parseFloat(yearlyMarketing.trim()),
lifetime: parseFloat(lifetimeMarketing.trim()),
};
});
}
const subscriptionToTerritoryToPricePoints = {};
const productTerritoryToPricePoints = {};
async function getFromApple(url) {
const token = generateJWT();
console.log("Fetching page", url);
let results = [];
let json;
do {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
json = await response.json();
results = results.concat(json.data);
url = json.links.next;
console.log("Fetching next page", url);
} while (json.links.next);
return results;
}
function findClosestPricePoint(pricePoints, targetPrice) {
return pricePoints.reduce((prev, curr) => {
return Math.abs(curr.price - targetPrice) < Math.abs(prev.price - targetPrice) ? curr : prev;
});
}
async function getSubscriptionPricePoints(subscriptionId, territory) {
const pricePoint = subscriptionToTerritoryToPricePoints[subscriptionId]?.[territory];
if (pricePoint) {
return pricePoint;
}
let pp;
fs.mkdirSync(`pricepoints`, { recursive: true });
if (fs.existsSync(`pricepoints/${subscriptionId}-${territory}.json`)) {
const pricePoints = fs.readFileSync(`pricepoints/${subscriptionId}-${territory}.json`, "utf8");
pp = JSON.parse(pricePoints);
} else {
const url = `${API_BASE_URL}/subscriptions/${subscriptionId}/pricePoints?include=territory&filter%5Bterritory%5D=${territory}`;
const json = await getFromApple(url);
pp = json.map((d) => {
return {
id: d.id,
price: d.attributes.customerPrice,
};
});
pp.sort((a, b) => b.price - a.price);
fs.writeFileSync(`pricepoints/${subscriptionId}-${territory}.json`, JSON.stringify(pp, null, 2));
}
subscriptionToTerritoryToPricePoints[subscriptionId] = subscriptionToTerritoryToPricePoints[subscriptionId] || {};
subscriptionToTerritoryToPricePoints[subscriptionId][territory] = pp;
return pp;
}
async function getProductPricePoints(territory) {
const pricePoint = productTerritoryToPricePoints[territory];
if (pricePoint) {
return pricePoint;
}
let pp;
fs.mkdirSync(`pricepoints`, { recursive: true });
if (fs.existsSync(`pricepoints/${PRODUCT_ID}-${territory}.json`)) {
const pricePoints = fs.readFileSync(`pricepoints/${PRODUCT_ID}-${territory}.json`, "utf8");
pp = JSON.parse(pricePoints);
} else {
const url = `${API_BASE_URL_V2}/inAppPurchases/${PRODUCT_ID}/pricePoints?include=territory&filter%5Bterritory%5D=${territory}`;
const json = await getFromApple(url);
pp = json.map((d) => {
return {
id: d.id,
price: d.attributes.customerPrice,
};
});
pp.sort((a, b) => b.price - a.price);
fs.writeFileSync(`pricepoints/${PRODUCT_ID}-${territory}.json`, JSON.stringify(pp, null, 2));
}
productTerritoryToPricePoints[territory] = pp;
return pp;
}
// Function to update subscription price for a country
async function updateSubscriptionPrices(subscriptionId, key) {
const token = generateJWT();
const newPrices = getNewPrices();
for (const price of newPrices) {
const pricePoints = await getSubscriptionPricePoints(subscriptionId, price.countryCode);
if (pricePoints.length === 0) {
console.log("No price point found for", subscriptionId, price.countryCode, price[key]);
return;
}
const pricePoint = findClosestPricePoint(pricePoints, price[key]);
const data = {
data: {
type: "subscriptionPrices",
attributes: {
preserveCurrentPrice: false,
startDate: formatYYYYMMDD(Date.now()),
},
relationships: {
subscription: {
data: {
type: "subscriptions",
id: subscriptionId,
},
},
subscriptionPricePoint: {
data: {
type: "subscriptionPricePoints",
id: pricePoint.id,
},
},
territory: {
data: {
type: "territories",
id: price.countryCode,
},
},
},
},
};
console.log("Updating price for", key, price.countryCode, price[key], pricePoint.price);
const url = `${API_BASE_URL}/subscriptionPrices`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const json = await response.json();
console.log(
util.inspect(json, {
showHidden: false,
maxArrayLength: null,
depth: null,
})
);
console.log("\n");
}
}
async function updateProductPrices() {
const token = generateJWT();
const newPrices = getNewPrices();
const key = "lifetime";
const manualPrices = await Promise.all(
newPrices.map(async (price) => {
const pricePoints = await getProductPricePoints(price.countryCode);
const pricePoint = findClosestPricePoint(pricePoints, price[key]);
return {
pricePoint,
price,
};
})
);
const data = {
data: {
type: "inAppPurchasePriceSchedules",
relationships: {
baseTerritory: {
data: {
type: "territories",
id: "USA",
},
},
inAppPurchase: {
data: {
type: "inAppPurchases",
id: PRODUCT_ID,
},
},
manualPrices: {
data: manualPrices.map(({ pricePoint }) => {
return {
type: "inAppPurchasePrices",
id: `pp-${pricePoint.id}`,
};
}),
},
},
},
included: manualPrices.map(({ pricePoint, price }) => {
return {
attributes: {
startDate: null,
endDate: null,
},
id: `pp-${pricePoint.id}`,
relationships: {
inAppPurchasePricePoint: {
data: {
id: pricePoint.id,
type: "inAppPurchasePricePoints",
},
},
inAppPurchaseV2: {
data: {
id: PRODUCT_ID,
type: "inAppPurchases",
},
},
},
type: "inAppPurchasePrices",
};
}),
};
console.log("Updating prices of lifetime");
const url = `${API_BASE_URL}/inAppPurchasePriceSchedules`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const json = await response.json();
console.log(
util.inspect(json, {
showHidden: false,
maxArrayLength: null,
depth: null,
})
);
console.log("\n");
}
async function main() {
for (const [subscriptionId, key] of Object.entries(SUBSCRIPTION_IDS)) {
await updateSubscriptionPrices(subscriptionId, key);
}
await updateProductPrices();
}
main();
@graemian
Copy link

graemian commented Jun 8, 2025

Thanks very much for sharing that. I wanted to set prices for a monthly subscription product relative to yearly for particular regions. My annual price for USA is $19.99, monthly is $2.49. That’s a 33% discount, which is a key selling point on my paywall. The default price conversion on ASC, however, sets the UK prices to £19.99 and £1.99, which is only a 16% discount. That’s a far less attractive proposition on the paywall and I expect it causes hesitation and lost sales. The same happens for other regions too.

So your script, together with my script below to get the current prices, helped me do this.

const fetch = require("node-fetch");
const fs = require("fs");
const jwt = require("jsonwebtoken");
const createCsvWriter = require('csv-writer').createObjectCsvWriter;

// To get the private key, go to App Store Connect (appstoreconnect.apple.com), then "Users and Access"
// at the top. Then go to "Integrations" -> "App Store Connect API", under "Team Keys" create a new key,
// and you'll be able to download the private key for it.
const PRIVATE_KEY = fs.readFileSync("AuthKey.p8", "utf8");

// On the App Store Connect -> Users and Access -> Integrations -> App Store Connect API, you can find "Issuer ID"
const ISSUER_ID = "...";

// Id of your app
const APP_ID = "...";

// On the App Store Connect -> Users and Access -> Integrations -> App Store Connect API, you can find Key ID for your
// private key you created following instructions above.
const KEY_ID = "...";

// Subscription ID from the issue description
const SUBSCRIPTION_ID = "...";

// API base URL
const API_BASE_URL = "https://api.appstoreconnect.apple.com/v1";

// List of territories to fetch prices for
const TERRITORIES = "GBR,CAN,AUS,DEU,ARE,ISR,FRA,NZL,HKG,ZAF,ESP,CHE,BEL,NLD,SGP,SWE,DNK,POL,QAT,SAU,BHR,FIN,NOR";

// Generate JWT token for authentication
function generateJWT() {
    const token = jwt.sign(
        {
            iss: ISSUER_ID,
            iat: Math.floor(Date.now() / 1000),
            exp: Math.floor(Date.now() / 1000) + 20 * 60, // 20 minutes expiration
            bid: APP_ID,
            aud: "appstoreconnect-v1",
        },
        PRIVATE_KEY,
        {
            algorithm: "ES256",
            header: {
                alg: "ES256",
                kid: KEY_ID,
                typ: "JWT",
            },
        }
    );
    return token;
}

// Function to fetch data from Apple API
// Modified to return the full response, not just the data array
async function getFromApple(url) {
    const token = generateJWT();
    console.log("Fetching data from:", url);

    const response = await fetch(url, {
        method: "GET",
        headers: {
            Authorization: `Bearer ${token}`,
            "Content-Type": "application/json",
        },
    });

    if (!response.ok) {
        throw new Error(`API request failed with status ${response.status}: ${await response.text()}`);
    }

    return await response.json();
}

// Function to get current prices
async function getCurrentPrices() {
    try {
        // Construct the URL as specified in the issue description
        const url = `${API_BASE_URL}/subscriptions/${SUBSCRIPTION_ID}/prices?include=subscriptionPricePoint,territory&filter[territory]=${TERRITORIES}`;

        // Fetch the data
        const data = await getFromApple(url);

        console.log(`Received data with ${data.data.length} entries and ${data.included ? data.included.length : 0} included items`);

        // Step 1: Group all prices by territory
        const byTerritory = {};
        for (const item of data.data) {
            const territoryId = item.relationships.territory.data.id;
            if (!byTerritory[territoryId]) byTerritory[territoryId] = [];
            byTerritory[territoryId].push(item);
        }

        // Step 2: For each territory, pick the latest price (max startDate or null fallback)
        const latestPrices = {};
        for (const [territory, prices] of Object.entries(byTerritory)) {
            let latest = prices
                .filter(p => p.attributes.startDate !== null)
                .sort((a, b) => new Date(b.attributes.startDate) - new Date(a.attributes.startDate))[0];

            if (!latest) {
                // No future-dated price? Use the preserved one.
                latest = prices.find(p => p.attributes.preserved);
            }

            if (!latest) {
                // No future-dated or preserved price? Use any available price.
                latest = prices[0];
            }

            if (latest) {
                const pricePointId = latest.relationships.subscriptionPricePoint.data.id;
                const pricePoint = data.included.find(
                    p => p.type === 'subscriptionPricePoints' && p.id === pricePointId
                );

                latestPrices[territory] = {
                    territory,
                    customerPrice: pricePoint?.attributes?.customerPrice || null,
                    // proceeds: pricePoint?.attributes?.proceeds || null,
                    // startDate: latest.attributes.startDate
                };
            }
        }

        // Convert to array for CSV output
        const result = Object.values(latestPrices);

        console.log(`Processed ${result.length} price entries`);

        return result;
    } catch (error) {
        console.error("Error getting current prices:", error);
        throw error;
    }
}

// Function to write results to CSV
async function writeToCSV(data) {
    const csvWriter = createCsvWriter({
        path: 'current_prices.csv',
        header: [
            { id: 'territory', title: 'Territory' },
            { id: 'customerPrice', title: 'CustomerPrice' },
            // { id: 'proceeds', title: 'Proceeds' },
            // { id: 'startDate', title: 'StartDate' }
        ]
    });

    await csvWriter.writeRecords(data);
    console.log('CSV file has been written successfully to current_prices.csv');
}

// Main function
async function main() {
    try {
        console.log("Fetching current subscription prices...");
        const prices = await getCurrentPrices();
        console.log(`Found ${prices.length} prices`);

        await writeToCSV(prices);
    } catch (error) {
        console.error("Error in main function:", error);
    }
}

// Run the main function
main();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment