Three docs for three audiences: service-overview.md for anyone integrating with or operating the service, implementation.md for engineers maintaining this codebase, and frontend-notification-click-actions.md as a self-contained handoff for the client team to start building tap-to-navigate against — it flags the click_url URL format as an unconfirmed placeholder needing their input, and catalogs every notification type currently sending one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6.1 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" }
}
}
}
Pull extras["client::notification"]["click"]["url"]. If it's present, tapping
navigates there. If it's absent, do nothing on tap — see next section.
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.
⚠️ The URL format below is a placeholder — needs your input
Every click_url currently being sent is built from a setting called
FRONTEND_BASE_URL, defaulting to https://app.gooyal.ir, with a path like
/ads/<uuid> or /escrow/<uuid> appended. Nobody has confirmed this
matches your actual app's routing — whether you use a custom URI scheme
(gooyal://ads/<uuid>), a universal/app link (https://...), or something
else entirely (a route name + params instead of a URL at all).
This was built centrally on purpose so it's a one-line change once you tell
us: every click_url in the system is built by one helper function per
producer service (utils/deep_links.py in advertising), not scattered
across dozens of call sites. Please confirm your scheme and we'll update
it — nothing about the client-side contract above changes either way,
only the string inside click.url.
What's clickable today
All of the following are live in the advertising service and carry a real
click_url pointing at the ad (/ads/<uuid>) or escrow deal (/escrow/<uuid>):
Billboards & content (→ /ads/<uuid>)
| Event | Notification text (fa) |
|---|---|
| Someone viewed your billboard | شخصی شروع به مشاهده بیلبورد شما کرد. |
| Billboard created | بیلبود شما با موفقیت ساخته شد. |
| Billboard approved | بیلبورد شما تایید شد. |
| Billboard rejected | بیلبود شما رد شد. |
| Someone commented on your billboard | یک نفر برای بیلبورد شما نظر گذاشت! |
| Someone replied to your comment | یک نفر به نظر شما پاسخ داد! |
| Someone bought your content | درآمد جدید دارید! یک نفر محتوای شما را خرید. |
| Someone supported your content | یک نفر از محتوای شما حمایت کرد! |
| Pin about to expire (time) | زمان پین رو به اتمامه! |
| Pin expired (time) | پین شما منقضی شد! |
| Pin credit about to run out (budget) | اعتبار پین رو به اتمامه! |
| Pin credit exhausted (budget) | اعتبار پین شما به پایان رسید! |
Note the last four are two independent signals — a billboard can lapse
because its display time ran out, or because its budget (balance vs.
reward-per-view) ran out. Both currently point at the same ad detail page;
if your UI wants to show a different call-to-action (extend time vs. top up
credit) based on which one fired, that distinction is in the extras payload
via which notification title/text arrived, not in the URL itself — ask if
you need a structured signal here instead of parsing title text.
Escrow deals (→ /escrow/<uuid>)
The full P2P deal lifecycle — buyer pays, seller approves/rejects, delivery,
confirm, dispute, payout/refund. All 11 possible state transitions notify
whichever party (buyer or seller) needs to act or be informed next, each
with a click_url to that deal's detail page. If you're building an escrow
detail screen, treat every push in this domain as "go look at this deal" —
the screen itself should reflect current state, not the specific notification
that triggered the tap.
Not yet migrated to this mechanism
- chat: sends
extras = {"conversation_uuid": ..., "post_id": ...}directly, without aclick_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. If you'd rather chat also send aclick_urlonce your routing scheme is confirmed, that's a small change on our side; let us know. - promotions: integrated with the notifications service but not currently sending any notification with real content (
extras={}today) — nothing to build against yet.
Questions for you before we finalize
- What's your app's actual deep-link scheme — custom URI, universal link, or something else?
- Do you want the "time expiring" vs. "credit expiring" pin notifications distinguished by something other than title text?
- Do you want chat migrated onto
click_urltoo, or is your existingconversation_uuid/post_idhandling staying as-is?