Skip to content

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's signingSecret. 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 webhookUrl must be publicly reachable — SimpleQ delivers from the cloud, so localhost won'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:

json
{
  "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"
}
FieldTypeMeaning
idstringThe 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.
queuestringThe queue this job belongs to. Useful when one worker endpoint serves multiple queues.
payloadobjectYour data, exactly as published — delivered verbatim. This is where your application data lives.
attemptnumberWhich delivery attempt this is, starting at 1.
maxAttemptsnumberThe queue's configured maximum attempts. When attempt === maxAttempts, a non-2xx response sends the job to the dead-letter queue (if enabled).
createdAtstringISO-8601 timestamp of when the job was originally published. Use it to detect stale work.

Your response decides what happens next

You returnSimpleQ does
2xx within 15sStandard 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 529Treated 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 15sCounts 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:

js
// 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
  }),
);
python
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.