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>
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 orclick_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.