Skip to content

Add NIP 9a for push notifications#2194

Open
staab wants to merge 8 commits intonostr-protocol:masterfrom
coracle-social:push-notifications
Open

Add NIP 9a for push notifications#2194
staab wants to merge 8 commits intonostr-protocol:masterfrom
coracle-social:push-notifications

Conversation

@staab
Copy link
Member

@staab staab commented Jan 21, 2026

@staab staab force-pushed the push-notifications branch from c63001a to 2c47af9 Compare January 21, 2026 02:22
@sanah9
Copy link

sanah9 commented Jan 21, 2026

How do you distinguish whether a device is online or offline? Is it determined by whether there is a connection to the server?

@staab
Copy link
Member Author

staab commented Jan 21, 2026

I'm going to add a pause_until thing and make the events replaceable. But don't apps normally ignore push notifications if they're in the foreground?

@staab staab force-pushed the push-notifications branch from 2c47af9 to 34624f7 Compare January 21, 2026 04:33
@sanah9
Copy link

sanah9 commented Jan 21, 2026

But don't apps normally ignore push notifications if they're in the foreground?

Yes, but on Android you have to use silent push.

@fiatjaf
Copy link
Member

fiatjaf commented Jan 21, 2026

What is that server URL that stuff should be pushed to?

@staab
Copy link
Member Author

staab commented Jan 21, 2026

That's the app's dedicated push server, which hold the various keys related to authenticating push notifications. Here's the flow:

client 1                     client 2                         relay                    push server
                                                                                                  
    │                            │       subscription setup     │                           │     
    ├────────────────────────────┼──────────────────────────────┼──────────────────────────►│     
    │                            │                              │                           │     
    │                            │       callback url           │                           │     
    │◄───────────────────────────┼──────────────────────────────┼───────────────────────────┤     
    │                            │                              │                           │     
    │                            │       subscription event     │                           │     
    ├────────────────────────────┼─────────────────────────────►│                           │     
    │                            │                              │                           │     
    │                            │       kind 1 event           │                           │     
    │                            ├─────────────────────────────►│                           │     
    │                            │                              │        notification       │     
    │                            │                              ├──────────────────────────►│     
    │                            │       push notification      │                           │     
    │◄───────────────────────────┼──────────────────────────────┼───────────────────────────┤     

@staab staab force-pushed the push-notifications branch from c4d74d0 to 0480065 Compare January 21, 2026 17:27
@staab
Copy link
Member Author

staab commented Jan 21, 2026

Just updated the NIP to leave as much up to the client/push server as possible. Now, clients will have to talk directly to push servers to set up device tokens, which reduces the amount of information relays have. Clients can also implement client activity status directly with the push server, solving @sanah9's problem. Push servers can also serve as a central point of control, since they can return a 404 error to indicate the subscription should be deleted.

@fiatjaf
Copy link
Member

fiatjaf commented Jan 21, 2026

We should make this a non-hex number just to make it easier on libraries that have a number type on the NIP-11 array of supported_nips.

@staab
Copy link
Member Author

staab commented Jan 21, 2026

Relay implementations have to be updated non-trivially anyway to support it, so they might as well convert supported_nips to a string type while they're at it.

@alltheseas
Copy link
Contributor

Example push notifications for android without requiring a server, or third party service/app install. Just use websockets/connect to relay:

damus-io/notedeck#1261

Might this change how the NIP is written?

@staab
Copy link
Member Author

staab commented Jan 22, 2026

I doubt it, from what I understand mobile platforms are picky about apps using background tasks because there's a necessary trade-off between keeping stuff active and battery life. Plus, this approach is a pain to implement, I was trying it earlier this week, but in capacitor you don't have access to websockets, which means you would have to write an http/websocket proxy, or write native code. It would be good if pokey could be improved to serve as a notifications hub for other nostr apps, but that comes with the UX tradeoff of users having to install another companion app. That's not a bad solution all-in-all though.

I've also built anchor.coracle.social, which doesn't work at all because it has to open a separate websocket connection for each subscriber/relay combo to avoid exfiltrating data related to relay access controls. It also only approximates access by using invite codes, which means lots of content will still be inaccessible.

Another alternative would be to share a bunker url pointing at the user's signer so that the push server could authenticate on the user's behalf. But this has the same problem with many websocket connections, and opens a blind signing attack vector.

This push solution is good because:

  • Relays know which user is subscribed and can authorize access by user internally
  • No long-lived connections are necessary between push servers and relays
  • It uses Apple/Android push servers which are the most reliable way to do this
  • Relays that don't do access control don't have to do anything, I'm working on an implementation that can bridge push servers and regular relays

