Job delivery to your webhook
When a job is ready, SimpleQ makes an HTTP POST to the webhookUrl you configured on the queue. This page is the authoritative contract for that request: the method, the headers, and the exact body your endpoint receives.
Your application data is delivered inside an envelope, not as the raw request body. SimpleQ wraps your payload together with delivery metadata (job id, attempt counters, timestamps), so your handler reads body.payload, not body directly.
The request
POST <your queue's webhookUrl>
Content-Type: application/json
x-simpleq-signature: sha256=<hex>x-simpleq-signature— HMAC-SHA256 of the raw body, keyed with the queue'ssigningSecret. Verify it on every request before processing. See Signature verification.- The connection has a 15-second timeout in standard mode. Standard mode is for fast handlers; work that can exceed 15s belongs in ack mode.
- Your
webhookUrlmust be publicly reachable — SimpleQ delivers from the cloud, solocalhostwon't work. For local dev, expose your worker with a tunnel: see Local development.
The body
The body is a single JSON object — the delivery envelope:
{
"id": "job_abc123",
"queue": "ai-jobs",
"payload": { "model": "gpt-4o-mini", "messages": [{ "role": "user", "content": "..." }] },
"attempt": 1,
"maxAttempts": 5,
"createdAt": "2025-01-15T10:30:00.000Z"
}| Field | Type | Meaning |
|---|---|---|
id | string | The job ID. Use it for the ack-mode callbacks (POST /v1/jobs/:id/ack | /nack | /defer) and as a stable dedupe key for idempotent processing. |
queue | string | The queue this job belongs to. Useful when one worker endpoint serves multiple queues. |
payload | object | Your data, exactly as published — delivered verbatim. This is where your application data lives. |
attempt | number | Which delivery attempt this is, starting at 1. |
maxAttempts | number | The queue's configured maximum attempts. When attempt === maxAttempts, a non-2xx response sends the job to the dead-letter queue (if enabled). |
createdAt | string | ISO-8601 timestamp of when the job was originally published. Use it to detect stale work. |
Your response decides what happens next
| You return | SimpleQ does |
|---|---|
2xx within 15s | Standard mode: the job is marked completed. Ack mode: the job moves to awaiting_ack and waits for your /ack, /nack, or /defer callback — a 2xx only confirms receipt, not completion. See Ack mode. |
429, 503, or 529 | Treated as backpressure: SimpleQ backs off (honoring a Retry-After header if present) and redelivers without burning an attempt. See Backpressure. |
| Any other non-2xx, or no response within 15s | Counts as a failed attempt. SimpleQ retries with backoff up to maxAttempts, then dead-letters the job (if the DLQ is enabled). |
A minimal handler
Verify the signature, parse the envelope, read payload, return 2xx:
// npm install @simpleq/sdk express
import express from 'express';
import { simpleqWebhookHandler } from '@simpleq/sdk/express';
const app = express();
// simpleqWebhookHandler captures the raw body, verifies x-simpleq-signature (401 on
// mismatch), parses the envelope, and maps your handler's outcome to a response:
// resolve → 200, throw → 500 (failed attempt, retried with backoff).
app.post(
'/webhook',
simpleqWebhookHandler(process.env.SQ_SIGNING_SECRET, async (job) => {
// job.id, job.queue, job.attempt, job.maxAttempts, job.createdAt
const isLastAttempt = job.attempt === job.maxAttempts;
await doWork(job.payload); // your data lives under .payload
}),
);import hashlib
import hmac
import json
import os
from fastapi import FastAPI, Header, Request, Response
app = FastAPI()
SIGNING_SECRET = os.environ["SQ_SIGNING_SECRET"]
def verify_signature(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
expected = "sha256=" + hmac.new(SIGNING_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
@app.post("/webhook")
async def webhook(request: Request, x_simpleq_signature: str | None = Header(default=None)):
# Read the raw bytes BEFORE parsing — that's what the signature was computed over.
raw = await request.body()
if not verify_signature(raw, x_simpleq_signature):
return Response(status_code=401)
job = json.loads(raw)
# job["id"], job["queue"], job["attempt"], job["maxAttempts"], job["createdAt"]
is_last_attempt = job["attempt"] == job["maxAttempts"]
do_work(job["payload"]) # your data lives under "payload"
return Response(status_code=200)For complete, copy-paste handlers across Node (Express), Nest.js, Python (FastAPI), Go, Java, PHP, and C#/.NET — plus rate-limit handling — see the examples.
Data retention
Job records — status, lastError, and the full per-attempt history — stay queryable via GET /v1/jobs/:id for 72 hours after the job was published. A dead job stays inspectable and replayable from the DLQ until that same 72-hours-from-publish deadline — a job that dies late in its life has correspondingly little replay time left. After the deadline (on the next sweep pass) the job and its DLQ record are permanently removed and GET /v1/jobs/:id returns 404. Idempotency keys dedupe for the same window (see Idempotency).
Source of truth
The envelope is the WebhookPayload type in the SimpleQ platform; this page mirrors it field-for-field. If you ever see a field here that your handler doesn't expect, prefer the live delivery — and the field list above stays in lockstep with the type.