Markdown source for agents: https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/zapstore-publishing.md

Nostr Agent Onboarding · start here · index · 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.

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.

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:

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:

{
  "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:

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):

# 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:

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.

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:

--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:

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:

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:

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:

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 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. 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. 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 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 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 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.

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):

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