Nostr Agent Onboarding · start here · index · source:
nostr-dev/docs/patterns/zapstore-publishing.md· snapshot 2026-10-10Paths such as
~/Documents/…,~/Production Environment/…,repos/…and services onlocalhostrefer 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
zspinstalled —go install github.com/zapstore/zsp@latestlands a binary in~/go/bin/zsp. v0.4.10 is what this recipe is verified against.- A signing identity (kind:0 profile) — see next section.
- The local
nostr-rs-relayrunning on:8080—nostr-rs-relay --config ~/Documents/nostr-dev/relays/nostr-rs-relay.config.toml &. Verify withss -ltn | grep 8080. - The local Blossom (ngit-relay container) running on
:8081— already up by default. Verify withcurl -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:
- The kind 32267
d-tag is the package id you expected (no typo, no debug suffix you didn't intend —com.example.app.debugvscom.example.appis a different listing entirely). - The kind 3063
apk_certificate_hashmatches 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:
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:
-
Replicate events to public relays. Either point
RELAY_URLSat a public set on the next run, or followpatterns/going-public.mdfor the pull-from-local-then-push-to-public idiom (kinder to public relays: you upload only the latest version of each replaceable event). -
Mirror Blossom blobs to a public Blossom. Same: either
BLOSSOM_URL=https://cdn.zapstore.devon the publish, or upload the content-addressed blobs separately (nak blossom upload --sec "$NSEC" -s https://cdn.zapstore.dev <file>). -
Whitelist on
relay.zapstore.devso the public relay accepts your pubkey. The documented path — commit azapstore.yamlwithpubkey: <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 inknown-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-11contactfield is a real Nostr pubkey monitoring an inbox (typically a NIP-17 kind 10050 inbox; the Zapstore admin's inbox iswss://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.jsonleaves an unmodified wrap intact, so nak can send it. Just add no flags that modify it (Gotcha #10). With an older nak, usesdk/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. -
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 defaultzsp identityrelay set is alreadyrelay.primal.net, relay.damus.io, relay.zapstore.dev. Override with--relaysif you want a tighter set. -
(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 thezapstore.yaml-in-repo flow above is the way.
Gotchas
-
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 standalonenostr-rs-relayon:8080. The kind:30617 repo announcement itself lives on the GRASP relay (that's where it gets published when youngit init); the app events that reference it live elsewhere. The same policy applies onwss://relay.ngit.dev— only the kind 32267 (which carries ana-tag) gets through there; 30509 / 30063 / 3063 / 0 all bounce. Seeknown-issues.md§4 for the full writeup. -
/dev/shmis volatile. Lost an nsec to a reboot once already; the orphan profile is fossilized inconfigs/identities/ots-publisher.json _orphaned_predecessor. Persist nsecs to~/.config/nostr-dev/keys/(outside the repo). -
Blossom
list/<pubkey>reports:3334URLs. The container-internal port leaks into the response body. Ignore it; the external port is whateverBLOSSOM_URLwas on upload. (Same quirk noted inCLAUDE.mdfor the upload response.) -
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.
-
Fat APKs publish under multiple
ftags; single-arch APKs don't fan out.zspemits one kind 3063 per APK file. A fat APK gets multiplefarch 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. -
zsp publish --quietis genuinely quiet. No output on success — only exit code 0. Pipe toteeif you want a record, or query the relay afterwards to confirm. (--jsongives you JSONL on stdout if you want machine-readable.) -
Some public Blossoms (
inner.sebastix.social, others) are member-write-only. A non-member upload gets HTTP 403 withonly pyramid members can upload blobs. The workaround is content-addressing: have the member upload the file, then have any publisher pointBLOSSOM_URLat that server.zspHEAD-checks before POSTing, finds the blob, skips the upload, and emits events whoseurltag is the public URL anyway. The icon for the OTSuite v2 worked example was handled this way (zspextracts the icon from the APK and stages it on the publisher's machine — hand it to the upstream member to push). -
Changing
BLOSSOM_URLbetween 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'setag follows the new asset, so client behaviour stays correct, but anak req -k 3063 -a $PUBHEXreturns every asset ever published. Tombstone the stale ones with NIP-09 kind:5 if it matters; otherwise leave as dust. -
.jkskeystores need PKCS12 conversion beforezsp identity. Step 4 has the one-linekeytoolcommand.zspwill tell you this if you try the JKS directly, but the error path costs a round-trip. -
Don't let
nak eventmodify a gift wrap before publishing, or it arrives undecryptable. Up to nak v0.19.1, any--secmadenak eventre-sign a piped event. It overwrote the wrap's ephemeral pubkey with the--seckey'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 onPATHis 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 thepublish-wrap-with-auth.tshelper, which handles AUTH and publishes the event byte-for-byte unchanged. Full writeup and the 2026-09-26 offline verification inknown-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