Warning

iOS Support Status: html2app does not yet support iOS apps. This documentation is likely incomplete and may not be accurate. Please check back later for updates.

Upload iOS App to App Store Connect

This guide explains how to upload your iOS app binary to Apple App Store Connect. There are multiple options depending on your platform and preferences.

Upload Options

Mac Users: Use Apple's Transporter app (simplest) Any Platform: Use the App Store Connect API via Node.js/TypeScript (programmatic)

Note: The App Store Connect web dashboard does NOT support direct IPA uploads. You must use one of the methods below.

Option 1: Mac - Using Transporter

If you have a Mac, the simplest way to upload is using Apple's Transporter app:

  1. Download Transporter from the Mac App Store
  2. Open Transporter and sign in with your Apple ID
  3. Drag and drop your signed .ipa file into Transporter
  4. Click Deliver to upload

Option 2: Any Platform - Using Vanilla Node.js/TypeScript (No External Libraries)

For programmatic uploads and CI/CD automation, use the App Store Connect API with Node.js.

Prerequisites

  • Node.js 24.x or later
  • Signed IPA File - Your app binary signed with a distribution certificate
  • App Store Connect Account - Access at App Store Connect
  • API Key - Create one in App Store Connect (see below)

Create API Key

  1. Go to App Store Connect
  2. Navigate to Users and Access → Keys
  3. Click the + button to create a new key
  4. Select App Manager role
  5. Download the private key file (.p8) and save it securely
  6. Note your Key ID and Issuer ID

Step 1: Create Upload Script

Create a TypeScript file upload-ipa-vanilla.ts:

import fs from "fs";
import path from "path";
import crypto from "crypto";

interface UploadConfig {
  issuerId: string;
  privateKeyId: string;
  privateKeyPath: string;
  appId: string;
  ipaPath: string;
  version: string;
  buildNumber: string;
}

// Generate JWT token for App Store Connect API authentication
function generateJWT(
  issuerId: string,
  privateKeyId: string,
  privateKeyPath: string
): string {
  const privateKey = fs.readFileSync(privateKeyPath, "utf8");

  // Create header
  const header = {
    alg: "ES256",
    kid: privateKeyId,
    typ: "JWT",
  };

  // Create payload
  const now = Math.floor(Date.now() / 1000);
  const payload = {
    iss: issuerId,
    iat: now,
    exp: now + 20 * 60, // 20 minutes
    aud: "appstoreconnect-v1",
  };

  // Encode header and payload
  const encodedHeader = Buffer.from(JSON.stringify(header))
    .toString("base64url");
  const encodedPayload = Buffer.from(JSON.stringify(payload))
    .toString("base64url");

  // Sign with private key
  const sign = crypto.createSign("SHA256");
  sign.update(`${encodedHeader}.${encodedPayload}`);
  sign.end();

  const signature = sign.sign(privateKey, "base64url");

  return `${encodedHeader}.${encodedPayload}.${signature}`;
}

// Make authenticated API request
async function apiRequest(
  method: string,
  endpoint: string,
  token: string,
  body?: any
): Promise<any> {
  const url = `https://api.appstoreconnect.apple.com${endpoint}`;

  const options: RequestInit = {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
  };

  if (body) {
    options.body = JSON.stringify(body);
  }

  const response = await fetch(url, options);

  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(
      `API request failed: ${response.status} ${response.statusText}\n${errorText}`
    );
  }

  return response.json();
}

