> **Nostr Agent Onboarding** · [start here](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/start.md) · [index](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/llms.txt) · source: `nostr-dev/docs/patterns/zapstore-publishing.md` · snapshot 2026-10-10
>
> Paths such as `~/Documents/…`, `~/Production Environment/…`, `repos/…` and services on `localhost` refer to the author's workstation and are **not available to you** — read them as worked examples of a setup you can recreate.

# Pattern: Publishing an Android app to Zapstore (from this workspace)

> **Source:** the live OTSuite Mobile v2.0.0 publish documented in this
> workspace on 2026-05-12. Staging dir kept at
> `repos/otsuite-publish/`. Upstream protocol reference:
> [`docs/sources/zapstore.md`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/sources/zapstore.md).

You have an Android app (your own or a third-party APK) and want to publish a
release to Zapstore. The local-first routing rule from `CLAUDE.md` applies:
**publish to the local sandbox first, mirror to public only when you're sure.**
This recipe walks the full loop from "fresh identity" to "events queryable on
the local relay" without touching `relay.zapstore.dev`.

For the protocol details (which kinds, which tags, why) read the source file
above first. This document is operational.

## The pieces

```
┌─────────────────────────┐    publishes      ┌──────────────────────────┐
│ zsp (Go CLI)            │ ─────────────────►│ kind 32267 + 30063 + 3063│
│ reads zapstore.yaml +   │                   │ on a Nostr relay         │
│ APK; signs with         │                   └──────────────────────────┘
│ SIGN_WITH=nsec…         │                                ▲
│                         │ ─── uploads ───► Blossom (the kind 3063 url)
└─────────────────────────┘
```

Three env-vars route the whole thing:

| Var | What it controls | Local-sandbox value |
|---|---|---|
| `SIGN_WITH` | The Nostr key the events are signed with | `nsec1…` (read from disk) |
| `BLOSSOM_URL` | Where the APK + icon + screenshots go | `http://localhost:8081` |
| `RELAY_URLS` | Where the events go | `ws://localhost:8080` |

Note: events go to `:8080` (the standalone `nostr-rs-relay`), **not** `:8081`
(the GRASP relay). See *Gotchas* below.

## Prerequisites

1. **`zsp` installed** — `go install github.com/zapstore/zsp@latest` lands a
   binary in `~/go/bin/zsp`. v0.4.10 is what this recipe is verified against.
2. **A signing identity (kind:0 profile)** — see next section.
3. **The local `nostr-rs-relay` running on `:8080`** —
   `nostr-rs-relay --config ~/Documents/nostr-dev/relays/nostr-rs-relay.config.toml &`.
   Verify with `ss -ltn | grep 8080`.
4. **The local Blossom (ngit-relay container) running on `:8081`** — already
   up by default. Verify with `curl -fsI http://localhost:8081/`.

## Step 1 — Publisher identity (kind:0)

Generate a keypair once. Persist it **outside `/dev/shm`** — `/dev/shm` is
tmpfs and wipes on reboot, which will silently orphan any profile you
published under the lost key.

```bash
mkdir -p ~/.config/nostr-dev/keys && chmod 700 ~/.config/nostr-dev/keys
nak key generate > ~/.config/nostr-dev/keys/<role>.nsec
chmod 600           ~/.config/nostr-dev/keys/<role>.nsec
# Optional fast-path mirror for the session:
mkdir -p /dev/shm/nostr-dev-session
cp ~/.config/nostr-dev/keys/<role>.nsec /dev/shm/nostr-dev-session/<role>.nsec
chmod 600           /dev/shm/nostr-dev-session/<role>.nsec
```

Publish the profile. The local rs-relay accepts any kind:0; the GRASP relay
on `:8081` will reject it:

```bash
NSEC=$(cat ~/.config/nostr-dev/keys/<role>.nsec)
nak event --sec "$NSEC" -k 0 \
  -c '{"name":"<Role>","display_name":"<Role>","about":"<one-liner>"}' \
  ws://localhost:8080/
```

Record the public reference (npub + pubhex, **no secret**) in
`configs/identities/<role>.json` so future sessions can find the identity
without re-deriving. Pattern:

```json
{
  "name": "<Role>",
  "purpose": "<one-line description>",
  "npub": "npub1…",
  "pubkey_hex": "…",
  "profile_published_to": ["ws://localhost:8080"],
  "nsec_storage": "~/.config/nostr-dev/keys/<role>.nsec",
  "created": "YYYY-MM-DD"
}
```

