notifications/docs/frontend-notification-click-actions.md
Ali Asadi a84e2b3793 FEAT(notifications): pass action_code through in Gotify extras
get_gotify_extras() previously only ever surfaced the *resolved* click_url,
never the action_code that produced it -- so the frontend had no way to
know which action_code a notification carried, and got nothing at all for
a code that hasn't been configured in NotificationLink yet. Now
action_code rides along under client::notification.action_code whenever
it's set, independent of whether it resolved to a click_url. Verified live
against a running Gotify container, both resolved and unresolved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 11:35:33 +03:30

7.3 KiB

Frontend integration guide: notification click actions

Audience: the team building the Gooyal client app(s) that receive Gotify push notifications. This is everything you need to start building tap-to-navigate, independent of the backend repos.

The one thing to build

When a push notification is tapped, read a URL out of the notification's payload and navigate to it. That's the entire client-side contract — one handler, used by every notification type below, present or future.

Where the URL lives

Gotify delivers a JSON payload per message. The URL — when the notification is clickable at all — is under a reserved key, per Gotify's own convention:

{
  "title": "بیلبورد شما تایید شد.",
  "message": "بیلبورد گربه (تست) تایید شد.",
  "priority": 5,
  "extras": {
    "ads::ad::approve": true,
    "ad_uuid": "8fb638c2-e6a4-4baf-aefe-83dab78fb5bd",
    "client::notification": {
      "click": { "url": "https://app.gooyal.ir/ads/8fb638c2-e6a4-4baf-aefe-83dab78fb5bd" },
      "action_code": "billboard.approved"
    }
  }
}

Pull extras["client::notification"]["click"]["url"]. If it's present, tapping navigates there. If it's absent, do nothing on tap — see next section.

extras["client::notification"]["action_code"] rides along too, whenever the producer sent one — even if it hasn't resolved to a click URL yet (no NotificationLink row for it, or it's inactive). You don't need it to implement tap-to-navigate; it's there for client-side logic keyed off the notification type itself (grouping, icons, analytics, a fallback in-app handler for a code before its admin row exists), not just its destination.

click_url is optional — most notifications are not clickable

Do not assume every notification carries a URL. The backend field this comes from is explicitly optional (nullable end-to-end, not just "sometimes empty string") specifically because not every notification type has a sensible destination. Any notification without a client::notification.click.url key should render and behave exactly as a non-interactive notification — no error, no dead tap target, just no navigation.

You may also see other keys under extras alongside (or instead of) client::notification — e.g. chat currently sends conversation_uuid and post_id directly, not yet through this mechanism (see "Not yet migrated" below). Ignore keys you don't recognize; don't treat an unfamiliar key as an error.

URLs are admin-configured, not hard-coded — nothing for you to build here

The actual destination string is no longer computed in backend code. Each notification type has an action_code, and the notifications service resolves that action_code against an admin-managed table (NotificationLink: action_code, url_template, is_active) at send time. url_template supports a {object_id} placeholder, e.g. https://app.gooyal.ir/ads/{object_id} or gooyal://ads/{object_id} — whichever scheme your app actually uses.

This means: your routing scheme is a config decision, not a code change. Whoever manages the notifications service's admin panel enters one row per action_code below with your actual URL format, and every notification of that type immediately starts carrying it. If an action_code has no row yet (or its row is marked inactive), that notification simply has no click_url — same as any other non-clickable notification, no error.

Practically, this doesn't change anything about what you build — you still just read extras["client::notification"]["click"]["url"] and navigate if it's present. It changes who's responsible for the URL string itself: not a backend deploy, just an admin panel entry. If you want a different value than what's currently configured for any action_code, ask whoever owns that panel to update it.

What's clickable today, by action_code

All of these are live in the advertising service:

Billboards & content

Action code Notification text (fa)
billboard.viewed شخصی شروع به مشاهده بیلبورد شما کرد.
billboard.created بیلبود شما با موفقیت ساخته شد.
billboard.approved بیلبورد شما تایید شد.
billboard.rejected بیلبود شما رد شد.
billboard.commented یک نفر برای بیلبورد شما نظر گذاشت!
billboard.replied یک نفر به نظر شما پاسخ داد!
billboard.content_bought درآمد جدید دارید! یک نفر محتوای شما را خرید.
billboard.content_supported یک نفر از محتوای شما حمایت کرد!
billboard.pin_expiring زمان پین رو به اتمامه!
billboard.pin_expired پین شما منقضی شد!
billboard.credit_low اعتبار پین رو به اتمامه!
billboard.credit_exhausted اعتبار پین شما به پایان رسید!

Every action_code above sends the ad's uuid as click_object_id_value, so a url_template of https://app.gooyal.ir/ads/{object_id} (or your app's real equivalent) resolves correctly for all twelve. They're still separate codes rather than one shared one, on purpose: billboard.pin_expiring and billboard.credit_low are genuinely different situations (time running out vs. budget running out) even though they'd point at the same screen today — giving each its own code means that can diverge later (e.g. deep-linking straight to an "extend time" vs. "top up credit" action) without any code change, just a new admin row.

Escrow deals

Action code Trigger
escrow.request_deal Buyer paid, seller needs to review
escrow.cancel_deal Buyer canceled before seller responded
escrow.reject_deal Seller declined the deal
escrow.approve_deal Seller confirmed the deal
escrow.cancel_deal_by_seller Seller canceled an active deal
escrow.request_cancel Buyer requested cancellation
escrow.approve_cancel Seller accepted the cancellation
escrow.reject_cancel Seller declined the cancellation
escrow.confirm_by_seller Seller marked it delivered
escrow.confirm_by_buyer Buyer confirmed receipt — deal completed
escrow.request_judge A dispute was opened
escrow.cancel_judge Buyer withdrew their dispute
escrow.approve_judge Dispute resolved for the buyer
escrow.reject_judge Dispute resolved for the seller
escrow.timeout Deal expired without a response

All fifteen send the escrow deal's uuid as click_object_id_value. Same one-code-per-situation reasoning as billboards — they all currently resolve to the same escrow-detail destination, but that's an admin config choice, not a hard-coded one, so it's free to diverge later.

Not yet migrated to this mechanism

  • chat: sends extras = {"conversation_uuid": ..., "post_id": ...} directly, without a code or click_url. If your client already has custom handling for these two keys (built before this mechanism existed), it keeps working — this doc doesn't change that. Let us know if you'd rather chat send a code too.
  • promotions: integrated with the notifications service but not currently sending any notification with real content (extras={} today) — nothing to build against yet.