/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 arePOST. “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.
- Scheduled
- Triggered
Invocation contract
Every job route follows the same thin orchestration shape:1
Method
The route accepts only
POST.2
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.3
Success
The route does its work, pings its BetterStack heartbeat (
pingHeartbeat(...)),
and returns 200 with a small JSON body.4
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.5
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.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 false → 401).
Configured — verify with the QStash Receiver
Configured — verify with the QStash Receiver
When
QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY are set, the
Upstash-Signature header is verified with the @upstash/qstash Receiver. A
missing header is rejected.Internal token fallback
Internal token fallback
If signing keys are absent but
INTERNAL_JOB_TOKEN is set, the request is
allowed only when it carries a matching x-internal-job-token header.Non-production bypass
Non-production bypass
With no signing keys and no internal token, the request is allowed outside
production (with a warning) so jobs can be run locally, and rejected in
production.
Heartbeats
On success each route callspingHeartbeat(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.
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 (processed)
- Reports
- Triggered
- stale-booking-alert
- Failures
Scheduled jobs that iterate records return a
processed count of how many
notifications were sent in that run: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.