`configs/identities/ots-publisher.json` is the live example.

## Step 2 — Stage the publish

Make a staging directory under `repos/` (gitignored). `zsp` resolves paths
relative to the directory it's run from, so keep the yaml and any local
assets together:

```bash
mkdir -p ~/Documents/nostr-dev/repos/<app>-publish
cd       ~/Documents/nostr-dev/repos/<app>-publish
```

Write `zapstore.yaml`. Minimum useful form for an app whose source repo is a
local GRASP repo (kind:30617):

```yaml
# The repository field accepts a NIP-34 naddr (preferred when the source repo
# is itself a Nostr-native GRASP repo). It also accepts forge URLs
# (github.com/…, gitlab.com/…, codeberg.org/…, …).
repository: naddr1qq8x7arnw45hgefdd4hky6tvv5q3gamnwvaz7tmjv4kxz7fwdenkjapwv3jhvqgnwaen5te0d3hkxctvdphhxap68qcrsvgzyq2ekklg2zl7atkynst5czefeze2e3gtk3vvtmgdkns9c3lfxrzgvqcyqqq80xg0dw096

# Local APK path — also accepts URLs and F-Droid/GitHub release coordinates.
release_source: /home/you/Desktop/MyApp-v1.2.3.apk

name:    MyApp
summary: One-line description shown in app listings.
description: |
  Longer markdown-friendly description.

icon: /home/you/Desktop/MyApp-icon.png   # local path or URL
tags: [topic-a, topic-b]
```

Build the naddr from a kind:30617 if you don't have one already:

```bash
nak encode naddr \
  --kind 30617 \
  --pubkey <maintainer-pubkey-hex> \
  --identifier <d-tag> \
  --relay wss://relay.ngit.dev \
  --relay ws://localhost:8081
```

## Step 3 — Offline preview (always do this first)

`--offline` signs the three events but uploads nothing and publishes nothing.
Events go to stdout, the upload manifest to stderr — read both before
committing.

```bash
NSEC=$(cat /dev/shm/nostr-dev-session/<role>.nsec)
SIGN_WITH="$NSEC" \
BLOSSOM_URL=http://localhost:8081 \
RELAY_URLS=ws://localhost:8080 \
zsp publish --offline --quiet --skip-certificate-linking zapstore.yaml
```

Inspect:

- The kind 32267 `d`-tag is the package id you expected (no typo, no debug
  suffix you didn't intend — `com.example.app.debug` vs `com.example.app` is
  a different listing entirely).
- The kind 3063 `apk_certificate_hash` matches what you expect for the build
  variant. A debug build will have the well-known Android debug cert
  fingerprint; a release build will have your release keystore's.
- The upload manifest lists every blob (APK, icon, screenshots) with size +
  sha256.

`--skip-certificate-linking` is the right choice for a sandbox publish. See
*Cert linking* below for when to drop the flag.

## Step 4 — Cert linking (one-time, optional for sandbox)

If you have a release keystore, link its cert to your Nostr identity once.
This produces a **kind 30509** NIP-C1 proof event (`d`-tag = cert SHA-256,
plus a `signature` tag holding the RSA signature over the npub). Clients use
it to detect repo hijacks. Skip for debug keystores and sandbox-only
publishes (use `--skip-certificate-linking` in Step 5 in that case).

`zsp identity --link-key` accepts `.p12`/`.pfx`/`.pem`/`.crt`; **`.jks` needs
a one-line `keytool` conversion first**:

```bash
KS_JKS=~/Documents/nostr-dev/secrets/<app>/release-keystore.jks
KS_P12=~/Documents/nostr-dev/secrets/<app>/release-keystore.p12
PW=$(cat ~/Documents/nostr-dev/secrets/<app>/release-keystore-pw.txt)
ALIAS=<your-key-alias>

keytool -importkeystore -noprompt \
  -srckeystore "$KS_JKS" -srcstoretype JKS -srcstorepass "$PW" -alias "$ALIAS" \
  -destkeystore "$KS_P12" -deststoretype PKCS12 -deststorepass "$PW"
chmod 600 "$KS_P12"
```

Then sign + publish the link event. `--offline` lets us inspect the event
before sending; pipe to `nak` for the actual publish:

```bash
NSEC=$(cat ~/.config/nostr-dev/keys/<role>.nsec)
KEYSTORE_PASSWORD="$PW" SIGN_WITH="$NSEC" \
  zsp identity --link-key "$KS_P12" --offline \
  | nak event ws://localhost:8080/
```

