Send Dings (push notifications), manage channels and subscribers, schedule broadcasts, and track engagement. One base URL, JSON in and out.
Nearly every endpoint requires a Firebase ID token sent as a Bearer token.
Sign in with a Firebase Auth account for this project, grab the ID token from the client SDK with
user.getIdToken(), and pass it on every request:
Authorization: Bearer <FIREBASE_ID_TOKEN>
401 {"error":"Authentication required"}.
CORS is open (*), so you can call the API straight from a browser app.
Public (no-token) endpoints: /health, GET /notifications/:id, /join/:id, and the billing result pages.https://go.ding.fyi/auth — the web console stores a fresh ID token in
localStorage (firebaseIdToken) you can lift for quick curl testing.A Ding is stored in Firestore and delivered over FCM to a channel's subscribers. Delivery is subject to channel ownership and your plan's recipient cap.
Send a Ding to all subscribers of a channel. For any channel other than the system ding channel, only the channel owner may broadcast.
| Field | Type | Description |
|---|---|---|
| topicoptional | string | Channel ID to broadcast to. Defaults to ding |
| titleoptional | string | Push title. Defaults to "Broadcast Alert" |
| messageoptional | string | Push body. Basic HTML is kept only if it contains a link, otherwise stripped to plain text |
| imageUrloptional | string | Publicly reachable image URL shown with the notification |
| attachmentsoptional | string[] | Array of attachment URLs |
| sponsoroptional | object | { "link": "…", "text": "Sponsored", "altText": "" } — applied only when link is present; clicks are tracked per user |
curl -X POST https://go.ding.fyi/broadcast \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"topic":"my-channel","title":"Doors open!","message":"Come on in 🎉"}'
{
"success": true,
"notification": { "id": "…", "title": "Doors open!", "channel": "my-channel", "openCount": 0, "readCount": 0 },
"broadcast": { "success": true, "messageId": "projects/…/messages/…" }
}
broadcast object switches to {"softLimited": true, "sent": …, "failed": …, "skipped": …}.
The Ding is always saved to inboxes regardless of push outcome. Errors: 403 if you don't own the channel, 404 if it doesn't exist.Legacy: create a Ding on the system ding channel and push it to everyone. Accepts title, message, imageUrl, attachments (defaults: "Proximity Alert" / "Wristband outside of designated area."). Prefer /broadcast.
Queue a Ding for future delivery. Scheduled items live in a pending state until a server-side scheduler fires them, after which they appear under sent items.
Schedule a future broadcast. Non-ding channels must exist and be owned by you.
| Field | Type | Description |
|---|---|---|
| messagerequired | string | Ding body |
| scheduledAtrequired | string | ISO timestamp; must parse to a future time |
| titleoptional | string | Defaults to "Broadcast Alert" |
| channeloptional | string | Channel ID, defaults to ding |
| timeTypeoptional | string | "local" to fire in each subscriber's timezone, anything else = "global" |
| imageUrl / attachmentsoptional | — | Same as /broadcast |
{ "success": true, "scheduledNotification": { "id": "…", "status": "pending", "scheduledAt": "2026-07-25T09:00:00.000Z", "timeType": "global" } }
List your pending scheduled Dings. Returns {"success": true, "scheduled": […]}.
List your scheduled Dings that have already fired (status: "sent") — useful for syncing a Sent box. Returns {"success": true, "sent": […]}.
Reschedule a pending Ding you created. Body: {"id": "…", "scheduledAt": "…", "timeType"?: "local"|"global"} — only the schedule is mutable. 403 if you're not the creator.
Cancel a scheduled Ding you created. Body: {"id": "…"}.
Reading and managing delivered Dings. List responses come from a server-side cache with a 5-minute TTL (writes refresh it immediately).
List notifications, newest first. Targeted notifications addressed to another user are filtered out. Items include engagement fields: openCount, readCount, sponsorClickCount, and reactions (map of emoji → user IDs).
curl https://go.ding.fyi/notifications -H "Authorization: Bearer $TOKEN"
Fetch one notification. Content-negotiated: API clients get JSON (enriched with channelName and channelAvatar); browsers (Accept: text/html) get a shareable landing page that deep-links into the app.
Attach free-text notes to a notification. Body: {"id": "…", "notes": "…"}.
Delete one notification (and its read-tracking data). Body: {"id": "…"}. Only the notification's creator or the channel owner may delete — otherwise 403.
Per-user read tracking, emoji reactions, sponsor-click analytics, and sender-facing stats.
Mark a notification opened/read by the calling user. Idempotent per user — only the first open increments the counters. Optional body: {"source": "app"}. Returns the updated openCount / readCount.
Toggle an emoji reaction. Body: {"emoji": "👍"}. Calling again with the same emoji removes your reaction. Returns the full reactions map.
Record a sponsor-link click. Optional body: {"link": "…", "sponsorText": "…", "source": "app"} (falls back to the notification's sponsor data). Not idempotent — every call counts a click.
Full analytics for a Ding: counts, per-user opens, sponsor-click users, and the 50 most recent sponsor clicks. Only the sender or the channel owner may view — otherwise 403.
Bulk read-state check: pass up to 50 comma-separated IDs, get back {"success": true, "readIds": […]} — the subset the calling user has opened.
Channels group subscribers. Each channel gets a shareable join URL and QR code at creation. Channel IDs are the lowercased name without the leading #.
List channels. Private channels are hidden unless you own them. Query params:
| Param | Description |
|---|---|
| subscribed=true | Only channels the calling user is subscribed to |
| channelId=<id> | Look up a single channel by ID (case-insensitive) |
Create a channel you own; you're auto-subscribed as owner. Returns 201 with the full channel including joinUrl and a qrCode data URL, or 409 if the name is taken.
| Field | Type | Description |
|---|---|---|
| namerequired | string | Letters, numbers, dashes only. Leading # optional |
| descriptionoptional | string | Shown on the join page |
| avatarUrloptional | string | Channel avatar image URL |
| isPrivateoptional | boolean | Hide from discovery (default false) |
| socialLinksoptional | object | Key → URL map |
Permanently delete a channel you own, plus all of its notifications and scheduled Dings. 403 if you're not the owner. No undo.
Subscribe the calling user; also subscribes their registered FCM tokens to the channel's push topic. Optional body: {"timezone": "America/New_York"} (used if your profile has no timezone). Idempotent.
Remove the calling user and detach their device tokens from the channel's push topic. No body required.
Compact list of channels you're subscribed to — {"channels": [{"id", "name", "avatarUrl"}]}. Handy for re-subscribing FCM topics on a new device. (Not billing — see Plans & Billing.)
Shareable HTML landing page for a channel with an "Open in app" deep link. This is the URL encoded in each channel's QR code — meant for humans, not API clients.
Mobile clients register their FCM token so pushes reach the device.
Register an FCM device token for the calling user and subscribe it to the topics of every channel they already follow. Body: {"token": "…"}. Duplicates are ignored.
Remove a token (e.g. on sign-out) and unsubscribe it from all channel topics. Body: {"token": "…"}.
Your full profile, including plan and subscription fields. 404 with {"success": false, "profile": null} if no profile exists yet.
Create or update your profile. First-time creation also triggers a welcome Ding, a welcome email, and auto-subscription to the #ding channel.
| Field | Type | Description |
|---|---|---|
| firstNamerequired | string | First name |
| lastNamerequired | string | Last name |
| agerequired | string | One of 12-17, 18-24, 25-34, 35-44, 45-54, 55-64, 65+ |
| locationrequired | string | Free-text location |
| timezoneoptional | string | IANA timezone, e.g. America/New_York |
Limited public profile of any user: name, age range, location, timezone. No email.
Update just your timezone. Body: {"timezone": "America/New_York"}. Propagates to your subscriber entries across channels in the background.
GDPR-style data export: your profile, subscription summary, owned channels, and usage stats in one JSON payload.
Permanently delete your account and all associated data — owned channels, notifications, scheduled Dings, subscription records, and the Firebase Auth user. If the response includes "clientAuthDeleteRequired": true, the client must finish deleting the Auth user itself.
File a support ticket. Body: {"name", "email", "subject", "message", "category"?}. Returns {"success": true, "ticketId": "…"}.
Liveness check. Returns {"status": "healthy", "timestamp": "…"}.
Paid plans raise the per-Ding recipient cap (Free 100 → Starter 1,000 → Unlimited ∞). Purchases go through Apple/Google in-app purchase (synced via /api/subscriptions/sync) or Stripe on the web.
The plan catalog: keys, product IDs, tiers, intervals, prices, entitlements, and whether Stripe direct purchase is available per plan.
Your effective plan, subscription status, and entitlements. Query params: includeTransactions (default true; pass false to skip) and limit (1–50, default 10) for recent purchase transactions.
Create a Stripe Checkout session for a plan. Body identifies the plan (e.g. {"planKey": "starter_monthly"}). Redirect the user to the returned url; plan activation happens via webhook after payment.
Create a Stripe Billing Portal session (manage/cancel a web subscription). Returns {"url": "…"}. Requires an existing Stripe customer.
Server Stripe configuration summary — use to detect test vs. live mode and whether web checkout is enabled.
Report a native in-app purchase or restore for server-side verification. Requires platform ("ios" | "android") plus plan and store receipt/token fields. Returns verification status, updated entitlements, and the recorded transaction. 402 if verification fails.
POST /api/webhooks/stripe (signature-verified Stripe events) and POST /api/webhooks/apple
(App Store Server Notifications), plus the human-facing /billing/direct, /billing/success, and /billing/cancel pages.Errors are JSON with a single error field.
| Status | Meaning |
|---|---|
| 400 | Malformed JSON body or failed validation (the error message says which field) |
| 401 | Missing, invalid, or expired Firebase ID token |
| 402 | In-app purchase verification failed (/api/subscriptions/sync) |
| 403 | Authenticated but not allowed — usually a channel-owner or creator-only action |
| 404 | Notification, channel, or profile not found |
| 409 | Channel name already exists |
| 503 | Backing service unavailable — retry later |
| 500 | Server or database error — retry, then flag it to the team |
302) to /auth instead of getting a 401 — send Accept: application/json from API clients to always get JSON. JSON request bodies are capped at 64 KB on most endpoints (2 MB for subscription sync).