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.
| Route | Schedule / cadence | What it does |
|---|---|---|
POST /api/jobs/appointment-reminders | Every 15 min, 8:00 AM–10:00 PM IST | Notifies customers of confirmed bookings entering the 24h or 1h reminder window (push + email). One send per booking + window. |
POST /api/jobs/membership-expiry | Daily 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-emails | Daily 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-followups | Daily 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-reminder | Daily 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-nudges | Daily 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-report | Daily 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-report | Monday 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-summary | Daily 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-expire | Daily 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-expire | Daily 12:05 AM IST (35 18 * * * UTC) | Deactivates offers whose end_date has passed the current IST calendar date. |
POST /api/jobs/gems-auto-expire | Daily 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-cleanup | Sunday 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-summary | 1st 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). |
| Route | Delay | What it does |
|---|---|---|
POST /api/jobs/post-service-followup | +24h after booking → completed | Sends 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 created | If still pending: older than 24h → auto-reject + notify customer; otherwise alert every receptionist. |
POST /api/jobs/noshow-check | +15 min after booking end_time | If 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_at | Sends a final "membership expired" renewal email to the owning customer. |
POST /api/jobs/invoice-pdf | Enqueued at booking completion | Renders 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/jsonNot 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 false → 401).
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
UnauthorizedAn internal failure returns a plain 500, which QStash retries:
HTTP/1.1 500 Internal Server Error
Job failedTriggered 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).
Was this page helpful?