// Upload IPA to App Store Connect
async function uploadIPA(config: UploadConfig): Promise<void> {
  console.log("šŸ“± Starting iOS app upload to App Store Connect...");

  // Generate JWT token
  console.log("šŸ” Generating JWT token...");
  const token = generateJWT(
    config.issuerId,
    config.privateKeyId,
    config.privateKeyPath
  );
  console.log("āœ… JWT token generated");

  // Step 1: Create a build
  console.log("šŸ“ Creating build entry...");
  const buildResponse = await apiRequest("POST", "/v1/builds", token, {
    data: {
      type: "builds",
      attributes: {
        version: config.version,
        buildNumber: config.buildNumber,
      },
      relationships: {
        app: {
          data: {
            type: "apps",
            id: config.appId,
          },
        },
      },
    },
  });

  const buildId = buildResponse.data?.id;
  if (!buildId) {
    throw new Error("Failed to create build");
  }
  console.log(`āœ… Build created: ${buildId}`);

  // Step 2: Read IPA file
  console.log("šŸ“‚ Reading IPA file...");
  const ipaBuffer = fs.readFileSync(config.ipaPath);
  const ipaSize = ipaBuffer.length;
  console.log(`āœ… IPA file size: ${(ipaSize / 1024 / 1024).toFixed(2)} MB`);

  // Step 3: Reserve upload space
  console.log("šŸ”– Reserving upload space...");
  const reserveResponse = await apiRequest(
    "POST",
    `/v1/builds/${buildId}/appBuildFiles`,
    token,
    {
      data: {
        type: "appBuildFiles",
        attributes: {
          fileName: path.basename(config.ipaPath),
          fileSize: ipaSize,
        },
      },
    }
  );

  const uploadId = reserveResponse.data?.id;
  if (!uploadId) {
    throw new Error("Failed to reserve upload space");
  }
  console.log(`āœ… Upload reserved: ${uploadId}`);

  // Step 4: Get upload URLs
  console.log("🌐 Getting upload URLs...");
  const uploadDetailsResponse = await apiRequest(
    "GET",
    `/v1/builds/${buildId}/appBuildFiles/${uploadId}`,
    token
  );

  const uploadOperations =
    uploadDetailsResponse.data?.attributes?.uploadOperations;
  if (!uploadOperations || uploadOperations.length === 0) {
    throw new Error("No upload operations available");
  }
  console.log(`āœ… Got ${uploadOperations.length} upload operation(s)`);

  // Step 5: Upload binary in parts
  console.log("ā¬†ļø  Uploading binary...");
  for (const operation of uploadOperations) {
    const { url, method, requestHeaders, offset, length } = operation;
    const chunk = ipaBuffer.subarray(offset, offset + length);

    const uploadHeaders: Record<string, string> = {
      ...requestHeaders,
      "Content-Length": length.toString(),
    };

    const uploadResponse = await fetch(url, {
      method,
      headers: uploadHeaders,
      body: chunk,
    });

    if (!uploadResponse.ok) {
      throw new Error(
        `Upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`
      );
    }
    console.log(
      `āœ… Uploaded part: ${offset}-${offset + length} (${(length / 1024 / 1024).toFixed(2)} MB)`
    );
  }

  // Step 6: Calculate checksum
  console.log("šŸ” Calculating checksum...");
  const checksum = crypto.createHash("md5").update(ipaBuffer).digest("base64");
  console.log(`āœ… Checksum: ${checksum}`);

  // Step 7: Commit upload
  console.log("āœ”ļø  Committing upload...");
  await apiRequest(
    "PATCH",
    `/v1/builds/${buildId}/appBuildFiles/${uploadId}`,
    token,
    {
      data: {
        type: "appBuildFiles",
        id: uploadId,
        attributes: {
          uploaded: true,
          sourceFileChecksum: checksum,
        },
      },
    }
  );
  console.log("āœ… Upload committed successfully");

  console.log("\nšŸŽ‰ Upload complete!");
  console.log(
    `šŸ“Š Check build status at: https://appstoreconnect.apple.com/apps/${config.appId}/testflight/ios`
  );
  console.log(
    "ā³ Apple will process your build. You'll receive an email when it's ready."
  );
}

// Main execution
const config: UploadConfig = {
  issuerId: process.env.ISSUER_ID || "",
  privateKeyId: process.env.PRIVATE_KEY_ID || "",
  privateKeyPath: process.env.PRIVATE_KEY_PATH || "",
  appId: process.env.APP_ID || "",
  ipaPath: process.argv[2] || "./app.ipa",
  version: process.argv[3] || "1.0.0",
  buildNumber: process.argv[4] || "1",
};

if (!config.issuerId || !config.privateKeyId || !config.privateKeyPath) {
  console.error(
    "āŒ Missing required environment variables: ISSUER_ID, PRIVATE_KEY_ID, PRIVATE_KEY_PATH"
  );
  process.exit(1);
}

uploadIPA(config).catch((error) => {
  console.error("āŒ Upload failed:", error.message);
  process.exit(1);
});

Step 2: Set Environment Variables

export ISSUER_ID="your-issuer-id"
export PRIVATE_KEY_ID="your-key-id"
export PRIVATE_KEY_PATH="/path/to/AuthKey_XXXXX.p8"
export APP_ID="your-app-id"

Step 3: Run Upload

npx ts-node upload-ipa-vanilla.ts ./path/to/app.ipa 1.0.0 1

Or compile and run:

npx tsc upload-ipa-vanilla.ts
node upload-ipa-vanilla.js ./path/to/app.ipa 1.0.0 1

Key Differences from Using External Libraries

  • No external dependencies - Uses only Node.js built-in modules (fs, crypto, fetch)
  • Manual JWT generation - Implements ES256 JWT signing from scratch
  • Direct API calls - Uses native fetch API for all HTTP requests
  • Full control - Complete visibility into every API interaction

This vanilla implementation is ideal for:

  • Learning how the App Store Connect API works
  • Environments where external dependencies are restricted
  • Custom CI/CD pipelines with specific requirements
  • Maximum transparency and control over the upload process

Complete Workflow Summary

The API upload process follows these steps:

  1. Create Build - Register a new build in App Store Connect
  2. Reserve Space - Tell the API you're uploading a file and get upload URLs
  3. Upload Binary - Upload the IPA file in parts to the reserved URLs
  4. Calculate Checksum - Generate MD5 checksum of the binary
  5. Commit Upload - Confirm the upload is complete with the checksum

After upload, Apple will automatically process and validate your build. You can monitor the status in the App Store Connect dashboard or wait for email notifications.

Resources