The main tradeoffs are:

  • The client developer has to run a server
  • Relays have to add support
  • Relays have to implement potentially expensive authorization logic for every event received

@staab
Copy link
Member Author

staab commented Jan 29, 2026

Just updated to encrypt messages

@staab staab force-pushed the push-notifications branch from 6581759 to 882b1e5 Compare January 29, 2026 20:24
Copy link
Collaborator

@vitorpamplona vitorpamplona left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this NIP is unnecessary. Since there is no way for clients to reuse each other's kind 30390, there is no need for interoperability on any of this, which means no need for specs.

9a.md Outdated

If a relay does not intend to fulfill the subscription, it SHOULD respond with an `OK` message with `false` as the result and a human-readable message.

Because client developers are expected to be running their own push server, any other features such as registration of device tokens, notification cancellation, or client status should be implemented directly between the client and the push server.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is the case, why are we developing a standard for registration only? Why isn't the registration just another hardcoded payload between the client and their push server? Why are we "forcing" all clients to support filters and ignore if they can make their own parameters that would probably be more specific than those?

Like, for Amethyst, I only send notifications from non-zero Web of Trust and zaps only over 10 sats. The tags here wouldn't support what we need. Every client would have something a little different.

The kind 30390 event would not be reusable between apps, so I am not sure why we are standardizing this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because relays have to understand them in order to push them to the push server. The reason we need this is because relays might choose to implement authorization policies, so it needs to be up to them who they push stuff to.

@staab
Copy link
Member Author

staab commented Jan 29, 2026

I think this NIP is unnecessary

Clients don't need to re-use the subscriptions, relays do need to know how to support them though. That's the part that needs to be interoperable. I tried doing pull-based, and it didn't work.

@staab
Copy link
Member Author

staab commented Jan 29, 2026

Implemented on https://github.com/coracle-social/npb, which is a bridge that enables this functionality for any public relay, and on https://github.com/coracle-social/nps which is an implementation of a application push server.

@vitorpamplona
Copy link
Collaborator

relays do need to know how to support them though.

I don't actually understand why this is a relay. This is a client running as a service on a server. We shouldn't bother relay devs in supporting this feature if it can be a client. There is no need for it.

@staab
Copy link
Member Author

staab commented Jan 29, 2026

Ok, back up: https://github.com/coracle-social/zooid is a relay for communities, which implements access controls. If we do this using a pull-based flow, push servers will be unable to request access-protected events from the community relay, because they are not a member of the relay. To solve this, you need push servers to be able to auth on behalf of users, which means users need to share their keys (or a bunker link). This is bad, and doesn't work anyway because the push server needs to maintain a separate connection per user since only one user can be authenticated on a connection (you don't want to broadcast the superset of permitted events to all users). Another alternative is to add the push server's key to the relay, but that doesn't account for the difference in permissions between different users.

So this isn't about cargo-culting the relay interface to the push server, it's about allowing relays to selectively share access-controlled data with particular users via a push channel/callback url.

@vitorpamplona
Copy link
Collaborator

Ok, so the goal here is to circumvent AUTH for NIP-29. That makes more sense.

@staab
Copy link
Member Author

staab commented Jan 30, 2026

Not to circumvent it exactly (the relay is still authorizing the user directly, just sending stuff in a delayed fashion via a different middleman [still encrypted]). But yes.

@vitorpamplona
Copy link
Collaborator

Feels like we could have put all the nip 29 nips in its own repo, like the BUDs.

@staab
Copy link
Member Author

staab commented Jan 30, 2026

There are other types of access controlled relay, this is relevant for those too.

@staab
Copy link
Member Author

staab commented Jan 30, 2026

It is defined as just the strigified event itself.

But this doesn't work for my use case, in which I want to hide the event from the receiver.

And to me, your use case should use the public version with an intermediary server/plugin running on the same machine, so it solves NIP-29, too.

Say relay A is running the plugin and relay B isn't, and I pass the same callback to both. What format do I expect? Both. And, it leaves it up to the relay to decide which one they send, not the client to decide in tandem with the server. Which means all receivers have to support both formats.

Not without AUTHing.

In the public use case, which is what you're talking about, you don't have to auth. That's your entire point, isn't it? The events aren't private? Which proves my point — push is only useful if the events are access controlled, in which case encryption makes sense.

@vitorpamplona
Copy link
Collaborator

I think you are not understanding what I mean by running the plugin. Let's take the case of Flotilla App and Flotilla's Push Server. Flotilla App would register the push event with public tags like I designed, pointing the callback URL to the relay's encryption service callback. Something like: http://relay.com/forwarder?to=http://push.flotilla.social

