Queues & Cron
Durable message queues that live in your database, scheduled jobs on a cron expression, and HMAC-signed outbound webhooks, all built in, no extension to install.
Queues
Every project has a pgmq schema of queue functions installed
in its database, owned by the project. You use them over a
direct SQL connection. A queue is created on demand
and backed by ordinary tables, so a message is as durable as any row.
| Function | What it does |
|---|---|
pgmq."create"(queue_name) | Create a queue. |
pgmq.send(queue_name, msg jsonb, delay int = 0) | Enqueue a message; delay seconds before it becomes visible. Returns the message id. |
pgmq.read(queue_name, vt_seconds, qty) | Claim up to qty visible messages and hide them for vt_seconds (the visibility timeout). |
pgmq.pop(queue_name) | Claim and delete one message in a single step. |
pgmq."delete"(queue_name, msg_id) | Delete a message you have finished processing. |
pgmq.archive(queue_name, msg_id) | Move a message to the queue's archive table instead of deleting it. |
-- producer
select pgmq.send('emails', '{"to": "a@example.com", "template": "welcome"}');
-- consumer: claim up to 10 messages, hidden for 30s while you work
select msg_id, message from pgmq.read('emails', 30, 10);
-- on success, remove them
select pgmq."delete"('emails', 42);
read and pop claim rows with
FOR UPDATE SKIP LOCKED, so many workers can drain one queue
without stepping on each other: a claimed message is invisible to other
readers until its visibility timeout lapses, at which point, if you have not
deleted it, it becomes available again for retry. Queue names must match
^[a-z][a-z0-9_]{0,47}$.
Cron
Schedule a job on a standard five-field cron expression (minute, hour, day-of-month, month, day-of-week), interpreted in UTC. Manage jobs on the project's Cron tab in the dashboard, or through the management API (owner or admin session):
GET /v1/projects/{ref}/cron-jobs
POST /v1/projects/{ref}/cron-jobs # { name, schedule, target_kind, target, payload }
DELETE /v1/projects/{ref}/cron-jobs/{name}
A job fires one of three target_kinds:
| Kind | Effect |
|---|---|
enqueue | target is a queue name; the job enqueues its payload onto that queue. |
webhook | target is an https:// URL; the job sends a signed HTTP POST (see below). |
workflow | target is a workflow name; the job starts a run of that durable workflow with payload as its input. |
The scheduler claims due jobs with SKIP LOCKED, so running
more than one instance is safe. Scheduling granularity is one minute; a job
with a schedule that fails to parse is disabled rather than run.
Webhooks
A webhook cron job delivers a signed POST to your
URL. Each job has its own signing secret, shown once when the
job is created: copy it then; it cannot be read back. Every delivery carries
two headers:
| Header | Value |
|---|---|
X-Kethosbase-Timestamp | Unix seconds when the request was signed. |
X-Kethosbase-Signature | Lower-case hex HMAC-SHA256 of "<timestamp>.<body>", keyed by the job's secret. |
Verify a delivery by recomputing the HMAC over the timestamp, a literal
., and the exact raw body, then comparing in constant time. Bind
the timestamp into the signature so a captured body cannot be replayed later,
and reject requests whose timestamp is too old for your tolerance:
# pseudocode for the receiver
signed = timestamp + "." + raw_request_body
expected = hmac_sha256(job_secret, signed) # lower-case hex
if not constant_time_equals(expected, header["X-Kethosbase-Signature"]):
reject(401)
if now - int(header["X-Kethosbase-Timestamp"]) > tolerance_seconds:
reject(401)
Deliveries go out through an SSRF-guarded HTTP client: the resolved IP is
checked at dial time, private / loopback / link-local addresses (including
the cloud metadata address) are refused, and redirects are not followed. A
delivery counts as delivered only on a 2xx response.
Limits
- Cron jobs are list / create / delete; there is no edit or manual "run now"; change a schedule by deleting and recreating the job.
- Webhooks are fire-and-forget: no automatic retries or per-attempt history, only the job's last status. Build idempotency and any retry expectation into your receiver.
- A webhook secret cannot be rotated in place; delete and recreate the job to get a new one.
- Schedules resolve to one-minute granularity; there are no sub-minute jobs.