---
name: appclub
description: Give away App Store offer codes with one appclub.fyi link, or verify a developer's App Store account on appclub. Use when someone wants to share, distribute or give away promo or offer codes for their app, top up a drop, or prove their apps are theirs on appclub. Works with the asc CLI for App Store Connect.
---

# appclub.fyi

> appclub.fyi turns a batch of App Store offer codes into one shareable page. People sign in and get one code each. This skill shows an AI agent how to prepare a drop with the `asc` CLI and open it in the human's browser, filled in. **The human always clicks Publish drop. You never publish.**

## The flow

1. Find the app and its offer in App Store Connect with `asc`.
2. Generate a batch of one-time codes, after the human confirms how many and until when.
3. Send the codes to appclub as a private draft.
4. Open the draft in the human's browser. They check it and click **Publish drop**.

Separately, you can [verify the human's App Store account](#verify-the-app-store-account) so their apps are provably theirs on appclub.

## Before you start

- `asc auth doctor` must pass. If `asc` is missing or not signed in, stop and tell the human. Don't install or configure anything without asking.
- If the human didn't name the app, ask.
- Use `--output json` on every `asc` call you parse.

## 1. Find the app

```sh
asc apps list --paginate --output json
```

Match by name and keep its `id` (e.g. `6801329394`).

## 2. Find the offer

Offer codes exist for in-app purchases and subscriptions.

**Paid apps with no in-app purchases:** App Store Connect has no API for paid-app promo codes. Tell the human to download promo codes from App Store Connect and drop the file at https://appclub.fyi/new. Stop here.

In-app purchases:

```sh
asc iap list --app APP_ID --output json
asc iap offer-codes list --iap-id IAP_ID --output json
```

Subscriptions:

```sh
asc subscriptions groups list --app APP_ID --output json
asc subscriptions list --group-id GROUP_ID --output json
asc subscriptions offers offer-codes list --subscription-id SUB_ID --output json
```

Pick an offer code with `"active": true`. If there are several, show the human the names and ask. If there are none, stop and ask the human to create one in App Store Connect. Offer prices and territories are their call.

## 3. Generate codes — ask first

Generated codes can't be deleted and count against your App Store Connect limits. Before running anything, confirm with the human:

- **How many** (people usually pick 500)
- **Expiry date** (`YYYY-MM-DD`). The appclub page closes on this date.

In-app purchase:

```sh
asc iap offer-codes one-time-codes create --offer-code-id OFFER_CODE_ID \
  --quantity 500 --expiration-date 2027-04-06 --output json
```

Subscription:

```sh
asc subscriptions offers offer-codes generate --offer-code-id OFFER_CODE_ID \
  --quantity 500 --expiration-date 2027-04-06 --output json
```

Keep the batch `id` from the response.

**Reusing an existing batch:** App Store Connect doesn't report which codes were redeemed. Reuse a batch only if the human says none of its codes have been handed out.

## 4. Create the draft

Fetch the codes straight into a temp file. **Never print code values** in chat, logs or command output. They're one-time gifts.

```sh
TMP=$(mktemp -d)

# In-app purchase: JSON {"codes": [...]}
asc iap offer-codes one-time-codes values --one-time-code-id BATCH_ID > "$TMP/codes.json"

# Subscription: plain text, one code per line
asc subscriptions offers offer-codes values --batch-id BATCH_ID --output "$TMP/codes.txt"
```

Build `$TMP/draft.json`:

```json
{
  "app_id": "6801329394",
  "offer": {
    "kind": "iap",
    "name": "Pro Lifetime",
    "price": "$5.99",
    "product_id": "com.example.app.pro.lifetime",
    "offer_code_id": "9b5df490-0000-0000-0000-000000000000",
    "offer_code_name": "PRO_LIFETIME_FREE"
  },
  "batch": {
    "id": "b1d2d05d-0000-0000-0000-000000000000",
    "environment": "PRODUCTION",
    "expires_on": "2027-04-06"
  },
  "codes": ["...every code from the batch..."],
  "page": {
    "headline": "optional",
    "note": "optional, from the human"
  }
}
```

- `offer.kind` is `"iap"` or `"subscription"`.
- `offer.name` is the in-app purchase or subscription name people see (from `asc iap list` / `asc subscriptions list`), and `offer.price` its usual US price (from `asc iap pricing summary`). Both show on the page.
- `offer.code_price` is what a member pays with a code, written the way people should read it (`"$0.99"`, `"$0.99/mo for 3 months"`). Leave it out when the code makes the purchase free, which is the usual case. Subscription offer codes can be a discount instead: check the offer's prices in App Store Connect, and ask the human if you're unsure.
- Leave out the app name, icon, price, screenshots and reviews. appclub reads them from the App Store.
- **Products:** a drop gives away one product: an in-app purchase or subscription, identified by `offer.product_id`. An app can have drops for several products, and one product can have many drops (each new batch is its own drop). Members get one code per product across all of its drops, so send each batch to the right product.
- **Topping up a live drop:** add `"drop_id": "c8m2qk"` (the last part of the drop's link). The codes join that drop when the human publishes, and the offer must match the drop's.
- **Verifying at the same time:** add `"verification_id": "vr_…"` from [Verify the App Store account](#verify-the-app-store-account). Publishing signed in then also makes the human the app's verified developer, so one visit does both.
- Fill in `page` only with words the human gave you. Otherwise leave it out.

Send it:

```sh
curl -sS https://appclub.fyi/api/drafts \
  -H 'content-type: application/json' \
  --data @"$TMP/draft.json"
```

Response `201`:

```json
{ "draft_url": "https://appclub.fyi/new?draft=dr_…", "expires_at": "2026-10-09T18:42:00Z" }
```

Then delete the temp files: `rm -rf "$TMP"`.

Errors come back as `{ "error": "…" }` with a 4xx status. Show the message to the human. Don't retry with changed codes.

## 5. Hand off

```sh
open "DRAFT_URL"        # macOS
xdg-open "DRAFT_URL"    # Linux
```

Then tell the human something like: *"Your ZenAlarm drop is ready. Check it in the browser and click Publish drop."* That's it. You're done.

## Verify the App Store account

Do this when the human asks you to verify their App Store account, or when the draft page says another member manages their app. It proves, through Apple, that the human is on the App Store Connect team behind their apps. appclub then shows them as the verified developer, only they can publish drops for those apps, and a verified developer takes over an app someone else published for first.

How it works: you sign an App Store Connect API token that expires in 5 minutes and can only list the team's apps with their names, bundle IDs and build icons (one exact request, in the script below), and send it to appclub. appclub makes that one request to Apple itself, so Apple's answer is the proof. The private key never leaves this machine, and the token can't read anything else or change anything. appclub refuses tokens with any other scope.

### 1. Find the key

```sh
asc auth status          # keyId of the default profile (JSON)
asc auth issuer-id       # issuer ID, for team keys
```

The key file is `AuthKey_<KEY_ID>.p8`, usually in `~/.appstoreconnect/private_keys`, `~/.private_keys` or `~/private_keys`; `~/.asc/config.json` may name it in `private_key_path`. If you can't find it, ask the human for the path. Never print, copy or upload the `.p8` file. For an individual key (no issuer ID), leave the issuer out.

### 2. Sign the token

Save this as `$TMP/sign.mjs` (Node 18+, no dependencies). It prints the request body for appclub:

```js
// A 5-minute App Store Connect API token that can only list this team's apps (names, bundle IDs, build icons), wrapped for appclub.
import { createPrivateKey, sign } from 'node:crypto'
import { readFileSync } from 'node:fs'
const [keyPath, keyId, issuerId] = process.argv.slice(2)
const b64 = (v) => Buffer.from(JSON.stringify(v)).toString('base64url')
const now = Math.floor(Date.now() / 1000)
const head = b64({ alg: 'ES256', kid: keyId, typ: 'JWT' })
const body = b64({ ...(issuerId ? { iss: issuerId } : { sub: 'user' }), iat: now, exp: now + 300, aud: 'appstoreconnect-v1', scope: ['GET /v1/apps?limit=200&include=builds&fields[apps]=name,bundleId,builds&fields[builds]=iconAssetToken,uploadedDate&limit[builds]=50'] })
const sig = sign('sha256', Buffer.from(`${head}.${body}`), { key: createPrivateKey(readFileSync(keyPath)), dsaEncoding: 'ieee-p1363' })
process.stdout.write(JSON.stringify({ asc_token: `${head}.${body}.${sig.toString('base64url')}` }))
```

### 3. Send it

Pipe it straight to appclub so the token is never printed:

```sh
node "$TMP/sign.mjs" "$KEY_PATH" "$KEY_ID" "$ISSUER_ID" \
  | curl -sS https://appclub.fyi/api/verifications -H 'content-type: application/json' --data @-
```

Response `201`:

```json
{
  "verification_id": "vr_…",
  "verify_url": "https://appclub.fyi/verify?v=vr_…",
  "expires_at": "2026-10-09T18:42:00Z",
  "apps": [{ "id": "6801329394", "name": "ZenAlarm" }]
}
```

Errors come back as `{ "error": "…" }` with a 4xx status (a wrong key ID or issuer, an expired token, a scope that isn't exactly the one above). Show the message to the human.

### 4. Hand off

- **On its own:** open `verify_url`. The human signs in and confirms.
- **With a drop:** put `verification_id` in the draft (see [Create the draft](#4-create-the-draft)) and open only the `draft_url`.

Tell the human which apps Apple listed. The link expires in 60 minutes. Delete `$TMP` when you're done.

## How drafts work

- A draft is private and only reachable through its `draft_url`. It expires 60 minutes after you create it. Codes in an unpublished draft are deleted when it expires.
- Opening the draft signed in: the page is listed on appclub and closes on `batch.expires_on`.
- Opening it signed out: the page is unlisted, closes after 2 weeks, and appclub asks the human for an email so they can manage it later.
- Once an app is managed on appclub (someone published a drop for it while signed in), only that member can publish its drops. The draft page asks the human to sign in; nothing changes on your side.

## Rules

- Never publish, and never share the page link yourself.
- Never print, log or paste code values, API tokens or private keys.
- Ask before generating codes. Never create or change offers yourself.
- Use `PRODUCTION` codes. `SANDBOX` codes only work for sandbox testers, so use them only if the human says they're testing.