The HTTP service running on relay.com of the custom NIP-29 relay gets the raw event from the hook callback, makes the encryptions that you want, and pushes to the to url, which goes encrypted to Flotilla's Push Server in the way you want.

Which means the NIP-29 version of the "hook" API defines the standard for this local encryption service that NIP-29 apps want to use. We could say, for instance, that all supporting relays for your NIP must offer the /forwarder?to=<actual push server> inside the relay server as a convention. Then you end up using the "public" hook infrastructure with an added component running in that relay for NIP-29 apps.

In that way, the NIP-29 relay operator doesn't need to change the relay code. All the operator needs to do is to add the local forwarder to be compliant with the encryption on your NIP.

In the public use case, which is what you're talking about, you don't have to auth

Most public relays require AUTH these days.. It's dumb but it is there. So, the "public" case exists.

@vitorpamplona
Copy link
Collaborator

Also, the DM inbox relays could use the public hook interface since giftwraps are encrypted anyway.

@vitorpamplona
Copy link
Collaborator

Don't get me wrong, making the redirection from the public hook API will be more complicated for your needs than this PR, so maybe it doesn't make sense for you, but it would be easier for everybody else that doesn't even have a pubkey for their relays and definitely doesn't have encryption capabilities in the relay itself.

@staab
Copy link
Member Author

staab commented Jan 30, 2026

We could say, for instance, that all supporting relays for your NIP must offer the /forwarder?to= inside the relay server as a convention.

I understand what you're saying in terms of decoupling software architecture. But how does the client know it can use the relay itself as a forwarder? Probably another value in the supported NIPs field. So however you slice it there are multiple interfaces going on here, internal to the infrastructure-powering-the-relay.

Most public relays require AUTH these days.. It's dumb but it is there.

Sure, but you can just auth with the relay's own key and pull, this still doesn't make push necessary. I still don't see how this architecture is useful for non-auth-gated content.

@vitorpamplona
Copy link
Collaborator

vitorpamplona commented Jan 30, 2026

But how does the client know it can use the relay itself as a forwarder?

If the supported NIPs include 9a as you described here. 9as then would include the forwarder by definition.

My version doesn't use NIP-11 tags anymore. It just checks if the OK return includes the "subscribed" message to see if the relay implements hooks and if the hook is active for the specific filter that the user/app wants. Knowing if the relay implements or not hooks without knowing the filters is somewhat irrelevant.

I still don't see how this architecture is useful for non-auth-gated content.

Picture a Web Of Trust provider. User signs in to the provider, but the user is using an AUTHed outbox relay for... reasons... The provider can't log in as a user, so the provider app makes the user sign for a hook in to their own outbox relay to send changes of the Follow List to the WoT provider. Now the WoT provider doesn't even need to keep Negentropy Syncing/REQing the follow list all the time. It's quite a solution.

@staab
Copy link
Member Author

staab commented Jan 30, 2026

It is a nicer flow, I'll give you that. I still think this is sort of a contrived example, why would the outbox relay be auth'd except by mistake, or to protect the user's data? In which case, encryption seems like it abides by the spirit of the relay's policy. But I understand why you wouldn't want that overhead and complexity.

So it sounds like we're basically doing entirely different things despite an architectural similarity. I don't want relays to have to implement two things that are so similar, but if you can't go with encryption and I can't go without, they're really two different interfaces. The two could be bolted onto each other as you described, but that seems like a nasty premature optimization to me.

The thing that makes me sad is I'm probably going to have to support both approaches now, because relays are going to be more likely to implement the un-encrypted version because it's easier to sell as a "generic" protocol feature. Or I yolo route plaintext events through my server, which sort of betrays the "private" nature of the thing. 😕

@vitorpamplona
Copy link
Collaborator

vitorpamplona commented Jan 31, 2026

why would the outbox relay be auth'd except by mistake

Everything is a mistake in Nostr. I gave up understanding why users use things the way they do.

Can't we do anything in the public version to make intercepting the callback to encrypt better/simpler for you?

Another way could be that NIP-29 relay operators can use regular relay implementations with the public hook support and change the code directly to encrypt it. So, maybe there is an implementation detail that allows operators to know the Push Server is encrypted and then go through your POST scheme.

I yolo route plaintext events through my server, which sort of betrays the "private" nature of the thing.

If the Push Server has to be run by the same dev of the app, who users trust, and since the app also has direct access to the event that the dev could steal, especially on a web app that can be updated at any time and the user won't even notice, I think raw events are fine for you. In the end, it's all trusted anyway