The cert-link event is replaceable per `d`-tag (cert hash) and per-pubkey, so
re-running is a no-op until the cert rotates. Default expiry is 1 year —
override with `--link-key-expiry 2y` (or `6mo`, `30d`, …).

## Step 5 — Real publish

Same command as the offline preview without `--offline`. Drop
`--skip-certificate-linking` if you did Step 4:

```bash
NSEC=$(cat ~/.config/nostr-dev/keys/<role>.nsec)
SIGN_WITH="$NSEC" \
BLOSSOM_URL=http://localhost:8081 \
RELAY_URLS=ws://localhost:8080 \
zsp publish --quiet zapstore.yaml          # add --skip-certificate-linking for debug builds
```

Replaceable events (32267, 30063) make the publish idempotent — re-run as
often as you like and the relay keeps the latest per `(pubkey, kind, d)`.
The 3063 asset event is regular, so a content-addressed re-run produces an
identical event id and is also a no-op.

**`zsp` HEAD-checks Blossom before uploading.** If your blob is already at
the configured `BLOSSOM_URL` (same SHA-256), the upload is skipped. This is
how a publish can succeed against a *write-restricted* public Blossom: a
member uploads, and your unprivileged publisher key just references the
existing blob. (Confirmed against `inner.sebastix.social` — see Gotcha #7.)

## Step 6 — Verify

Three queries (one per kind) and one Blossom check:

```bash
PUBHEX=<your-publisher-pubkey-hex>

# App metadata
nak req -k 32267 -a "$PUBHEX" ws://localhost:8080/

# Release set (walk its `e` tag to the asset event clients will actually fetch)
nak req -k 30063 -a "$PUBHEX" ws://localhost:8080/

# Asset metadata (kind 3063 is *regular* — relay accumulates one per
# distinct content; URL changes mean a new event, the old one stays as dust)
nak req -k 3063 -a "$PUBHEX" ws://localhost:8080/

# Cert-link proof (if Step 4 was run)
nak req -k 30509 -a "$PUBHEX" ws://localhost:8080/

# APK actually on Blossom?
curl -fsI <kind-3063-url-tag> | head -1   # expect "HTTP/1.1 200 OK"

# Everything we uploaded under our pubkey (local Blossom only)
curl -fs "http://localhost:8081/list/$PUBHEX" | jq .
```

## Going public

Local-sandbox is enough for development and for handing a colleague an
`naddr` they can resolve against your relay. To make the app discoverable on
the open Zapstore takes three additional steps, each independent:

1. **Replicate events to public relays.** Either point `RELAY_URLS` at a
   public set on the next run, or follow
   [`patterns/going-public.md`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/going-public.md) for the
   pull-from-local-then-push-to-public idiom (kinder to public relays:
   you upload only the latest version of each replaceable event).

2. **Mirror Blossom blobs to a public Blossom.** Same: either
   `BLOSSOM_URL=https://cdn.zapstore.dev` on the publish, or upload the
   content-addressed blobs separately (`nak blossom upload --sec "$NSEC" -s
   https://cdn.zapstore.dev <file>`).

3. **Whitelist on `relay.zapstore.dev`** so the public relay accepts your
   pubkey. The documented path — commit a `zapstore.yaml` with
   `pubkey: <your-publisher-npub>` to the root of the linked source repo —
   **does not work when the source repo is a NIP-34 / GRASP repo**: the
   relay's verifier only resolves github / gitlab / codeberg / gitea /
   forgejo URLs. Full writeup in
   [`known-issues.md` §3](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/design/known-issues.md#3-relayzapstoredev-whitelist-verifier-does-not-follow-nip-34--grasp-repos).
   Practical options for GRASP-native projects:
   - **Mirror to a supported forge** (github / gitlab / codeberg / gitea /
     forgejo). The relay re-fetches the yaml from there and binds the
     pubkey automatically.
   - **Vertex reputation.** Passive; depends on social activity.
   - **DM the admin.** The relay's NIP-11 `contact` field is a real Nostr
     pubkey monitoring an inbox (typically a NIP-17 kind 10050 inbox; the
     Zapstore admin's inbox is `wss://auth.nostr1.com`, behind NIP-42
     AUTH). A polite NIP-17 gift-wrapped DM citing the publisher npub,
     repo coordinate, and current event ids has worked at least once
     from this workspace. Since nak v0.19.2, `nak event --auth --sec
     "$NSEC" <inbox-relay> < wrap.json` leaves an unmodified wrap intact,
     so nak can send it. Just add no flags that modify it (Gotcha #10).
     With an older nak, use
     [`sdk/examples/publish-wrap-with-auth.ts`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/examples/publish-wrap-with-auth.md).
     Reference message: `repos/otsuite-publish/sent-dms/`.
   - **Accept partial reach.** The three big general-purpose relays
     (`damus.io`, `nos.lol`, `primal.net`) already carry the publish and
     provide outbox-model discovery. The Zapstore-client default relay is
     the only thing missing.
   Required only for the *Zapstore client's* default-relay set; your own
   relays don't care.

4. **NIP-C1 cert linking** is covered inline in Step 4. For the public
   Zapstore listing, that link event needs to be on the same public relays
   the kind 32267 lands on (`relay.zapstore.dev`, `relay.damus.io`, …) — the
   default `zsp identity` relay set is already `relay.primal.net,
   relay.damus.io, relay.zapstore.dev`. Override with `--relays` if you want
   a tighter set.

5. **(If publisher == repo maintainer) you self-serve the whitelist.** When
   the same npub signs the kind:30617 GRASP repo announcement *and* the
   kind:32267 app event, `relay.zapstore.dev`'s verifier can match the two
   directly. Otherwise the `zapstore.yaml`-in-repo flow above is the way.

## Gotchas

1. **Don't publish app events to `ws://localhost:8081`.** The GRASP relay
   policy rejects events that don't relate to a stored git repository
   (`blocked: event does not relate to a stored repository`). Publish to the
   standalone `nostr-rs-relay` on `:8080`. The kind:30617 repo announcement
   itself lives on the GRASP relay (that's where it gets published when you
   `ngit init`); the app events that *reference* it live elsewhere. The
   same policy applies on `wss://relay.ngit.dev` — only the kind 32267
   (which carries an `a`-tag) gets through there; 30509 / 30063 / 3063 / 0
   all bounce. See
   [`known-issues.md` §4](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/design/known-issues.md#4-relayngitdev-rejects-events-that-dont-carry-an-nip-34-a-tag)
   for the full writeup.

2. **`/dev/shm` is volatile.** Lost an nsec to a reboot once already; the
   orphan profile is fossilized in
   `configs/identities/ots-publisher.json _orphaned_predecessor`. Persist
   nsecs to `~/.config/nostr-dev/keys/` (outside the repo).

3. **Blossom `list/<pubkey>` reports `:3334` URLs.** The container-internal
   port leaks into the response body. Ignore it; the external port is
   whatever `BLOSSOM_URL` was on upload. (Same quirk noted in `CLAUDE.md` for
   the upload response.)

4. **Debug-signed APKs are bad NIP-C1 subjects.** The default Android debug
   keystore is identical across many developer machines. Linking that cert
   fingerprint to your npub effectively links *every other dev with the same
   debug keystore* to your identity too. Always use a release-signed APK
   when running the cert-linking step.

5. **Fat APKs publish under multiple `f` tags; single-arch APKs don't fan
   out.** `zsp` emits one kind 3063 per APK file. A fat APK gets multiple
   `f` arch tags on the same event; a release that ships separate
   per-arch APKs produces multiple kind 3063 events, one per file, all
   referenced from the same kind 30063 release set.

6. **`zsp publish --quiet` is genuinely quiet.** No output on success — only
   exit code 0. Pipe to `tee` if you want a record, or query the relay
   afterwards to confirm. (`--json` gives you JSONL on stdout if you want
   machine-readable.)

7. **Some public Blossoms (`inner.sebastix.social`, others) are
   member-write-only.** A non-member upload gets HTTP 403 with `only pyramid
   members can upload blobs`. The workaround is *content-addressing*: have
   the member upload the file, then have any publisher point `BLOSSOM_URL`
   at that server. `zsp` HEAD-checks before POSTing, finds the blob, skips
   the upload, and emits events whose `url` tag is the public URL anyway.
   The icon for the OTSuite v2 worked example was handled this way (`zsp`
   extracts the icon from the APK and stages it on the publisher's machine
   — hand it to the upstream member to push).

8. **Changing `BLOSSOM_URL` between publishes leaves orphan kind 3063
   events on the relay.** Kind 3063 is regular (not addressable). A
   different URL → different event content → different event id. The kind
   30063 release set's `e` tag follows the new asset, so client behaviour
   stays correct, but a `nak req -k 3063 -a $PUBHEX` returns every asset
   ever published. Tombstone the stale ones with NIP-09 kind:5 if it
   matters; otherwise leave as dust.

9. **`.jks` keystores need PKCS12 conversion before `zsp identity`.** Step 4
   has the one-line `keytool` command. `zsp` will tell you this if you try
   the JKS directly, but the error path costs a round-trip.

10. **Don't let `nak event` modify a gift wrap before publishing, or it
    arrives undecryptable.** Up to nak v0.19.1, any `--sec` made `nak event`
    re-sign a piped event. It overwrote the wrap's ephemeral pubkey with the
    `--sec` key's pubkey, rehashed and re-signed it, and published the rebuilt
    event. The ciphertext was still bound to the original ephemeral key, so
    NIP-44 v2 decryption failed on the recipient's side. That is how the
    DM-the-admin whitelist attempt in [Going public](#going-public) item 3
    failed twice. Fixed in nak **v0.19.2** (the one on `PATH` is v0.20.7):
    `nak event --auth --sec ...` now publishes a signed, unmodified wrap
    exactly as given. The overwrite still happens with **`--force-sign`**,
    or with any flag that modifies the event (`-t`, `-p`, `-c`, `--ts`,
    `--pow`, …), so pass neither with a wrap. Pre-flight the command
    without relay URLs and compare its output with the wrap (`jq -cS .` on
    both). On an older nak, use the
    [`publish-wrap-with-auth.ts`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/examples/publish-wrap-with-auth.md)
    helper, which handles AUTH and publishes the event byte-for-byte
    unchanged. Full writeup and the 2026-09-26 offline verification in
    [`known-issues.md` §5](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/design/known-issues.md#5-nak-event---sec--silently-re-signed-fully-formed-events-on-stdin--resolved-in-nak-v0192).

## Reference — OTSuite Mobile v2.0.0 (release build)

What a worked, full-fat publish actually looks like — publisher npub matches
the GRASP repo maintainer, APK signed with a real 4096-bit RSA release
keystore, NIP-C1 cert-link in place, public Blossom hosting the binaries.

| Field | Value |
|---|---|
| Publisher npub (== repo maintainer) | `npub1zkd4h6zshlh2a3yuzaxqk2wgk2kv2za5trz76rd5upwy06fscjrq8mk5ta` |
| App package id (kind 32267 `d`) | `com.otsuite.lite` |
| Source repo (`a` tag) | `30617:159b5be8…c486:otsuite-mobile@wss://relay.ngit.dev` |
| Release cert SHA-256 | `de5c1e9c…0999`  (CN=OTSuite Mobile, RSA-4096) |
| APK | 201.99 MB, sha256 `6cf382b8…9c8d` — at `https://inner.sebastix.social/<sha256>` (member-uploaded) |
| Icon | 14.9 KB PNG, sha256 `6106cb33…94bb` — same Blossom |
| kind 32267 (app) | `4f7ec823…597c` |
| kind 30063 (release) | `cba23d18…e5b4` → `e:1fd90f9e…cab1` |
| kind 3063  (asset) | `1fd90f9e…cab1` |
| kind 30509 (NIP-C1) | `65776dc4…fe9a` (`d`=cert hash, expires 2027-05-12) |

`zapstore.yaml`: `repos/otsuite-publish/zapstore.yaml`.
Earlier debug-build config preserved at `zapstore.yaml.debug-build` for
comparison. Identity record:
`configs/identities/otsuite-mobile-publisher.json`.
Secrets: `secrets/otsuite-mobile/` (gitignored).

Replaying this publish end-to-end (from a clean shell, assuming services are
up and the APK is at the path):

```bash
APP=otsuite-mobile
cd ~/Documents/nostr-dev/repos/${APP%-publish}-publish 2>/dev/null || \
   cd ~/Documents/nostr-dev/repos/otsuite-publish

# One-time: cert link
KS_P12=~/Documents/nostr-dev/secrets/otsuite-mobile/release-keystore.p12
PW=$(cat ~/Documents/nostr-dev/secrets/otsuite-mobile/release-keystore-pw.txt)
NSEC=$(cat ~/Documents/nostr-dev/secrets/otsuite-mobile/session-nsec.txt)
KEYSTORE_PASSWORD="$PW" SIGN_WITH="$NSEC" \
  zsp identity --link-key "$KS_P12" --offline \
  | nak event ws://localhost:8080/

# Publish (idempotent)
SIGN_WITH="$NSEC" \
BLOSSOM_URL=https://inner.sebastix.social \
RELAY_URLS=ws://localhost:8080 \
zsp publish --quiet --overwrite-release zapstore.yaml
```
