1 Overview
Every Nostr action needs a signature. If every client app stores the private key, one bad app leaks it. This NIP moves the key into one trusted app: the signer.
The signer is an Android application. It holds the user's private key and answers requests from a client. The client is a user-facing application that sends signing requests. It may be another Android application or a web page. The two apps run on the same device and talk over standard Android mechanisms. The client never handles the private key.
Communication is 2-way. The client sends a request with a method name and a payload. The signer asks the user for approval, or answers silently if the user pre-approved the method. Then it returns a result to the client.
This chapter is the local, same-device cousin of NIP-46 remote signing and the NIP-07 browser extension. Same idea, different transport: Android Intents instead of relays or window.nostr.
nostrsigner: URL and get the result back by callback or clipboard.2 Roles and Terms
The spec uses five terms. Use them exactly as defined here. All public keys in this NIP are hex strings.
| Term | Meaning |
|---|---|
| user | A person trying to use Nostr. |
| client | A user-facing application that sends signing requests. |
| signer | An Android application that holds the user's private key and answers requests from the client. |
| user-pubkey | The public key that represents the user. The signer returns it from get_public_key. |
| package name | The Android package name of the signer, for example com.example.signer. The client uses it to address all later requests. |
A client can talk to the signer in three ways:
- Intents — for Android clients. The signer opens. The user approves or rejects the request manually. The result returns to the client.
- Content Resolver — for Android clients. The request runs in the background and the signer does not open. This works only for permissions the user chose to remember.
- Web — for web clients. The signer returns the result through a
callbackUrl, or copies it to the clipboard.
3 Setup and Connection
An Android client must declare the nostrsigner scheme in its AndroidManifest.xml. Without this declaration the client cannot detect or call the signer.
<queries> <intent> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="nostrsigner" /> </intent> </queries>
The client can then check for an installed signer. Query the package manager for activities that handle a nostrsigner: view intent:
fun isExternalSignerInstalled(context: Context): Boolean { val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")) return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty() }
Initiating a connection
The connection handshake has four steps:
- The client sends a
get_public_keyrequest. - The signer responds with the
user-pubkeyand itspackage name. - The client stores both. It SHOULD NOT call
get_public_keyagain while the user stays logged in. - The client addresses all further requests to that
package name.
The client MAY pass a list of permissions with get_public_key. The user can then pre-authorize methods in one step. The Content Resolver uses these remembered permissions to answer in the background. Each permission is an object with a type (a method name) and, for sign_event, an optional kind:
[
{ "type": "sign_event", "kind": 22242 },
{ "type": "nip44_decrypt" }
]
4 Methods
Every request has a type (the method name) and a payload. Each transport passes the payload differently, but the meaning is shared. Seven methods exist:
| Method | Payload | Extra params | Result |
|---|---|---|---|
| get_public_key | (empty) | permissions | the user-pubkey |
| sign_event | event JSON | current_user | the signature, and the signed event |
| nip04_encrypt | plaintext | pubkey, current_user | the ciphertext |
| nip44_encrypt | plaintext | pubkey, current_user | the ciphertext |
| nip04_decrypt | ciphertext | pubkey, current_user | the plaintext |
| nip44_decrypt | ciphertext | pubkey, current_user | the plaintext |
| decrypt_zap_event | event JSON | current_user | the decrypted event JSON |
Three shared parameters appear across methods:
current_user— theuser-pubkeycurrently logged in to the client, in hex format.pubkey— the public key of the other party in encryption or decryption, in hex format.id— an optional client-chosen string. The signer echoes it back in the result. Use it to match responses when you send several requests without waiting.
The encryption and decryption methods implement NIP-04 and NIP-44. The decrypt_zap_event method decrypts a private zap (NIP-57).
5 Intent Flow
In the Intent flow, the payload is the data of a nostrsigner: URI. All other parameters travel as intent extras. The client launches the intent and receives the result through registerForActivityResult or rememberLauncherForActivityResult.
Set the intent's package to the stored signer package name. Omit it only for get_public_key, before the client knows the package name.
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$payload")).apply { `package` = signerPackageName // omit only for get_public_key putExtra("type", "sign_event") putExtra("id", event.id) // optional, echoed back putExtra("current_user", userPubkey) } launcher.launch(intent)
The signer answers through the returned intent's extras:
| Extra | Description |
|---|---|
| result | The method result: pubkey, signature, ciphertext, plaintext, or event JSON. |
| id | The id sent in the request, echoed back. |
| event | The signed event JSON. Present for sign_event only. |
| package | The signer package name. Present for get_public_key only. |
| rejected | true when the user rejected the request. |
Do not read the resultCode as a rejection. A resultCode other than Activity.RESULT_OK means the signer failed — for example, it crashed. When the user rejects, the signer returns RESULT_OK with the rejected extra set to true. Check both.
onResult = { result ->
if (result.resultCode != Activity.RESULT_OK) {
// signer error (e.g. crash)
} else if (result.data?.getBooleanExtra("rejected", false) == true) {
// user rejected the request
} else {
val value = result.data?.getStringExtra("result")
}
}
RESULT_OK answer.Signing multiple events in one launch
To sign several events without reopening the signer for each one, add two flags to the intent. The signer then returns one array of results in a results extra. The signer must declare android:launchMode="singleTop" for this to work.
// client adds: intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) // signer answers with: intent.putExtra("results", listOf(Result(package = signerPackageName, result = signature, id = intentId)).toJson())
6 Content Resolver Flow
The Content Resolver flow runs in the background. The signer does not open. It answers only for permissions the user chose to remember. The client queries a content URI built from the signer package name and the method name in uppercase:
content://<package-name>.<TYPE> // e.g. SIGN_EVENT, NIP44_ENCRYPT
The query's selectionArgs carry the payload and the parameters. The order is fixed: [payload, pubkey, current_user]. Pass an empty string for a parameter the method does not use.
val result = context.contentResolver.query( Uri.parse("content://com.example.signer.SIGN_EVENT"), listOf(eventJson, "", loggedInUserPubkey), null, null, null, )
The cursor returns a result column. For sign_event it also returns an event column with the signed event JSON. The query fails — it returns null, or the cursor has a rejected column — when any of these is true:
- The user did not enable "remember my choice" for this request.
- The
user-pubkeyis not present in the signer. - The request
typeis not recognized. - The user chose to always reject this request. The signer returns a
rejectedcolumn. The client SHOULD NOT then fall back to an Intent.
if (result == null) return if (result.getColumnIndex("rejected") > -1) return if (result.moveToFirst()) { val value = result.getString(result.getColumnIndex("result")) // for sign_event also: result.getColumnIndex("event") }
Clients SHOULD store the user-pubkey locally. Do not call get_public_key again after the user is logged in.
result column.The source spec lists the four failure conditions under one sentence: "the query returns null, or a rejected column is present". It states the rejected column only for the always-reject case. For the first three conditions it does not say which of the two failure shapes applies. Handle both shapes for every condition, as the sample code above does.
7 Web Flow
A web page cannot receive an intent result. Instead, the signer appends the result to a callbackUrl. If the request has no callbackUrl, the signer copies the result to the clipboard. The request is a nostrsigner: URL. The path is the payload. The query string carries the parameters:
nostrsigner:<payload>?type=<method>&pubkey=<hex_pubkey>&callbackUrl=https://example.com/?result=
| Parameter | Description |
|---|---|
| type | The method name. |
| pubkey | The other party's hex pubkey. Used by the encryption and decryption methods. |
| callbackUrl | The URL the signer appends the result to. If omitted, the signer copies the result to the clipboard. |
| returnType | signature or event. |
| compressionType | none (default) or gzip. With gzip, the returned event is the string "Signer1" plus Base64(gzip(event JSON)). Useful because intents and URLs have length limits. |
window.href = `nostrsigner:${eventJson}?compressionType=none&returnType=signature` + `&type=sign_event&callbackUrl=https://example.com/?event=`;
Consider NIP-46 remote signing for web applications instead. The web flow here cannot call the signer in the background. The user sees a signer popup for every single request.
The source spec's JavaScript samples assign window.href. That property does not trigger navigation; the working form is window.location.href. The samples are reproduced as written. Adjust in real code.
8 Worked Example
A web page asks the signer for one signature. It sends a kind-1 event, then reads the result from its own query string when the signer calls back.
<script> window.onload = function() { // 2. when the signer calls back, the result is in the URL var params = new URL(window.location.href).searchParams; var result = params.get("event"); if (result) alert(result); // 1. build the request link var json = { kind: 1, content: "test" }; var encodedJson = encodeURIComponent(JSON.stringify(json)); var anchor = document.createElement("a"); anchor.href = `nostrsigner:${encodedJson}?compressionType=none` + `&returnType=signature&type=sign_event` + `&callbackUrl=https://example.com/?event=`; anchor.textContent = "Open External Signer"; document.body.appendChild(anchor); } </script>
The same request over the Intent flow, as a transcript. Orange lines go to the signer. Blue lines come back:
-- connect: no package yet -- → launch nostrsigner: {type: "get_public_key", permissions: [{"type":"sign_event","kind":1}]} ← RESULT_OK {result: "b1a649ebe8…", package: "com.example.signer"} -- sign: addressed to the stored package -- → launch nostrsigner:{"kind":1,"content":"test",…} package=com.example.signer {type: "sign_event", id: "req-1", current_user: "b1a649ebe8…"} ← RESULT_OK {result: "e8551d85…", id: "req-1", event: "{\"id\":\"5c83…\",\"sig\":\"e8551d85…\",…}"} -- a rejection looks like this -- ← RESULT_OK {rejected: true}
9 Version History
greenart7c3 wrote this NIP for the Amber signer. It entered the repository as a draft numbered 100 and became NIP-55 in mid-2024. Notable changes, newest first:
- 20262026-06Full rewrite: shorter text, one shared methods table, clearer transport sections.
- 20262026-01Fixed the
nip44_encryptreturn field; clarified the connection handshake (store the pubkey and package name once). - 20252025-10Documented
get_public_keyover the Content Resolver. - 20252025-02Added the web-flow warning, the
rejectedchecks in the samples, and the package-name usage rules. Removed theget_relaysmethod. - 20242024-11Added batch signing: intent flags,
singleToplaunch mode, and theresultsarray. - 20242024-10Renamed the return field from
signaturetoresult. - 20242024-06fiatjaf renumbered the document from NIP-100 to NIP-55. Weeks earlier, web requests moved from
intent:tonostrsigner:URLs. - 20242024Typo fixes, heading cleanup, and review edits.
- Origin2023-11greenart7c3 published the first draft, with permissions and package-name discovery added in the same month.
Original Proposal - Credits
| Author | greenart7c3 |
| First commit | 2023-11-08 — “Android Signer Application nip” (filed as 100.md; renumbered to 55.md on 2024-06-05) |
| Status | draft - optional — for Android clients that delegate key handling |
| Source | 55.md on GitHub - full change history |
10 See Also
- NIP-01 — the event object, signatures, and kinds that the signer produces.
- NIP-46 — remote signing over relays; the recommended option for web clients.
- NIP-07 — the same capability model as a browser extension (
window.nostr). - NIP-04 — the legacy encryption behind
nip04_encryptandnip04_decrypt. - NIP-44 — the current encryption behind
nip44_encryptandnip44_decrypt. - NIP-57 — zaps;
decrypt_zap_eventopens private zap requests.