Royal Glow internal docs · now fully interactive — Steps, API tables, file trees & live status
Royal Glow Docs
API Reference

Background Jobs

Machine-invoked QStash job routes under /api/jobs — signature-verified POST endpoints that run scheduled and event-triggered automations.

Background Jobs

The routes under /api/jobs/* are machine-invoked endpoints, not part of the customer/admin API. They are called by Upstash QStash — either on a fixed schedule or enqueued with a delay when a business event occurs — and they perform the platform's background automations: reminders, report emails, nudges, follow-ups, and stale-state checks. They are hosted in apps/admin and served from admin.theroyalglow.in/api/jobs/*.

Base URL: admin.theroyalglow.in · Auth: Upstash-Signature header (no session). These are POST endpoints invoked by QStash, not by browsers or API clients. Each route reads the raw request body once, verifies the Upstash-Signature header via verifyQStashSignature, and returns 401 Unauthorized if verification fails. They deliberately do not use the { success, data } envelope or withErrorHandler — instead they return a minimal { "success": true, ... } JSON on success and a non-2xx on failure so QStash retries. On success each route pings a BetterStack heartbeat. External integrations (Resend, Brevo, web-push, Slack) are guarded extension points — when their keys are absent the send is a no-op

  • log, and the job still completes and returns 200.

Job summary

All 19 routes are POST. "Scheduled" jobs are fired by a QStash schedule; "Triggered" jobs are enqueued with a delay by an application event. There are 14 scheduled and 5 triggered QStash routes under /api/jobs/*. One further automation — the pprd DB sync — runs as a GitHub Actions cron (not an HTTP route, so it is not listed here). Schedules and cadences are from background-jobs.md; times shown in IST.

RouteSchedule / cadenceWhat it does
POST /api/jobs/appointment-remindersEvery 15 min, 8:00 AM–10:00 PM ISTNotifies customers of confirmed bookings entering the 24h or 1h reminder window (push + email). One send per booking + window.
POST /api/jobs/membership-expiryDaily 12:30 AM IST (0 19 * * * UTC)Push-notifies the owning customer of active memberships expiring in exactly 30, 7, or 1 days.
POST /api/jobs/birthday-emailsDaily 9:30 AM IST (0 4 * * * UTC)Sends a birthday offer (email + in-app) to customers whose DOB is today and who have marketing consent.
POST /api/jobs/lead-followupsDaily 10:30 AM IST (0 5 * * * UTC)Pushes a follow-up reminder to the assigned staff for follow_up leads not contacted in 48h. Unassigned leads are skipped.
POST /api/jobs/gems-expiry-reminderDaily 10:30 AM IST (0 5 * * * UTC)Push-only reminder to customers whose earned gems expire in exactly 7 days (one combined total per customer).
POST /api/jobs/membership-usage-nudgesDaily randomized batch (30 5 * * * UTC = 11:00 AM IST)Picks a random subset (max 20/run) of active members with unused hours and nudges them (push) to use their SPA time.
POST /api/jobs/daily-sales-reportDaily 10:30 PM IST (0 17 * * * UTC)Builds the day's sales report from paid invoices/bookings and posts it to Slack + emails the report recipients.
POST /api/jobs/weekly-reportMonday 9:00 AM IST (30 3 * * 1 UTC)Same as the daily report over the last 7 days, plus a week-over-week comparison. Slack + email.
POST /api/jobs/nightly-sales-summaryDaily 11:30 PM IST (0 18 * * * UTC)Aggregates the previous IST day's paid invoices + bookings into one daily_sales_summary row per branch. Idempotent upsert.
POST /api/jobs/membership-auto-expireDaily 12:00 AM IST (30 18 * * * UTC)Flips active SPA memberships past their expiry to expired (hard expiry). Status-guarded UPDATE.
POST /api/jobs/offer-auto-expireDaily 12:05 AM IST (35 18 * * * UTC)Deactivates offers whose end_date has passed the current IST calendar date.
POST /api/jobs/gems-auto-expireDaily 12:10 AM IST (40 18 * * * UTC)Expires earned gems past their 365-day window, writing an offsetting expired transaction and decrementing the balance. Idempotent via an expired:<id> marker.
POST /api/jobs/session-cleanupSunday 2:30 AM IST (0 21 * * 0 UTC)Deletes expired Better Auth session rows from the Neon-backed session store.
POST /api/jobs/monthly-gst-summary1st of month 1:00 AM IST (30 19 1 * * UTC)Aggregates the previous IST month's paid service + membership_purchase invoices into monthly_gst_summary (SAC 999721).
RouteDelayWhat it does
POST /api/jobs/post-service-followup+24h after bookingcompletedSends a review-request email (Brevo/Resend) — only when the booking completed and the customer has marketing consent.
POST /api/jobs/stale-booking-alert+2h after booking createdIf still pending: older than 24h → auto-reject + notify customer; otherwise alert every receptionist.
POST /api/jobs/noshow-check+15 min after booking end_timeIf the booking is still confirmed past its end, alert receptionists to review. Never auto-marks a no-show.
POST /api/jobs/membership-expired-notice+1h after spa_membership.expires_atSends a final "membership expired" renewal email to the owning customer.
POST /api/jobs/invoice-pdfEnqueued at booking completionRenders the GST invoice PDF via the standalone Cloud Run invoicing service (@rgss/invoicing) over HMAC-signed HTTP, stores the PDF URL on the invoice, and emails the customer their invoice with the PDF attached. Degrades to a no-attachment email when the service is unconfigured/errors.

Invocation contract

Every job route follows the same thin orchestration shape:

Method

The route accepts only POST.

Signature verification

The raw body is read once, then verifyQStashSignature(req, bodyText) validates the Upstash-Signature header against the QStash signing keys. On failure the route returns 401 Unauthorized and does no work.

Success

The route does its work, pings its BetterStack heartbeat (pingHeartbeat(...)), and returns 200 with a small JSON body.

Failure

On any internal error the route returns a non-2xx (500 Job failed). QStash treats non-2xx as a failure and retries with exponential backoff (up to 3 retries by default), so heavy lifting must be idempotent.

Idempotency

Scheduled notification jobs dedupe via a notification log row keyed by recipient + type (+ booking where relevant), so re-runs and QStash retries never double-send.

POST /api/jobs/appointment-reminders
Upstash-Signature: <qstash-signature>
Content-Type: application/json

Not for direct client use. These endpoints are intended to be called only by QStash. Requests without a valid Upstash-Signature are rejected with 401. There is no session/role check and no { success, data } envelope — do not call them from the web app or from API integrations.

Signature verification & local fallback

verifyQStashSignature reads the signing keys directly from the environment so the app builds without them. It never throws — any verification error fails closed (returns false401).

Heartbeats

On success each route calls pingHeartbeat(name), which fetches process.env['BETTER_STACK_HEARTBEAT_' + name]. If that variable is unset the ping is a no-op (the job still succeeds); the helper never throws.

Prop

Type

invoice-pdf is the exception — it does not ping a heartbeat. Instead it returns a 200 with an outcome flag (attached, reason, sent) describing whether the PDF was rendered and attached, falling back to a no-attachment invoice email when the Cloud Run render service is unconfigured or errors.

Response shapes

Scheduled jobs that iterate records return a processed count of how many notifications were sent in that run:

{ "success": true, "processed": 3 }

The report jobs (daily-sales-report, weekly-report) return only success:

{ "success": true }

Triggered jobs return an action/outcome flag describing what they did for the single record in the payload:

{ "success": true, "sent": true }
{ "success": true, "notified": 2 }

stale-booking-alert reports both the action it took and how many people were notified:

{ "success": true, "action": "rejected", "notified": 1 }

action is one of none (booking no longer pending), rejected (still pending past 24h → auto-rejected, customer notified), or alerted (still pending under 24h → receptionists alerted).

A verification failure on any route returns a plain 401:

HTTP/1.1 401 Unauthorized

Unauthorized

An internal failure returns a plain 500, which QStash retries:

HTTP/1.1 500 Internal Server Error

Job failed

Triggered job payloads

The five triggered jobs are enqueued with a JSON body identifying the single record to act on. The body is parsed defensively — a missing or malformed payload yields {}, and the route simply does nothing (still 200) rather than erroring.

Prop

Type

{ "bookingId": "bk_8f1c9a2e7d04" }

Scheduled jobs take no meaningful body — they query the database for the records due in the current run, so the request body is empty (the raw text is still read for signature verification).

OpenReport an issue

Was this page helpful?

On this page