JMAP’s model (RFC 8620 <https://datatracker.ietf.org/doc/html/rfc8620>) is sound and very robust, though at the cost of not being the simplest possible in all situations. In essence:

• Every type of object has a state string. e.g. in the email domain (RFC 8621), you might have Email at state "12345" and Mailbox at state "678". Think Git commit IDs or SVN commit numbers. From an implementation perspective, you could use just one state string for absolutely everything if you wanted to, it’d just mean that you’d be querying for Mailbox changes when every Email came in; it’s an efficiency thing, not a correctness thing.

• You can ask the server what’s changed since a given time, e.g. Email/changes { sinceState: "12345" } (simplifying pseudosyntax used, the full JSON is longer), and it’ll tell you the IDs that have changed and the new state string, and then you can retrieve the changes for those IDs (in the same HTTP request if you want, using back-references).

• Even queries can have states (so long as the server implements that—all this state stuff is actually optional in practice, just an optimisation thing that you want for almost all types), so that you can efficiently update search results and only fetch records that match a query. (Think an email search getting new messages that match popping up at the top of the list, but without having had to download all the ones that didn’t match to see if they matched along the way.)

• It provides push (section 7) via both ~web hooks (section 7.2) and /events (section 7.3), telling the client “this type that you said you were interested in is now at state such-and-such” and leaving you to decide what to do, rather than the typical web hooks approach of sending a heavy object with some fields you use, some you don’t, and lacking some you wanted so that you probably end up talking to the server again anyway, for each and every entity that changed. (And for Event Source, the client can even send a Last-Event-ID so that the server might be able to skip even the first message of the states.) End result is that it generally takes a little more effort to implement a client, but is actually robust, allows linear processing, and can perform vastly better and more predictably.

• (In practice, that “don’t include any data, just type state metadata” principle PushSubscription goes for is a bit limited for mobile app purposes, where messages get delivered to apps but they may not be able to make requests; so Fastmail’s apps extend it to provide the necessary data about the emails—encrypted so the message broker can’t read it—to make email notifications work.)

JMAP’s model doesn’t apply cleanly to all domains, but it does work for most, and even where it doesn’t quite work I think it’s regularly still worth picking over and seeing if you can apply some of its concepts, because it has distilled some pretty solid practices in areas like Push.

One particularly aspect of the design of JMAP that will be a limitation in some domains is that it’s modelling objects, not events at all or even deltas all that much. JMAP is very thoroughly an object synchronisation protocol. You won’t get a customer.subscription.deleted event like in Stripe’s /events, but rather when it tells you that the CustomerSubscription state has changed and you ask which objects changed it’ll tell you {"destroyed": ["id"]}—or more likely {"updated": ["id"]} and the corresponding get (again, probably sent in the same HTTP request) will report that the field "deleted" has been set to true.