@staab
Copy link
Member Author

staab commented Feb 2, 2026

Everything is a mistake in Nostr. I gave up understanding why users use things the way they do.

You can't justify your use for push to circumvent auth based on poor implementations while also dismissing my inclination to encrypt events just in case relays are stupid.

If the Push Server has to be run by the same dev of the app, who users trust, and since the app also has direct access to the event that the dev could steal, especially on a web app that can be updated at any time and the user won't even notice, I think raw events are fine for you.

No, there's a big difference between touching user data on the server and sending users code they run in their browsers. The threat model isn't that much different if you're talking about a malicious service provider, but having user data on your actual server exposes the server provider to different sorts of risks, and a way bigger attack surface area.

@staab
Copy link
Member Author

staab commented Feb 4, 2026

I had an idea while falling asleep last night — what if the push payload was {relay, id}? That would be lighter than encryption, and would leak no metadata (the hash is useless without access to the event itself). How's that for a compromise @vitorpamplona?

@vitorpamplona
Copy link
Collaborator

Requiring the app to connect and download the event is probably better for Push Notification systems since large payloads are punished and most services have size limits (FCM is 4KB)

But that doesn't solve the AUTH problem that services have.

So, maybe we do need 2 kinds.

@staab
Copy link
Member Author

staab commented Feb 4, 2026

Ok, I just pushed a new update. I removed all the paranoid guard rails like relay, p, encryption, etc. You can include_event if the user doesn't mind sharing the full event with the recipient server. I saw you kept relay in your version, is that necessary?

@vitorpamplona
Copy link
Collaborator

I saw you kept relay in your version, is that necessary?

I am trying to avoid events being broadcast everywhere to DDOS a receiver.

@staab
Copy link
Member Author

staab commented Feb 4, 2026

I am trying to avoid events being broadcast everywhere to DDOS a receiver.

Well, if you feature detect based on supported_nips and relays don't relay these subscriptions, you're ok. Still, I figured out I needed the relay for my bridge, so I put it back in.

@staab
Copy link
Member Author

staab commented Feb 4, 2026

But that doesn't solve the AUTH problem that services have.

What do you mean? The client can reconnect using the user's key to AUTH.

@vitorpamplona
Copy link
Collaborator

What do you mean? The client can reconnect using the user's key to AUTH.

Not a WoT service receiving this event will not have the nsec to AUTH. So, it will only work if the event is sent.

- `include_event` - whether push payloads should include the complete `event` data

If a relay does not intend to fulfill the subscription, it SHOULD respond with an `OK` message with `false` as the result and a human-readable message.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add expiry information. If a relay accepts subscription they have to send expiry time of subscription. As currently it is unclear when client has to resync subscription with relay.

Copy link
Collaborator

@vitorpamplona vitorpamplona Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the relay should also delete the event when the subscription expires, then clients can check if the relay has the event if not send it again.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@prhasn how do you imagine this working? OK messages aren't good for structured data. The relay could publish a subscription status event, but that seems like overkill. Maybe we should require an expiration tag on the subscription event, and relays can reject it if it's too far out?

@staab staab force-pushed the push-notifications branch from 330b62e to 635449e Compare February 4, 2026 19:35
@staab
Copy link
Member Author

staab commented Feb 4, 2026

Not a WoT service receiving this event will not have the nsec to AUTH. So, it will only work if the event is sent.

Right, which IMO is sort of bad because it circumvents relay policies to allow for exfiltration. But with the include_event change, relays have the ability to refuse, so I think this is solved.

@vitorpamplona
Copy link
Collaborator

Wait, with the current version, how does your callback server know which user the event is for so that it can get the device tokens for that user?

@staab
Copy link
Member Author

staab commented Feb 4, 2026

Client generates token and sends to the push server, push server generates callback URL with embedded ID and sends it to the client, client shares this with relays via subscription event, relay posts to the callback url (hosted by the push server), push server maps callback url/id to device token, sends push notification.

@vitorpamplona
Copy link
Collaborator

Ohh.. so basically the pubkey is still there... It's just "hashed" in the url itself.

Can the push server also register the urls of the relays the user is registering these events and compare the incoming HTTP IP with the resolved DNS record? That does make a lot of sense.

@staab
Copy link
Member Author

staab commented Feb 4, 2026

Can the push server also register the urls of the relays the user is registering these events and compare the incoming HTTP IP with the resolved DNS record? That does make a lot of sense.

Sure, I don't see why not

@staab
Copy link
Member Author

staab commented Feb 11, 2026

Implemented on zooid pending coracle-social/zooid#8

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants